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 49e0e11ef4..061ddeed50 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 @@ -21,8 +21,8 @@ en: 985e0eb63f3d1fd6 zh: 337cc44fadc6227e /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/build-pipeline-and-artifacts: - en: 9d885e8d58bc8bbb - zh: 41a9779fbdea8a1a + en: 9a4ba22142a7be24 + zh: 8553b02a923641a3 /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/python-sdk-distribution-two-carriers-exe-for-production-node-for-development: en: b2a740a1ab7a5002 zh: a48be6ca6fc65912 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 8349e7cf31..34d346c96a 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 @@ -48,7 +48,7 @@ The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed de ### 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.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **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 --compress Brotli` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it with its lightweight resource manifest and Office skill assets 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 reference](../../../../packages/subprocess/subprocess-local/README.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 supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; 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. +[`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 --config.hoist-workspace-packages=false` **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 --compress Brotli` once per target, where the vendored pkg patch pins Brotli's quality to 6: pkg passes no options, so its default quality 11 measured 132.8 s against 3.2 s at quality 6 for an archive 12% larger → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it with its lightweight resource manifest and Office skill assets 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 reference](../../../../packages/subprocess/subprocess-local/README.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 supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; 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, verifies their package and executable origin, 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 f28f3da059..6e1e2c51b6 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 @@ -48,7 +48,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.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `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 --compress Brotli` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/`,并将它与轻量资源清单及 Office skill 资源一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.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` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`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 --config.hoist-workspace-packages=false` **直接写入** `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 --compress Brotli`,其中 vendor 的 pkg 补丁把 Brotli 质量固定为 6:pkg 不传参数时默认质量 11,同一载荷实测 132.8 s,质量 6 为 3.2 s,产物大 12% → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/`,并将它与轻量资源清单及 Office skill 资源一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.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` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`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-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml index 23935ef2ad..93feec2b95 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml @@ -27,14 +27,14 @@ en: d5d0d6a57a4fa581 zh: 265973b157f957d9 /agent-note-session-history-control-state-and-remote-event-transport/decision/session-controller: - en: e338ac27490a380f - zh: 12600011ec742e5b + en: f432480a52414d56 + zh: 15a3f175b69cdb53 /agent-note-session-history-control-state-and-remote-event-transport/decision/session-controller/activation-policy: - en: 534dcc62a26d305e - zh: 03d64abc0a46bbec + en: 03e24cdee8519a92 + zh: 82dc02eb26d7d7f4 /agent-note-session-history-control-state-and-remote-event-transport/decision/session-controller/session-journal: - en: bb710f8cf35f2028 - zh: aae2cfb8cd848069 + en: 4de2965717900b96 + zh: 1d129003824fa6bd /agent-note-session-history-control-state-and-remote-event-transport/decision/session-controller/session-live-control: en: a11d627cd68650c3 zh: 9aff565b9f712853 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md index de05a0e62b..ee43b551eb 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -139,11 +139,11 @@ If a page request is canceled with its physical carrier generation, the journal `packages/api/session-controller` provides Host `ctx.sessionController` and the generated `ctx.remote.session` namespace. -It owns Session list, search, create, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, page, follow, and control. The Host-generation model catalog is exposed separately through `session/modelCatalog` because it is not Session-specific. +It owns Session list, search, create, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, `events`, the deprecated `page`, `follow`, `control`, plus `observeEvent`, `projections`, `observeProjections`, `state`, `observeState`, and `wait`. The Host-generation model catalog is exposed separately through `session/modelCatalog` because it is not Session-specific. The package separates agent, commands, control, history, and list controllers internally, but Session identity resolution, activation policy, subagent ownership, and Remote error projection have one public owner. -Other Host Remote namespaces reuse the same identity rules through `ctx.sessionController.inspect()` or `resolveAgent()`; they do not retain a second Session resolver. +Other Host Remote namespaces reuse the same resume-capable identity rules through `ctx.sessionController.resolveAgent()`; they do not retain a second Session resolver. #### Activation policy @@ -157,7 +157,8 @@ Each method explicitly selects a cold inspection, live-only lookup, or resume-ca | `session.page(address)` | attached Session or persistence log | Never resumes an Agent | | `session.follow(address)` | one live or prepared observation carrying the opening page and projections | Publishes the snapshot first, then promotes an ordinary cold Session once in the background | | `session.control()` | current attached Agents, pending registry, and process-local registries | Baseline and reconnect do not resume an Agent | -| `session.attachment`, fork source read | authorized durable Session data | A read does not resume an Agent | +| Fork source read | authorized durable Session data | A read does not resume an Agent | +| `session.attachment` | attachment store under the [image access rule](2026-10-23-store-scoped-image-reads.md) | A read does not resume an Agent | | `session.updateQueue` | live Agent or ordinary persisted Session | Resumes an ordinary cold Session before mutating its Inbox | | `session.cancel` | only the current live Agent | Does not resume vanished state | | `models`, `selectModel`, `rename`, `prompt` | command resolves the target Session | Resumes only when the method explicitly permits it | @@ -173,9 +174,9 @@ Reading titles, lists, and projections does not require an Agent. An observation #### Session journal -`session.page` returns a history window clipped on message boundaries with contiguous internal sequence numbers. Every request must carry an explicit `throughSeq`; this value comes from the corresponding `session.follow` generation's opening cursor and fixes the read at the same log cut. A tail page without `beforeSeq` must end exactly at `throughSeq`, where `-1` denotes an empty log. `beforeSeq` only selects an older page before that cut and cannot replace the synchronization cursor. `maxMessages` limits user/assistant message count without dropping chunks, tools, or state events between those messages. +`session.events` returns a history window clipped on message boundaries with contiguous internal sequence numbers. `throughSeq` is optional: when omitted, the read selects the observed log end and returns it as the page's inclusive `cursor`; a supplied value fixes the read at that cut. A page without `beforeSeq` ends exactly at the selected cut, where `-1` denotes an empty log. `beforeSeq` only selects an older page before that cut and cannot replace the synchronization cursor. `maxMessages` limits user/assistant message count without dropping chunks, tools, or state events between those messages. The deprecated `session.page` entry returns the same type. -The tail page also carries a projection baseline no later than `throughSeq`; older pages carry only historical entries. The Client merges pages and subsequent live control updates by projection watermark. +A page carries only its records, `hasMore`, and inclusive `cursor`; a projection baseline comes from a follow opening snapshot or the control stream, never from a page. The Client merges pages and subsequent live control updates by projection watermark. Ordinary Sessions and direct subagents use one `SessionAddress` protocol. A direct-subagent address carries parent Session, child Session, and mode; a cold Host read verifies durable ownership and descriptor rather than authorizing access from the child id alone. diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md index 004da80fee..9df5c55701 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -139,11 +139,11 @@ repair 期间旧 window 保持可读;page 与期间积累的 live entries 拼 `packages/api/session-controller` 提供 Host `ctx.sessionController` 与生成的 `ctx.remote.session` namespace。 -它拥有 Session list、search、create、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、page、follow 与 control。Host generation 的 model catalog 通过独立的 `session/modelCatalog` 公开,因为它不属于特定 Session。 +它拥有 Session list、search、create、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、`events`、已弃用的 `page`、`follow`、`control`,以及 `observeEvent`、`projections`、`observeProjections`、`state`、`observeState` 与 `wait`。Host generation 的 model catalog 通过独立的 `session/modelCatalog` 公开,因为它不属于特定 Session。 包内的 agent、commands、control、history 与 list controller 分开实现,但 Session 身份解析、激活策略、subagent ownership 和 Remote 错误投影只有一个公开 owner。 -其他 Host Remote namespace 通过 `ctx.sessionController.inspect()` 或 `resolveAgent()` 复用同一身份规则,不保留第二份 Session resolver。 +其他 Host Remote namespace 通过 `ctx.sessionController.resolveAgent()` 复用同一套允许恢复 Session 的身份规则,不保留第二份 Session resolver。 #### 激活策略 @@ -157,7 +157,8 @@ Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类 | `session.page(address)` | attached Session 或 persistence 日志 | 永不恢复 Agent | | `session.follow(address)` | 一份携带 opening page 与 projection 的 live 或 prepared observation | 先发布 snapshot,再在后台把普通冷 Session 提升一次 | | `session.control()` | 当前 attached Agent、pending registry 与进程内 registry | baseline 与重连不恢复 Agent | -| `session.attachment`、fork 源读取 | 已授权的持久 Session 数据 | 读取不恢复 Agent | +| Fork 源读取 | 已授权的持久 Session 数据 | 读取不恢复 Agent | +| `session.attachment` | 按[图片访问规则](2026-10-23-store-scoped-image-reads.zh.md)读取附件存储 | 读取不恢复 Agent | | `session.updateQueue` | live Agent 或普通持久 Session | 修改 Inbox 前恢复普通冷 Session | | `session.cancel` | 仅命中当前 live Agent | 不为已消失状态恢复 Agent | | `models`、`selectModel`、`rename`、`prompt` | 命令解析目标 Session | 仅按方法约定显式恢复 | @@ -173,9 +174,9 @@ Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类 #### Session 日志 -`session.page` 返回一段按消息边界裁剪、内部 seq 连续的历史窗口。每个请求必须显式携带 `throughSeq`;该值来自对应 `session.follow` generation 的 opening cursor,并把本次读取固定在同一个日志切点。无 `beforeSeq` 的 tail page 必须精确结束于 `throughSeq`,其中 `-1` 表示空日志;`beforeSeq` 只选择该切点之前的更早页面,不能替代同步 cursor。`maxMessages` 限制 user/assistant 消息数,不丢弃这些消息之间的 chunk、tool 或状态事件。 +`session.events` 返回一段按消息边界裁剪、内部 seq 连续的历史窗口。`throughSeq` 可省略:省略时选取观察到的日志末尾,并把该切点作为页面的 inclusive `cursor` 返回;显式提供时把本次读取固定在该切点。无 `beforeSeq` 的 page 精确结束于所选切点,其中 `-1` 表示空日志;`beforeSeq` 只选择该切点之前的更早页面,不能替代同步 cursor。`maxMessages` 限制 user/assistant 消息数,不丢弃这些消息之间的 chunk、tool 或状态事件。已弃用的 `session.page` 入口返回同一类型。 -tail page 同时携带不晚于 `throughSeq` 的 projection baseline;旧页只携带历史 entries。Client 以 projection watermark 合并 page 与后续 live control 更新。 +page 只携带记录、`hasMore` 和 inclusive `cursor`;projection baseline 来自 follow 的开场快照或 control 流,不来自 page。Client 以 projection watermark 合并 page 与后续 live control 更新。 普通 Session 与 direct subagent 使用同一个 `SessionAddress` 协议。direct subagent 地址同时携带父 Session、子 Session 与 mode,Host 冷读时验证持久 ownership 和 descriptor,不能只凭 child id 越权读取。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml index e70e858963..86b5f42ac4 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml @@ -51,8 +51,8 @@ en: 51f99cfca9c4c808 zh: ce95bab241d306f7 /agent-note-client-derived-presentation-from-raw-session-tool-events/host-design: - en: 16fd2b3e545ede21 - zh: 4d28f52132495441 + en: d5e5d1376dc42558 + zh: c2f302b2a5d1de39 /agent-note-client-derived-presentation-from-raw-session-tool-events/host-design/performance-constraints: en: 102b14e3eb9367ff zh: d391099dbc41db24 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md index 8a0c4ea6fc..8c32436c6a 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md @@ -195,9 +195,9 @@ The Client narrows `meta` locally at runtime. Renaming `presentationMeta` to mor ## Host Design -After obtaining source events, `SessionHistoryController.page()` performs only pagination and the existing projection-baseline calculation. Attached Sessions use the projection registry snapshot; detached Sessions use its restore path over the inspected log. History does not mount a preset to change the registered projection set. +After obtaining source events, `HistoryController.history()` performs only pagination and requests projections only for a subagent address that needs descriptor validation. Attached Sessions use the projection registry snapshot; detached Sessions use its restore path over the inspected log. History does not mount a preset to change the registered projection set. -`SessionHistoryController.follow()` retains listener-first setup, opening cursors, gap-free replay, live buffering, cancellation, and teardown. It maintains no additional state for tool events. +`WebController.follow()` retains listener-first setup, opening cursors, gap-free replay, live buffering, cancellation, and teardown. It maintains no additional state for tool events. The controller has no `presenterScopeFor()`, `viewFor()`, `backscanArgs()`, `parseToolCall()`, or `jsonView()` path. Page state contains no presenter scope or argument resolver; follow state contains no `openCalls`, `fallbackEvents`, or presentation argument resolver. Each page/follow event is wrapped only as `{event}` while addressing, ownership, cursor, sequence, and projection logic remains intact. diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md index 2dd4ad8bc9..6ba3e040bd 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md @@ -195,9 +195,9 @@ Client 对 `meta` 做局部运行时收窄。是否把 `presentationMeta` 改名 ## Host 端设计 -`SessionHistoryController.page()` 在取得 source events 后只执行分页与现有 projection baseline 计算。attached Session 使用 projection registry snapshot;detached Session 使用该 registry 对 inspected log 的 restore 路径。history 不通过挂载 preset 改变已注册的 projection 集合。 +`HistoryController.history()` 在取得 source events 后只执行分页,并且只有需要校验 descriptor 的 subagent 地址才请求投影。attached Session 使用 projection registry snapshot;detached Session 使用该 registry 对 inspected log 的 restore 路径。history 不通过挂载 preset 改变已注册的 projection 集合。 -`SessionHistoryController.follow()` 保留 listener-first、opening cursor、gap-free replay、live buffering、取消和 teardown;它不为工具事件维护额外状态。 +`WebController.follow()` 保留 listener-first、opening cursor、gap-free replay、live buffering、取消和 teardown;它不为工具事件维护额外状态。 Controller 不存在 `presenterScopeFor()`、`viewFor()`、`backscanArgs()`、`parseToolCall()` 或 `jsonView()` 路径。page state 不含 presenter scope 或参数 resolver;follow state 不含 `openCalls`、`fallbackEvents` 或 presentation 参数 resolver。每个 page/follow event 只包装成 `{event}`,地址、ownership、cursor、seq 与 projection 逻辑保持完整。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index bd2626e850..c67dff0722 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml @@ -15,17 +15,17 @@ en: 480284ae1a6ce210 zh: d42c72ada9f019f8 /agent-note-session-observations-and-projection-owned-client-state/decision/observation-is-the-point-read-unit: - en: fac92f8547482a49 - zh: 60240c47fbe4611f + en: 283136ae36512767 + zh: e342b446c77ae300 /agent-note-session-observations-and-projection-owned-client-state/decision/source-resolution-and-lifetime: - en: e550ae9892474276 - zh: fb6a2d9e91ebd4d9 + en: bced4b90cb03525d + zh: b0c1ca4bf1064f89 /agent-note-session-observations-and-projection-owned-client-state/decision/projection-execution-boundary: - en: 148de86941b90f41 - zh: c065a8e96e28b9d7 + en: b3b235fa0250868a + zh: 0bb5eedf14cdafee /agent-note-session-observations-and-projection-owned-client-state/decision/read-audiences: - en: 888f62659f4d868d - zh: e74491e8c85046ee + en: e394e9480340f69c + zh: 7eea994ee9fa380d /agent-note-session-observations-and-projection-owned-client-state/decision/replayable-client-facts-are-projection-owned: en: efded09f0cd30a8e zh: 5e16580a39ff3359 @@ -48,11 +48,11 @@ en: 8d699dfc22ab5c5f zh: 8dcd9fc6bd1594db /agent-note-session-observations-and-projection-owned-client-state/verification: - en: df17217023668428 - zh: 88f7da4deffc6bf9 + en: 29f364d1031b7d85 + zh: 2b193ceb52fb0406 /agent-note-session-observations-and-projection-owned-client-state/alternatives-considered: - en: a25138b4298cda16 - zh: 92799a9bd8867d47 + en: 90574351058d5d8c + zh: 6aa514cf47495ed2 /agent-note-session-observations-and-projection-owned-client-state/consequences: en: e1f8bd8fa68de603 zh: 5afcd06f2611bc1e diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index 523122c9ae..530aeb36e1 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -24,7 +24,7 @@ The two ownership rules meet at the observation's projection snapshot. Lightweig flowchart LR List["list / search"] --> Corpus["SessionQuery corpus"] Follow["follow"] --> Observe["observeSession"] - Page["page / attachment / fork"] --> Observe + Page["page / fork"] --> Observe Subagent["subagent list / continuation"] --> Observe Corpus --> Cache["projection cache hints"] Cache --> ClientList["Client Session list"] @@ -46,7 +46,7 @@ flowchart LR `SessionQueryEngine.observeSession(sessionId, options)` returns a disposable `SessionObservation` containing one source kind, header, contiguous event prefix, cursor, optional projection snapshot, and the durable revision for a prepared source. An attached Session wins. Otherwise the reader's own prepared cache — keyed by `stat().revision` and pinned by observation leases — serves the cold Session, sharing one persistence read (`open(id, 'read')` + `read`) across concurrent observations, including an in-flight cold load. -Every owner disposes its observation. `retain()` creates another lease over the same cut, which lets `session.follow` publish a snapshot and then transfer that exact prepared source to background Agent promotion without rereading the log. A live Session that appears during cold resolution wins before publication; a disappeared live source is retried as cold. +Every owner disposes its observation. `retain()` creates another lease over the same cut, which lets `session.follow` transfer that exact prepared source to background Agent promotion before delivering the snapshot, without rereading the log. A live Session that appears during cold resolution wins before publication; a disappeared live source is retried as cold. ### Source resolution and lifetime @@ -56,7 +56,7 @@ Live preference is checked both before and after a cold borrow. The second check Persistence absence maps to Session-not-found only after no attached Session exists. Durable corruption, source-identity conflict, cancellation, and operational persistence failure remain distinct `SessionQueryError` outcomes so API owners can preserve their own public error vocabulary without duplicating source detection. -The observation owns no mutation authority. Its event array is an immutable prefix, and its prepared Session remains unpublished. Promotion is an explicit ownership transfer performed by the Session Controller after it has emitted the opening snapshot; other readers cannot turn an observation into a live Agent. +The observation owns no mutation authority. Its event array is an immutable prefix, and its prepared Session remains unpublished. Promotion is an explicit ownership transfer the Session Controller starts once the opening is computed, before delivering it; other readers cannot turn an observation into a live Agent. Projection work is deliberately `all | none`. `all` computes every registered projection at the observation's event cursor; `none` leaves projection state untouched. There is no per-key preparation state, `projectionKeys` mode, or cached `viewedState`/`viewedValue` layer. A publisher may filter the completed values for an audience, but the underlying observation is never partly projected. @@ -72,7 +72,7 @@ The registry owns fold state; each domain owns its `init`, `apply`, `view`, sche Corpus listing remains a separate lightweight operation. `listSessions()` returns live-preferred headers without materializing every log. Session list first uses live projection state or durable projection-cache rows. Subagent discovery reads parent-catalog projections through exact observations. Session list may take one complete observation for an individually stored artifact within its configured small-log limit when cached metadata cannot establish whether it is blank; a large or unreadable cache miss remains visible with unknown hints. -`session.follow` publishes a required opening snapshot containing header, cursor, the initial event window, and a complete projection baseline. Reconnect replaces the previous generation from another complete snapshot. `session.page` is reserved for older-history reads and gap repair. Observation-only reads never activate an Agent; only an ordinary follow may retain its prepared observation and request promotion after the opening snapshot has been delivered. +`session.follow` publishes a required opening snapshot containing header, cursor, the initial event window, and a complete projection baseline. Reconnect replaces the previous generation from another complete snapshot. `session.page` supports current or fixed-cut history reads, older-history reads, and gap repair. Observation-only reads never activate an Agent; only an activation-enabled ordinary Follow may retain its prepared observation and request promotion once the opening snapshot is computed, before it is delivered. The [snapshot and subscription decision](2026-10-21-session-history-stream-and-snapshot.md) defines the Controller composition and passive subscription semantics. ### Read audiences @@ -82,9 +82,10 @@ Each public operation chooses one query and projection policy. The choice is par |---|---|---|---| | `session.list` | Corpus headers, live state, and cached rows; bounded small-log fallback | Partial hints, or one full small-log observation | Never | | `session.search` | Corpus authorization plus the configured search provider | None for result listing | Never | -| `session.follow` | One exact observation | All, carried in the opening snapshot | Ordinary cold Session only, after snapshot delivery | +| `session.follow` | One exact observation | All, carried in the opening snapshot | Only with `activate: true`, for an ordinary cold Session once the opening is computed, before delivery | | `session.page` | One exact observation | None, except projection-backed subagent authorization | Never | -| Attachment and fork source | One exact observation | None unless authorization requires it | Never for the source | +| Fork source | One exact observation | None unless authorization requires it | Never for the source | +| Attachment | Direct store read under the [image access rule](2026-10-23-store-scoped-image-reads.md) | None | Never | | Subagent list and continuation | Parent-catalog observations for listing; target observations for continuation | All; listing consumes catalog values, continuation consumes identity and inherited values | Never for listing; continuation follows its explicit command semantics | ### Replayable Client facts are projection-owned @@ -171,7 +172,7 @@ These rules apply to new Session-derived Client state even when a direct event s ## Verification -Persistence and SessionQuery tests pin shared cold loading, cancellation, live-source races, retained observations, disposal, and all-or-none projection calculation. Session Controller and Gateway tests pin snapshot-first opening, replacement reconnect, older-page reads, gap repair, list-cache hints, bounded small-log fallback, and promotion after snapshot delivery. +Persistence and SessionQuery tests pin shared cold loading, cancellation, live-source races, retained observations, disposal, and all-or-none projection calculation. Session Controller and Gateway tests pin snapshot-first opening, replacement reconnect, older-page reads, gap repair, list-cache hints, bounded small-log fallback, and promotion once the opening is computed. Client tests pin higher-sequence-wins projection storage, title updates, model catalog and selection readiness, preset roster refresh and Session-specific selection, and subagent loading without transient offline presentation. Subagent tests pin parent-catalog order, recursive traversal, branch diagnostics, cancellation, fork isolation, and no Agent activation during listing. @@ -179,7 +180,7 @@ Client tests pin higher-sequence-wins projection storage, title updates, model c **Keep source resolution in each consumer.** Rejected because every caller would continue to implement its own live race, persistence error mapping, preparation lifetime, cancellation, and projection cut, allowing both duplicate work and inconsistent results. -**Activate an Agent for every exact read.** Rejected because list, history, attachment, search, and subagent inspection are read operations. Activation loads plugins and changes process state, and it has no natural retirement point for pagination or catalog reads. +**Activate an Agent for every exact read.** Rejected because list, history, search, and subagent inspection are read operations. Activation loads plugins and changes process state, and it has no natural retirement point for pagination or catalog reads. **Prepare only requested projection keys.** Rejected because a partially projected Session creates another lifecycle state that every cache, restore, plugin-registration, and caller path must track. Projection units are pure and few; computing all registered units for an exact observation is simpler than maintaining `O(E*k)` partial state instead of `O(E*P)` complete state. diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index 6639047731..62ac9db852 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -24,7 +24,7 @@ Session 精确读取使用可保留的 `SessionObservation`,向 Client 暴露 flowchart LR List["list / search"] --> Corpus["SessionQuery corpus"] Follow["follow"] --> Observe["observeSession"] - Page["page / attachment / fork"] --> Observe + Page["page / fork"] --> Observe Subagent["subagent list / continuation"] --> Observe Corpus --> Cache["projection cache hints"] Cache --> ClientList["Client Session list"] @@ -46,7 +46,7 @@ flowchart LR `SessionQueryEngine.observeSession(sessionId, options)` 返回可 dispose(资源释放)的 `SessionObservation`,其中包含同一份 source kind、header、连续事件前缀、cursor、可选 projection snapshot,以及 prepared source 的持久化 revision。已挂载 Session 优先;否则由读取方自己的 prepared cache——以 `stat().revision` 为键、由 observation lease 固定——提供冷 Session,让并发 observation 共享同一次持久化读取(`open(id, 'read')` + `read`),包括尚未完成的冷加载。 -每个 owner 都会 dispose 自己的 observation。`retain()` 为同一切面创建另一份 lease,使 `session.follow` 能够先发布 snapshot,再把完全相同的 prepared source 转交给后台 Agent promotion,而无需重读日志。冷解析期间出现的 live Session 会在发布前胜出;已经消失的 live source 会按 cold source 重试。 +每个 owner 都会 dispose 自己的 observation。`retain()` 为同一切面创建另一份 lease,使 `session.follow` 能把完全相同的 prepared source 转交给后台 Agent promotion,并在交付 snapshot 前启动它,而无需重读日志。冷解析期间出现的 live Session 会在发布前胜出;已经消失的 live source 会按 cold source 重试。 ### 数据源解析与生命周期 @@ -56,7 +56,7 @@ flowchart LR 只有在不存在已挂载 Session 后,persistence absence 才映射为 Session-not-found。持久数据损坏、source identity 冲突、取消和 persistence 操作失败分别保留不同的 `SessionQueryError`,API owner 因而可以维持自身公开错误词汇,而不用重复数据源判定。 -Observation 不拥有任何 mutation 权限。其事件数组是不可变前缀,prepared Session 保持未发布。Promotion 是 Session Controller 在 opening snapshot 发出后执行的显式 ownership transfer;其他读方不能把 observation 变成 live Agent。 +Observation 不拥有任何 mutation 权限。其事件数组是不可变前缀,prepared Session 保持未发布。Promotion 是 Session Controller 在 opening 计算完成后、交付前启动的显式 ownership transfer;其他读方不能把 observation 变成 live Agent。 Projection 工作明确只有 `all | none` 两种模式。`all` 在 observation 的事件 cursor 上计算所有已注册 projection;`none` 完全不触碰 projection 状态。系统不存在按 key preparation 的状态、`projectionKeys` 模式或额外的 `viewedState`/`viewedValue` cache。发布方可以按 audience 筛选已完成的值,但底层 observation 不会处于只算完部分 projection 的状态。 @@ -72,7 +72,7 @@ Registry 拥有 fold state;各领域拥有自己的 `init`、`apply`、`view` 语料库 list 仍是独立的轻量操作。`listSessions()` 返回 live-preferred header,而不物化每份日志。Session list 先读取 live projection 状态或持久 projection-cache row。Subagent 发现通过精确 observation 读取父目录 projection。当 cache 无法判断 Session 是否为空,且该 Session 拥有的独立产物未超过配置的小日志限制时,Session list 可以执行一次完整 observation;大型或不可读的 cache miss 仍以 hints 未知但 row 可见的方式返回。 -`session.follow` 发布必需的 opening snapshot,其中包含 header、cursor、首个事件窗口和完整 projection baseline。重连使用另一份完整 snapshot 替换上一 generation。`session.page` 仅用于旧历史读取与 gap repair。只读 observation 不激活 Agent;只有普通 follow 可以保留 prepared observation,并在 opening snapshot 已交付后请求 promotion。 +`session.follow` 发布必需的 opening snapshot,包含 header、cursor、初始事件窗口与完整 projection baseline。重连通过另一份完整快照替换之前的 generation。`session.page` 支持当前或固定切点的历史读取、旧历史分页及缺口修复。只读 Observation 不激活 Agent;只有显式启用激活的普通 Follow 可以在开场计算完成后、交付之前 retain prepared observation 并请求提升。[快照与订阅决策](2026-10-21-session-history-stream-and-snapshot.zh.md)定义 Controller 组合和纯订阅语义。 ### 读取 audience @@ -82,9 +82,10 @@ Registry 拥有 fold state;各领域拥有自己的 `init`、`apply`、`view` |---|---|---|---| | `session.list` | Corpus header、live state 和 cached row;有界小日志 fallback | 部分 hints,或一次完整小日志 observation | 从不 | | `session.search` | Corpus 鉴权加已配置 search provider | 结果列表不计算 | 从不 | -| `session.follow` | 一份精确 observation | 全算,并由 opening snapshot 携带 | 仅普通 cold Session,且在 snapshot 交付后 | +| `session.follow` | 一份精确 observation | 全算,并由 opening snapshot 携带 | 仅 `activate: true` 的普通 cold Session,且在开场计算完成后、交付前 | | `session.page` | 一份精确 observation | 不计算,但 projection-backed subagent 鉴权除外 | 从不 | -| Attachment 与 fork source | 一份精确 observation | 鉴权不要求时不计算 | source 从不激活 | +| Fork source | 一份精确 observation | 鉴权不要求时不计算 | source 从不激活 | +| Attachment | 按[图片访问规则](2026-10-23-store-scoped-image-reads.zh.md)直接读取存储 | 不计算 | 从不 | | Subagent list 与 continuation | Listing 观察父目录;continuation 观察目标 | 全算;listing 消费目录值,continuation 消费身份与继承值 | Listing 从不;continuation 遵循显式命令语义 | ### 可回放的 Client 事实归 projection 所有 @@ -171,7 +172,7 @@ Client 本地交互状态也继续留在本地:loading 和 error 状态、打 ## 验证 -Persistence 与 SessionQuery 测试固定共享冷加载、取消、live-source race、retained observation、dispose 和 all-or-none projection 计算。Session Controller 与 Gateway 测试固定 snapshot-first opening、replacement reconnect、旧分页读取、gap repair、list-cache hints、小日志有界 fallback,以及 snapshot 交付后的 promotion。 +Persistence 与 SessionQuery 测试固定共享冷加载、取消、live-source race、retained observation、dispose 和 all-or-none projection 计算。Session Controller 与 Gateway 测试固定 snapshot-first opening、replacement reconnect、旧分页读取、gap repair、list-cache hints、小日志有界 fallback,以及开场计算完成后的 promotion。 Client 测试固定 higher-sequence-wins projection store、title 更新、model catalog 与 selection readiness、preset roster refresh 与 Session 专属选择,以及不会短暂展示离线状态的 subagent loading。Subagent 测试固定父目录顺序、递归遍历、分支诊断、取消、fork 隔离,以及 listing 期间不激活 Agent。 @@ -179,7 +180,7 @@ Client 测试固定 higher-sequence-wins projection store、title 更新、model **由各消费方继续解析数据源。** 否决,因为每个调用方都需要重复实现 live race、persistence error mapping、preparation lifetime、cancellation 和 projection cut,既会重复工作,也会产生不一致结果。 -**每次精确读取都激活 Agent。** 否决,因为 list、history、attachment、search 与 subagent inspection 都是读取操作。Activation 会加载插件并改变进程状态,也没有适合分页或 catalog 读取的自然退出点。 +**每次精确读取都激活 Agent。** 否决,因为 list、history、search 与 subagent inspection 都是读取操作。Activation 会加载插件并改变进程状态,也没有适合分页或 catalog 读取的自然退出点。 **只 prepare 被请求的 projection key。** 否决,因为只完成部分 projection 的 Session 会增加一种生命周期状态,所有 cache、restore、plugin registration 和调用路径都必须追踪它。Projection unit 数量少且为纯函数;精确 observation 计算全部已注册 unit,比为了 `O(E*k)` 而维护部分状态、取代 `O(E*P)` 完整状态更简单。 diff --git a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml index 93080791e0..ed47cbd168 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml @@ -30,8 +30,8 @@ en: 9d2d50cb76754b74 zh: 9ceed3652475f18d /agent-note-historical-session-reads-prepare-before-write-publication/decision/follow-and-agent-promotion: - en: 692c29db3e7ab07e - zh: 7da0cfdbc80f407d + en: 46da45b9b63e7298 + zh: 3d55099479a80bd9 /agent-note-historical-session-reads-prepare-before-write-publication/problem-to-solution-mapping: en: e067e187cd4d0a8c zh: b0c79b635a637296 diff --git a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md index 081044e34d..4948e31a32 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md +++ b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md @@ -102,7 +102,7 @@ No external caller can append before the handle exists. `append`, `flush`, and ` ### Follow and Agent promotion -`session.follow` opens history through the read path, restores the Session and projections, emits the opening snapshot, and then starts Agent promotion. Agent resume uses write open, so it waits for publication before the Agent accepts a new turn. History visibility and write readiness are separate timing points without introducing an unpublished append state. +`session.follow` opens history through the read path, restores the Session and projections, starts Agent promotion from the separately retained Observation when the caller requests activation, and then delivers the opening snapshot. Agent resume uses write open, so it waits for publication before the Agent accepts a new turn. History visibility and write readiness are separate timing points without introducing an unpublished append state. ## Problem-to-solution mapping diff --git a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md index 7406e7cf31..41a87efa86 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md @@ -102,7 +102,7 @@ Handle 返回前,外部 caller 无法 append。因此 `append`、`flush` 与 ` ### Follow 与 Agent promotion -`session.follow` 通过 read path 打开历史、恢复 Session 与 projections、发出 opening snapshot,然后启动 Agent promotion。Agent resume 使用 write open,因此会在 Agent 接收新一轮对话前等待 publication。历史可见与写入就绪成为两个明确时间点,同时不引入 unpublished append state。 +`session.follow` 通过 read path 打开历史、恢复 Session 与 projections,在调用方请求激活时先用单独 retain 的 Observation 启动 Agent promotion,再交付 opening snapshot。Agent resume 使用 write open,因此会在 Agent 接收新一轮对话前等待 publication。历史可见与写入就绪成为两个明确时间点,同时不引入 unpublished append state。 ## 问题与方案对照 diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml index 38d42e7d93..59746973b8 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml @@ -39,8 +39,8 @@ en: ef015f605f7b00ab zh: eee9c79faebf3dff /agent-note-workspace-file-service/alternatives-considered: - en: 8eee658526db4465 - zh: 4a655accf6a5c08a + en: c750d88e0e746a99 + zh: 9e904ad4172fda49 /agent-note-workspace-file-service/consequences: en: 246bb7aed62f6c9b zh: debf6d4129ee477b diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md index 02937e54e2..e475fb24b2 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md @@ -117,7 +117,7 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, **Serving navigable workspace documents.** Rejected on origin grounds; the [binary transfer](2026-09-17-workspace-file-binary-transfer.md) returns multipart data while HTML remains isolated in a Blob iframe. -**Log-reachable or workspace-contained authorization for file reads.** The one precedent that sends file content over the wire, command attachments, authorizes only files that appear in the session log. That excludes typed paths, while workspace containment excludes readable files elsewhere on the Session backend. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) instead makes the backend's read decision authoritative and keeps containment only for workspace-shaped operations. +**Log-reachable or workspace-contained authorization for file reads.** Requiring a Session-log reference excludes typed paths, while workspace containment excludes readable files elsewhere on the Session backend. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) makes the backend's read decision authoritative and keeps containment only for workspace-shaped operations. Image attachment reads separately follow the [store-level access rule](2026-10-23-store-scoped-image-reads.md). **Whole-file read and slice for the byte window.** The interim form of `readBytes` read the file from its start to the window's end through `readBytes(target, signal, offset + length)` and sliced. It cannot read a window of a file longer than that end — the seam refuses such a file as too large — so no window could ever report `eof: false`, which contradicts the reason the method exists. Rejected in favour of the `readByteRange` seam, whose bound is the window. diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md index 60b9597225..b11f7d5388 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md @@ -117,7 +117,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存 **提供可导航的工作区文档。** 因源隔离问题不采用;[二进制传输](2026-09-17-workspace-file-binary-transfer.zh.md)返回 multipart 数据,HTML 仍隔离在 Blob iframe 中。 -**文件读取采用“日志可达”或工作区包含授权。** 唯一把文件内容送过线路的先例——命令附件——只授权出现在会话日志里的文件,这会排除手输路径;工作区包含则会排除 Session 后端其他位置的可读文件。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)改为以后端读取决策为准,只对工作区形态的操作保留包含限制。 +**文件读取采用“日志可达”或工作区包含授权。** 要求 Session 日志引用会排除手输路径;工作区包含则会排除 Session 后端其他位置的可读文件。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)以后端读取决策为准,只对工作区形态的操作保留包含限制。图片附件读取另行遵循[存储范围的访问规则](2026-10-23-store-scoped-image-reads.zh.md)。 **为字节窗口整文件读取再切片。** `readBytes` 的临时形态经 `readBytes(target, signal, offset + length)` 从文件开头读到窗口末端再切片。它读不了比该末端更长的文件的窗口——seam 会以过大拒绝这样的文件——因此没有任何窗口能报告 `eof: false`,与该方法存在的理由相悖。被否,改为以窗口为界的 `readByteRange` seam。 diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml index 2663220c51..c0587791fd 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml @@ -15,5 +15,5 @@ en: b78785f2a603eba8 zh: b8c268228da7e2e7 /agent-note-active-tool-headers-and-stateful-request-projection/consequences: - en: 93c32b795b71a1cc - zh: d42b5c563341c898 + en: 60d7bf28fa5a8fd1 + zh: a17b222e7aca01e2 diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md index 1b505eec4b..a40b85601c 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md @@ -30,7 +30,7 @@ DeepSeek translates projected updates into system-role `tool_addition` and `tool ## Consequences -Tool lists and logical update events are capability-independent; only outgoing declarations and messages vary. Calls to removed retained tools fail through the existing `UNKNOWN_TOOL` path. Changed definitions and incomplete auxiliary prefixes sacrifice prefix reuse for coherent declarations; unchanged restoration keeps the cached prefix on `in-history` routes only while the request series continues. Routes supporting tool updates and in-history system prompts preserve earlier messages when tools are added alongside a changed prompt or after an earlier prompt update. Routes without tool-update support still consolidate on tool changes; explicit series starts and surface replacements still consolidate on every route. No persisted fields or Session format revision are added. +Tool lists and logical update events are capability-independent; only outgoing declarations and messages vary. Calls to removed retained tools fail through the existing `UNKNOWN_TOOL` path. Changed definitions and incomplete auxiliary prefixes sacrifice prefix reuse for coherent declarations; unchanged restoration keeps the cached prefix on `in-history` routes only while the request series continues. Routes supporting tool updates and in-history system prompts preserve earlier messages when tools are added alongside a changed prompt or after an earlier prompt update. Routes without tool-update support still start a new series on tool changes; explicit series starts and surface replacements consolidate the prompt only when the effective text differs, and otherwise leave the surviving prompt node in place. No persisted fields or Session format revision are added. The authored [headless prompt/tool scenario](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml) pins an appended prompt beside a deferred tool addition and verifies that the first request's messages stay unchanged. It shares the stable-prompt scenario's tool lifecycle and schema expectations. diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md index 6a8fb256c5..372dcb27af 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md @@ -30,7 +30,7 @@ DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `to ## Consequences -工具列表和逻辑更新事件不依赖能力;只有发出的声明和消息不同。调用已移除但保留声明的工具时,通过现有 `UNKNOWN_TOOL` 路径失败。定义变化和不完整辅助前缀为保证声明一致而放弃前缀复用;仅当请求序列继续时,未变化的恢复才在 `in-history` 路由上保留缓存前缀。同时支持工具更新和历史内系统提示词的路由,在工具添加与提示词变化同时发生或晚于此前提示词更新时,保留此前消息。不支持工具更新的路由仍在工具变化时归并提示词;所有路由仍在显式新序列和 surface 替换时归并。不增加持久字段或 Session 格式版本。 +工具列表和逻辑更新事件不依赖能力;只有发出的声明和消息不同。调用已移除但保留声明的工具时,通过现有 `UNKNOWN_TOOL` 路径失败。定义变化和不完整辅助前缀为保证声明一致而放弃前缀复用;仅当请求序列继续时,未变化的恢复才在 `in-history` 路由上保留缓存前缀。同时支持工具更新和历史内系统提示词的路由,在工具添加与提示词变化同时发生或晚于此前提示词更新时,保留此前消息。不支持工具更新的路由仍在工具变化时开启新序列;显式新序列和 surface 替换只在有效文本不同时归并提示词,否则保留存活的提示词节点。不增加持久字段或 Session 格式版本。 手写的[无界面提示词/工具场景](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml)钉住追加提示词与延迟工具添加同时发生的行为,并验证首次请求的消息保持不变。它复用稳定提示词场景的工具生命周期和 schema 期望值。 diff --git a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml index 46925831d8..8b028995ae 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml @@ -9,11 +9,11 @@ en: abd0263d14861926 zh: e1736cd08e87f795 /agent-note-shared-time-vocabulary-for-wall-clock-selectors/decision: - en: 52b45f1ac62e65ab - zh: 7f83cd4fb8dd09a6 + en: fdd39b7e8fa89d70 + zh: 4a0dfc81509e5485 /agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered: en: 1737484c099fab52 zh: 303b144c52d167bd /agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences: - en: 6ec74e7ac065bd5e - zh: 04d029059dea732b + en: 79d8d66071453d1f + zh: f940f0b6d74148d6 diff --git a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md index 607dc68b7a..2cc2c8be43 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md +++ b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md @@ -20,6 +20,8 @@ Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `d `Duration` holds one span as a single millisecond count, and it is a class rather than a branded number because TypeScript's arithmetic erases a brand: adding two differently branded numbers answers an unbranded `number`, so a brand cannot stop seconds and milliseconds from being mixed, while an instance can. The unit is milliseconds because every producer and consumer in this repository already uses it (`Date.now()`, `SessionEvent.time`, the session log, the client), and because a double holds every millisecond of the whole `Date` range exactly, so no precision is spent on the unit. Instances are frozen, no coercion hook is overridden, and a span is bounded by the exactly representable millisecond range, which is what makes the split exact. The published surface is `fromMilliseconds` and a whole-part `decompose()`, the two entries the migrated consumer calls. +The last copy of the canonical instant profile moves too. `packages/session/session-format-v0-to-v1/src/payload-validation.ts` validated a stored schedule instant with its own regular expression and round trip; the shared `fourDigitYearEpoch` owns that same profile, so the migration now asks it and keeps its own error type and message. Because that migration reads released logs, its accepted set is a compatibility promise: `packages/session/session-format-v0-to-v1/tests/validation.spec.ts` accepts the four-digit-year bounds and refuses each spelling a regular expression alone would admit (`0000`, `2026-02-30`, a four-digit fraction, a numeric offset, surrounding whitespace). + The maintained `@js-temporal/polyfill` 0.5.1 becomes a runtime dependency of `dsh-util-time` as well as of `dsh-schedule`. Local resolution continues to use it, so the Host's IANA data still decides gaps and overlaps and no caller owns offset sampling. `util/time`'s own description and README drop the `zero-dependency` phrase as ambiguous: the group's charter forbids harness dependencies so any group may depend on util, and does not ban external packages, which is why `packages/README.md` and the `util/` line in `AGENTS.md` keep the phrase while `packages/util/README.md` and this package's own description name what it loads. Observable behavior does not change. Every accepted input resolves to the same epoch, every refused input raises the same `ScheduleInputError` or `ScheduleLogError` code and the same message, and the stored `scheduledAt` profile is unchanged, so no durable record and no recorded snapshot is re-recorded. Schedule's suites assert error codes and a few unrelated message substrings, so the assertion this change adds to `packages/schedule/schedule/tests/domain.spec.ts` is the only place that pins the three local-calendar message strings. `packages/util/time/tests/instant.spec.ts` covers the new entries directly, per-file 100% included. @@ -48,4 +50,4 @@ A value type publishes what its consumers call, so the span exposes the construc The elapsed reading keeps its split in the library and its wording in the consumer: `dsh-time-context` still decides that a backward clock reading clamps to zero and how the compact line reads, while `Duration.decompose()` answers the whole parts. That swap is byte-identical for every finite sample, measured over more than eight thousand inputs including negatives, fractions, and values past a year. That measurement is not a committed test: the consumer's own suite pins the readings it renders (`1d 1h 1m 1s`, `1m 1s`, `4m 2s`, and the clamped zero). Two samples do change: `NaN` rendered `NaNs` and `+Infinity` rendered `Infinityd NaNs`, and both now throw from the span constructor, so a reading that is not a finite number fails where it is produced instead of rendering text. A `-Infinity` sample is unchanged, because the consumer clamps it to zero before the span is built. -Time-context keeps the two policies this change did not touch: its ISO-shaped timestamp rendering in `packages/context/time-context/src/timestamp.ts`, which is that package's durable reading format, and the elapsed-duration wording in its own plugin. The browser client keeps its own localized date rendering and its own UTC-offset reading, which serve a different purpose (a reader's locale) from a boundary value; unifying those means deciding whether the shared library should also own display formatting, and which package then owns the durable reading format. `packages/session/session-format-v0-to-v1/src/payload-validation.ts` also validates the canonical four-digit-year UTC instant with its own regular expression and round trip, which is a remaining copy of the instant profile this change did not touch. +Time-context keeps the two policies this change did not touch: its ISO-shaped timestamp rendering in `packages/context/time-context/src/timestamp.ts`, which is that package's durable reading format, and the elapsed-duration wording in its own plugin. The browser client keeps its own localized date rendering and its own offset reading for display, which serve a different purpose (a reader's locale) from a boundary value; unifying those means deciding whether the shared library should also own display formatting, and which package then owns the durable reading format. Five time sites keep their own code, each for a different reason. `packages/experimental/tool-session-query/src/input.ts` parses a wider profile than the shared entry — seconds optional, any number of fractional digits, and the digits below the millisecond kept so two instants can be compared exactly — so substituting the shared parser would narrow what it accepts, and moving that profile into the library is a design step of its own. The `Date.parse(record.scheduledAt)` reads in `dsh-schedule` parse a value the write path already validated, so a discriminated decode would add a rejection branch that cannot occur in practice and change what a corrupt record reports. The `everySeconds`, `afterSeconds`, and `refreshMarginSeconds` multiplications each sit beside a validation that raises the package's own error, so routing them through a constructor that throws its own type would change the failure a caller sees. The UTC offset a client reports is computed in `packages/client/ui-settings-account/src/client/client-metadata.ts` instead of shared: the client bundle admits a cross-plugin value import only for the packages it lists as inline-safe, `dsh-util-time` is not one of them, and it imports the `Temporal` polyfill at module scope, so inlining it would ship that polyfill to the browser. The Desktop host computes the same offset from the shared library, where no such rule applies. `dsh-workspace` writes its record timestamps with the platform's `toISOString`, which is the canonical rendering for every representable epoch: the extended-year and fractional-truncation cases a shared writer would refuse are unreachable from `Date.now()` and from a `SessionHeader` epoch, so adopting the shared formatter there would add a module and a dependency without changing a byte. diff --git a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md index 838f6b9d4b..72d4e6b12b 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md @@ -20,6 +20,8 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec `Duration` 以单一毫秒计数保存一段时间;它做成类而不是 branded number,是因为 TypeScript 的算术会抹掉 brand:两个不同品牌的数字相加得到的是无品牌的 `number`,所以 brand 挡不住秒与毫秒的混用,而实例可以。单位选毫秒,是因为本仓库的每个生产者与消费者本来就用它(`Date.now()`、`SessionEvent.time`、Session 日志、客户端),也因为 double 能精确表示 `Date` 全范围内的每一个毫秒,这个单位不消耗任何精度。实例被冻结,不覆盖任何强制转换钩子,且时间段受可精确表示的毫秒范围约束——这正是拆分精确的前提。公开面就是 `fromMilliseconds` 与整数量拆解 `decompose()`,即已迁移消费方所调用的两个入口。 +最后一份规范时刻剖面副本也一并迁移。`packages/session/session-format-v0-to-v1/src/payload-validation.ts` 原本用自己的一段正则与往返比对校验持久记录里的调度时刻;共享的 `fourDigitYearEpoch` 拥有同一剖面,因此该迁移改为询问它,并保留自己的错误类型与文案。由于这个迁移要读取已发布的日志,其接受集是兼容性承诺:`packages/session/session-format-v0-to-v1/tests/validation.spec.ts` 接受四位年份两端,并拒绝只靠正则会放进来的每种拼写(`0000`、`2026-02-30`、四位小数、数字偏移、首尾空白)。 + 受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 自身的描述与 README 去掉了 `zero-dependency` 这一说法,因为它容易被误读:该组的约定禁止的是 harness 依赖(以便任何组都能依赖 util),并不禁止外部包;因此 `packages/README.md` 与 `AGENTS.md` 里的 `util/` 一行保留该说法,而 `packages/util/README.md` 与本包自己的描述写明它加载了什么。 可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 或 `ScheduleLogError` 错误码与相同的消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。Schedule 的测试断言错误码以及少量无关的消息子串,因此本次在 `packages/schedule/schedule/tests/domain.spec.ts` 新增的断言是唯一固定这三条本地日历消息的地方。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。 @@ -48,4 +50,4 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec 耗时读数的拆分上收到库、措辞留在一侧:`dsh-time-context` 仍然决定回退的时钟读数钳到零、以及那行紧凑文本怎么写,而 `Duration.decompose()` 回答整数量。这次替换对每个有限样本都逐字不变,用一份覆盖八千多个输入(含负数、小数与一年以上的值)的差分测得。该测量不是提交进仓库的测试:消费方自己那套测试固定了它渲染出的读数(`1d 1h 1m 1s`、`1m 1s`、`4m 2s`,以及被钳到零的那次)。有两个样本会变:`NaN` 原本渲染出 `NaNs`、`+Infinity` 原本渲染出 `Infinityd NaNs`,现在两者都由时间段构造函数抛出,于是非有限的读数在产生它的地方失败,而不是渲染出一段文本。`-Infinity` 样本不变,因为消费方在构造时间段之前已把它钳到零。 -本次未触及、仍归 time-context 的两项:`packages/context/time-context/src/timestamp.ts` 中的 ISO 形状时间戳渲染(那是该包的持久读数字格式),以及其插件内的耗时措辞。浏览器客户端也保留自己的本地化日期渲染与自己的 UTC 偏移读取——它们服务的目的(读者的区域设置)与边界值不同;统一它们意味着先决定共享库是否也要拥有展示格式化,以及届时由谁拥有持久读数字格式。`packages/session/session-format-v0-to-v1/src/payload-validation.ts` 也用自己的一段正则与往返比对校验规范的四位年份 UTC 时刻,这是本次未触及、仍然存在的时刻剖面副本。 +本次未触及、仍归 time-context 的两项:`packages/context/time-context/src/timestamp.ts` 中的 ISO 形状时间戳渲染(那是该包的持久读数字格式),以及其插件内的耗时措辞。浏览器客户端也保留自己的本地化日期渲染与用于展示的偏移读取——它们服务的目的(读者的区域设置)与边界值不同;统一它们意味着先决定共享库是否也要拥有展示格式化,以及届时由谁拥有持久读数字格式。有五处时间代码各自保留实现,原因各不相同。`packages/experimental/tool-session-query/src/input.ts` 解析的剖面比共享入口更宽——秒可省略、小数位不限、并保留下毫秒数字以便精确比较两个时刻——因此换成共享解析器会收窄它接受的输入,而把那条剖面搬进本库是另一项独立的设计。`dsh-schedule` 里的 `Date.parse(record.scheduledAt)` 读的是写入路径已经校验过的值,换成带判别值的解码会在每个读取点新增一个实际不可能发生的拒绝分支,并改变损坏记录的报告方式。`everySeconds`、`afterSeconds` 与 `refreshMarginSeconds` 三处乘法旁边都紧跟一条抛本包自有错误的校验,因此把它们改走会抛自身错误类型的构造函数,会改变调用方看到的失败。客户端上报的 UTC 偏移由 `packages/client/ui-settings-account/src/client/client-metadata.ts` 自行计算而不共享:客户端 bundle 只允许对列入 inline-safe 的包做跨插件值导入,`dsh-util-time` 不在其中,而且它在模块顶层静态导入 `Temporal` polyfill,内联它等于把该 polyfill 打进浏览器;Desktop 的宿主侧则用共享库计算同一个偏移,那里没有这条限制。`dsh-workspace` 用平台的 `toISOString` 写记录时间戳,对每个可表示的纪元那已是规范渲染:共享写入器会拒绝的扩展年份与小数截断两种情形,从 `Date.now()` 与 `SessionHeader` 的纪元出发都不可达,因此在那边采用共享格式化器只会增加一个模块与一条依赖,却不会改变任何字节。 diff --git a/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.i18n.yaml index 2f89f011b6..1198e6d21b 100644 --- a/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.i18n.yaml @@ -9,11 +9,11 @@ en: b32a7ce6483b9b48 zh: ec0938ad032b99d8 /agent-note-application-consent-for-native-computer-use/decision: - en: 36c560c24c376f12 - zh: 5e4ff72f66574e38 + en: 28ec4cc587ba487b + zh: 8b89bbdff8eff6eb /agent-note-application-consent-for-native-computer-use/alternatives-considered: - en: 80cd5fd93a42d937 - zh: 2a0e241b1b8c2e1d + en: 9f41ecc5e34efe17 + zh: b30ba6dae25b20b2 /agent-note-application-consent-for-native-computer-use/consequences: en: 4bda3366af65250a zh: 7c83561b4db07284 diff --git a/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.md b/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.md index 389a994714..806c1ef7db 100644 --- a/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.md +++ b/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.md @@ -10,7 +10,7 @@ macOS privileges authorize an execution host, not a user's choice of target appl ## Decision -The CUA executor owns application consent for every object-API and native-tool dispatch. It uses the existing User Questions service for human decisions and the provider's existing profile configuration for saved grants. The existing question UI presents the offered labels; the executor rejects custom text or answers that do not select exactly one offered label. Permission-page icons and names are presentation metadata, excluded from saved identities and model results. General tool approval and operating-system privileges remain separate requirements. Arbitrary persistent PTC code and separately authorized shell tools retain their own execution authority. +The CUA executor owns application consent for every object-API and native-tool dispatch. It uses the existing User Questions service for human decisions and profile-scoped domain storage for saved grants. Its fixed approval presentation reuses the tool-approval card without adopting the session's tool-approval policy. Native icons and names are presentation metadata, excluded from saved identities and model results. General tool approval and operating-system privileges remain separate requirements. Arbitrary persistent PTC code and separately authorized shell tools retain their own execution authority. System-permission buttons call the native helper on the DSH host and remain available to remote browsers. The [usage documentation](../../../../packages/experimental/cua/README.md#use-this-package) explains where to complete authorization; the page has no persistent remote-access notice. @@ -18,9 +18,7 @@ A saved identity combines the canonical executable path with a verified designat Cold application selection prepares an executable identity and handle without launching. The same consent decision guards the private launch continuation, and the helper checks the executable again before launch and binds the resulting process afterward. Signed selections retain the normal designated-requirement identity; an unverifiable executable uses a content identity for this pending selection and cannot create a saved grant. Preparing metadata before asking avoids starting an application the user refuses. -Session decisions are kept in memory by Session ID and application identity. Agent disposal and helper replacement release native handles without discarding these decisions; provider disposal or DSH restart clears them. This scope covers follow-up turns without relying on the model to decide when a user intent has ended. - -Selections of the same application within one session are serialized, so a pending question does not produce competing decisions. Saved-grant additions and revocations share one mutation queue and read the current grant list inside it. Provider shutdown cancels writes still waiting for HMR admission; admitted writes settle before disposal completes. Refusals prevent another prompt until the user permits asking again; a restored saved grant takes precedence in both dispatch and the permission-page projection. +Conversation decisions survive provider disposal and DSH restart according to [profile consent storage](2026-10-19-cua-profile-consent-storage.md). That decision owns persistence, migration and cross-process revocation. The executor continues to serialize application selections within a conversation, refuse repeated prompts after denial, and give an explicit long-term grant precedence over conversation refusals. Revocation invalidates the Agent connections that retain the application's handles, then awaits native cleanup. Reauthorization uses a fresh connection, so old app and window snapshots cannot become valid again. The [native object decision](2026-09-17-experimental-cua-object-api.md) continues to own process identity, coordinate semantics, and separation from the evaluator. @@ -30,7 +28,7 @@ Revocation invalidates the Agent connections that retain the application's handl **Persist application names or unsigned paths.** A replacement can reuse both. Durable grants require a verifiable signing identity; session access supports unsigned development applications without that promise. -**Add an approval database or Session control stream.** Profile settings already own user configuration, and User Questions already owns interactive answers. The permission UI observes the provider and its owning settings namespace without another authorization store or event channel. +**Add an approval database or Session control stream.** User Questions already owns interactive answers. The existing domain service provides durable state without introducing a dedicated database or Session control stream; storage tradeoffs are owned by the profile consent decision. **Disable permission buttons based on the browser address.** A user may operate the DSH host through screen sharing and complete authorization there. Port forwarding and proxies make the address an unreliable indication of whether the browser and DSH run on the same computer. diff --git a/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.zh.md b/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.zh.md index e7311dfc6d..ddecafb871 100644 --- a/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.zh.md @@ -10,7 +10,7 @@ macOS 权限授权执行宿主,不能表达用户允许操作哪些目标应 ## 决策 -CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。人工决定复用 User Questions 服务,长期许可保存在 provider 的现有 profile 配置中。通用工具审批和操作系统权限仍是独立要求。任意 persistent PTC 代码和另行授权的 shell 工具保留各自的执行权限。现有问答界面展示授权选项;执行器拒绝自由文本或未准确选中一个所给选项的回答。权限页图标和名称仅用于展示,不进入保存的身份信息或模型结果。 +CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。人工决定复用 User Questions 服务,长期许可保存在按 profile 隔离的存储域中。通用工具审批和操作系统权限仍是独立要求。任意 persistent PTC 代码和另行授权的 shell 工具保留各自的执行权限。 固定审批呈现复用工具审批卡片,同时保留独立于会话工具审批策略的授权规则。本机图标和名称仅用于展示,不进入保存的身份信息或模型结果。 系统权限按钮调用运行 DSH 的电脑上的原生 helper,远程访问时仍可使用。授权地点写在[使用说明](../../../../packages/experimental/cua/README.zh.md#use-this-package)中,页面不常驻远程访问提示。 @@ -18,9 +18,7 @@ CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。 选择尚未运行的应用时,先准备可执行文件身份和句柄,不立即启动。同一授权决定保护内部启动续接,helper 在启动前再次核对可执行文件,并在启动后绑定实际进程。已签名应用沿用签名指定要求身份;签名不可验证的程序使用本次待启动选择的内容身份,不能生成长期许可。先准备元数据再询问,可以避免启动用户拒绝的应用。 -会话决定按 Session ID 和应用身份保存在内存中。Agent 销毁或 helper 重建会释放原生句柄,但保留授权决定;提供方卸载或 DSH 重启后清除。会话范围可以覆盖用户的后续指令,无须由模型判断一次用户意图何时结束。 - -同一会话对同一应用的选择串行执行,避免未完成的问题产生相互冲突的决定。长期许可的新增和撤销共用写入队列,并在队列内读取当前许可列表。提供方关闭时取消尚在等待 HMR 调度的写入,已开始的写入会在销毁完成前结束。拒绝会阻止再次询问,直到用户允许重询;重新添加的长期许可在执行检查和权限页中均优先于旧拒绝记录。 +会话决定按[profile 授权存储](2026-10-19-cua-profile-consent-storage.zh.md)的规则,在提供方卸载和 DSH 重启后保留。该记录负责持久化、迁移和跨进程撤销。执行器继续将同一对话对同一应用的选择串行化,拒绝后不再次询问,显式长期许可优先于会话拒绝。 撤销使保留目标应用句柄的 Agent 连接失效,并等待原生清理完成。重新授权使用新连接,因此旧应用对象和窗口快照不能重新生效。[原生对象决策](2026-09-17-experimental-cua-object-api.zh.md)继续负责进程身份、坐标语义和与执行器分离的设计。 @@ -30,7 +28,7 @@ CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。 **保存应用名称或未签名路径。** 替换程序可以复用二者。长期许可需要可验证的签名身份;会话授权支持未签名的开发应用,但不承诺跨进程复用。 -**新增审批数据库或 Session 控制流。** Profile settings 已负责用户配置,User Questions 已负责交互回答。权限界面查询 provider,并监听其配置 namespace,无须另一份授权存储或事件通道。 +**增加审批数据库或 Session 控制流。** User Questions 已经负责交互式回答。现有域服务提供持久状态,无须增加专用数据库或 Session 控制流;存储取舍由 profile 授权存储决策负责。 **根据浏览器访问地址禁用权限按钮。** 用户可能通过屏幕共享操作运行 DSH 的电脑,并在该电脑上完成授权。端口转发和代理使访问地址无法可靠判断浏览器与 DSH 是否运行在同一台电脑上。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml index 9378bc4f0b..4cf8989458 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml @@ -27,17 +27,17 @@ en: a01ac57fc71a84f0 zh: 3e488ef3da5e4b8c /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control: - en: a55516339b0d49dd - zh: 3281df9ea7878fc0 + en: 725a2d4145c0a75e + zh: b98a64532cadeaba /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/correspondence-with-rbac: en: a78a2410eb52f777 zh: a868bf1b0e690c9f /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/resources-and-resource-readers-deepseek-ai-dsh-typert-protocol-deepseek-ai-dsh-typert-registry: - en: 625b20552962e268 - zh: 1136c0fd7cdc6901 + en: d50ae22978165c57 + zh: 8c1fcfb7dc2d3367 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/endpoint-resources: - en: ec1677d8e338e115 - zh: 6bedd163f208eaa9 + en: 582f5f527ecc58c8 + zh: 4a4b68361de2dcf0 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/roles-bindings-and-policies-deepseek-ai-dsh-api-remote-access: en: e57f396239ea9f56 zh: ddb26278b1521ceb @@ -45,8 +45,8 @@ en: 3c911f57f0f6ca4b zh: 1509781eeda4c922 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/host-access-check-order: - en: 6c58dc8777e107ef - zh: bc14605c96e4d8f3 + en: 2d97a251cc4c9db0 + zh: df072d5a96a9e82f /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/forwarded-events: en: 5fe34ee5ec31942a zh: ef39b020b0539b8f @@ -75,14 +75,14 @@ en: 305ec45a7cd85ed7 zh: d2cd4209a56b1d0c /agent-note-peer-principals-and-method-level-remote-access-control/alternatives-considered: - en: 130df2981ba550df - zh: e9a0a89af6fb50a3 + en: 60da38bca6e9c3d6 + zh: 61ea578fe8185b61 /agent-note-peer-principals-and-method-level-remote-access-control/consequences: en: 89452c3c3f042e75 zh: 9bcad00a60c65303 /agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification: - en: 2279a8c153cc5b89 - zh: 63c0b6462722f0a3 + en: d7d0909974609247 + zh: 6ebf20714f8d545b /agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits: en: 1e254cec680b1a37 zh: 06a31e057ba965aa diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md index c9894ae409..7c13d393f3 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md @@ -131,7 +131,7 @@ The single read point is `this.ctx.invocation.peer.principal`; an owner test is ### Method-level access control -Resource types and the `resources` readers of `@Remote` live in `@deepseek-ai/dsh-typert-protocol`; resource kinds live in typert beside lookups and Contexts: `TypertResourceMap` declares the kinds, `typert.resources` holds each kind's default wire field, and lookups and Host Context adapters declare the resource kind their wire identity names. The Gateway resolves one call or forwarded event to the resources it touches. Decisions, list filtering, and the access snapshot live in the new package `@deepseek-ai/dsh-api-remote-access` (`packages/api/remote-access`), which reads no argument field. The Gateway and domain packages depend on remote-access only for types and inject `remoteAccess`; their only value dependency is `dsh-typert-protocol`, so `package-dependency-policy` gains no remote-access entry. Business packages define and register roles, bindings, and policies; liveshare is the first user. Apart from the built-in `global`, no resource-kind name appears in remote-access or the Gateway, which follows the [`packages/api` carries generic orchestration only](../../../../docs/defensive-patterns/remote-and-api.md) review rule. +Resource types and the `resources` readers of `@Remote` live in `@deepseek-ai/dsh-typert-protocol`; resource kinds live in typert beside lookups and Contexts: `TypertResourceMap` declares the kinds, `typert.resources` holds each kind's default wire field or resolver, and lookups and Host Context adapters declare the resource kind their wire identity names. The Gateway resolves one call or forwarded event to the resources it touches. Decisions, list filtering, and the access snapshot live in the new package `@deepseek-ai/dsh-api-remote-access` (`packages/api/remote-access`), which reads no argument field. The Gateway and domain packages depend on remote-access only for types and inject `remoteAccess`; their only value dependency is `dsh-typert-protocol`, so `package-dependency-policy` gains no remote-access entry. Business packages define and register roles, bindings, and policies; liveshare is the first user. Apart from the built-in `global`, no resource-kind name appears in remote-access or the Gateway, which follows the [`packages/api` carries generic orchestration only](../../../../docs/defensive-patterns/remote-and-api.md) review rule. #### Correspondence with RBAC @@ -165,7 +165,7 @@ export interface TypertResourceMap {} // @deepseek-ai/dsh-workspace/types: workspace: TypertResource // Runtime registries in typert -ctx.typert.resources.register(kind, { field }): TypertDisposer +ctx.typert.resources.register(kind, { field } | { resolve }): TypertDisposer ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter @@ -213,11 +213,11 @@ export function RemoteScope | Rule | Content | |---|---| -| Syntax | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`; a Context method writes `@RemoteScope('agent')` and gets its Session resource from the Agent Context's `resource: 'session'`, and writes `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })` when it needs another resource. Most methods write no `resources` and get their resources from automatic resolution | +| Syntax | `@Remote({ name: 'prompt', resources: { session: (request) => request.sessionId } })` when a method must name its resource by hand; `session/page` and `session/follow` instead rely on the registered `session` default resolver; a Context method writes `@RemoteScope('agent')` and gets its Session resource from the Agent Context's `resource: 'session'`, and writes `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })` when it needs another resource. Most methods write no `resources` and get their resources from automatic resolution | | Resource kinds | The lower-level packages that declare the identity types merge the resource kinds: `session: TypertResource` in `@deepseek-ai/dsh-session/types` and `workspace: TypertResource` in `@deepseek-ai/dsh-workspace/types`. session-controller references the commands package, so a merge in session-controller would make commands reference session-controller and form a project-reference cycle. remote-access builds in `global`, which has no id and stays out of `TypertResourceMap`; typert reserves the name, and `typert.resources.register('global', …)` throws | -| Default fields and declarations | The domain that owns a kind registers its default wire field when its controller is constructed: the `SessionController` constructor calls `typert.resources.register('session', { field: 'sessionId' })` and the `WorkspaceController` constructor registers `workspace` → `workspaceId`; registering a kind twice throws. The Agent, Session, and `workspaceFileScope` lookups declare `resource: 'session'`, and the agent package's Agent Context declares `resource: 'session'` | -| Resolution precedence | Each resource kind takes, in order: ① that kind's reader in the method's `resources`; ② every lookup parameter that declares that kind as its `resource` contributes its wire identity, and an omitted optional lookup parameter contributes none; when the Context of a Context method declares that kind as its `resource`, the Context's wire identity contributes too; ③ that kind's default field: a top-level named argument of that name when one exists, otherwise that field of the first object argument. A default field names one identity, so an array there is an invalid value | -| Argument values | Resource resolution and the `args` of `decide()` read the named arguments validated against their Typert-compiled types: each JSON argument is decoded once by its codec, which drops object fields its type does not declare, and dispatch reuses that value, so a caller cannot change the resources with an undeclared field; lookup parameters and the Context identity keep their wire identities, and no lookup or Agent is resolved at that point | +| Default extraction declarations | The owning domain registers either `{ field }` or `{ resolve }`, never both. SessionController registers `resolveSessionResource`; WorkspaceController registers `workspaceId`. Duplicate kinds are rejected. Lookup and Host Context resource declarations remain independent of these defaults | +| Resolution precedence | Per kind: explicit method reader, then lookup and Host Context identities, then the registered default field or resolver. A field reads the named top-level parameter or the first object argument. A resolver receives validated named `args` and ordered parameter descriptions once before lookup resolution. Defaults return one non-empty string or undefined; arrays are invalid | +| Argument values | Compiled codecs strip undeclared object fields; SRC fallback checks JSON safety without stripping them. Resolution and dispatch use the same validated values. The Session resolver rejects a request object containing both sessionId and address, even when equal. Lookup and Context identities remain wire values until after authorization | | Resolution result | The resources of every kind together are the resources the call touches; with none, the call touches `[global]`; when resources resolve, only those are used and `global` is not added | | Readers | Called with the method's positional arguments: the named arguments mapped by the descriptor back to the method's parameter order (the validated value in a JSON position, the wire identity in a lookup position, and the call's signal in the cancellation position); return one id, an id array (every one must be allowed), or `undefined` (no resource of that kind) | | Invalid values | A reader that throws or returns an empty array, or a read value (including a Context's wire identity) that is not a non-empty string, makes the resources invalid: the Gateway refuses the call with `remote-access/denied` (reason `resource-invalid`) without asking `decide()` | @@ -228,7 +228,7 @@ export function RemoteScope #### Endpoint resources -The table lists the endpoints #3854 viewers use and where their resources come from. Only `session/page` and `session/follow` need explicit `resources`; the rest rely on a lookup's `resource` or the default field: +The table lists the endpoints #3854 viewers use and where their resources come from. Session history endpoints use the registered Session resolver; special method readers can override defaults: | Endpoint | Resource | Resolution source | Filtering inside the method | |---|---|---|---| @@ -241,16 +241,18 @@ The table lists the endpoints #3854 viewers use and where their resources come f | `llm/listProviders` | `global` | no resource | no | | `commands/list` | session: `args.agentId` (the Agent lookup's wire field) | Agent lookup `resource: 'session'` | no | | `goals/get` | session: `args.agentId` | Agent lookup `resource: 'session'` | no | -| `session/attachment` | session: `args.request.sessionId` | default field | no | -| `skills/list` | session: `args.request.sessionId` | default field | no | -| `session/fork` | session: `args.request.sessionId` | default field | no | -| `session/prompt` | session: `args.request.sessionId` | default field | no | -| `session/cancel` | session: `args.request.sessionId` | default field | no | -| `session/projections` | session: `args.request.sessionId` | default field | no | -| `session/page` | session: `args.request.address.sessionId` for a Session address; `args.request.address.childSessionId` for a subagent address | `resources` | no | -| `session/follow` | same as above | `resources` | no | +| `session/attachment` | session: `args.request.sessionId` | default resolver | no: a grant on one Session permits reads from the whole attachment store | +| `skills/list` | session: `args.request.sessionId` | default resolver | no | +| `session/fork` | session: `args.request.sessionId` | default resolver | no | +| `session/prompt` | session: `args.request.sessionId` | default resolver | no | +| `session/cancel` | session: `args.request.sessionId` | default resolver | no | +| `session/projections` | session: `args.request.sessionId` | default resolver | no | +| `session/page` | session: `args.request.address.sessionId` for a Session address; `args.request.address.childSessionId` for a subagent address | default resolver | no | +| `session/follow` | same as above | default resolver | no | | `remoteAccess/follow` | `global` | no resource; remote-access allows it for every principal | no | +For `session/attachment`, `sessionId` identifies the resource for the Gateway check; it does not constrain which image may be read. Once that check permits the call, an image may belong to another Session or no Session, and the handler does not check whether the supplied Session exists. Policy authors must treat this action as access to the whole attachment store, as defined by the [image access rule](2026-10-23-store-scoped-image-reads.md). + A subagent address reads the child transcript, so its resource is the child Session. A Client rereads the projections of known Sessions after a reconnect, including the parent of a subagent Session; a reader without a grant on that parent has its projection read refused. Whether a grant on a parent Session covers its subagent children is the policy's decision, that is, whether it gives a binding for the child Session; the generic layer derives no parent resource. Methods that need no non-owner access declare nothing, and a method no role lists is refused. The request fields of subagent `prompt` are `parentSessionId` / `childSessionId`, so automatic resolution finds no Session and resolves it to `global`; it must therefore stay out of every `global` role. `session/search` filters its hits per Session inside the method. `subagents/list` is not a Remote endpoint (`SubagentRuntime.list()` carries no `@Remote`). @@ -392,7 +394,7 @@ Every call path (HTTP `/api`, WebSocket streams, in-process `fetch(request, peer | 1 | Admission yields the Peer: network paths from Connection `admit()` (`browserPeer`), in-process paths from the caller. The Gateway checks `peer.signal` before it reads the wire payload | Failed network admission answers HTTP 401 / 403 without entering the Gateway; a disposed Peer: `gateway/peer-disposed` | | 2 | Resolve the endpoint and validate the argument field set (`resolveDescriptor`, `assertExactArguments`), then validate the JSON arguments against their Typert-compiled types, decoding each once | `gateway/invocation-unavailable`, `gateway/ambiguous-endpoint`, `gateway/definition-unavailable`; extra or missing fields: `gateway/arguments-invalid`; a JSON argument failing validation: `gateway/input-invalid` (for owners and non-owners alike, before the access check) | | 3 | Owner pass-through: for `principal.kind === 'owner'` go straight to step 5 | — | -| 4 | Non-owner: the Gateway resolves resources from the arguments validated in step 2 (`resources` readers, then lookup parameters and the `resource` a Context declares, then default fields; lookup and Context identities are wire identities, and neither a lookup nor the Context is resolved), then asks `remoteAccess.decide({ principal, action: endpoint, resources, args })` with the validated arguments as `args`; invalid resources are refused with `resource-invalid` without asking `decide()`; a `deny` decision becomes the thrown error | `remote-access/denied` with details `{ action, reason }` | +| 4 | Non-owner: the Gateway resolves resources from the arguments validated in step 2 (`resources` readers, then lookup parameters and the `resource` a Context declares, then the registered default field or resolver; lookup and Context identities are wire identities, and neither a lookup nor the Context is resolved), then asks `remoteAccess.decide({ principal, action: endpoint, resources, args })` with the validated arguments as `args`; invalid resources are refused with `resource-invalid` without asking `decide()`; a `deny` decision becomes the thrown error | `remote-access/denied` with details `{ action, reason }` | | 5 | Dispatch: `resolveReceiverContext` (which can activate an Agent), lookup resolution (JSON arguments reuse the step 2 values), and the method call | Existing codes unchanged, such as `gateway/lookup-not-found`, `gateway/context-not-found`, `gateway/input-invalid`, `gateway/service-unavailable`, `gateway/method-unavailable`, and business errors | The access check (steps 3–4) precedes lookup and Context resolution, so a refused call activates no Session and causes no other side effect. The only new Gateway error code is `gateway/peer-disposed`; remote-access declares the refusal code `remote-access/denied`. @@ -600,7 +602,7 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R **Refusal reasons from conditions only.** Without a binding there is no condition to give a reason, so a viewer page could not tell "share expired" from "share not writable"; `explain` gives a reason only after the decision is already a refusal and never changes it. -**A global resource-resolver registry (domain packages register recognizers by field name).** Field recognition separates from the method, nothing flags a method signature change, and same-named fields in other packages are misread; resource resolution uses a default convention on the method plus per-method `resources`, and a domain package registers only its own kind's default field. +**A global resource-resolver registry (domain packages register recognizers by field name).** Field recognition separates from the method, nothing flags a method signature change, and same-named fields in other packages are misread; resource resolution uses a default convention on the method plus per-method `resources`, and a domain package registers only its own kind's default field or resolver. **Dot-path resource declarations (`'request.address.sessionId'`).** Types cannot constrain a path; the parameter types of `resources` readers are inferred from the decorated method, so a typo fails compilation. @@ -635,8 +637,8 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R | Check order | The owner calls any method normally without reading a policy; a method no role lists refuses non-owners with `remote-access/denied` (`no-allow`); the access check precedes lookups, so a refused call activates no Session | Gateway host spec | | Policy registration | One policy per non-owner principal kind, and a second registration throws; once the disposer withdraws it, that kind is refused with `no-allow`; it is withdrawn with its fiber | remote-access host spec | | Decisions | Every resource needs a matching binding (AND); a role without the action, an unequal resource, or a refusing condition does not allow; the refusal reason follows condition > `explain` > `no-allow`; `explain` never changes the decision; conditions receive args for calls but not for snapshots and list filtering; a throwing `bindings()`, condition, or `explain()` refuses with `policy-failed`, each policy is logged once between `invalidate()` calls, and on the event path the owner stream and the shared event source continue; `remoteAccess/follow` is allowed for every principal | remote-access host spec, Gateway host spec | -| Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) | -| Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec | +| Resource resolution | A default field or resolver yields one resource; a default resolver returning an array is invalid; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, with undeclared fields stripped only by compiled codecs, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) | +| Resource registry | `typert.resources` registers one default field or resolver per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec | | Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) | | First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the snapshot of the Peer each index speaks for; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs | | Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) | diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md index ed124e8159..9503b430aa 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md @@ -131,7 +131,7 @@ export function principalOf(invocation: RemoteInvocation | undefined): Principal ### 方法级访问控制 -资源类型与 `@Remote` 的 `resources` 读取函数在 `@deepseek-ai/dsh-typert-protocol`;资源种类在 typert 中与 lookups、Contexts 并列:`TypertResourceMap` 声明种类,`typert.resources` 保存每个种类的默认 wire 字段,lookup 与 Host Context adapter 声明其 wire 身份所属的资源种类。Gateway 把一次调用或一个转发事件解析为它访问的资源。判定、列表过滤与访问快照在新包 `@deepseek-ai/dsh-api-remote-access`(`packages/api/remote-access`),该包不读取任何参数字段。Gateway 与领域包对 remote-access 只做 type-only 依赖并 inject `remoteAccess`,值依赖只有 `dsh-typert-protocol`,不为 remote-access 新增 `package-dependency-policy` 条目。role、binding 与 policy 由业务包定义和注册,liveshare 为首个使用方。除内置的 `global` 外,remote-access 与 Gateway 中不出现任何资源种类名,符合 [`packages/api` 只承载通用编排](../../../../docs/defensive-patterns/remote-and-api.zh.md) 的评审规则。 +资源类型与 `@Remote` 的 `resources` 读取函数在 `@deepseek-ai/dsh-typert-protocol`;资源种类在 typert 中与 lookups、Contexts 并列:`TypertResourceMap` 声明种类,`typert.resources` 保存每个种类的默认 wire 字段或 resolver,lookup 与 Host Context adapter 声明其 wire 身份所属的资源种类。Gateway 把一次调用或一个转发事件解析为它访问的资源。判定、列表过滤与访问快照在新包 `@deepseek-ai/dsh-api-remote-access`(`packages/api/remote-access`),该包不读取任何参数字段。Gateway 与领域包对 remote-access 只做 type-only 依赖并 inject `remoteAccess`,值依赖只有 `dsh-typert-protocol`,不为 remote-access 新增 `package-dependency-policy` 条目。role、binding 与 policy 由业务包定义和注册,liveshare 为首个使用方。除内置的 `global` 外,remote-access 与 Gateway 中不出现任何资源种类名,符合 [`packages/api` 只承载通用编排](../../../../docs/defensive-patterns/remote-and-api.zh.md) 的评审规则。 #### 与 RBAC 的对应 @@ -165,7 +165,7 @@ export interface TypertResourceMap {} // @deepseek-ai/dsh-workspace/types: workspace: TypertResource // Runtime registries in typert -ctx.typert.resources.register(kind, { field }): TypertDisposer +ctx.typert.resources.register(kind, { field } | { resolve }): TypertDisposer ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter @@ -213,11 +213,11 @@ export function RemoteScope | 规则 | 内容 | |---|---| -| 写法 | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`;Context 方法写 `@RemoteScope('agent')`,由 Agent Context 的 `resource: 'session'` 得到会话资源,需要其他资源时写 `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })`。多数方法不写 `resources`,由自动解析得到资源 | +| 写法 | 方法必须手工点名资源时写 `@Remote({ name: 'prompt', resources: { session: (request) => request.sessionId } })`;`session/page` 与 `session/follow` 则依赖已注册的 `session` 默认 resolver;Context 方法写 `@RemoteScope('agent')`,由 Agent Context 的 `resource: 'session'` 得到会话资源,需要其他资源时写 `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })`。多数方法不写 `resources`,由自动解析得到资源 | | 资源种类 | 资源种类由声明 id 类型的底层包合并:`session: TypertResource` 在 `@deepseek-ai/dsh-session/types`,`workspace: TypertResource` 在 `@deepseek-ai/dsh-workspace/types`。session-controller 引用 commands 包,若在 session-controller 合并,commands 要引用 session-controller,形成 project reference 环。`global` 由 remote-access 内置,没有 id,不进 `TypertResourceMap`;typert 保留该名,`typert.resources.register('global', …)` 抛错 | -| 默认字段与声明 | 拥有该种类的领域在控制器构造时登记默认 wire 字段:`SessionController` 构造函数调用 `typert.resources.register('session', { field: 'sessionId' })`,`WorkspaceController` 构造函数登记 `workspace` → `workspaceId`;同一种类重复登记即抛错。Agent、Session 与 `workspaceFileScope` lookup 声明 `resource: 'session'`,agent 包的 Agent Context 声明 `resource: 'session'` | -| 解析优先级 | 对每个资源种类依次取:① 该方法 `resources` 中该种类的读取函数;② 声明了该种类 `resource` 的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Context 方法的 Context 声明了该种类 `resource` 时,其 wire 身份同样贡献;③ 该种类的默认字段:命名参数中有同名顶层参数就读它,否则读第一个对象参数的同名字段。默认字段只表示一个身份,读到数组即为非法值 | -| 参数来源 | 资源解析与 `decide()` 的 `args` 读取经 Typert 编译类型校验后的命名参数:每个 JSON 参数由其 codec 解码一次,类型未声明的对象字段被移除,分派复用该值,因此调用方无法用未声明字段改变资源;lookup 参数与 Context 身份取 wire 身份,此时不解析 lookup、不激活 Agent | +| 默认提取声明 | 资源所属领域登记 `{ field }` 或 `{ resolve }`,两者互斥。SessionController 登记 `resolveSessionResource`;WorkspaceController 登记 `workspaceId`。重复种类被拒绝;lookup 和 Host Context 的资源声明独立于默认规则 | +| 解析优先级 | 每个种类依次采用方法显式 reader、lookup 与 Host Context 身份、注册的默认字段或 resolver。字段读取同名顶层参数或首个对象参数。resolver 在 lookup 解析前调用一次,接收已校验的命名 `args` 及有序参数描述。默认规则返回一个非空字符串或 undefined,数组非法 | +| 参数来源 | 编译 codec 移除未声明的对象字段;SRC fallback 只检查 JSON 安全性,不移除字段。解析和分派使用相同的已校验值。Session resolver 拒绝同时包含 sessionId 与 address 的请求对象,即使两者相同。lookup 和 Context 身份在授权完成前保持 wire 值 | | 解析结果 | 各种类的资源合在一起即本次调用访问的资源;一个都没有即 `[global]`;解析出资源时只用这些资源,不附加 `global` | | 读取函数 | 以方法的位置参数调用:按描述符把命名参数映射回方法参数顺序(JSON 位为校验后的值,lookup 位为 wire 身份,取消参数位为调用的 signal);返回一个 id、id 数组(每个都须被允许)或 `undefined`(该种类无资源) | | 非法值 | 读取函数抛错或返回空数组,或读到的值(含 Context 的 wire 身份)不是非空字符串即资源非法:Gateway 不询问 `decide()`,直接以 `remote-access/denied`(reason `resource-invalid`)拒绝调用 | @@ -228,7 +228,7 @@ export function RemoteScope #### 端点的资源 -下表列出 #3854 读者用到的端点及其资源来源。只有 `session/page` 与 `session/follow` 需要显式 `resources`,其余靠 lookup 的 `resource` 或默认字段: +下表列出 #3854 读者用到的端点及其资源来源。Session 历史端点使用注册的 Session resolver;特殊方法可用显式 reader 覆盖默认规则: | 端点 | 资源 | 解析来源 | 方法内过滤 | |---|---|---|---| @@ -241,16 +241,18 @@ export function RemoteScope | `llm/listProviders` | `global` | 无资源 | 否 | | `commands/list` | session:`args.agentId`(Agent lookup 的 wire 字段) | Agent lookup `resource: 'session'` | 否 | | `goals/get` | session:`args.agentId` | Agent lookup `resource: 'session'` | 否 | -| `session/attachment` | session:`args.request.sessionId` | 默认字段 | 否 | -| `skills/list` | session:`args.request.sessionId` | 默认字段 | 否 | -| `session/fork` | session:`args.request.sessionId` | 默认字段 | 否 | -| `session/prompt` | session:`args.request.sessionId` | 默认字段 | 否 | -| `session/cancel` | session:`args.request.sessionId` | 默认字段 | 否 | -| `session/projections` | session:`args.request.sessionId` | 默认字段 | 否 | -| `session/page` | session:Session 地址读 `args.request.address.sessionId`;subagent 地址读 `args.request.address.childSessionId` | `resources` | 否 | -| `session/follow` | 同上 | `resources` | 否 | +| `session/attachment` | session:`args.request.sessionId` | 默认 resolver | 否:一条 Session 上的授权允许读取整个附件存储 | +| `skills/list` | session:`args.request.sessionId` | 默认 resolver | 否 | +| `session/fork` | session:`args.request.sessionId` | 默认 resolver | 否 | +| `session/prompt` | session:`args.request.sessionId` | 默认 resolver | 否 | +| `session/cancel` | session:`args.request.sessionId` | 默认 resolver | 否 | +| `session/projections` | session:`args.request.sessionId` | 默认 resolver | 否 | +| `session/page` | session:Session 地址读 `args.request.address.sessionId`;subagent 地址读 `args.request.address.childSessionId` | 默认 resolver | 否 | +| `session/follow` | 同上 | 默认 resolver | 否 | | `remoteAccess/follow` | `global` | 无资源;remote-access 对任何 principal 放行 | 否 | +对于 `session/attachment`,`sessionId` 指定 Gateway 检查的资源,不限制可读取哪张图片。该检查放行后,图片可以属于其他 Session 或不属于任何 Session,处理函数也不检查请求中的 Session 是否存在。policy 作者必须把此 action 视为整个附件存储的读取权限,具体规则见[图片访问规则](2026-10-23-store-scoped-image-reads.zh.md)。 + subagent 地址读取的是子会话转录,因此其资源是子会话。Client 在重连后会重新读取已知会话的 projections,其中包括 subagent 会话的父会话;没有该父会话授权的读者,其 projection 读取被拒。父会话的授权是否覆盖其 subagent 子会话由 policy 决定,即是否为子会话给出 binding;通用层不推导父资源。 不需要非 owner 访问的方法不做任何声明,没有 role 列出它即被拒。subagent `prompt` 的请求字段为 `parentSessionId` / `childSessionId`,自动解析不到会话,解析为 `global`,因此不得放进任何 `global` 级 role;`session/search` 在方法内按会话逐项过滤命中。`subagents/list` 不是 Remote 端点(`SubagentRuntime.list()` 未标 `@Remote`)。 @@ -392,7 +394,7 @@ const childPrompt = child => ({ action, args }) => | 1 | 准入得到 Peer:网络路径由 Connection `admit()` 给出(`browserPeer`);进程内路径由调用方传入。Gateway 在读取 wire payload 之前检查 `peer.signal` | 网络准入失败返回 HTTP 401 / 403(不进入 Gateway);Peer 已 dispose:`gateway/peer-disposed` | | 2 | 解析端点并做参数字段集校验(`resolveDescriptor`、`assertExactArguments`),再按 Typert 编译类型校验 JSON 参数,每个参数解码一次 | `gateway/invocation-unavailable`、`gateway/ambiguous-endpoint`、`gateway/definition-unavailable`;字段多余或缺失:`gateway/arguments-invalid`;JSON 参数校验失败:`gateway/input-invalid`(owner 与非 owner 相同,先于访问检查) | | 3 | owner 直通:`principal.kind === 'owner'` 时直接进入第 5 步 | — | -| 4 | 非 owner:Gateway 从第 2 步校验后的参数解析资源(`resources` 读取函数、其次 lookup 参数与 Context 声明的 `resource`、再次默认字段;lookup 与 Context 身份取 wire 身份,不解析 lookup 与 Context),再以校验后的参数为 `args` 询问 `remoteAccess.decide({ principal, action: endpoint, resources, args })`;资源非法时不询问 `decide()`,直接以 `resource-invalid` 拒绝;`deny` 结论变为抛出的错误 | `remote-access/denied`,details `{ action, reason }` | +| 4 | 非 owner:Gateway 从第 2 步校验后的参数解析资源(`resources` 读取函数、其次 lookup 参数与 Context 声明的 `resource`、再次注册的默认字段或 resolver;lookup 与 Context 身份取 wire 身份,不解析 lookup 与 Context),再以校验后的参数为 `args` 询问 `remoteAccess.decide({ principal, action: endpoint, resources, args })`;资源非法时不询问 `decide()`,直接以 `resource-invalid` 拒绝;`deny` 结论变为抛出的错误 | `remote-access/denied`,details `{ action, reason }` | | 5 | 分派:`resolveReceiverContext`(可能激活 Agent)、lookup 解析(JSON 参数复用第 2 步的值)、调用方法 | 既有错误码不变,如 `gateway/lookup-not-found`、`gateway/context-not-found`、`gateway/input-invalid`、`gateway/service-unavailable`、`gateway/method-unavailable` 与业务错误 | 鉴权(第 3–4 步)位于 lookup 与 Context 解析之前,未授权调用不会激活会话或产生其他副作用。Gateway 新增的错误码只有 `gateway/peer-disposed`;拒绝码 `remote-access/denied` 由 remote-access 声明。 @@ -600,7 +602,7 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot **拒绝原因只由 condition 给出。** 没有 binding 时没有 condition 可以给出原因,读者页面无法区分「分享已过期」「分享不允许写入」等情况;`explain` 只在结论已为拒绝后给出原因,不改变结论。 -**全局资源 resolver 注册表(领域包按字段名注册识别函数)。** 字段识别与方法分离,方法改签名时无人提醒,跨包同名字段也会被误认;资源解析采用方法上的默认约定加 per-method `resources`,领域包只登记自己种类的默认字段。 +**全局资源 resolver 注册表(领域包按字段名注册识别函数)。** 字段识别与方法分离,方法改签名时无人提醒,跨包同名字段也会被误认;资源解析采用方法上的默认约定加 per-method `resources`,领域包只登记自己种类的默认字段或 resolver。 **按点路径声明资源(`'request.address.sessionId'`)。** 路径无法由类型推导约束;`resources` 读取函数的参数类型从被装饰方法推断,拼错即编译失败。 @@ -635,8 +637,8 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot | 鉴权顺序 | owner 调用任何方法照常分派且不读取 policy;没有 role 列出的方法对非 owner 以 `remote-access/denied`(`no-allow`)拒绝;访问检查早于 lookup,被拒调用不激活会话 | Gateway host spec | | policy 注册 | 每个非 owner principal kind 一个 policy,重复注册抛错;disposer 撤回后该 kind 以 `no-allow` 拒绝;随 fiber 撤回 | remote-access host spec | | 判定 | 每个资源都需要匹配的 binding(AND);role 不含该 action、资源不相等或 condition 拒绝都不放行;拒绝原因按 condition > `explain` > `no-allow` 取;`explain` 不改变结论;condition 在调用时收到 args,在快照与列表过滤时不收;`bindings()`、condition 或 `explain()` 抛错以 `policy-failed` 拒绝,每个 policy 在两次 `invalidate()` 之间只记录一次日志,事件路径上 owner 流与共享事件源照常;`remoteAccess/follow` 对任何 principal 放行 | remote-access host spec、Gateway host spec | -| 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) | -| 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec | +| 资源解析 | 默认字段或 resolver 得到一个资源,默认 resolver 返回数组为非法;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,只有编译 codec 才移除未声明字段,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) | +| 资源注册表 | `typert.resources` 每个种类登记一个默认字段或 resolver,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec | | 转发事件 | 非 owner 的 `$events` 流只在 `event:` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) | | 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为每个 index 注入其所代表 Peer 的快照;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec | | 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) | diff --git a/.agents/notes/implemented/architecture/2026-10-19-cua-profile-consent-storage.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-19-cua-profile-consent-storage.i18n.yaml new file mode 100644 index 0000000000..8b9d673b1e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-cua-profile-consent-storage.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-19-cua-profile-consent-storage.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-19-cua-profile-consent-storage.md +/agent-note-durable-computer-use-consent-within-one-profile: + en: b2aa270d586efd93 + zh: 99606fa28dc4a752 +/agent-note-durable-computer-use-consent-within-one-profile/problem: + en: f2c70036a57ddb41 + zh: 9bbbf3dd09d486f5 +/agent-note-durable-computer-use-consent-within-one-profile/decision: + en: 1df102f3d0440a6a + zh: 3456af8e552729a3 +/agent-note-durable-computer-use-consent-within-one-profile/alternatives-considered: + en: 2af74afa3ecaf3b9 + zh: 96f7f43c5d1150df +/agent-note-durable-computer-use-consent-within-one-profile/consequences: + en: 66fe232589e46d64 + zh: fc69831be76248d9 diff --git a/.agents/notes/implemented/architecture/2026-10-19-cua-profile-consent-storage.md b/.agents/notes/implemented/architecture/2026-10-19-cua-profile-consent-storage.md new file mode 100644 index 0000000000..a7134629c7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-cua-profile-consent-storage.md @@ -0,0 +1,31 @@ +# Agent Note: Durable Computer Use consent within one profile + +Status: implemented + +English | [中文](2026-10-19-cua-profile-consent-storage.zh.md) + +## Problem + +Application grants stored as plugin configuration make each authorization change depend on configuration reconciliation. Process-local conversation decisions also disappear when the same conversation resumes after a restart. A shared in-memory storage handle cannot observe another process revoking permission. + +## Decision + +The CUA owner uses the existing domain storage service, with one domain per canonical profile directory. It stores long-term grants and conversation decisions by native application identity. Conversation IDs scope permission without turning it into permission for every conversation. The native identity policy remains owned by [application consent](2026-09-26-cua-application-consent.md); this note replaces only that note's storage and restart-lifetime choices. + +Every operation opens fresh domain state while holding the profile's existing file-lock primitive and closes the domain before releasing the lock. Native dispatch and revocation use a separate lock for the selected application. Storage reads and unrelated applications remain available during a native call. Revocation cancels this process's affected work first, then commits after other processes' already-admitted operations have finished. A completed revocation therefore leaves no operation using the old authority. App-wide revocation generations and per-conversation decision revisions prevent an old handle or delayed answer from restoring revoked access. + +The JSON backend remains the shipped default. The domain name contains the SHA-256 digest of the canonical profile directory, isolating profiles that share one storage root. Legacy `allowedApps` is imported once; a persisted migration marker prevents an old configuration from restoring a revoked grant. Users can remove that configuration after checking the migrated list. Corrupt or unavailable profile storage fails closed. A composition without a profile has only temporary conversation state. + +## Alternatives considered + +**Keep authorization in configuration.** It preserves existing storage but retains configuration-write and HMR coupling for runtime application decisions. + +**Keep one domain open and rely on change events.** Domain notifications are process-local. Another host could continue to use stale grants after revocation. + +**Add a new watcher, revision file or authorization broker.** The existing domain and file-lock primitives provide fresh reads and serialized operation admission without a new shared storage or IPC framework. Operations on the same application are serialized across processes; other applications and their conversation decisions remain independent. + +## Consequences + +File locks coordinate processes on one machine and PID namespace. A network-mounted profile shared across hosts is unsupported. Revocation may wait for another process's native call to settle. No timer establishes authorization validity. + +The permission page still omits conversation-only grants and reads current state on entry, focus, actions and reconnection. Storage changes do not emit configuration events. System permissions remain owned by macOS. Tests cover restart retention, profile isolation, another process's write, cancellation, corrupted data, legacy import and non-resurrection after revocation. Stored conversation records currently require explicit retention management rather than automatic eviction. diff --git a/.agents/notes/implemented/architecture/2026-10-19-cua-profile-consent-storage.zh.md b/.agents/notes/implemented/architecture/2026-10-19-cua-profile-consent-storage.zh.md new file mode 100644 index 0000000000..6f6eccbc56 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-cua-profile-consent-storage.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 同一 profile 内的持久 Computer Use 授权 + +Status: implemented + +[English](2026-10-19-cua-profile-consent-storage.md) | 中文 + +## 问题 + +把应用许可保存为插件配置,会让每次授权变更依赖配置重组。只保存在进程内的会话决定,也会在重启后恢复同一对话时丢失。长期持有的内存存储句柄无法观察另一个进程的撤销操作。 + +## 决策 + +CUA 使用现有域存储服务,每个 profile 的真实目录对应独立存储域,按本机应用身份保存长期许可和会话决定。对话 ID 限定权限范围,不把本会话许可扩展为所有对话的许可。本机身份规则仍由[应用授权](2026-09-26-cua-application-consent.zh.md)负责,本记录仅替代其中的存储和重启后有效期选择。 + +每个操作使用现有文件锁,在 profile 锁内打开并读取最新存储状态,关闭域后才释放锁。原生操作和撤销使用单独的目标应用锁;原生调用期间仍可读取存储或访问其他应用。撤销先取消本进程中受影响的工作,再等待其他进程已获准执行的操作结束后提交。因此,撤销完成后不会留下仍使用旧授权的操作。应用级撤销代次和各对话的决定版本,阻止旧句柄或迟到回答恢复被撤销的访问权限。 + +随附配置继续使用 JSON 后端。域名包含 profile 真实目录的 SHA-256 摘要,隔离共用存储根目录的不同 profile。旧 `allowedApps` 只导入一次,持久化迁移标记阻止旧配置恢复已撤销的许可。用户确认列表迁移完成后可删除旧配置。profile 存储损坏或不可用时拒绝访问。没有 profile 的组合只保留临时会话状态。 + +## 考虑过的替代方案 + +**继续将授权保存在配置中。** 可以复用旧存储,但运行时应用决定仍依赖配置写入和 HMR。 + +**长期打开域并依赖变更事件。** 域事件只在进程内传播,另一个宿主可能在撤销后继续使用旧许可。 + +**增加新的监听器、版本文件或授权代理。** 现有域存储和文件锁已经能提供最新状态与串行准入,无须增加通用存储或 IPC 框架。不同进程对同一应用的操作串行执行,其他应用及其会话决定保持独立。 + +## 后果 + +文件锁协调同一电脑及 PID 命名空间中的进程,不支持跨主机共用网络目录。撤销可能等待其他进程的原生调用结束。授权是否有效不由计时器决定。 + +权限页仍不显示仅对单个会话生效的许可,在进入页面、返回窗口、完成操作和重连后读取最新状态。存储变更不发送配置事件,系统权限仍归 macOS 管理。测试覆盖重启恢复、profile 隔离、其他进程写入、取消、数据损坏、旧配置导入和撤销后不恢复。已保存的会话记录目前需要显式管理保留期限,不自动淘汰。 diff --git a/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.i18n.yaml new file mode 100644 index 0000000000..9ae0452069 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-10-21-session-history-stream-and-snapshot.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.md +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation: + en: 0c9a7bc6591feb16 + zh: 3606a2c1caffd36f +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/problem: + en: ac1583e7ae1377f8 + zh: c60bdbda9ee89144 +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision: + en: 45c63df5dac389eb + zh: d72a5bcb94de22f1 +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision/reads-and-subscriptions: + en: 38df4f3c4fba2cc9 + zh: a34f3376faf26f0c +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision/follow-ordering-and-ownership: + en: 352ff87a3e9cd110 + zh: 4ac6366cecfc08a3 +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision/state-and-wait: + en: a87c7427582e6b03 + zh: b28d2a73b36eb27f +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/alternatives-considered: + en: c5fb06ab9df9e7ad + zh: b20adf151018111c +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/consequences: + en: dc1da262b4911a31 + zh: 2a4e503696fc477b diff --git a/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.md b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.md new file mode 100644 index 0000000000..b5e1112a18 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.md @@ -0,0 +1,59 @@ +# Agent Note: Session snapshots, passive subscriptions, and Web aggregation + +Status: implemented + +English | [中文](2026-10-21-session-history-stream-and-snapshot.zh.md) + +## Problem + +A point read, an event subscription, and a Web conversation opening have different lifetimes. Combining them inside one history controller makes passive observation perform unnecessary reads and gives each aggregate its own acquisition logic. Reading before subscribing also creates a gap that requires another read to repair. The APIs need independent operations that can compose without losing events or duplicating ownership. + +## Decision + +`SessionController` is the single Session Remote Service. It owns ordinary `HistoryController`, `StateController`, and `WebController` instances, not three additional Cordis Services or Remote namespaces. The implementation separates six operations: history snapshot and observation, projection snapshot and observation, and state snapshot and observation. Follow and Wait compose those capabilities for their callers; the existing `page` entry forwards to the history reader. + +### Reads and subscriptions + +History reads return chronological records, `hasMore`, and an inclusive cursor. An omitted `throughSeq` selects the observed log end; `beforeSeq` selects older records without changing that read cut. Projection reads return exact sequenced values, sequence-free cache hints when migration is required, or null when the Session is absent. Live projection reads avoid persistence metadata access. + +`usingObservation({ sessionId, projection }, signal, callback?)` acquires the existing disposable SessionObservation. A callback borrows it until the callback settles; without a callback the caller owns disposal. Ordinary history, state, and Wait output reads do not request projections. Follow, exact projection reads, and subagent history descriptor validation request them. No read activates an Agent implicitly. + +`observeEvent` and `observeProjections` install listeners without acquiring an Observation. Their initial `ready` confirms subscription, not a snapshot cursor, existence, parent-child validity, or Agent readiness. History establishes continuity from its first received event; projection updates replace individual keys and may share a sequence number. `observeState` instead begins with a complete state read and coalesces later Session and Agent invalidations. + +History, history observation, and Follow accept Address requests; projection and state operations accept Session IDs. All internal readers and listeners use Session IDs. History and Follow validate Address against their opening Observation at the Controller layer, including the existing subagent descriptor rules. Pure history observation does not acquire a read for entity validation. Gateway resource authorization remains independent: the registered Session resolver extracts the target ID from the request before access is decided. + +### Follow ordering and ownership + +Follow installs history and optional assistant listeners first, then acquires one projected Observation. It captures the assistant baseline and binding-local arrival ordinal synchronously, sets both cuts, publishes the opening snapshot, and consumes the shared output queue. History notifications at or before the snapshot cursor and assistant notifications at or before the ordinal are discarded at consumption. Later notifications remain ordered without a second history read, background merge pumps, or a tail repair introduced by the opening itself. + +`session/created` contributes constructor seed events through the same history buffer. The adapter retains the supplied Session and its captured end position, then reads that in-memory prefix when consumed; it does not acquire another Observation. Each binding owns its subscriptions; a shared output queue belongs to Follow rather than to either listener. + +Follow activates only when the caller passes `activate: true`; the Web Client does so explicitly. Background promotion starts once the opening is computed and before it is delivered, and does not promise Agent readiness. Ordinary promotion reuses the existing Agent controller and receives a separately retained Observation. Either Follow releases its opening Observation once the opening is computed, before yielding it; activation continues on the separately retained lease, so the cold entry's reference count drops to that lease alone. Subagent Sessions do not use ordinary promotion. + +### State and Wait + +State contains the event cursor, active and running flags, the complete readable queue or null, and pending-input kinds. It exposes neither a header nor an internal revision. Gateway owns unsettled Remote events; State samples their detached metadata and classifies approval, question, or other input without copying a request registry. + +Wait evaluates complete states, confirms active-Agent quiescence with `whenIdle`, and rejects stale observations using listener-local revisions and Agent/Session identity checks. Session and Agent events wake reads immediately; while waiting, a one-second recheck detects pending input that produces no such event. This avoids adding a Gateway change event, at the cost of polling and bounded sampling delay rather than instantaneous notification. `observeState` does not poll, so pending-only changes need another state invalidation to become visible there. + +Wait returns idle, input-required, or timeout with the last state it accepted and bounded assistant output. Output contains only `assistant/message` records in `(afterSeq, state.cursor]`, newest first, and reports truncation only when another eligible record exists. The caller's cancellation signal controls that output read independently of the expired wait deadline. A wait that accepts no state before its deadline fails instead of inventing a state. + +## Alternatives considered + +**Read history, then subscribe and repair the gap.** Subscription-before-read already captures opening-time increments. A second read adds acquisition and replay obligations without improving that ordering. + +**Give pure subscriptions an Observation or ready cursor.** These values conflate subscription readiness with an independently selected read cut. Callers that need both subscribe and buffer first, then read and discard covered increments. + +**Keep separate Remote Services for the implementation controllers.** The classes divide behavior, not external service identity. One Session Service owns their composition, resource policy, and Remote entry points. + +**Have Gateway interpret pending input or emit Session status.** Gateway only owns Remote event transport and unsettled-event metadata. State owns classification; Wait's periodic sampling avoids adding a transport notification solely for Session waiting. + +**Merge assistant and history with independent asynchronous pumps.** One caller-owned buffer retains cross-source arrival order without extra pump cancellation or queue ownership. Snapshot construction and per-binding buffering retain distinct lifetimes. + +## Consequences + +The standard reads and Web aggregate share one Observation acquisition path, while pure subscriptions remain read-free. The old combined History controller is removed; still-used pagination, validation, and wire helpers remain at their original path. Cancellation, consumer termination, failed opening reads, and Context disposal release subscriptions and owned leases without cancelling Agent execution. + +The [Observation decision](2026-08-25-session-observations-and-projection-owned-client-state.md) remains the authority for exact read cuts and projection derivation. The [history and Remote transport decision](2026-08-18-session-history-and-event-transport.md) retains ownership of Client reconnection and transport lifetimes. This decision refines Session API composition rather than replacing either broader decision. + +The migrated history, cold-read, and transport tests exercise the new classes, including cursor overlap, assistant ordinal cuts, sequence gaps, subagent validation, and teardown. Slow consumers can still retain queued notifications; silent dropping is not a supported recovery strategy. Independent completion-acknowledged activation is not implemented by these reads or by Follow's background activation option. diff --git a/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md new file mode 100644 index 0000000000..773ab039b3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md @@ -0,0 +1,59 @@ +# Agent Note: Session 快照、纯订阅与 Web 聚合 + +Status: implemented + +[English](2026-10-21-session-history-stream-and-snapshot.md) | 中文 + +## Problem + +定点读取、事件订阅和 Web 会话开场具有不同的生命周期。把它们合并在同一个历史控制器中,会使被动观察执行不必要的读取,并让每个聚合各自维护获取数据的逻辑。先读后订阅还会产生需要再次读取来修复的缺口。接口需要相互独立的操作,使组合既不丢事件,也不重复资源所有权。 + +## Decision + +`SessionController` 是唯一的 Session Remote Service。它持有普通的 `HistoryController`、`StateController` 和 `WebController` 实例,而不是额外的三个 Cordis Service 或 Remote namespace。实现分离六个操作:历史快照与观察、投影快照与观察、状态快照与观察。Follow 和 Wait 为调用方组合这些能力;已有的 `page` 入口转发到历史读取器。 + +### 读取与订阅 + +历史读取返回时间正序的记录、`hasMore` 和包含末项的游标。省略 `throughSeq` 时选择观察到的日志末尾;`beforeSeq` 只选择更早的记录,不改变读取点位。投影读取返回精确的有序值、需要迁移时无序号的缓存提示,或在 Session 不存在时返回 null。live 投影读取不访问持久化元信息。 + +`usingObservation({ sessionId, projection }, signal, callback?)` 获取已有的可释放 SessionObservation。有回调时租约借给回调直到其结束;无回调时由调用方释放。普通历史、状态及 Wait 输出读取不请求投影。Follow、精确投影读取和子会话历史 descriptor 校验请求投影。读取不会隐式激活 Agent。 + +`observeEvent` 和 `observeProjections` 安装监听而不获取 Observation。首个 `ready` 确认订阅生效,不代表快照游标、实体存在、父子关系有效或 Agent 就绪。历史从首个收到的事件建立连续性;投影更新替换单个键,多个更新可以共享序号。`observeState` 则从完整状态读取开始,并合并后续 Session 和 Agent 失效通知。 + +历史、历史观察和 Follow 接收 Address 请求;投影和状态操作接收 Session ID。所有内部读取器和监听器都使用 Session ID。History 和 Follow 在 Controller 层以开场 Observation 校验 Address,包括已有的子会话 descriptor 规则。纯历史观察不为实体校验获取读取。Gateway 资源授权与之独立:注册的 Session 解析器在访问判定前从请求提取目标 ID。 + +### Follow 顺序与所有权 + +Follow 先安装历史和可选 assistant 监听,再获取一份带投影的 Observation。它同步捕获 assistant 基线和绑定局部的到达序号,设置两种截断点,发布开场快照,然后消费共享输出队列。消费时丢弃序号不超过快照游标的历史通知,以及到达序号不超过截断点的 assistant 通知。后续通知保持顺序,无需第二次历史读取、后台合并任务,或由开场自身引入的尾部修复。 + +`session/created` 经同一历史缓冲提供构造期种子事件。适配器保留收到的 Session 及捕获的末尾位置,在消费时读取对应内存前缀,不获取另一份 Observation。每个绑定拥有自己的订阅;共享输出队列属于 Follow,而不是任一监听器。 + +只有调用方传入 `activate: true` 时 Follow 才激活;Web Client 显式传入该参数。后台提升在开场计算完成后、交付之前启动,不承诺 Agent 已就绪。普通提升复用已有 Agent controller,并接收单独 retain 的 Observation。两种 Follow 都在开场计算完成后、交付前释放开场 Observation;激活则继续使用单独 retain 的租约,因此冷 entry 的引用计数只剩那一份。子会话不走普通提升。 + +### 状态与 Wait + +State 包含事件游标、active 与 running 标志、完整可读队列或 null,以及待输入种类。它不暴露 header 或内部 revision。Gateway 拥有未结算的 Remote 事件;State 采样独立元数据,将输入分类为 approval、question 或 other,不复制请求注册表。 + +Wait 判断完整状态,通过 `whenIdle` 确认活跃 Agent 已静止,并通过监听器局部 revision 和 Agent/Session 身份检查拒绝过时观察。Session 和 Agent 事件立即唤醒读取;等待期间每秒重查一次,检测没有产生上述事件的待输入请求。这避免新增 Gateway change 事件,代价是轮询和采样延迟,而非即时通知。`observeState` 不轮询,因此仅有 pending 变化时,需要另一项状态失效通知才会显示更新。 + +Wait 返回 idle、input-required 或 timeout,以及最后一次被接受的状态和有界 assistant 输出。输出仅包含 `(afterSeq, state.cursor]` 内从新到旧的 `assistant/message` 记录,只有存在另一条符合条件的记录时才报告截断。输出读取使用调用方的取消信号,不受已过期的等待截止信号控制。截止前没有任何状态通过校验的 wait 会失败,不构造虚假的状态。 + +## Alternatives considered + +**先读历史,再订阅并修复缺口。** 先订阅后读取已经捕获开场期间的增量。第二次读取增加资源获取和重放责任,却不改善这个顺序。 + +**给纯订阅提供 Observation 或 ready 游标。** 这会混淆订阅就绪和独立选取的读取点位。需要两者的调用方先订阅并缓冲,再读取并丢弃已覆盖增量。 + +**为各实现 Controller 保留独立 Remote Service。** 这些类划分行为,而不是外部服务身份。一个 Session Service 拥有其组合、资源策略和 Remote 入口。 + +**让 Gateway 解释待输入状态或发送 Session status。** Gateway 只拥有 Remote 事件传输和未结算事件元数据。State 拥有分类;Wait 定期采样,避免仅为 Session 等待增加传输通知。 + +**用独立异步任务合并 assistant 与历史。** 调用方拥有的单个缓冲保留跨来源到达顺序,不引入额外任务取消或队列所有权。快照构造和各绑定缓冲仍保留不同生命周期。 + +## Consequences + +标准读取和 Web 聚合共享同一 Observation 获取路径,纯订阅不读取数据。旧的合并 History controller 已删除;仍使用的分页、校验和 wire 辅助函数留在原路径。取消、消费者结束、开场读取失败和 Context 释放都会释放订阅及拥有的租约,而不取消 Agent 执行。 + +[Observation 决策](2026-08-25-session-observations-and-projection-owned-client-state.zh.md)仍拥有精确读取点位和投影派生规则。[历史与 Remote 传输决策](2026-08-18-session-history-and-event-transport.zh.md)继续拥有 Client 重连和传输生命周期。本决策细化 Session API 组合,而不取代上述更广泛的决策。 + +迁移后的历史、冷读取和传输测试覆盖新类,包括游标重叠、assistant 到达序号截断、序号缺口、子会话校验和释放。慢消费者仍可能积累队列通知;静默丢弃不是支持的恢复策略。这些读取以及 Follow 的后台激活选项均未实现独立的、确认完成后返回的激活接口。 diff --git a/.agents/notes/implemented/architecture/2026-10-23-independent-session-forks.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-23-independent-session-forks.i18n.yaml new file mode 100644 index 0000000000..5f7f17ddbb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-23-independent-session-forks.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-23-independent-session-forks.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-23-independent-session-forks.md +/agent-note-ordinary-forks-have-no-durable-creator-relationship: + en: 543a84d51e8aea24 + zh: 24aeec7c3cdf5900 +/agent-note-ordinary-forks-have-no-durable-creator-relationship/problem: + en: d2edaafdd83e5974 + zh: 57ce9ad368c404c2 +/agent-note-ordinary-forks-have-no-durable-creator-relationship/decision: + en: aae0053e9dc3be9f + zh: bd37095fd5c43c3d +/agent-note-ordinary-forks-have-no-durable-creator-relationship/compatibility: + en: 8393e8526f927134 + zh: c957cbde9dc6712d +/agent-note-ordinary-forks-have-no-durable-creator-relationship/alternatives-considered: + en: 748aa21283186e02 + zh: 51d3b0207b250784 +/agent-note-ordinary-forks-have-no-durable-creator-relationship/consequences: + en: adcb06ccf7503e55 + zh: 30e02c6104eefe01 diff --git a/.agents/notes/implemented/architecture/2026-10-23-independent-session-forks.md b/.agents/notes/implemented/architecture/2026-10-23-independent-session-forks.md new file mode 100644 index 0000000000..1518ee8ac3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-23-independent-session-forks.md @@ -0,0 +1,35 @@ +# Agent Note: Ordinary forks have no durable creator relationship + +Status: implemented + +English | [中文](2026-10-23-independent-session-forks.zh.md) + +## Problem + +Using `parentSession` for both copied history and delegation makes an ordinary fork appear to be a subagent. Consumers can emit false child-start notifications, hide resumable sessions, or impose source-relative ordering. Removing the writer alone leaves historical ordinary forks misclassified and exposes a V4 reader assumption: inherited delivery acknowledgements require a parent id even though the validated seed range already identifies copied events. + +## Decision + +Ordinary forks preserve their complete selected prefix, `isSeeded`, `inheritedEventCount`, and inherited end-seed marker, but record no source id. Subagents retain their direct creator, including when their backend copies history. `origin: subagent` supplies coarse classification; a valid own-range descriptor separately supplies continuation identity. Historical parent metadata remains readable and unchanged. Runtime Agent ownership remains governed by the [explicit identity decision](2026-08-31-explicit-agent-runtime-identity.md). + +The V4 reader permits another Session's delivery id only inside the validated inherited range. Own-range markers still require the current id, and every active-generation `throughSeq` must precede its marker. These checks do not require a persistent source relationship. + +## Compatibility + +This is a same-version behavior change with no declared persistence-type change. The generated inventory reports no schema transitions, so no new type acknowledgement or finalization checkpoint is needed. Existing ordinary forks and subagent logs retain their meanings, and the new reader accepts both parent-bearing and parentless seeds without rewriting either. Frozen schemas and historical generations remain untouched. + +The released `dsh-v0.2.0-rc.2` validator and the reviewed master baseline share the parent-dependent delivery check. Before changing that check, real plain and compressed JSONL probes verified rejection through both read and write opens for parentless ordinary and nested forks, with byte-identical files afterward. Rejection occurs during completed-log relationship validation, before a handle is granted or tail repair runs. Older readers report corruption rather than an upgrade diagnostic; that diagnostic limitation is explicit in the [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.3/independent-session-forks/guide.md). Parentless seeds without active inherited delivery markers remain readable. This observed admission behavior meets the [safe-refusal rule](../process/2026-10-08-session-reader-compatibility-review.md); optionality alone is not the justification. + +## Alternatives considered + +**Keep the source under another field.** A replacement such as `forkSourceId` preserves the durable relationship the user chose to remove, along with its downstream maintenance cost. + +**Erase parent metadata from existing logs.** Historical mutation loses recorded information and violates immutable-generation guarantees. Consumers distinguish old ordinary forks without cleanup. + +**Bump the format solely because older readers reject some new logs.** The existing inherited range and complete-log check prevent unsafe execution and truncation. A global bump and adjacent copy add no protection to this transition; same-version refusal has a documented diagnostic limitation. + +## Consequences + +Ordinary-fork source tracing is intentionally unavailable in new data. Manual ordering preserves saved positions and treats new forks like other ordinary sessions. Operation-local source access preserves title increments and workspace selection. The [side-session proposal](../../proposed/feature/2026-07-08-interactive-side-sessions.md) retains its separate handback idea but cannot use a stored ordinary-fork parent. Runtime ownership, projection-cache identity, and Team membership decisions remain independently useful and are not superseded. + +Verification covers real fork/persist/reopen paths for ordinary, nested, historical, and subagent seeds, rejection without writes for foreign own-range delivery and invalid coordinates, SDK notification classification, ACP resume, client ordering, and title generation. TypeScript and Python recorded SDK scenarios create ordinary and nested forks plus historical parent-bearing metadata while retaining the expected real-subagent lifecycle. diff --git a/.agents/notes/implemented/architecture/2026-10-23-independent-session-forks.zh.md b/.agents/notes/implemented/architecture/2026-10-23-independent-session-forks.zh.md new file mode 100644 index 0000000000..a11be9b69d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-23-independent-session-forks.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 普通 fork 不保留持久创建者关系 + +Status: implemented + +[English](2026-10-23-independent-session-forks.md) | 中文 + +## 问题 + +同时用 `parentSession` 表示复制历史和委派,会让普通 fork 看起来像子代理。消费方可能发送错误的子代理启动通知、隐藏可恢复会话,或施加相对来源的排序。仅删除写入仍会误分类历史普通 fork,并暴露 V4 reader 的一项假设:继承投递确认要求父 ID,尽管已验证的 seed 区间已经标识复制事件。 + +## 决策 + +普通 fork 保留完整的选定前缀、`isSeeded`、`inheritedEventCount` 及继承结束标记,但不记录来源 ID。子代理保留直接创建者,包括 backend 复制历史的情况。`origin: subagent` 提供粗粒度分类;有效的自身区间 descriptor 单独提供继续运行身份。历史父元数据保持可读且不变。运行时 Agent 归属仍遵循[显式身份决策](2026-08-31-explicit-agent-runtime-identity.zh.md)。 + +V4 reader 仅在已验证的继承区间中允许其他 Session 的投递 ID。自身区间标记仍要求当前 ID,每个活动代际的 `throughSeq` 必须早于其标记。这些检查不需要持久来源关系。 + +## 兼容性 + +这是没有声明持久化类型变化的同版本行为变更。生成清单报告没有 schema 转换,因此不需要新增类型确认或定稿检查点。已有普通 fork 和子代理日志保留其含义,新 reader 接受有父关系及无父关系的 seed,且不改写任一种。冻结 schema 与历史代际保持不变。 + +已发布的 `dsh-v0.2.0-rc.2` 校验器与所审查 master 基线具有相同的依赖父关系的投递检查。修改此检查之前,真实的普通及压缩 JSONL 探针验证:无父关系的普通和嵌套 fork 在 read、write 打开时都被拒绝,之后文件字节完全一致。拒绝发生在完整日志的关系校验期间,早于授予句柄或尾部修复。旧 reader 报告损坏而非升级诊断;[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.3/independent-session-forks/guide.zh.md)明确说明这一诊断限制。没有活动继承投递标记的无父关系 seed 仍然可读。这一已观察到的接纳行为满足[安全拒绝规则](../process/2026-10-08-session-reader-compatibility-review.zh.md);可选字段本身不是依据。 + +## 考虑过的替代方案 + +**用其他字段保留来源。** `forkSourceId` 等替代字段保留了用户决定移除的持久关系及其下游维护成本。 + +**清除已有日志的父元数据。** 修改历史会丢失已记录信息,并违反代际不可变保证。消费方可以在不清理的情况下区分旧普通 fork。 + +**仅因旧 reader 拒绝部分新日志就升级格式。** 现有继承区间和完整日志检查阻止不安全执行与截断。全局升级和相邻复制不会为这次转换增加保护;同版本拒绝的诊断限制已有明确记录。 + +## 影响 + +新数据有意不支持普通 fork 的来源追踪。手动排序保留已保存位置,将新 fork 当作其他普通会话处理。操作期间访问来源保留标题递增和工作区选择。[侧会话提案](../../proposed/feature/2026-07-08-interactive-side-sessions.zh.md)保留独立的交回设想,但不能使用普通 fork 的持久父字段。运行时归属、投影缓存身份和 Team 成员关系决策仍分别有用,未被取代。 + +验证覆盖普通、嵌套、历史及子代理 seed 的真实 fork/持久化/重新打开路径,自身区间外来投递及非法坐标的无写入拒绝,SDK 通知分类、ACP 恢复、客户端排序与标题生成。TypeScript 和 Python 录制 SDK 场景创建普通及嵌套 fork 和携带历史父元数据的会话,同时保留真正子代理的预期生命周期。 diff --git a/.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.i18n.yaml new file mode 100644 index 0000000000..0acdacdd56 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-23-store-scoped-image-reads.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.md +/agent-note-image-reads-use-attachment-store-access: + en: 31c8a0d9016d99af + zh: 05db308dec85db67 +/agent-note-image-reads-use-attachment-store-access/problem: + en: ff8886cfa4634163 + zh: 46dfdd50638b64c2 +/agent-note-image-reads-use-attachment-store-access/decision: + en: 439a3277ee3b169d + zh: c9f1e10254850844 +/agent-note-image-reads-use-attachment-store-access/alternatives-considered: + en: 0c7a8319c0c91dc2 + zh: 6b7b58b142839571 +/agent-note-image-reads-use-attachment-store-access/consequences: + en: a46c3d951077ec7d + zh: 30a0cc5bcb681a21 +/agent-note-image-reads-use-attachment-store-access/verification: + en: 89603b3a4c3e12df + zh: f1b01d166f399f87 diff --git a/.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.md b/.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.md new file mode 100644 index 0000000000..8805102882 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.md @@ -0,0 +1,52 @@ +# Agent Note: Image reads use attachment-store access + +Status: implemented + +English | [中文](2026-10-23-store-scoped-image-reads.zh.md) + +## Problem + +Historical image loading read the requesting Session and searched its event content for every uncached attachment. The search established that the Session referenced the image; it did not locate the bytes, whose storage path already follows from the content-addressed ID. This added work proportional to history length to each image read. The reported loading delay also involved uploads; this finding alone does not identify all of that delay. + +## Decision + +Session membership is not an image-access restriction. After connection authentication and Gateway permission checks authorize `session/attachment`, every caller may read any image in the same attachment store using a complete `ImageAttachmentRef`, including objects referenced only by other Sessions or not referenced at all. The request retains `sessionId` for the existing Session resource permission check. After that check passes, the handler does not require that Session to exist and does not return `session/not-found` solely because it is absent. No identity receives a separate history-scanning path. + +The Client already holds the message's complete image reference and sends it as `attachment` beside `sessionId`. Session Controller delegates directly to the existing `AttachmentStore.readImage(ref)`. Its local provider validates the canonical ID, derives the path within its object directory, verifies SHA-256, and compares the actual format, byte length, width, and height with the supplied reference. A missing, unreadable, corrupt, or mismatched object rejects the operation. No Session history is needed to recover the reference. + +The response preserves the supplied reference. Its optional `name` and `originalDimensions` are caller-supplied display information and are not verified against the stored object. The Client's existing historical-image cache continues to own per-Session results and object URLs; the Host adds no image-byte cache. + +This rule narrows the readers covered by [Session observations](2026-08-25-session-observations-and-projection-owned-client-state.md) and the [history transport activation policy](2026-08-18-session-history-and-event-transport.md). [Workspace file reads](2026-09-05-workspace-files-service.md) keep their own filesystem authorization. + +## Alternatives considered + +**Keep a Session-to-image map or projection.** An index removes repeated scans but preserves an access restriction the product does not require, adding derivation, lifetime, and invalidation work. + +**Issue signed image tickets.** Tickets add issuance and validation state for the same unnecessary Session-membership restriction. + +**Optimize only owner reads.** That gives identities different image-access semantics and leaves the scan cost for permitted non-owner callers without a product requirement. + +## Consequences + +Knowing a valid image reference and having permission to invoke the endpoint for the supplied Session resource permits reading that object across Sessions. The local ID is the SHA-256 of normalized bytes. For known PNG, JPEG, or WebP bytes that admission preserves unchanged, a caller can calculate the ID, derive the format, byte length, and dimensions, and use an authorized read to test whether the object exists. The required reference fields need not come from a Session log. + +Image isolation requires separate Host instances with separate `DSH_HOME` directories. The shipped composition uses one attachment store per Host and does not select a store by Session. Authentication, Gateway action/resource checks, fixed storage paths, digest verification, and explicit failures remain required. + +Image reads no longer depend on history length or Session materialization. File reading, integrity verification, encoding, and transport still cost time; this change makes no claim about upload or model inference latency. Existing Session data and fork references need no migration. + +## Verification + +The read-path invariant is zero Session-history queries or traversal for attachment reads. Coverage must distinguish a permitted cross-Session image read from a denied Gateway call and exercise valid references, invalid IDs, mismatched intrinsic metadata, preserved optional display fields, missing and corrupt objects, old history pages, and inherited fork images. Performance evidence must separate historical scanning, storage work, and transport rather than attribute the entire reported delay to the scan. + +The diagnostic is [`image-read.perf.ts`](../../../../packages/api/session-controller/tests/image-read.perf.ts), compiled by its [tsdown configuration](../../../../packages/api/session-controller/tests/image-read.perf.config.ts). The baseline worker pairs the pre-change controller with `{ sessionId, attachmentId: ref.attachmentId }`; the candidate worker pairs the current controller with `{ sessionId, attachment: ref }`. Both use the same image fixtures, workload, and output checks, and run against the same built Local store with `readImage(ref)` verification. From the candidate checkout root, compile and run: + +```sh +pnpm exec tsdown --config-loader native --config packages/api/session-controller/tests/image-read.perf.config.ts +(cd packages/api/session-controller && node --expose-gc .dsh-build/image-read/image-read.perf.js --expect-no-history) +``` + +Run the separately compiled baseline worker without `--expect-no-history`; its diagnostic request must match the baseline controller's ID-only request fields. The baseline negative control adds `--sample typical --expect-no-history` and must fail because that implementation queries and traverses history. The parent process runs each workload in five fresh child processes, each with a loaded Session and a first uncached read of every image. Images are deterministic synthetic 1024 × 768 PNGs; the two workloads contain 120 turns, 960 events, and 8 images, or 2,000 turns, 16,000 events, and 32 images. Fixture writes can leave the operating system's file cache warm. + +`controllerMs` covers the direct controller call through base64 return. `storeMs` includes file I/O, SHA-256, and header verification; `snapshotMs` measures history snapshot acquisition separately. `scanAndDispatchMs` is the time before store entry minus `snapshotMs`, so it includes dispatch overhead and is not an isolated scan timer. JSON serialization and loopback HTTP transfer are separate measurements over retained responses. `requestJsonBytes` and `idOnlyRequestJsonBytes` compare serialized request lengths; they do not measure request transmission time. Cold Session restoration, Gateway processing, browser rendering, remote-network latency, upload, and model inference are excluded. PR #7021 records the worker revisions and before/after results under these limits. + +The memory endpoints run GC after fixture setup and after all image responses. The Session, admitted image references, and every base64 response remain reachable at the second endpoint. These retained endpoints include setup-owned objects, and peak RSS is cumulative from process start, including fixture generation. They do not measure image-request memory in isolation or impose a retained-memory budget. diff --git a/.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.zh.md b/.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.zh.md new file mode 100644 index 0000000000..0fe69e71a0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.zh.md @@ -0,0 +1,52 @@ +# Agent Note: 图片读取使用附件存储访问权限 + +Status: implemented + +[English](2026-10-23-store-scoped-image-reads.md) | 中文 + +## Problem + +历史图片加载会为每个未缓存的附件读取请求中的 Session,并搜索其事件内容。搜索用于确认 Session 引用了图片;它不负责定位字节,存储路径已经能由内容寻址 ID 推导。每次读图因此增加了与历史长度成正比的工作。用户反馈的加载延迟还涉及上传;这项发现本身不能确定全部延迟的来源。 + +## Decision + +Session 关联不构成图片访问限制。连接身份认证和 Gateway 权限检查允许调用 `session/attachment` 后,任何调用者都可以通过完整的 `ImageAttachmentRef` 读取同一附件存储中的任意图片,包括仅被其他 Session 引用或未被任何 Session 引用的对象。请求保留 `sessionId`,供现有 Session 资源权限检查使用。该检查通过后,处理函数不要求该 Session 存在,也不会仅因其不存在而返回 `session/not-found`。所有身份使用相同的读图路径。 + +Client 已经持有消息中的完整图片引用,将其作为 `attachment` 与 `sessionId` 一起发送。Session Controller 直接调用已有的 `AttachmentStore.readImage(ref)`。本地提供方校验规范 ID,在自己的对象目录内推导路径,校验 SHA-256,并把实际格式、字节数、宽度和高度与传入引用比对。对象缺失、不可读、损坏或与引用不符时,操作失败。取得引用无需查询 Session 历史。 + +响应保留传入引用。可选的 `name` 和 `originalDimensions` 是调用方提交的显示信息,不根据存储对象校验。Client 现有的历史图片缓存继续按 Session 管理结果和对象 URL;Host 不增加图片字节缓存。 + +该规则缩小了 [Session observation](2026-08-25-session-observations-and-projection-owned-client-state.zh.md) 与[历史传输激活策略](2026-08-18-session-history-and-event-transport.zh.md)涵盖的读取范围。[工作区文件读取](2026-09-05-workspace-files-service.zh.md)保留自己的文件系统授权。 + +## Alternatives considered + +**保留 Session 到图片的 Map 或 projection。** 索引可以消除重复扫描,但保留了产品不需要的访问限制,并增加派生、生命周期和失效处理。 + +**签发图片签名票据。** 票据为同一项不需要的 Session 关联限制增加签发和验证状态。 + +**只优化 owner 的读取。** 这会让不同身份使用不同的图片访问规则,并让获准调用的非 owner 继续承担扫描成本,没有相应产品需求。 + +## Consequences + +调用方知道有效图片引用,且有权为请求中的 Session 资源调用该接口,就可以跨 Session 读取对象。本地 ID 是规范化字节的 SHA-256。对于准入时原样保留的已知 PNG、JPEG 或 WebP 字节,调用方可以计算 ID,取得格式、字节数和尺寸,并通过获准的读取请求探测对象是否存在。这些必需的引用字段不一定来自 Session 日志。 + +图片隔离需要独立 Host 实例及各自的 `DSH_HOME` 目录。随附组合在每个 Host 中使用一个附件存储,不会按 Session 选择存储。身份认证、Gateway 操作与资源检查、固定存储路径、摘要校验和明确失败仍是必需的。 + +图片读取不再依赖历史长度或 Session 物化。文件读取、完整性校验、编码和传输仍有耗时;本次改动不说明上传或模型推理延迟已得到改善。已有 Session 数据和 fork 引用无需迁移。 + +## Verification + +附件读取不得查询或遍历 Session 历史。验证必须区分获准的跨 Session 图片读取与被 Gateway 拒绝的调用,并覆盖正常引用、无效 ID、固有元数据不符、可选显示字段原样保留、缺失和损坏的对象、旧历史页以及 fork 继承图片。性能证据必须区分历史扫描、存储操作和传输,不能把用户反馈的全部延迟归因于扫描。 + +诊断脚本是 [`image-read.perf.ts`](../../../../packages/api/session-controller/tests/image-read.perf.ts),通过配套的 [tsdown 配置](../../../../packages/api/session-controller/tests/image-read.perf.config.ts)编译。基线 worker 使用改动前的控制器与 `{ sessionId, attachmentId: ref.attachmentId }`;候选 worker 使用当前控制器与 `{ sessionId, attachment: ref }`。两者使用相同图片 fixture、工作量和输出校验,并通过同一套已构建的 Local store 执行 `readImage(ref)` 校验。在候选检出目录的根目录编译并运行: + +```sh +pnpm exec tsdown --config-loader native --config packages/api/session-controller/tests/image-read.perf.config.ts +(cd packages/api/session-controller && node --expose-gc .dsh-build/image-read/image-read.perf.js --expect-no-history) +``` + +运行单独编译的基线 worker 时不带 `--expect-no-history`;诊断请求必须匹配基线控制器仅接收 ID 的请求字段。基线负对照加上 `--sample typical --expect-no-history`,该实现会查询和遍历历史,因此负对照必须失败。父进程为每种工作量运行五个新子进程,每个子进程使用已加载的 Session,并对每张图片执行第一次未缓存读取。图片是确定性合成的 1024 × 768 PNG;两种工作量分别为 120 轮、960 个事件、8 张图片,以及 2,000 轮、16,000 个事件、32 张图片。fixture 写入可能已经预热操作系统的文件缓存。 + +`controllerMs` 覆盖从直接调用控制器到返回 base64 的时间。`storeMs` 包含文件 I/O、SHA-256 和文件头校验;`snapshotMs` 单独测量取得历史快照的时间。`scanAndDispatchMs` 是进入存储前的时间减去 `snapshotMs`,因此也包含分派开销,不是独立的扫描计时。JSON 序列化和本机回环 HTTP 传输使用保留的响应单独测量。`requestJsonBytes` 与 `idOnlyRequestJsonBytes` 比较序列化后的请求长度,不测量请求传输时间。冷 Session 恢复、Gateway 处理、浏览器渲染、远端网络延迟、上传和模型推理均不在测量范围内。PR #7021 记录 worker 修订,以及在这些限制下测得的修改前后结果。 + +内存端点在 fixture 准备完成后,以及所有图片响应完成后执行 GC。第二个端点仍保留 Session、已准入的图片引用和每个 base64 响应。这两个保留内存端点包含准备阶段拥有的对象,峰值 RSS 则从进程启动累计,包含 fixture 生成。它们不单独测量图片请求内存,也不规定保留内存预算。 diff --git a/.agents/notes/implemented/bug-fix/2026-10-22-series-start-unchanged-prompt-prefix.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-10-22-series-start-unchanged-prompt-prefix.i18n.yaml new file mode 100644 index 0000000000..272a511792 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-22-series-start-unchanged-prompt-prefix.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-22-series-start-unchanged-prompt-prefix.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-10-22-series-start-unchanged-prompt-prefix.md +/agent-note-a-series-start-with-an-unchanged-prompt-keeps-the-surviving-system-node: + en: f62e2980951e0cb3 + zh: 3cd4fd49a89fae40 +/agent-note-a-series-start-with-an-unchanged-prompt-keeps-the-surviving-system-node/problem: + en: b2b7d5e9a4850ece + zh: cbd91b67969bca02 +/agent-note-a-series-start-with-an-unchanged-prompt-keeps-the-surviving-system-node/decision: + en: 8a3404207a36ecfb + zh: d8bc1decefa60a20 +/agent-note-a-series-start-with-an-unchanged-prompt-keeps-the-surviving-system-node/verification: + en: 1970ea84337ce9b5 + zh: da5b9928606c07c6 +/agent-note-a-series-start-with-an-unchanged-prompt-keeps-the-surviving-system-node/alternatives-considered: + en: 2e87f55619f04607 + zh: 7ca5a8e9a42a799a +/agent-note-a-series-start-with-an-unchanged-prompt-keeps-the-surviving-system-node/consequences: + en: bf8a9d6c56722efd + zh: 18dfdf8a7b9bcb5c diff --git a/.agents/notes/implemented/bug-fix/2026-10-22-series-start-unchanged-prompt-prefix.md b/.agents/notes/implemented/bug-fix/2026-10-22-series-start-unchanged-prompt-prefix.md new file mode 100644 index 0000000000..34352ed8b0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-22-series-start-unchanged-prompt-prefix.md @@ -0,0 +1,39 @@ +# Agent Note: A series start with an unchanged prompt keeps the surviving system node + +Status: implemented + +English | [中文](2026-10-22-series-start-unchanged-prompt-prefix.zh.md) + +## Problem + +On an in-history route, a prompt change is appended as a later `system/message`, so node 0 stays stale while the latest surviving node carries the rendering. A surface replacement (compaction, tool-result prune, image offload) starts a new request series. Re-baselining node 0 at a series start while the rendering already matched that latest node emptied the later node and wrote the identical text into node 0. The wire request then differed at its first message, so the provider prefix cache missed from the first token although no prompt text changed. + +Real session `session-da55bbc9-…`, turn 710 step 2: `compaction/prune` replaced one tool result (seq 34669, 34670); the next request wrote 7,618 characters identical to the node it emptied (seq 34672, 34673), `request/header` logged reason `series` (seq 34674), and the step billed 722,332 uncached input tokens with 1,152 cache-read tokens (0.16% hit) against about 99.9% on the neighboring steps. A build update at turn 707 had appended the prompt node; nothing changed the rendering at turn 710. + +## Decision + +`SystemPromptProjection.project` re-baselines node 0 only when the effective text must move: an incapable route, an empty rendering, or a series start whose latest surviving non-empty node text differs from the rendering. When the route is in-history, the rendering is non-empty, and the latest surviving node already holds it, the projection returns no updates and the node stays where the cached history carried it. The loop still records the request series through `request/header` (`series`, or `change` with `startsSeries: true`) and rebuilds declarations as before; when the unchanged-rendering skip coincides with a tool-schema series start, the header records the declaration change without `startsSeries`. + +Retry recovery keeps its guarantee: when a retry compaction shadows the latest node, the effective text falls back to an older node, differs from the rendering, and the head is rewritten before users are admitted again. The projection never appends an update after already-admitted users. + +## Verification + +- [`system-prompt-projection.spec.ts`](../../../../packages/core/agent-loop/tests/system-prompt-projection.spec.ts) pins the no-operation decision for a capable series start whose surviving node already matches. +- [`system-prompt-admission.spec.ts`](../../../../packages/core/agent-loop/tests/system-prompt-admission.spec.ts) pins the wire request: a compaction replacement with an unchanged rendering keeps both system messages, the previous request messages remain a prefix, and the neighboring steps regain the provider prefix. +- The [`in-history-series-start`](../../../../snapshots/session/in-history-series-start/) recorded session replays the shipped headless profile through a prompt change and a later surface replacement, pinning the unchanged-rendering series start to two retained prompt versions and no `system/message` event. + +## Alternatives considered + +**Always keep the appended node and never re-baseline.** Simpler, but a route that cannot append prompts, or a prompt that must clear, still needs a single current head, and a changed rendering at a series start must not append after users already admitted by a failed attempt. Rejected. + +**Skip consolidation whenever the head differs.** A stale head is authoritative again on an incapable destination route, because that route ignores later system nodes; the route capability, not the head text, decides whether the later node is effective. Rejected. + +**Stop treating local surface replacements as series starts.** A prune changes one tool result, so the cache loss is local, but image-offload and compaction decisions still legitimately start a series; the projection must not depend on which replacement produced the boundary. Rejected: the projection owns the prompt decision. + +**Compare reconstructed requests instead of surface text.** The loop derives requests from surface events, so comparing past wire messages would duplicate request reconstruction and still need the same effective-text comparison. Rejected. + +## Consequences + +- A capable route can retain earlier prompt versions in model history after a series start; they cost input tokens until a later consolidation or compaction shadows them. +- A tool-schema series start on a route without tool updates no longer writes a `system/message` event, so its header records the declaration change without `startsSeries`. The request still carries the complete active declaration set through the unsupported-route projection (`projectToolUpdates`), while `Session.toolHistory()` keeps its incremental baseline instead of resetting. +- Consolidation still applies when the rendering actually differs: route switches, prompt updates concurrent with compaction, and retries that shadow the latest node. diff --git a/.agents/notes/implemented/bug-fix/2026-10-22-series-start-unchanged-prompt-prefix.zh.md b/.agents/notes/implemented/bug-fix/2026-10-22-series-start-unchanged-prompt-prefix.zh.md new file mode 100644 index 0000000000..3d8b2cb0ac --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-22-series-start-unchanged-prompt-prefix.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 渲染未变的新序列保留存活的系统节点 + +Status: implemented + +[English](2026-10-22-series-start-unchanged-prompt-prefix.md) | 中文 + +## Problem + +在 in-history 路由上,提示词变化会追加为后续的 `system/message`,因此 node 0 保持陈旧,由最新存活节点承载渲染文本。surface 替换(压缩、工具结果剪枝、图片省略)会开启新的请求序列。在渲染文本已经与该最新节点一致时重写 node 0,会清空该节点并把完全相同的文本写进 node 0。于是 wire 请求的第一条消息发生变化,提供方前缀缓存从第一个 token 起未命中,尽管提示词文本没有任何变化。 + +真实会话 `session-da55bbc9-…` 第 710 轮第 2 步:`compaction/prune` 替换了一条工具结果(seq 34669、34670);下一次请求写入的 7,618 个字符与它清空的节点完全相同(seq 34672、34673),`request/header` 记录 reason `series`(seq 34674),该步计费 722,332 个未命中输入 token、1,152 个缓存读取 token(命中率 0.16%),而相邻步骤约为 99.9%。第 707 轮的构建更新追加了提示词节点;第 710 轮没有任何东西改变渲染文本。 + +## Decision + +`SystemPromptProjection.project` 只在有效文本必须移动时重写 node 0:不具备能力的路由、空渲染文本,或最新存活非空节点文本与渲染文本不同的新序列开始。当路由为 in-history、渲染文本非空、且最新存活节点已经持有它时,投影不产生任何更新,节点保持在已缓存历史携带它的位置。loop 仍通过 `request/header` 记录请求序列(`series`,或带 `startsSeries: true` 的 `change`),并照旧重建声明;当「渲染未变则跳过」与工具 schema 起因的新序列重合时,header 记录声明变化但不带 `startsSeries`。 + +重试恢复保持原有保证:重试压缩遮蔽最新节点后,有效文本回退到更旧的节点,与渲染文本不同,于是头节点在用户消息再次被接纳之前重写。投影从不会在已接纳的用户消息之后追加更新。 + +## Verification + +- [`system-prompt-projection.spec.ts`](../../../../packages/core/agent-loop/tests/system-prompt-projection.spec.ts) 固定「能力路由的新序列开始时存活节点文本已一致则不产生操作」的决策。 +- [`system-prompt-admission.spec.ts`](../../../../packages/core/agent-loop/tests/system-prompt-admission.spec.ts) 固定 wire 请求:渲染文本未变的压缩替换保留两条系统消息,上一次请求的消息仍构成前缀,相邻步骤重新获得提供方前缀。 +- [`in-history-series-start`](../../../../snapshots/session/in-history-series-start/) 录制会话通过 shipped headless profile 回放「提示词变化 + 后续 surface 替换」,固定渲染未变的新序列保留两份提示词版本且不写 `system/message` 事件。 + +## Alternatives considered + +**永远保留追加节点、从不重写。** 更简单,但无法追加提示词的路由、或必须清空的提示词仍需要单一当前头节点;新序列开始时若渲染文本确实变化,也不能在失败尝试已接纳的用户消息之后追加。拒绝。 + +**只要 head 不同就跳过归并。** 在目标路由变为不具备能力时,陈旧 head 会重新成为权威,因为该路由忽略后续系统节点;决定后续节点是否生效的是路由能力,而不是 head 文本。拒绝。 + +**让局部 surface 替换不再算新序列。** 剪枝只改一条工具结果的文本,缓存损失是局部的,但图片省略与压缩决定仍合理地开启新序列;投影不应依赖是哪种替换制造了边界。拒绝:提示词决策属于投影。 + +**比较重建后的请求而不是 surface 文本。** loop 从 surface 事件派生请求,比较历史 wire 消息会重复请求重建,而且仍然需要同样的有效文本比较。拒绝。 + +## Consequences + +- 具备能力的路由在新序列开始后可能在模型历史中保留更早的提示词版本;在被后续归并或压缩遮蔽之前,它们会计入输入 token。 +- 不支持工具更新的路由上,工具 schema 变化开启的新序列不再写出 `system/message` 事件,因此其 header 记录声明变化但不带 `startsSeries`。请求仍通过不支持更新的路由投影(`projectToolUpdates`)携带完整的生效声明集合,而 `Session.toolHistory()` 保留其增量基线、不再重置。 +- 渲染文本确实不同时归并仍然生效:路由切换、与压缩同时发生的提示词更新,以及遮蔽最新节点的重试。 diff --git a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml index 379e4f0ad9..0a0b79c7f5 100644 --- a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml @@ -15,8 +15,8 @@ en: 2f23fa55e1ce652c zh: 335695fc654ab1db /agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision/the-decision-rule: - en: 554d01427ebebee1 - zh: 664c9bf0a9337a04 + en: bd990101d220c905 + zh: 98f54d523b9ffe52 /agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision/presentation-and-accounting: en: 800d579e2fcf2cce zh: 1e92354d9161c50a diff --git a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md index 82402feff9..795f6cfe5d 100644 --- a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md +++ b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md @@ -24,18 +24,19 @@ The loop records the mode in the session: `RequestContext.systemPromptUpdate` jo ### The decision rule -`SystemPromptProjection.project(rendered, { inHistory, startsSeries })` in `packages/core/agent-loop/src/runtime-context.ts` scans the surviving `system/message` nodes of the current surface on every call. It returns ordered per-node commits. With no surviving system node it reserves the head even for an empty rendering. Effective text comes from the latest non-empty system node, falling back to the head; dormant empty tails neither supply effective text nor need another empty replacement. An empty rendering clears every active system node, regardless of route or series state. For a non-empty rendering on an incapable route or at a new request series, consolidation applies even when the effective text is unchanged. Otherwise matching effective text emits nothing. The operations are: +`SystemPromptProjection.project(rendered, { inHistory, startsSeries })` in `packages/core/agent-loop/src/runtime-context.ts` scans the surviving `system/message` nodes of the current surface on every call. It returns ordered per-node commits. With no surviving system node it reserves the head even for an empty rendering. Effective text comes from the latest non-empty system node, falling back to the head; dormant empty tails neither supply effective text nor need another empty replacement. An empty rendering clears every active system node, regardless of route or series state. For a non-empty rendering, an incapable route or a new request series whose effective text differs consolidates; a series start whose latest surviving node already holds the rendering emits nothing and keeps that node in place. Otherwise matching effective text emits nothing. The unchanged-text case is owned by the [series-start prefix decision](../bug-fix/2026-10-22-series-start-unchanged-prompt-prefix.md). The operations are: | Route capability | Prefix state | Operation | |---|---|---| | none | non-empty rendering, any prefix state | log an empty replacement for each non-empty later system node, then rewrite the first system node with the rendering if needed | | `in-history` | the current request series continues | append a new `system/message` before the step's `user/message` events; the append alone needs no `request/header` | -| `in-history` | non-empty rendering, a new series starts | log empty replacements for non-empty later system nodes, then rewrite the first system node if needed, even when the latest effective text is unchanged | +| `in-history` | non-empty rendering, a new series starts and the latest effective text differs | log empty replacements for non-empty later system nodes, then rewrite the first system node if needed | +| `in-history` | non-empty rendering, a new series starts with the rendering already current | no operation: the surviving node stays where the cached history carried it | | any | the rendered prompt is empty | log empty replacements for non-empty later system nodes, then empty the head if needed; no prompt version remains in derived messages | -`startsSeries` is true when the `agent/pre-step` decision declares `startsRequestSeries`, when the surface replace generation moved since the last request (a compaction or any other replacement), or when the visible tool-schema set changed on a route without `toolUpdate` support. Supported tool additions can accompany a prompt append without starting a series. A provider or model swap alone is not a series start for this rule: on a capable destination route the changed prompt is appended, which costs nothing because the route change already misses the cache. A series start already costs the cache, so consolidation keeps only the current prompt in model history. Logged per-node empty replacements remove later prompts from derived messages without a surface delete operation or any replacement of intervening conversation nodes. This also keeps compaction recovery from appending a system update after users already admitted by the failed attempt. +`startsSeries` is true when the `agent/pre-step` decision declares `startsRequestSeries`, when the surface replace generation moved since the last request (a compaction or any other replacement), or when the visible tool-schema set changed on a route without `toolUpdate` support. Supported tool additions can accompany a prompt append without starting a series. A provider or model swap alone is not a series start for this rule: on a capable destination route the changed prompt is appended, which costs nothing because the route change already misses the cache. A series start does not by itself discard the whole provider prefix: a replacement or image-offload decision may invalidate only the tail, so consolidation waits until the effective text must move; when it does, the series is the point to keep only the current prompt in model history and to avoid appending after already-admitted users on a retry. Logged per-node empty replacements remove later prompts from derived messages without a surface delete operation or any replacement of intervening conversation nodes. This also keeps compaction recovery from appending a system update after users already admitted by the failed attempt. -The first attempt admits the prompt after assembly, an accepted `agent/pre-step` decision, `step/start`, the `agent/request` waterfall, and `prepareCall()`. A rejected or empty first input opens no step. Neither async request phase commits the pending system prompt or accepted users, and cancellation during either commits neither. Every attempt synchronously reconciles the same rendered assembly after its own `agent/request` and `prepareCall()`, appends the accepted user batch only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming through the same prepared call. Retries do not repeat assembly, `agent/pre-step`, or user admission. Reconciliation sees both pre-step compaction (`compaction-basic` with `auto: true`) and recovery compaction, and consolidates non-empty prompt text at the head when either starts a new series. Resume is series-continuing — the `resume` header is not a series start — so a prompt that changed across a restart is appended; the provider cache may still be warm across a process boundary. +The first attempt admits the prompt after assembly, an accepted `agent/pre-step` decision, `step/start`, the `agent/request` waterfall, and `prepareCall()`. A rejected or empty first input opens no step. Neither async request phase commits the pending system prompt or accepted users, and cancellation during either commits neither. Every attempt synchronously reconciles the same rendered assembly after its own `agent/request` and `prepareCall()`, appends the accepted user batch only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming through the same prepared call. Retries do not repeat assembly, `agent/pre-step`, or user admission. Reconciliation sees both pre-step compaction (`compaction-basic` with `auto: true`) and recovery compaction, and consolidates non-empty prompt text at the head when either starts a new series whose effective text differs. Resume is series-continuing — the `resume` header is not a series start — so a prompt that changed across a restart is appended; the provider cache may still be warm across a process boundary. An empty head with no active later system node represents no prompt. Dormant empty tails do not supply effective text, so repeated clearing and resume cannot resurrect an older prompt. Restoring non-empty text uses the same admission rule: a continuing capable route may append it; an incapable route or a new series refills the head. Clearing uses ordinary per-node replacements, not a surface delete or an initial empty-head creation. diff --git a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md index a2c8c60bbf..79ff75b3ec 100644 --- a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md +++ b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md @@ -24,18 +24,19 @@ Status: implemented ### 决策规则 -`packages/core/agent-loop/src/runtime-context.ts` 中的 `SystemPromptProjection.project(rendered, { inHistory, startsSeries })` 每次调用都扫描当前 surface 上存活的 `system/message` 节点。它返回有序的逐节点提交。没有存活的系统节点时,即使渲染文本为空也预留头节点。有效文本取自最新的非空系统节点,没有时回退到头节点;未生效的空尾节点既不提供有效文本,也无需再次以空内容替换。无论路由或序列状态如何,空渲染文本都会清除每个生效的系统节点。不具备能力的路由或新请求序列面对非空渲染文本时,即使有效文本未变也执行归并。除此之外,有效文本相同时不产生事件。具体操作如下: +`packages/core/agent-loop/src/runtime-context.ts` 中的 `SystemPromptProjection.project(rendered, { inHistory, startsSeries })` 每次调用都扫描当前 surface 上存活的 `system/message` 节点。它返回有序的逐节点提交。没有存活的系统节点时,即使渲染文本为空也预留头节点。有效文本取自最新的非空系统节点,没有时回退到头节点;未生效的空尾节点既不提供有效文本,也无需再次以空内容替换。无论路由或序列状态如何,空渲染文本都会清除每个生效的系统节点。面对非空渲染文本,不具备能力的路由或有效文本不同的新请求序列执行归并;新序列开始时若最新存活节点已经持有该渲染文本,则不产生事件并保留该节点。除此之外,有效文本相同时不产生事件。有效文本未变的新序列由[系列开始前缀决策](../bug-fix/2026-10-22-series-start-unchanged-prompt-prefix.zh.md)拥有。具体操作如下: | 路由能力 | 前缀状态 | 操作 | |---|---|---| | 无 | 非空渲染文本,任意前缀状态 | 为每个非空的后续系统节点记录空内容替换,随后按需用渲染文本重写首个系统节点 | | `in-history` | 当前请求序列延续 | 在该步骤的 `user/message` 事件之前追加新的 `system/message`;仅追加本身不需要记录 `request/header` | -| `in-history` | 非空渲染文本,新序列开始 | 为非空的后续系统节点记录空内容替换,再按需重写首个系统节点,即使最新有效文本未变也执行 | +| `in-history` | 非空渲染文本,新序列开始且最新有效文本不同 | 为非空的后续系统节点记录空内容替换,再按需重写首个系统节点 | +| `in-history` | 非空渲染文本,新序列开始但渲染文本已经是当前文本 | 不产生操作:存活节点保持在已缓存历史携带它的位置 | | 任意 | 渲染后的提示词为空 | 为非空的后续系统节点记录空内容替换,再按需清空头节点;派生消息中不保留任何提示词版本 | -`startsSeries` 在以下情况为真:`agent/pre-step` 决定声明了 `startsRequestSeries`、surface 的替换代数自上次请求以来发生了移动(压缩或任何其他替换)、不支持 `toolUpdate` 的路由上可见工具 schema 集合发生了变化。受支持的工具添加可以与提示词追加一起发生,无需开启新序列。仅 provider 或 model 切换对本规则不算序列开始:目标路由具备能力时,变更后的提示词被追加,这不花任何代价,因为路由变更本身已经使缓存未命中。序列开始已经付出了缓存代价,因此归并让模型历史只保留当前提示词。有日志记录的逐节点空内容替换会从派生消息中移除后续提示词,无需 surface 删除操作,也不替换其间的对话节点。这也使压缩恢复不会在失败尝试已接纳的用户消息之后追加系统更新。 +`startsSeries` 在以下情况为真:`agent/pre-step` 决定声明了 `startsRequestSeries`、surface 的替换代数自上次请求以来发生了移动(压缩或任何其他替换)、不支持 `toolUpdate` 的路由上可见工具 schema 集合发生了变化。受支持的工具添加可以与提示词追加一起发生,无需开启新序列。仅 provider 或 model 切换对本规则不算序列开始:目标路由具备能力时,变更后的提示词被追加,这不花任何代价,因为路由变更本身已经使缓存未命中。序列开始本身并不必然使整段提供方前缀作废:替换或图片省略决定可能只使尾部失效,因此归并会等到有效文本必须移动时才执行;此时借助该序列只保留当前提示词,并避免重试时在已接纳的用户消息之后追加指令。有日志记录的逐节点空内容替换会从派生消息中移除后续提示词,无需 surface 删除操作,也不替换其间的对话节点。这也使压缩恢复不会在失败尝试已接纳的用户消息之后追加系统更新。 -首次尝试在组装、被接纳的 `agent/pre-step` 决策、`step/start`、`agent/request` waterfall 与 `prepareCall()` 之后才接纳提示词。被拒绝或为空的首次输入不打开步骤。两个异步请求阶段都不提交待处理的系统提示词与已接纳用户消息,在任一阶段取消都不会提交这两者。每次尝试都在各自的 `agent/request` 与 `prepareCall()` 之后同步协调同一份已渲染组装结果、仅在首次尝试追加已接纳用户批次、按需记录 header/context、派生并冻结请求,再通过同一个已准备调用发起流式请求。重试不重复组装、`agent/pre-step` 或用户消息准入。协调过程可见 pre-step 压缩(`auto: true` 的 `compaction-basic`)与恢复压缩,并在任一种压缩开启新序列时将非空提示词文本归并到头部。恢复属于序列延续——`resume` header 不是序列开始——因此跨重启发生变化的提示词被追加;提供方缓存在进程边界之后可能仍是热的。 +首次尝试在组装、被接纳的 `agent/pre-step` 决策、`step/start`、`agent/request` waterfall 与 `prepareCall()` 之后才接纳提示词。被拒绝或为空的首次输入不打开步骤。两个异步请求阶段都不提交待处理的系统提示词与已接纳用户消息,在任一阶段取消都不会提交这两者。每次尝试都在各自的 `agent/request` 与 `prepareCall()` 之后同步协调同一份已渲染组装结果、仅在首次尝试追加已接纳用户批次、按需记录 header/context、派生并冻结请求,再通过同一个已准备调用发起流式请求。重试不重复组装、`agent/pre-step` 或用户消息准入。协调过程可见 pre-step 压缩(`auto: true` 的 `compaction-basic`)与恢复压缩,并在任一种压缩开启有效文本不同的新序列时将非空提示词文本归并到头部。恢复属于序列延续——`resume` header 不是序列开始——因此跨重启发生变化的提示词被追加;提供方缓存在进程边界之后可能仍是热的。 空头节点且没有生效的后续系统节点表示没有提示词。未生效的空尾节点不提供有效文本,因此重复清除与恢复会话都不会使旧提示词重新生效。重新提供非空文本使用同一准入规则:延续中的具备能力路由可以追加它;不具备能力的路由或新序列则重新填充头节点。清除使用普通的逐节点替换,而非 surface 删除或初次创建空头节点。 diff --git a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml index 121838e838..f7726e4c5a 100644 --- a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml +++ b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml @@ -9,8 +9,8 @@ en: e5c984fa32cc5e69 zh: 25d5742b2106d830 /agent-note-pr-artifact-builds-and-aggregate-typescript-checks/decision: - en: 8353d57b20ca01c3 - zh: e0b74906846d759c + en: 2b047b7d8ffaaeb9 + zh: 035e1a99cb0ffe84 /agent-note-pr-artifact-builds-and-aggregate-typescript-checks/alternatives-considered: en: f61da066f9ddcd62 zh: 816fd6e0063017cd diff --git a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md index 0b403e7892..00378de709 100644 --- a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md +++ b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md @@ -20,7 +20,7 @@ The Python executable builder selects `build:artifacts` artifact compilation wit PR artifact builds in CI invoke the root manifest's `build:artifacts`; a PR that edits these root manifest scripts activates the separate native-addon platform matrix, a trade-off [the build split](2026-10-11-build-artifacts-check-split.md) records. The vendor rehearsal and benchmark lanes invoke the internal build helpers directly. -Job inventory, runner selectors, and benchmark sample counts stay fixed; gate and worker ceilings do not increase. The two compatibility cases run one job at a time; both x64 Python runtime targets start after the keyless Python SDK job succeeds in CI Python. Four Linux lanes, one compatibility job, three Windows lanes, and the Python SDK job permit at most nine worker jobs initially. Once the SDK job finishes, those four Linux lanes, one compatibility job, and three Windows lanes run beside the two runtime targets, permitting at most ten. Each runtime caller's plan and wheel stages precede its target and each use one job. This permits runtime validation to overlap the Node 24.9 build within the original ten-job ceiling. Coverage partitions reuse compact file-cost metadata within their platform and runner pool. Parallel release packing uses pnpm's existing bounded scheduler; serial publication keeps family order. +Benchmark sample counts stay fixed. The Linux artifact consumers emit artifacts only; the aggregate test and script type checks belong to the master lanes, and lint plus duplication run in their own lane, which compiles package declarations because oxlint's type-aware rules read them, so a red lint no longer costs a rerun of the snapshot and artifact consumers. The two compatibility cases run one job at a time; both x64 Python runtime targets start after the keyless Python SDK job succeeds in CI Python. Five Linux lanes, one compatibility job, three Windows lanes, and the Python SDK job permit at most eleven worker jobs initially. Once the SDK job finishes, those five Linux lanes, one compatibility job, and three Windows lanes run beside the two runtime targets, permitting at most eleven. Each runtime caller's plan and wheel stages precede its target and each use one job. This permits runtime validation to overlap the Node 24.9 build within the eleven-job ceiling. Coverage partitions reuse compact file-cost metadata within their platform and runner pool. Parallel release packing uses pnpm's existing bounded scheduler; serial publication keeps family order. Each Node version runs the same eight source compatibility specs in one serial Vitest invocation. Both file parallelism and the CLI worker limit are fixed to one, and the gate sets `VITEST_MAX_WORKERS=1` because that environment variable overrides Vitest's CLI limit. Files retain fork isolation; the build-backed Node 24.9 lazy-search smoke keeps its separate build dependency. The original seven-spec paired samples preserve all 253 assertion outcomes and reduce startup-only median time from 11.52 to 8.69 seconds, without increasing owned descendant-process peak. diff --git a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md index 56fa7cbaa5..45ea09f6f6 100644 --- a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md +++ b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md @@ -20,7 +20,7 @@ Python executable builder 在验证 runtime closure 后,通过 `--build-artifa CI 的 PR 产物构建调用 root manifest 中的 `build:artifacts`;修改 root manifest 中这些 script 的 PR 会激活独立的 native-addon 平台矩阵,[构建拆分](2026-10-11-build-artifacts-check-split.zh.md)记录这一取舍。vendor 演练与 benchmark lane 直接调用内部 build helper。 -job 清单、runner 选择和 benchmark sample 数量保持固定;gate 与 worker 上限不提高。两个 compatibility case 每次运行一个 job;两个 x64 Python runtime target 都在 CI Python 中于 keyless Python SDK job 成功后启动。四个 Linux lane、一个 compatibility job、三个 Windows lane 与 Python SDK job,初始最多占用九个 worker job。SDK job 完成后,这四个 Linux lane、一个 compatibility job 与三个 Windows lane 与两个 runtime target 并行,最多占用十个 job。每个 runtime 调用方的 plan 与 wheel 阶段先于其 target,各占一个 job。这允许 runtime 验证与 Node 24.9 构建重叠,同时保持原有十个 job 的上限。覆盖率分区在各自的平台和 runner pool 内复用精简的文件成本元数据。并行 release 打包使用 pnpm 现有的有界调度器;串行发布保持 family 顺序。 +benchmark sample 数量保持固定。Linux 产物消费者只产出产物;聚合的 test 与 script 类型检查归 master lane;lint 与 duplication 单独占用一个 lane,该 lane 会先编译包声明(oxlint 的类型感知规则要读它们),因此 lint 变红不再让 snapshot 与 artifact 消费者整体重跑。两个 compatibility case 每次运行一个 job;两个 x64 Python runtime target 都在 CI Python 中于 keyless Python SDK job 成功后启动。五个 Linux lane、一个 compatibility job、三个 Windows lane 与 Python SDK job,初始最多占用十一个 worker job。SDK job 完成后,这五个 Linux lane、一个 compatibility job 与三个 Windows lane 与两个 runtime target 并行,最多占用十一个 job。每个 runtime 调用方的 plan 与 wheel 阶段先于其 target,各占一个 job。这允许 runtime 验证与 Node 24.9 构建重叠,同时保持十一个 job 的上限。覆盖率分区在各自的平台和 runner pool 内复用精简的文件成本元数据。并行 release 打包使用 pnpm 现有的有界调度器;串行发布保持 family 顺序。 每个 Node 版本在一次串行 Vitest 调用中运行相同的八个 source compatibility spec。文件并行和 CLI worker 上限均固定为一,gate 还设置 `VITEST_MAX_WORKERS=1`,因为该环境变量会覆盖 Vitest 的 CLI 上限。文件保持 fork 隔离;依赖构建的 Node 24.9 lazy-search smoke 保留独立的 build 依赖。原来七个 spec 的成对样本保持全部 253 个 assertion outcome,将启动开销比较的 median 从 11.52 秒降至 8.69 秒,且不增加所拥有 descendant process 的峰值。 diff --git a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml index 30e665ca0b..f318bc236a 100644 --- a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml @@ -9,14 +9,14 @@ en: 212258ada8f174eb zh: 9a02b100533118fe /agent-note-interactive-side-sessions-and-merge-back/proposal: - en: 5fa7d2000db6b265 - zh: a892b74299e0f984 + en: 7f24e61247bf2d84 + zh: aaa397f7d4aaf351 /agent-note-interactive-side-sessions-and-merge-back/alternatives-considered: en: de50cab213a61b91 zh: 85b912193092dfe2 /agent-note-interactive-side-sessions-and-merge-back/acceptance-criteria: - en: 17d0937a1f28b1ac - zh: 9710ab0af3268da1 + en: 3dbfd9c97279519f + zh: 719f54d2c0a68e0a /agent-note-interactive-side-sessions-and-merge-back/risks: en: 2435d8e2aba0d1c1 zh: 3854c476bea061d4 diff --git a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.md b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.md index fd1b79a820..0e6c85bfa2 100644 --- a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.md +++ b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.md @@ -12,7 +12,7 @@ A user may want to explore a question from a live session without changing its m A **side session** is an ordinary live session forked at the source's last completed turn, attached to its own agent, framed as a read-only advisor, and able to **merge back** one condensed note. -- **Fork and attach:** create the child with the parent's balanced completed-turn prefix, `parentSession`, `meta.isSeeded: true`, and the exact sibling `inheritedEventCount`. This composes `ctx.agents.create({ seed, inheritedEventCount, meta })`; it adds no core service or session-store method. +- **Fork and attach:** create the child with the parent's balanced completed-turn prefix, `meta.isSeeded: true`, and the exact sibling `inheritedEventCount`. The caller retains any handback target separately from the copied Session under the [independent-fork decision](../../implemented/architecture/2026-10-23-independent-session-forks.md). This composes `ctx.agents.create({ seed, inheritedEventCount, meta })`; it adds no core service or session-store method. - **Advisor framing:** inject one plugin-sourced `context/message` after creation that tells the child to explain without mutating or continuing the task. Keeping the system prompt byte-identical preserves the provider prefix cache over inherited history. - **Merge-back:** ask the child for a length-capped handback, then inject one plugin-sourced `context/message` into the parent. The next parent request sees it at its logged position, preserving replay and [request reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) without a new session event. - **Presentation:** invocation, session switching, and handback rendering belong to the first client UI. This Agent Note specifies only the client-independent mechanics. @@ -28,7 +28,7 @@ Rewind productization, session-tree views, a model-facing side-session tool, and ## Acceptance criteria -- Forking leaves the source untouched and creates a child with the balanced completed-turn prefix, `parentSession`, `isSeeded: true`, the exact `inheritedEventCount`, and a byte-identical system prompt. +- Forking leaves the source untouched and creates a child with the balanced completed-turn prefix, `isSeeded: true`, the exact `inheritedEventCount`, and a byte-identical system prompt. - Advisor framing adds exactly one plugin-sourced `context/message` at the head of the child's appended history, rather than changing its system prompt. - Merge-back adds exactly one length-capped `context/message` with source `plugin: sidechat`; the next parent request and replay see it at the same position. - Parent and child run concurrently without log or stream cross-talk. diff --git a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md index 5837897709..8bd39eca70 100644 --- a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md +++ b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md @@ -12,7 +12,7 @@ Status: proposed **侧会话(side session)**是一个普通的活跃会话,从源会话的最后一个已完成轮次 fork 而来,绑定到自己的 agent(智能体),定位为只读顾问,并能**合并回写**一条精简笔记。 -- **Fork 并绑定:**用 parent 的平衡 completed-turn prefix、`parentSession`、`meta.isSeeded: true` 与精确 sibling `inheritedEventCount` 创建 child。这组合 `ctx.agents.create({ seed, inheritedEventCount, meta })`;不新增 core service 或 Session-store method。 +- **Fork 并绑定:**用 parent 的平衡 completed-turn prefix、`meta.isSeeded: true` 与精确 sibling `inheritedEventCount` 创建 child。这组合 `ctx.agents.create({ seed, inheritedEventCount, meta })`;不新增 core service 或 Session-store method。 依据[独立 fork 决策](../../implemented/architecture/2026-10-23-independent-session-forks.zh.md),调用方将交回目标保存在所复制 Session 之外。 - **顾问定位:**创建后注入一条插件来源的 `context/message`,告知子会话只做解释,不执行变更或继续任务。保持系统提示词逐字节一致,可在继承的历史上保留提供方的前缀缓存。 - **合并回写:**向子会话请求一条有长度上限的 handback,然后向父会话注入一条插件来源的 `context/message`。父会话的下一次请求在日志所记录的位置看到该消息,保持回放与[请求可重建性](../../implemented/architecture/2026-07-05-reconstructable-requests.zh.md),无需新增会话事件。 - **呈现:**调用方式、会话切换与 handback 渲染属于首个客户端拥有的界面。本 Agent Note 仅规定与界面无关的机制。 @@ -28,7 +28,7 @@ Status: proposed ## 验收标准 -- Fork 不改变 source Session,创建的 child 带有平衡 completed-turn prefix、`parentSession`、`isSeeded: true`、精确 `inheritedEventCount` 与逐字节相同 system prompt。 +- Fork 不改变 source Session,创建的 child 带有平衡 completed-turn prefix、`isSeeded: true`、精确 `inheritedEventCount` 与逐字节相同 system prompt。 - 顾问定位在子会话追加历史的头部恰好添加一条插件来源的 `context/message`,而非修改其系统提示词。 - 合并回写恰好添加一条有长度上限的 `context/message`,来源为 `plugin: sidechat`;父会话的下一次请求与回放在相同位置看到它。 - 父会话与子会话并发运行,日志和流之间无串扰。 diff --git a/.gitattributes b/.gitattributes index e878c9db19..2bead994d2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,3 +13,6 @@ docs/persistence-schema.json linguist-generated=true docs/persistence-changes/*.schema.json linguist-generated=true docs/persistence-changes/releases/*.schema.json linguist-generated=true docs/persistence-changes/historical-formats/*.schema.json linguist-generated=true + +# Unified patch blank context lines require their leading space. +patches/*.patch whitespace=-blank-at-eol diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 0c869d008a..6bcdbee5ac 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -264,6 +264,10 @@ jobs: - name: Build single-exe env: DSH_BUILD_CLIENT_PROFILE: official + # Brotli runs at quality 9 through the vendored pkg patch: the same + # payload measured 16.3 s at quality 9 against 132.8 s at pkg's default + # quality 11, for an archive 10% larger. Which quality each lane uses is + # therefore not a workflow choice. run: >- pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }} ${{ inputs.ci && github.event_name == 'pull_request' && '--build-artifacts' || '' }} diff --git a/.github/workflows/build-preview-cloudflare.yml b/.github/workflows/build-preview-cloudflare.yml index c2ff5d3231..5625d75a9c 100644 --- a/.github/workflows/build-preview-cloudflare.yml +++ b/.github/workflows/build-preview-cloudflare.yml @@ -3,7 +3,9 @@ name: Build PR preview # Every push to a pull request publishes that pull request's preview to # Cloudflare Pages under its own branch alias, behind Cloudflare Access. The # upload carries build products only: the workflow never grants the deployment -# platform access to this repository's sources. +# platform access to this repository's sources. This lane also owns the +# aggregate test and script type checks that the required Linux consumers no +# longer repeat on every pull request. on: pull_request: @@ -32,7 +34,13 @@ env: jobs: preview: - runs-on: ubuntu-24.04 + # The required outcome is the build, which runs four TypeScript programs + # (Host and Client package compilation, then each face's aggregate test and + # script check) after the Typert pass. Publishing is best effort: transport, + # Access, and comment failures warn instead of failing the check. + runs-on: >- + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404' + || 'ubuntu-24.04' }} name: cloudflare pages preview steps: - uses: actions/checkout@v7.0.1 @@ -70,8 +78,11 @@ jobs: # apps/web consumes workspace packages as built lib products, and # build:preview packs the image through the packer's installed bin # (lib/bin.js), so neither half exists before the artifact build runs. - - name: Build workspace - run: pnpm run build:artifacts + # The required Linux consumers emit artifacts only; the complete build + # here also runs the aggregate test and script checks, which share the + # Typert output and the two package compilations. + - name: Build workspace and check aggregate programs + run: pnpm run build - name: Build the preview page and pack the VFS image env: @@ -88,6 +99,8 @@ jobs: cp apps/web/dist/preview.html apps/web/dist/index.html - name: Upload to Cloudflare Pages + id: upload + continue-on-error: true env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} @@ -112,6 +125,8 @@ jobs: # Accept-Encoding is sent because a browser sends it; the assertion is # about what the platform does with a body that is already compressed. - name: Verify the protected deployment serves the image + id: verify + continue-on-error: true env: CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }} CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }} @@ -151,6 +166,8 @@ jobs: # idempotent: a pull request opened before this workflow existed never # sees an `opened` event, and every later push must not restate the URL. - name: Comment the preview URL + id: comment + continue-on-error: true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR: ${{ github.event.pull_request.number }} @@ -168,3 +185,12 @@ jobs: "$marker" \ "[Preview for #$PR](https://pr-$PR.${CF_PROJECT}.pages.dev) (requires Cloudflare Access sign-in)" \ | gh pr comment "$PR" --body-file - + + # Publishing stays outside the required verdict: a Cloudflare, Access, or + # comment failure warns, while a build failure still fails this check. + - name: Report preview publication failures + if: steps.upload.outcome == 'failure' || steps.verify.outcome == 'failure' || steps.comment.outcome == 'failure' + run: | + message='preview publication failed; the build passed, see the upload, verify, and comment steps' + echo "::warning::$message" + echo "$message" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index fa2473a3a3..a4ed098609 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -76,7 +76,7 @@ jobs: - name: Parse Windows runner supervision scripts shell: pwsh run: | - foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-launcher.ps1')) { + foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/scripts/start-windows-packaging-runner.ps1', 'apps/desktop/scripts/windows-runner-network.ps1', 'apps/desktop/scripts/run-windows-runner-network-process.ps1', 'apps/desktop/tests/windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-launcher.ps1', 'apps/desktop/tests/windows-runner-network.ps1', 'apps/desktop/tests/windows-runner-network-process.ps1')) { $tokens = $null $parseErrors = $null $null = [Management.Automation.Language.Parser]::ParseFile((Join-Path $env:GITHUB_WORKSPACE $path), [ref]$tokens, [ref]$parseErrors) @@ -85,6 +85,15 @@ jobs: - name: Verify windowless Windows runner launcher shell: pwsh run: ./apps/desktop/tests/windows-runner-launcher.ps1 + - name: Verify Windows packaging priority inheritance + shell: pwsh + run: ./apps/desktop/tests/windows-packaging-priority.ps1 + - name: Verify Windows runner network supervision + shell: pwsh + run: | + ./apps/desktop/tests/windows-runner-network.ps1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + ./apps/desktop/tests/windows-runner-network-process.ps1 - name: Run Windows observational gates on built artifacts id: observational continue-on-error: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae9db81e61..916971cfa4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,12 +202,81 @@ jobs: path: .coverage-times.json key: coverage-times-Linux-${{ runner.environment }}-${{ vars.DSH_CI_FAILOVER_LINUX || 'enterprise' }}-${{ github.run_id }}-${{ github.run_attempt }} + # Lint and duplication get their own lane instead of riding the artifact + # consumers: a red lint no longer costs the snapshot and artifact consumers a + # full rerun. The lane compiles package declarations because oxlint's + # type-aware rules read them. + node-24-lint: + if: github.event_name == 'pull_request' + runs-on: >- + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-8vcpu-ubuntu-2404' + || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' + && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') + || 'dsh-ubuntu-24-04-16core' }} + name: node 24 / lint + env: + # Shared hosts expose host CPUs, not an isolated allocation. + DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }} + DSH_OXLINT_THREADS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }} + steps: + # Runner cleanup owns tool caches and fixtures, including cancelled runs. + - name: Use runner-owned temporary storage + run: | + echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV" + + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + + - name: Redirect Node compile cache to runner temp + run: echo "NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache" >> "$GITHUB_ENV" + + - uses: pnpm/action-setup@v5.0.0 + with: + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + + - uses: actions/setup-node@v7.0.0 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + + - uses: actions/cache/restore@v6.1.0 + if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + # oxlint's type-aware rules resolve workspace imports through the package + # declarations, so this lane compiles them without bundling anything. + - name: Compile package declarations + run: pnpm run typecheck + + - name: Run lint and duplication + run: pnpm run check:ci:lint:contracts-ready + node-24-bench: if: github.event_name == 'pull_request' - runs-on: ubuntu-24.04 + runs-on: >- + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404' + || 'ubuntu-24.04' }} name: node 24 / benchmarks # Wall-clock budgets need an otherwise idle runner, so this job runs the - # benchmark lane alone instead of joining a concurrent gate aggregate. + # benchmark lane alone instead of joining a concurrent gate aggregate, and + # keeps its own two-file parallelism inside that runner. The self-hosted + # failover pool is shared, so the benchmarks stay on hosted Linux there. timeout-minutes: 20 steps: - uses: actions/checkout@v7.0.1 @@ -503,7 +572,7 @@ jobs: && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'ubuntu-latest' }} - needs: [node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat] + needs: [node-24, node-24-coverage, node-24-lint, node-24-bench, node-24-consumers, node-compat] if: ${{ !cancelled() && github.event_name == 'pull_request' }} steps: - name: Fail if any needed job did not succeed diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 726bd070a5..32df0173e9 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -122,6 +122,6 @@ jobs: DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} DEEPSEEK_BASE_URL: https://api.deepseek.com/anthropic DSH_PRIMARY_RUNTIME: ${{ github.workspace }}/.cache/e2e-office/primary-runtime - DSH_E2E_MAX_WORKERS: 4 + DSH_E2E_MAX_WORKERS: 6 DSH_EXAMPLE_MODE: lib run: pnpm run test:e2e diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index b0c3164729..eeb451266e 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -9,6 +9,12 @@ on: options: [test, production] default: test required: true + zip_compression_level: + description: 'ZIP 压缩等级:只影响 ZIP 和自动升级下载,不影响 DMG;3 打包较快,7 压缩更强' + type: choice + options: ['3', '7'] + default: '3' + required: true skip_notarization: description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选' type: boolean @@ -57,6 +63,7 @@ jobs: run: shell: bash env: + PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.zip_compression_level }} PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }} PACKAGE_SOURCE_REF: ${{ inputs.source_ref }} PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} @@ -95,6 +102,12 @@ jobs: - uses: actions/setup-node@v7.0.0 with: node-version: '24' + - name: Configure persistent package caches + env: + DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache + run: | + echo "DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR" >> "$GITHUB_ENV" + echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV" - name: Install locked dependencies run: pnpm install --frozen-lockfile - name: Validate preset and resolve build version @@ -138,5 +151,6 @@ jobs: name: macos-arm64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} include-hidden-files: true path: ${{ steps.retained.outputs.directory }}/* + compression-level: 0 if-no-files-found: error retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9def1dec1..c88dec8687 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,8 @@ jobs: name: Dependency layout # Persistent runners accept only trusted, credential-free rehearsals. runs-on: >- - ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404' + || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.repository == 'deepseek-harness/deepseek-harness' && github.actor != 'dependabot[bot]' && ((github.event_name == 'push' && github.ref == 'refs/heads/master') @@ -80,11 +81,20 @@ jobs: - name: Install (immutable) run: pnpm install --frozen-lockfile - - name: Verify dependency policy - run: pnpm run verify-package-dependencies - - - name: Verify npm install layout - run: pnpm run verify-npm-install-layout + # The two verifications read different inputs (manifests versus an npm + # install in a scratch directory), so they run beside each other. + - name: Verify dependency policy and npm install layout + run: | + pnpm run verify-package-dependencies & + dependencies_pid=$! + pnpm run verify-npm-install-layout & + layout_pid=$! + dependencies_status=0 + wait "$dependencies_pid" || dependencies_status=$? + layout_status=0 + wait "$layout_pid" || layout_status=$? + if (( dependencies_status != 0 )); then exit "$dependencies_status"; fi + exit "$layout_status" pack: name: Pack npm tarballs diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index bc76eca87b..c38fd7d5a0 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -42,6 +42,22 @@ on: type: string default: '' required: false + cache_namespace: + description: '缓存测量命名空间:留空使用共享缓存;新名称用于冷缓存,再次使用同名测热缓存,不清除现有缓存' + type: string + default: '' + required: false + build_root: + description: '独立构建根目录:填写绝对路径,每次任务创建独立源码副本;留空使用 runner 工作区' + type: string + default: '' + required: false + compression_level: + description: '安装器压缩等级:3 打包较快,7 压缩更强;覆盖本轮机器配置' + type: choice + options: ['3', '7'] + default: '3' + required: true permissions: contents: read @@ -65,6 +81,9 @@ jobs: DSH_PACKAGING_PRESET: ${{ inputs.preset }} PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }} PACKAGE_SIGNING: ${{ inputs.signing }} + PACKAGE_CACHE_NAMESPACE: ${{ inputs.cache_namespace }} + PACKAGE_BUILD_ROOT: ${{ inputs.build_root }} + PACKAGE_WINDOWS_COMPRESSION_LEVEL: ${{ inputs.compression_level }} steps: - name: Reset packaging checkout working-directory: ${{ runner.temp }} @@ -77,6 +96,20 @@ jobs: } node -e "const fs = require('node:fs'); fs.rmSync(process.argv[1], { recursive: true, force: true, maxRetries: 3 }); fs.mkdirSync(process.argv[1])" $workspace if ($LASTEXITCODE -ne 0) { throw 'Checkout cleanup failed' } + - name: Checkout workflow helpers + uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + ref: ${{ github.workflow_sha }} + sparse-checkout: apps/desktop/scripts + - name: Stage workflow helpers + run: | + $helpers = Join-Path $env:RUNNER_TEMP "packaging-helpers-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" + New-Item -ItemType Directory -Path $helpers -ErrorAction Stop | Out-Null + foreach ($name in @('windows-packaging-priority.ps1', 'windows-build-workspace.mjs')) { + Copy-Item -LiteralPath (Join-Path 'apps/desktop/scripts' $name) -Destination $helpers -ErrorAction Stop + } + "PACKAGE_CI_HELPERS=$helpers" >> $env:GITHUB_ENV - uses: actions/checkout@v7.0.1 id: checkout with: @@ -107,16 +140,50 @@ jobs: - uses: actions/setup-node@v7.0.0 with: node-version: '24' - - name: Install locked dependencies + - name: Select packaging workspace + id: build_workspace + env: + PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} run: | + if (!$env:PACKAGE_BUILD_ROOT) { + "directory=$env:GITHUB_WORKSPACE" >> $env:GITHUB_OUTPUT + } else { + node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs" + exit $LASTEXITCODE + } + - name: Configure persistent pnpm store + # PNPM_HOME belongs to this run; keep downloaded packages outside its temporary directory. + env: + DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache + run: | + $storeRoot = Join-Path ([IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)) '.pnpm-store' + $namespace = $env:PACKAGE_CACHE_NAMESPACE + if ($namespace) { + if ($namespace -cnotmatch '^[a-z0-9][a-z0-9-]{0,63}\z') { throw 'Cache namespace must contain 1-64 lowercase letters, digits or hyphens' } + if ($namespace -match '^(con|prn|aux|nul|com[1-9]|lpt[1-9])$') { throw 'Cache namespace cannot be a Windows device name' } + $storeRoot = Join-Path $storeRoot $namespace + $env:DSH_DESKTOP_PACKAGE_CACHE_DIR = Join-Path $env:DSH_DESKTOP_PACKAGE_CACHE_DIR $namespace + } + "DSH_DESKTOP_PACKAGE_CACHE_DIR=$env:DSH_DESKTOP_PACKAGE_CACHE_DIR" >> $env:GITHUB_ENV + "PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV + - name: Install locked dependencies + working-directory: ${{ steps.build_workspace.outputs.directory }} + run: | + & "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort + node -e "const os=require('node:os'); console.log(JSON.stringify({node:process.version,availableParallelism:os.availableParallelism(),logicalCpus:os.cpus().length,totalMemoryBytes:os.totalmem(),freeMemoryBytes:os.freemem()}))" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + pnpm store path + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } pnpm install --frozen-lockfile exit $LASTEXITCODE - name: Validate preset and resolve build version id: settings + working-directory: ${{ steps.build_workspace.outputs.directory }} env: PACKAGE_BUILD_VERSION: ${{ inputs.build_version }} run: node apps/desktop/scripts/prepare-windows-ci.mjs - name: Check packaging configuration and toolchain + working-directory: ${{ steps.build_workspace.outputs.directory }} env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: | @@ -125,15 +192,23 @@ jobs: pnpm @packageArgs exit $LASTEXITCODE - name: Build installer and verify packaged runtime + working-directory: ${{ steps.build_workspace.outputs.directory }} env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} + PACKAGE_BUILD_TEMP: ${{ steps.build_workspace.outputs.temporaryDirectory }} run: | + & "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort + if ($env:PACKAGE_BUILD_TEMP) { + $env:TEMP = $env:PACKAGE_BUILD_TEMP + $env:TMP = $env:PACKAGE_BUILD_TEMP + } $packageArgs = @('run', 'package:desktop:win:x64', '--build-version', $env:PACKAGE_VERSION) if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' } pnpm @packageArgs exit $LASTEXITCODE - name: Retain complete package outside the checkout id: retained + working-directory: ${{ steps.build_workspace.outputs.directory }} env: PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} PACKAGE_VERSION: ${{ steps.settings.outputs.version }} @@ -144,7 +219,7 @@ jobs: with: name: windows-package-logs-${{ github.run_id }}-${{ github.run_attempt }} include-hidden-files: true - path: apps/desktop/.desktop-build/packaging-runs/ + path: ${{ steps.build_workspace.outputs.directory || github.workspace }}/apps/desktop/.desktop-build/packaging-runs/ if-no-files-found: warn retention-days: 14 - name: Retain verified unsigned installer @@ -152,6 +227,7 @@ jobs: uses: actions/upload-artifact@v7.0.1 with: name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + compression-level: 0 include-hidden-files: true path: ${{ steps.retained.outputs.directory }}/* if-no-files-found: error @@ -161,7 +237,13 @@ jobs: uses: actions/upload-artifact@v7.0.1 with: name: windows-x64-signed-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + compression-level: 0 include-hidden-files: true path: ${{ steps.retained.outputs.directory }}/* if-no-files-found: error retention-days: 14 + - name: Remove isolated packaging workspace + if: success() && inputs.build_root != '' + env: + PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} + run: node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs" --remove diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 51737ac1ed..34f5886c04 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -112,6 +112,7 @@ External packages installed for runtime use or embedded in distributed Host or b | [`micromark-factory-space`](https://github.com/micromark/micromark/tree/main/packages/micromark-factory-space) | MIT | | [`micromark-util-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) | MIT | | [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT | +| [`micromark-util-decode-string`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-decode-string) | MIT | | [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT | | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | | [`mime`](https://github.com/broofa/mime) | MIT | diff --git a/apps/cli/package.json b/apps/cli/package.json index f6d4f804f1..e372b13247 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile launch, plugin management, and configuration inspection", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, @@ -107,7 +107,8 @@ "@deepseek-ai/dsh-experimental-session-search": "workspace:*", "@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*", "@deepseek-ai/dsh-cua-bundle": "workspace:*", - "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*" + "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*", + "@deepseek-ai/dsh-personalized-recommendations": "workspace:*" }, "devDependencies": { "@deepseek-ai/dsh-skill-badge": "workspace:*", diff --git a/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts index 9468f874cc..c97e093237 100644 --- a/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts +++ b/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts @@ -1,4 +1,4 @@ -/** Account catalog diagnostics preserve the shipped ACP profile's JSON-RPC stream. */ +/** Account catalog and MCP resource-listing diagnostics keep the shipped ACP profile's stdout JSON-RPC only. */ import { once } from 'node:events' import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' @@ -6,10 +6,22 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' import { launchAcpTestAgent, type AgentUnderTest, type LaunchedAcpTestAgent } from '@deepseek-ai/dsh-session-snapshot' import { expect, it } from 'vitest' import { cleanupAcpExampleTest } from './cleanup.ts' +const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url)) +const binScript = join(repoRoot, 'apps/cli/src/bin.ts') +const tsconfigPath = join(repoRoot, 'tsconfig.json') + +/** Parse every stdout line as a JSON-RPC frame; a diagnostic line fails the parse with its leading text. */ +function jsonRpcFrames(launched: LaunchedAcpTestAgent): Record[] { + const frames = launched.rawStdout().trim().split('\n').map(line => JSON.parse(line) as Record) + for (const frame of frames) expect(frame).toMatchObject({ jsonrpc: '2.0' }) + return frames +} + it('keeps account model-listing diagnostics on stderr during API-key session creation', async () => { const cwd = await mkdtemp(join(tmpdir(), 'dsh-acp-stdout-')) const token = 'acp-catalog-fixture-token' @@ -100,3 +112,48 @@ it('keeps account model-listing diagnostics on stderr during API-key session cre if (failures.length > 0) throw new AggregateError(failures, 'ACP catalog test cleanup failed') } }) + +it('keeps MCP resource-listing diagnostics off stdout for a tools-only client server', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-acp-stdout-')) + const llm = await startMockLlmServer({ + sequence: ['tool_call_success', 'success'], + toolName: 'list_mcp_resources', + toolArguments: JSON.stringify({ server: 'fixture' }), + }) + let launched: LaunchedAcpTestAgent | undefined + try { + const configPath = join(cwd, 'mcp.patch.yml') + await writeFile(configPath, JSON.stringify([ + { id: 'llm-deepseek', config: { baseURL: llm.baseURL, models: [{ id: 'fixture-api-key-model' }] } }, + { id: 'acp', config: { provider: 'deepseek-official', model: 'fixture-api-key-model' } }, + { id: 'session-persistence-jsonl', config: { root: join(cwd, 'sessions'), compression: 'none' } }, + ]) + '\n') + launched = launchAcpTestAgent({ + agent: { binScript, configPath, profile: 'acp', tsconfigPath }, + cwd, + env: { DEEPSEEK_API_KEY: 'acp-fixture-api-key', DEEPSEEK_BASE_URL: llm.baseURL, DSH_TELEMETRY_DISABLED: '1' }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + // The fixture server advertises only the tools capability. + const mcpServer = join(repoRoot, 'packages/mcp/mcp-client/tests/fixture-server.ts') + const created = await launched.client.newSession({ + cwd, mcpServers: [{ name: 'fixture', command: process.execPath, args: [mcpServer], env: [] }], + }) + await expect(launched.client.prompt({ + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'list the fixture resources' }], + })).resolves.toEqual({ stopReason: 'end_turn' }) + expect(launched.updates).toContainEqual(expect.objectContaining({ sessionUpdate: 'tool_call_update', status: 'completed' })) + await launched.client.closeSession({ sessionId: created.sessionId }) + await launched.close() + + expect(launched.child.exitCode).toBe(0) + expect(llm.requests.map(request => request.behavior)).toEqual(['tool_call_success', 'success']) + expect(jsonRpcFrames(launched).length).toBeGreaterThanOrEqual(5) + } finally { + const cleanup = await Promise.allSettled([cleanupAcpExampleTest(launched, cwd), llm.close()]) + const failures: unknown[] = [] + for (const result of cleanup) if (result.status === 'rejected') failures.push(result.reason) + if (failures.length > 0) throw new AggregateError(failures, 'ACP MCP test cleanup failed') + } +}) diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index 5dcddb3daa..ef3bfd7af1 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop-host", "description": "Private Node-mode host process for the Electron desktop application", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/.env.macos.example b/apps/desktop/.env.macos.example index da5502ce5d..cc3d21f3e8 100644 --- a/apps/desktop/.env.macos.example +++ b/apps/desktop/.env.macos.example @@ -1,7 +1,7 @@ # Release configuration only; local --unsigned builds ignore this file. # Copy to .env.macos (Git ignored). Use UTF-8 and quote secrets containing # or spaces. # Paths may be absolute or relative to apps/desktop. Values are not shell-expanded. -DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_APP_ID=com.deepseek.dsh DSH_DESKTOP_AUTO_UPDATE_ENV=test DOWNLOAD_TEST_ORIGIN= # Required for test releases: 32 lowercase hexadecimal characters from a secure random generator. @@ -14,6 +14,7 @@ DOWNLOAD_TEST_RELEASE_ID= # Workspace tarball workers; omit to use 4. Must be a positive integer. DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4 +DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=3 # Optional independent proxy origins; empty values preserve inherited networking. # Downloads: HTTP/HTTPS proxy for Electron, runtime assets, pnpm and builder downloads. DSH_DESKTOP_MACOS_DOWNLOAD_PROXY= diff --git a/apps/desktop/.env.windows.example b/apps/desktop/.env.windows.example index 4950c70b27..9d1a976d5d 100644 --- a/apps/desktop/.env.windows.example +++ b/apps/desktop/.env.windows.example @@ -1,6 +1,6 @@ # Copy to .env.windows (Git ignored). Use UTF-8 and quote secrets containing # or spaces. # Credential file paths may be absolute or relative to apps/desktop. Values are not shell-expanded. -DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_APP_ID=com.deepseek.dsh DSH_DESKTOP_AUTO_UPDATE_ENV=test DOWNLOAD_TEST_ORIGIN= # Required for test releases: 32 lowercase hexadecimal characters from a secure random generator. @@ -31,6 +31,9 @@ DSH_DESKTOP_WINDOWS_TOKEN_PIN= # Cache-hit restore and verification workers (1-8). Hardware signing remains serial. DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4 +# Installer archive compression (0-9); lower values trade size for packaging time. +DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3 + # Optional upload configuration; packaging does not require these credentials. # DOWNLOAD_TEST_COS_BUCKET= # DOWNLOAD_TEST_COS_SECRET_ID= diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index e1b34a0dca..e7fced6b8e 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -15,8 +15,8 @@ en: 127ea90553cb6af7 zh: 7b1c5aec8558c684 /deepseek-harness-desktop/application-overview: - en: 4ef114678c4e4cc3 - zh: 2a65eab776634cbc + en: b57525e17b9638ab + zh: 274fe8344f7c2202 /deepseek-harness-desktop/terminal-command: en: 5f2d25a1d8472647 zh: 2acb5c5d7d1917e2 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 57da4db0f3..f961a72a92 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -43,7 +43,7 @@ The first application-menu command, **About DeepSeek Harness**, opens Electron's Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog. -Desktop ships one pnpm distribution at `resources/runtime/primary-runtime/dependencies/pnpm`. Build-time production installation, the installed `dsh` CLI, Creator and the Web Plugin Manager execute its `bin/pnpm.mjs` with Electron Node mode; package operations do not require pnpm on PATH. The private Node launcher environment applies only to package operations. Agent workspace dependencies copy the same self-contained primary-runtime payload into the Harness home and execute pnpm with its standalone Node. +Desktop ships one pnpm distribution at `resources/runtime/primary-runtime/dependencies/pnpm`, without the native packages of other targets. Build-time production installation, the installed `dsh` CLI, Creator and the Web Plugin Manager execute its `bin/pnpm.mjs` with Electron Node mode; package operations do not require pnpm on PATH. The private Node launcher environment applies only to package operations. Agent workspace dependencies copy the same self-contained primary-runtime payload into the Harness home and execute pnpm with its standalone Node. Embedded Platform documents use a persistent WebContentsView partition keyed by a hash of the Platform origin and stable account ID. localStorage preferences, including dismissed notices, survive view closure and application restart; different accounts and origins use separate storage. The Host supplies the account ID from its latest successful profile read; while no ID is known, the document opens in a disposable partition that keeps no preference across opens. Opening a persistent partition clears cookies, filesystem, IndexedDB, Cache Storage, the HTTP and shader caches, service workers, and HTTP authentication before the document loads; a disposable partition clears all of its storage. Closing a view destroys the document, removes its request interceptors, and schedules the same cleanup, so authentication left by an unclean exit is cleared before the next document loads. The next open and application shutdown await that cleanup, and an update install waits for it before the installer takes over the exit. A failed cleanup rejects that open and blocks the same account until a later cleanup succeeds; other accounts keep opening. Sign-out destroys the document but retains account-scoped preferences for the next sign-in. A document already open in a disposable partition stays mounted when the account ID arrives for the same credential; the next open uses the account-scoped partition. The [storage decision](../../.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.md) describes the retention policy. Host sends account credentials through private Node IPC; account RPC and the Harness renderer receive no token. Before page scripts execute, the Platform preload performs one synchronous IPC to read the prepared main-process credential. It exposes displayMode, synchronous getAuthToken() and getLocale() getters, and onLocaleChange(listener), which returns an unsubscribe function. Both getters read preload memory without further IPC. The bootstrap includes the resolved Desktop language (`zh_CN` or `en_US`); Settings language changes update the preload cache and notify the open Platform document without reloading. Platform applies this language before its first render and does not persist it as a browser preference. The main-process handler performs only sender validation and an in-memory read; it never waits for Host, disk, or network. Trusted-page initialization failures retain embedded mode and make the getter throw, preventing browser-credential fallback. Only the owned Platform main frame on the configured issuer origin can initialize. Sign-out, credential replacement, Host shutdown, and view closure destroy the document. Cross-origin document navigation is blocked. HTTPS links requesting a new window open in the system browser without the embedded session or token; other schemes and URL credentials are rejected. Native views occupy the viewport below the Account feature’s return bar. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index d9760964bb..a7500d5d28 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -43,7 +43,7 @@ Desktop 绑定 `127.0.0.1`,即 Electron 为就绪 URL 与 WebSocket 凭据过 Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。 -Desktop 在 `resources/runtime/primary-runtime/dependencies/pnpm` 中携带一份 pnpm 分发包。构建时的生产依赖安装、安装版 `dsh` CLI、Creator 和 Web 插件管理器通过 Electron Node 模式执行其中的 `bin/pnpm.mjs`;包操作不要求 PATH 上存在 pnpm。私有 Node 启动器环境仅用于包操作。agent(智能体)的工作区依赖将同一份完整 primary-runtime 复制到 Harness 主目录,并使用其中的独立 Node 执行 pnpm。 +Desktop 在 `resources/runtime/primary-runtime/dependencies/pnpm` 中携带一份 pnpm 分发包,不含其他目标平台的原生包。构建时的生产依赖安装、安装版 `dsh` CLI、Creator 和 Web 插件管理器通过 Electron Node 模式执行其中的 `bin/pnpm.mjs`;包操作不要求 PATH 上存在 pnpm。私有 Node 启动器环境仅用于包操作。agent(智能体)的工作区依赖将同一份完整 primary-runtime 复制到 Harness 主目录,并使用其中的独立 Node 执行 pnpm。 Platform 内嵌文档使用持久化 WebContentsView 分区,分区名由 Platform 来源和稳定账号 ID 的哈希决定。localStorage 中的页面偏好(包括已关闭的通知)在关闭视图和重启应用后保留;不同账号和来源使用独立存储。账号 ID 来自 Host 最近一次成功的资料读取;尚无该 ID 时,文档在一次性分区中打开,该分区不跨次保留偏好。打开持久分区会先清理 Cookie、文件系统、IndexedDB、Cache Storage、HTTP 与着色器缓存、Service Worker 及 HTTP 认证状态;一次性分区则清理其全部存储。关闭视图会销毁文档、移除请求拦截器并安排相同的清理,因此异常退出遗留的认证会在下一个文档加载前被清除。下次打开和应用退出都会等待该清理完成,更新安装也会在安装器接管退出前等待。清理失败会使该次打开失败,并在后续清理成功前阻止同一账号打开;其他账号不受影响。退出登录会销毁文档,但保留账号偏好供下次登录使用。同一凭证下账号 ID 迟到时,已以一次性分区打开的文档保持挂载;下次打开使用账号分区。[存储决策](../../.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.zh.md)说明保留策略。Host 通过私有 Node IPC 发送账号凭证;账号 RPC 和 Harness 渲染进程不接收 token。Platform preload 在页面脚本执行前通过一次同步 IPC 读取主进程中已准备的凭证。它暴露 displayMode、同步的 getAuthToken() 和 getLocale() getter,以及返回取消订阅函数的 onLocaleChange(listener)。两个 getter 都只读取 preload 内存,不再调用 IPC。bootstrap 包含 Desktop 已解析的语言(`zh_CN` 或 `en_US`);Settings 语言变更会更新 preload 缓存并通知已打开的 Platform 文档,无需重载。Platform 在首屏渲染前应用该语言,且不将其持久化为浏览器偏好。主进程处理器仅校验调用来源并读取内存,不等待 Host、磁盘或网络。可信页面初始化失败时保留内嵌模式,由 getter 抛错,避免回退到浏览器凭证。只有受控 Platform 页面中、位于所配置签发来源的主 frame 能完成初始化。退登、凭证替换、Host 关闭及视图关闭都会销毁文档。跨来源文档导航被阻止。请求新窗口的 HTTPS 链接在系统浏览器中打开,不携带内嵌会话或 token;其他协议及带 URL 凭证的链接被拒绝。原生视图占据 Account 功能返回栏下方的视口。 diff --git a/apps/desktop/config/macos-packaging/production.env.example b/apps/desktop/config/macos-packaging/production.env.example index 18360eadef..a5a5477fbe 100644 --- a/apps/desktop/config/macos-packaging/production.env.example +++ b/apps/desktop/config/macos-packaging/production.env.example @@ -1,8 +1,9 @@ # Copy to production.env and configure the production service before selecting this preset. -DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_APP_ID=com.deepseek.dsh DSH_DESKTOP_AUTO_UPDATE_ENV=production DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN= DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4 +DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=3 DSH_DESKTOP_MACOS_DOWNLOAD_PROXY= DSH_DESKTOP_MACOS_NOTARIZATION_PROXY= # Optional absolute private directory; setup fills this in for machine presets. Omit to use checkout-local storage. diff --git a/apps/desktop/config/macos-packaging/test.env.example b/apps/desktop/config/macos-packaging/test.env.example index aa40a119ce..4c5de070a5 100644 --- a/apps/desktop/config/macos-packaging/test.env.example +++ b/apps/desktop/config/macos-packaging/test.env.example @@ -1,5 +1,5 @@ # Copy to test.env; paths in secret configuration resolve beside macos-signing.env. -DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_APP_ID=com.deepseek.dsh DSH_DESKTOP_AUTO_UPDATE_ENV=test DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN= DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}' @@ -7,6 +7,7 @@ DOWNLOAD_TEST_ORIGIN= # Generate a 32-character lowercase hexadecimal release ID; retain it for upgrade testing. DOWNLOAD_TEST_RELEASE_ID= DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4 +DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=3 DSH_DESKTOP_MACOS_DOWNLOAD_PROXY= # Temporarily changes the active network service's system HTTP/HTTPS proxies. DSH_DESKTOP_MACOS_NOTARIZATION_PROXY= diff --git a/apps/desktop/config/windows-packaging/production.env.example b/apps/desktop/config/windows-packaging/production.env.example index 2e05825590..dbbcfb73d5 100644 --- a/apps/desktop/config/windows-packaging/production.env.example +++ b/apps/desktop/config/windows-packaging/production.env.example @@ -1,7 +1,9 @@ # Copy to production.env in the same machine configuration directory and fill in the values. -DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_APP_ID=com.deepseek.dsh DSH_DESKTOP_AUTO_UPDATE_ENV=production DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN= DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{}' # DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR= DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4 +# Installer archive compression (0-9). +DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3 diff --git a/apps/desktop/config/windows-packaging/test.env.example b/apps/desktop/config/windows-packaging/test.env.example index 718481f13a..5a4e2ccf90 100644 --- a/apps/desktop/config/windows-packaging/test.env.example +++ b/apps/desktop/config/windows-packaging/test.env.example @@ -1,6 +1,6 @@ # Copy to test.env in the same machine configuration directory and fill in the values. # Public test configuration. No PIN or upload credentials belong here. -DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_APP_ID=com.deepseek.dsh DSH_DESKTOP_AUTO_UPDATE_ENV=test DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN= # Replace login.example.com with the real login origin before packaging. @@ -11,3 +11,5 @@ DOWNLOAD_TEST_RELEASE_ID= # Optional absolute private cache directory. Omit to retain the account default cache. # DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR= DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4 +# Installer archive compression (0-9). +DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 36cd65ce03..b626dab3f9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop", "description": "Electron desktop shell for a bundled dsh runtime and external plugins", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "private": true, "license": "MIT", "author": { diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 96c2ee0cf0..7a9ff55492 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -15,8 +15,8 @@ en: a6203dc2e690c7e1 zh: 07b516549b8e3328 /desktop-packaging/choose-a-build-path: - en: 17c6bcdefa05a8f8 - zh: 4ee18f832276144d + en: 0b35c7471c09b27a + zh: cc4a382b99fc3210 /desktop-packaging/release-versions: en: 3f38ab26ee195b39 zh: f00eddafa8f3b9ca @@ -24,20 +24,20 @@ en: 5b4f0f80a117f202 zh: ff05be196c181087 /desktop-packaging/windows-actions-packaging: - en: e1b101593f6f755e - zh: 121f225de399de1e + en: 364223725374d9ac + zh: c4d4cc58d7cbafbd /desktop-packaging/macos-self-hosted-packaging: - en: a3af9523fdc60036 - zh: 5e18d4740e9b7f57 + en: bb1dcdfef3f3a113 + zh: 9c151800a61e2505 /desktop-packaging/unsigned-windows-test-installer: - en: 6843a0fdc03ead58 - zh: 957f92fc8126e23e + en: f429bc0c280e2dfa + zh: 4adbcc9e906bdf80 /desktop-packaging/local-macos-dmg-without-release-signing: en: 2feaa0412247d81f zh: 461dde8e13fd4734 /desktop-packaging/macos-release-signing-and-diagnostics: - en: f4a2b14da34ba883 - zh: 6292f3a7b4038213 + en: 29fa6bd8919f3ec0 + zh: 9e31e037fa3d9378 /desktop-packaging/directory-builds-and-preparation: en: 5860c306375ddd63 zh: a76e3cdbbe25c79e @@ -45,14 +45,14 @@ en: b42f103358d0dfd7 zh: a534164ad9eb00da /desktop-packaging/runtime-file-selection-and-installation-checks: - en: bfd89e1d139c5729 - zh: 5d6cced5462ba13e + en: 83e1075417039410 + zh: 3088a7ae0a2cc3c5 /desktop-packaging/desktop-runtime-preparation: - en: a7aa3066e649ffe8 - zh: 4334637910058c09 + en: ab73b67d899e611d + zh: d77b0525c6b81e91 /desktop-packaging/installation-and-release-verification: en: eb35eb973b796643 zh: c1c52311632b7296 /desktop-packaging/dev-note: - en: b75c8458928b72b9 - zh: 003ba4e17b4b5269 + en: 691194744993dfaa + zh: e5798faf99546ff8 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index a77d260591..14c1eac90b 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -32,6 +32,8 @@ Build Desktop installers locally or through Windows or macOS Actions, choose dep Run local commands from the repository root after installing locked dependencies. Use platform-specific commands for a local build, or platform Actions for the registered self-hosted runner. Choose test or production deployment independently from signed or unsigned mode. Read [Windows signing and runner operations](../windows-signing/README.md#trust-and-isolation) before any Windows signed build or shared-runner use. Installation is separate from packaging; [publication](../publishing/README.md) is a further explicit operation. +Set `DSH_DESKTOP_PACKAGE_CACHE_DIR` to an absolute build-cache directory for reuse across checkouts; see [cache configuration and lifecycle](#desktop-runtime-preparation). + ## Release versions @@ -113,18 +115,26 @@ Select the workflow in Actions, choose the source branch, preset, signing mode a Open the completed run in Actions and download its Artifacts. `windows-x64-signed---` and `windows-x64-unsigned---` contain the corresponding EXE; `windows-package-logs--` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication. +For path-sensitive Windows build measurements, `build_root` optionally selects an absolute directory outside existing Git checkouts. Each run clones the validated source commit into a new `windows-package--/source` directory; ignored files and previous build outputs are not copied. Only the packaging process uses a sibling run-owned `temp` directory through `TEMP` and `TMP`; Actions steps and post-job hooks retain their original temporary directories. Dependency and signing caches retain their existing locations. Successful runs remove only their owned checkout after artifact uploads, retrying transient filesystem failures up to three times with 100 ms increasing delays; persistent cleanup errors fail the Job and may leave a partially removed directory. Failed builds retain their checkout for inspection. Leave the input empty to build in the original Actions checkout. Include checkout creation and cleanup in the complete Job timing. + +For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. Before dependency installation, the workflow records the Node version, available CPU parallelism, logical CPU count, and total/free memory. + +The dependency-install and packaging steps normalize their own shell to normal CPU, I/O and memory priority before launching children. Priority and isolated-workspace helpers are copied from the exact workflow commit into a run-specific temporary directory, independently of the selected product source. Missing helpers stop CI setup; unavailable priority adjustment emits a warning and marks scheduling unsuitable for performance comparison, but does not stop packaging. This does not modify the runner task or other processes. Version, preset and retained-artifact adapters remain tied to product source and its formats. + +Installer uploads store the already-compressed release files without ZIP recompression; this preserves installer bytes and signatures. Diagnostic uploads retain their ordinary compression. + ## macOS self-hosted packaging -The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it. +The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Installer upload disables redundant ZIP compression; diagnostic logs retain normal compression. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it. The `zip_compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only the ZIP and automatic-update downloads, not the DMG. Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior. Run `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" --check` to check the host tools and configuration files. The script selects the active Xcode and its macOS SDK together and compiles a small linker probe. After registration, omit `--check` to run in the foreground with a cleared inherited environment and prevent idle sleep while the runner is alive. Stop it with Ctrl-C before installing the service below. The runner owns its `_work` checkout, separate from daily development. The account can read its signing files, so run only trusted code. The existing notarization proxy restoration rules apply; after forced termination, inspect retained recovery records before another build. New setup templates set `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` to the absolute `cache/macos-signatures` directory outside checkout. For existing installations, rerun setup and add that field to each preset; existing files are preserved. This retains signatures across clean checkouts; cache verification and limits are described under [macOS signing](#macos-release-signing). -The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing, DMG signature verification and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets//unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds. +ZIP compression uses the same configured level with or without notarization; the App is assembled once, then DMG and ZIP are built separately so the ZIP setting does not change DMG compression. The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing, DMG signature verification and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets//unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds. Install the user service from the checkout as the same account that registered the runner, without `sudo`: @@ -146,7 +156,7 @@ On Windows x64, use the complete unsigned packaging command for local installati pnpm run package:desktop:win:x64:unsigned ``` -The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mandatory-update policy settings](../README.md#mandatory-update-policy), including a real HTTPS service origin. The local run and CI use Node 24; the remaining tools are the root `packageManager` version of pnpm, Git, PowerShell, tar, Python, Visual C++ Build Tools and a Windows SDK. Set `PYTHON` when Python is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits ordinary automatic-update configuration, strips signing credentials, and creates no release completion record. EV and upload credentials are unnecessary. Signed packaging and upload retain their release requirements. +The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mandatory-update policy settings](../README.md#mandatory-update-policy), including a real HTTPS service origin. The local run and CI use Node 24; the remaining tools are the root `packageManager` version of pnpm, Git, PowerShell 7 (`pwsh`), tar, Python, Visual C++ Build Tools and a Windows SDK. Set `PYTHON` when Python is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits ordinary automatic-update configuration, strips signing credentials, and creates no release completion record. EV and upload credentials are unnecessary. Signed packaging and upload retain their release requirements. Run `pnpm install --frozen-lockfile`, then `pnpm run package:desktop:win:x64:unsigned --check` to validate configuration and tooling before the full command above. Packaging performs its own complete build and isolated runtime smoke without an online model. Stage logs remain under `.desktop-build/packaging-runs/`; a successful build alone does not qualify the installer. Dependency installation and runtime preparation need access to npm, GitHub release assets and nodejs.org; use the existing registry and proxy configuration when the network requires it. Caches and tool locations belong to the build machine, not tracked personal paths. @@ -179,11 +189,12 @@ Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory un App and DMG notarization each record a submission ID and separate `notarytool:upload:*` and `notarytool:wait:*` durations. Upload uses `submit --no-wait` and includes authentication, local validation, transfer and server acceptance; it is not pure transfer time. Waiting starts after that command returns and includes polling and remaining Apple processing; Apple can start processing before upload submission returns. Apple status and diagnostic logs are retained, including rejection; signature checks, acceptance checks and stapling remain owned by `@electron/notarize`. Directory-only package commands use this same measured App notarization path. Individual subprocess output remains live in the terminal, while phase failures and nested errors are retained in the journal. Both platforms record parent-process packaging failures and print redacted diagnostics, including proxy recovery instructions, to the terminal. The configuration-only `check:package` command creates no run log. -Mac packaging reads three tuning fields from `.env.macos`: +Mac packaging reads these tuning fields from `.env.macos`: | Setting | Default | Scope | |---|---|---| | `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | Workers for first-party and vendor workspace tarballs; requires a positive integer. | +| `DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL` | `3` | Final update ZIP compression, integer `0`–`9`; lower levels trade larger downloads for shorter builds. App notarization archives and DMG compression are unchanged. | | `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | Empty | HTTP/HTTPS proxy origin for Electron, runtime assets, pnpm installation and builder downloads. | | `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | Empty | HTTP proxy origin for Apple tools through temporary system proxy settings. | @@ -254,18 +265,26 @@ When extraction fails, the installer writes a report with the 7-Zip result and i Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract. +`DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` in the Windows dotenv or machine preset selects installer archive compression from `0` to `9`. If omitted, the existing `ELECTRON_BUILDER_COMPRESSION_LEVEL` environment override remains effective, otherwise the default is `3` for both test and production. The manual Windows workflow's `compression_level` dropdown defaults to `3` and also offers `7`, matching the Mac ZIP choices; it overrides the file setting for that run. Direct local builds retain explicit machine settings. Mac ZIP workflow overrides must be `3` or `7`; an empty value is invalid even on macOS. Any defined Mac ZIP workflow override, including an empty value, rejects Windows; nonempty Windows workflow overrides reject macOS. Lower levels trade installer size for packaging time; qualify the complete signed workflow and artifact size before changing a release preset. + NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree. ## Desktop runtime preparation +Windows prepares Electron and primary-runtime concurrently; macOS prepares them serially. CLI preparation and `versions.json` publication require both payloads to succeed. If either Windows payload fails, preparation waits for the other to settle before reporting failure and publishes neither CLI resources nor versions. Nested preparation timings overlap and must not be added together. + +`DSH_DESKTOP_PACKAGE_CACHE_DIR` in the packaging process environment selects a nonempty absolute directory for shared `downloads` and `pnpm-store` caches; omission uses `.desktop-build`. Temporary installation projects, lockfiles and configuration remain separate per run. Runtime installation verifies store integrity and copies package files so later signing cannot modify cached bytes; cleanup retains these caches. Both packaging workflows use `runner.tool_cache/dsh-desktop-package-cache`; Windows repository dependency installation uses a separate `.pnpm-store` at the workspace drive root. Both installation logs identify their store and report package reuse. A fresh runtime lockfile still resolves dependencies on every run. Share writable caches only among trusted build jobs; integrity checks do not isolate untrusted writers. Clean caches only when no job is using them. Operators manage cache disk usage; no automatic eviction runs. + Desktop [patch policy](../scripts/runtime-patch-policy.ts) explicitly classifies every workspace patch as shared, workspace-only, or runtime-only. Shared entries reuse the root patch bytes and verify their hash against the root lockfile; runtime-only entries use separate repository files without changing workspace installation. Workspace-only patches belong to build tools or dependencies already embedded in client bundles. Unclassified or stale entries, missing files, changed hashes, incompatible resolved versions, and workspace-only packages entering the runtime stop packaging. The [preparation script](../scripts/prepare-runtime-patches.ts) writes only to the temporary project; pnpm applies the selected patches and rejects application failures. Desktop dependency overrides are declared separately in the patch-policy module, not inferred from patch versions. The pi-ai constraint keeps its runtime version at the version qualified by the shared patch. Desktop still resolves a fresh runtime lockfile for each build, then installs with `--frozen-lockfile`; dependency resolution across separate builds is not yet reproducible. CLI packed-installation tests and other distribution flows retain their own configuration. Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution plus primary-runtime with its shared pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. +Windows runtime sealing reads at most eight files concurrently, before and after native signing. A read failure drains outstanding reads without replacing the descriptor; successful sealing still requires the subsequent complete integrity and smoke checks. + Signed macOS packaging writes `Contents/Resources/app-update.yml` while assembling the App and before code signing, including the directory build that feeds the parallel ZIP and DMG lanes. The signing hook verifies the exact feed and updater cache directory. Both lane copies and the promoted App are checked again before the release completion record is written; a missing or mismatched configuration prevents artifact promotion and therefore prevents upload. An unpacked artifact contains Electron, the materialized dsh production tree, pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine. @@ -280,4 +299,4 @@ Keep the generated installer, its version and packaging records together. Review ## Dev Note -None. +XXX: Evaluate disk-space monitoring, a maintenance entry point and automatic eviction if disk shortages, sustained cache growth or frequent manual cleanup occur. Any automated cleanup would need coordination with every cache user. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 54e9d740f8..0f059c74a4 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -32,6 +32,8 @@ description: "在本地或 Windows/macOS Actions 中生成 Desktop 安装包 安装锁定依赖后,从仓库根目录运行本地命令。本地构建使用平台命令,已注册的自托管 runner 使用对应平台的 Actions。test/production 部署与 signed/unsigned 模式分别选择。任何 Windows 签名构建或共享 runner 使用前,先阅读 [Windows 签名与 runner 运维](../windows-signing/README.zh.md#trust-and-isolation)。安装与打包分开进行;[发布](../publishing/README.zh.md)还需要单独的显式操作。 +将 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 设为绝对构建缓存目录以跨 checkout 复用;详见[缓存配置与生命周期](#desktop-runtime-preparation)。 + ## 发布版本 @@ -113,18 +115,26 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s 在 Actions 打开完成的运行并下载 Artifacts。`windows-x64-signed---` 与 `windows-x64-unsigned---` 包含对应 EXE;`windows-package-logs--` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。 +测量 Windows 构建路径对性能的影响时,可用 `build_root` 指定现有 Git checkout 之外的绝对目录。每轮任务将已验证的源码提交克隆到新的 `windows-package--/source` 目录;不会复制忽略文件或此前的构建输出。仅打包进程通过 `TEMP` 和 `TMP` 使用同轮所属的同级 `temp` 目录;Actions 步骤及 Job 后置钩子保留原临时目录。依赖缓存和签名缓存沿用原有位置。成功任务在产物上传后仅删除本轮所属的 checkout,临时文件系统错误最多重试三次,等待时间按 100 ms 递增;持续清理失败会使 Job 失败,并可能留下部分删除的目录。构建失败时保留 checkout 供检查。输入留空时使用原 Actions checkout。完整 Job 耗时须包含 checkout 创建和清理。 + +测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。workflow 在依赖安装前记录 Node 版本、可用 CPU 并行数、逻辑 CPU 数及总内存和空闲内存。 + +依赖安装和打包步骤在启动子进程前,将自身 shell 的 CPU、I/O 和内存优先级调整为普通值。优先级及独立构建目录辅助脚本从 workflow 的精确提交复制到本轮临时目录,与选中的产品源码分离。缺失辅助脚本会终止 CI 准备;无法调整优先级时明确告警并标记调度条件不适合性能对比,但继续打包。这不会修改 runner 任务或其他进程。版本、preset 和产物留存适配器仍跟随产品源码及其格式。 + +安装器上传直接存储已经压缩的发布文件,不再进行 ZIP 二次压缩;安装器字节和签名保持不变。诊断日志上传保留通常的压缩方式。 + ## macOS 自托管打包 -`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。 +`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。安装包上传关闭重复 ZIP 压缩;诊断日志保留普通压缩。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。 `zip_compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。 运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。 运行 `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" --check` 检查宿主工具和配置文件。脚本同时选择当前 Xcode 及其 macOS SDK,并编译小型链接探针。注册后省略 `--check`,清理继承环境并在前台启动 runner,存活期间阻止空闲睡眠。安装下方服务前,先用 Ctrl-C 停止。runner 拥有 `_work` 下的源码目录,与日常开发分开。该账户可读取签名文件,因此只执行可信代码。沿用现有公证代理恢复规则;强制终止后,先检查保留的恢复记录再构建。新建模板将 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 设为 checkout 外 `cache/macos-signatures` 的绝对路径。已有部署重新运行 setup 后,在各 preset 中补上该字段;已有文件保持不变。这样可跨干净检出保留签名;缓存验证和容量限制见 [macOS 签名](#macos-release-signing)。 -工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名、DMG 签名验证和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets//unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。 +无论是否公证,ZIP 都使用同一压缩配置;App 只组装一次,再分别生成 DMG 和 ZIP,确保 ZIP 设置不改变 DMG 压缩。工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名、DMG 签名验证和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets//unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。 在 checkout 中,以注册 runner 的同一账户安装用户服务,不使用 `sudo`: @@ -146,7 +156,7 @@ bash apps/desktop/scripts/macos-runner-service.sh status "$HOME/dsh-ci/runner" pnpm run package:desktop:win:x64:unsigned ``` -该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](../README.zh.md#mandatory-update-policy),包括真实的 HTTPS 服务 origin。本机验收和 CI 使用 Node 24;其他工具为根 `packageManager` 固定版本的 pnpm、Git、PowerShell、tar、Python、Visual C++ Build Tools 和 Windows SDK。Python 不在 `PATH` 中时设置 `PYTHON`。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略普通自动更新配置,清除签名凭据,且不生成发布完成记录。无需 EV 或上传凭据。签名打包和上传仍遵循正式发布要求。 +该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](../README.zh.md#mandatory-update-policy),包括真实的 HTTPS 服务 origin。本机验收和 CI 使用 Node 24;其他工具为根 `packageManager` 固定版本的 pnpm、Git、PowerShell 7(`pwsh`)、tar、Python、Visual C++ Build Tools 和 Windows SDK。Python 不在 `PATH` 中时设置 `PYTHON`。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略普通自动更新配置,清除签名凭据,且不生成发布完成记录。无需 EV 或上传凭据。签名打包和上传仍遵循正式发布要求。 先运行 `pnpm install --frozen-lockfile`,再运行 `pnpm run package:desktop:win:x64:unsigned --check` 校验配置和工具,最后执行上面的完整命令。打包自行执行完整构建和隔离的运行时 smoke,不调用在线模型。阶段日志保留在 `.desktop-build/packaging-runs/`;仅构建成功不代表安装包验证通过。依赖安装和运行时准备需要访问 npm、GitHub release 资源和 nodejs.org;网络需要时使用现有 registry 和代理配置。缓存与工具位置属于构建机器配置,不在跟踪文件中固定个人路径。 @@ -179,11 +189,12 @@ Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop- App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。 -Mac 打包从 `.env.macos` 读取三个调优字段: +Mac 打包从 `.env.macos` 读取以下调优字段: | 设置 | 默认值 | 作用范围 | |---|---|---| | `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | 第一方与 vendor workspace tarball 的打包 worker 数;必须是正整数。 | +| `DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL` | `3` | 最终更新 ZIP 的压缩等级,整数 `0`–`9`;较低等级以更大的下载体积换取更短的构建时间。App 公证归档和 DMG 压缩不变。 | | `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | 空 | Electron、运行时资源、pnpm 安装及 builder 下载使用的 HTTP/HTTPS 代理 origin。 | | `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | 空 | 通过临时系统代理设置供 Apple 工具使用的 HTTP 代理 origin。 | @@ -254,18 +265,26 @@ Windows 安装器在启动时和选定目标目录后检查应用是否正在运 Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。 +Windows dotenv 或机器 preset 中的 `DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` 选择 `0` 至 `9` 的安装包归档压缩等级。省略时,现有 `ELECTRON_BUILDER_COMPRESSION_LEVEL` 环境覆盖仍然生效,否则 test 和 production 均默认为 `3`。手动 Windows workflow 的 `compression_level` 下拉默认选 `3`,也提供 `7`,与 Mac ZIP 选项一致;它对本轮覆盖文件中的设置。直接本地打包保留显式机器配置。Mac ZIP workflow 覆盖值必须为 `3` 或 `7`;空值在 macOS 上也非法。任何已定义的 Mac ZIP workflow 覆盖值(包括空值)均拒绝用于 Windows;非空的 Windows workflow 覆盖值拒绝用于 macOS。较低等级以安装包体积换取打包时间;修改发布 preset 前应验证完整签名 workflow 和产物体积。 + NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。 ## Desktop 运行时准备 +Windows 并行准备 Electron 和 primary-runtime;macOS 串行准备二者。只有两份运行时资源均成功,才会准备 CLI 并写入 `versions.json`。任一 Windows 运行时准备失败时,流程会等待另一路结束后再报告失败,且不写入 CLI 资源或版本记录。嵌套准备阶段的计时存在重叠,不能直接相加。 + +打包进程环境中的 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 为共享的 `downloads` 和 `pnpm-store` 缓存指定非空绝对目录;省略时使用 `.desktop-build`。临时安装项目、lockfile 和配置仍逐次独立创建。运行时安装校验 store 完整性并复制包文件,防止后续签名修改缓存字节;清理时保留这些缓存。两个打包工作流使用 `runner.tool_cache/dsh-desktop-package-cache`;Windows 仓库依赖安装使用工作区盘根目录下独立的 `.pnpm-store`。两次安装日志均标明各自的 store 并报告包复用情况。每次运行仍通过新的运行时 lockfile 解析依赖。可写缓存仅供可信构建任务共享;完整性校验不能隔离不可信的写入者。仅在没有任务使用缓存时清理。缓存磁盘用量由操作者管理,不自动淘汰。 + Desktop [补丁策略](../scripts/runtime-patch-policy.ts) 将每份工作区补丁明确归为共享、仅工作区或仅运行时。共享项复用根目录补丁字节,并对照根锁文件校验 hash;仅运行时项使用仓库内的独立文件,不改变工作区安装。仅工作区补丁用于构建工具或已嵌入客户端 bundle 的依赖。未分类或过期的条目、文件缺失、hash 改变、解析版本不兼容,以及仅工作区包进入运行时,都会阻止打包。[准备脚本](../scripts/prepare-runtime-patches.ts) 只写入临时项目;pnpm 负责应用选中的补丁,应用失败时会报错。 Desktop 依赖 overrides 在补丁策略模块中单独声明,不从补丁版本推导。pi-ai 约束使运行时版本保持在共享补丁已验证的版本。Desktop 仍会在每次构建时重新解析运行时锁文件,再通过 `--frozen-lockfile` 安装;不同构建之间的依赖解析尚不保证可复现。CLI 打包安装测试及其他交付流程保留各自的配置。 每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包和包含共享 pnpm CLI 的 primary-runtime。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 +Windows 运行时在原生签名前后封存文件清单,每次最多并发读取八个文件。读取失败时会等待未完成的读取结束,不替换描述符;封存成功后仍须完成后续的完整性与 smoke 检查。 + macOS 签名打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。 未压缩产物包含 Electron、物化后的 dsh 生产依赖树、pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。 @@ -280,4 +299,4 @@ Windows 以不带版本后缀的产品名称显示已安装应用,版本保留 ## 开发备注 -无。 +XXX:出现磁盘空间不足、缓存持续增长或频繁人工清理时,评估空间监测、维护入口和自动淘汰。任何自动清理方案都需要与所有使用缓存的任务协调。 diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index c7b8173ca1..910f3032ee 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -1,6 +1,6 @@ import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment.mjs' -/** Mutable target directories plus the shared immutable download cache. */ +/** Mutable target directories plus shared download and package caches. */ export interface DesktopTargetBuildPaths { readonly root: string readonly artifacts: string @@ -15,6 +15,7 @@ export interface DesktopTargetBuildPaths { readonly packedVendor: string readonly packedLandlock: string readonly downloads: string + readonly pnpmStore: string } /** @@ -33,9 +34,10 @@ export function resolveDesktopBuildTarget( /** * Return the mutable preparation and artifact directories owned by one release target. * @param target - Supported Desktop target name. - * @returns Target paths plus the shared immutable download cache. + * @param environment - Packaging environment with an optional absolute cache directory. + * @returns Target paths plus shared download and package caches. */ -export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget): DesktopTargetBuildPaths +export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget, environment?: NodeJS.ProcessEnv): DesktopTargetBuildPaths /** * Return the platform and architecture of the payload one release target prepares. diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index e577b1d3fe..bf2361a695 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -1,6 +1,6 @@ -/** Resolve build-owned Desktop paths without sharing mutable state across release targets. */ +/** Resolve target-owned Desktop outputs and shared package caches. */ -import { join, resolve } from 'node:path' +import { isAbsolute, join, resolve } from 'node:path' const APP_ROOT = resolve(import.meta.dirname, '..') const BUILD_ROOT = join(APP_ROOT, '.desktop-build') @@ -38,10 +38,15 @@ function assertSupportedTarget(target) { /** * 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, unsignedArtifacts: string, unnotarizedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. + * @param {NodeJS.ProcessEnv} environment - Packaging environment with an optional absolute cache directory. + * @returns {{ root: string, artifacts: string, unsignedArtifacts: string, unnotarizedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string, pnpmStore: string }} Target paths plus shared download and package caches. */ -export function desktopTargetBuildPaths(target) { +export function desktopTargetBuildPaths(target, environment = process.env) { assertSupportedTarget(target) + const cacheRoot = environment.DSH_DESKTOP_PACKAGE_CACHE_DIR ?? BUILD_ROOT + if (!cacheRoot.trim() || !isAbsolute(cacheRoot) || cacheRoot.includes('\0')) { + throw new Error('desktop package: DSH_DESKTOP_PACKAGE_CACHE_DIR must be a nonempty absolute directory') + } const root = join(BUILD_ROOT, 'targets', target) const packed = join(root, 'packed') return { @@ -57,7 +62,8 @@ export function desktopTargetBuildPaths(target) { packedDsh: join(packed, 'dsh'), packedVendor: join(packed, 'vendor'), packedLandlock: join(packed, 'landlock'), - downloads: join(BUILD_ROOT, 'downloads'), + downloads: join(cacheRoot, 'downloads'), + pnpmStore: join(cacheRoot, 'pnpm-store'), } } @@ -87,7 +93,7 @@ export function resolveDesktopTargetBuildPaths( hostPlatform = process.platform, hostArch = process.arch, ) { - return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) + return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch), env) } /** diff --git a/apps/desktop/scripts/desktop-package-environment.d.mts b/apps/desktop/scripts/desktop-package-environment.d.mts index 995c117d07..ec9da721f1 100644 --- a/apps/desktop/scripts/desktop-package-environment.d.mts +++ b/apps/desktop/scripts/desktop-package-environment.d.mts @@ -10,6 +10,9 @@ export function localMacOSPackageEnvironment(environment?: NodeJS.ProcessEnv): N /** * Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. * PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID. + * PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL overrides the Mac file setting with 3 or 7; an empty value is invalid. + * Any defined Mac ZIP override rejects Windows, including an empty value; nonempty Windows overrides reject macOS. + * PACKAGE_WINDOWS_COMPRESSION_LEVEL selects 3 or 7 for Windows workflow builds; blank retains the file setting. * @param platform Target platform. * @param environment Parent environment, retained only for unrelated build tools. * @param appRoot Desktop application directory; relative credential paths resolve here. diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 32f58ce874..9877b25d09 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -12,11 +12,12 @@ import { validateMacOSSignatureCacheDirectory } from './macos-signature-cache-di import { resolveMacOSPackageSettings } from './macos-package-settings.mjs' import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' +import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u -const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY)$/u -const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u +const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u +const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK'] @@ -35,7 +36,10 @@ export function localMacOSPackageEnvironment(environment = process.env) { /** * Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. + * PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL overrides the Mac file setting with 3 or 7; an empty value is invalid. + * Any defined Mac ZIP override rejects Windows, including an empty value; nonempty Windows overrides reject macOS. * PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID. + * PACKAGE_WINDOWS_COMPRESSION_LEVEL selects 3 or 7 for Windows workflow builds; blank retains the file setting. * @param {'win32' | 'darwin'} platform Target platform. * @param {NodeJS.ProcessEnv} environment Parent environment, retained only for unrelated build tools. * @param {string} appRoot Desktop application directory; relative credential paths resolve here. @@ -43,6 +47,17 @@ export function localMacOSPackageEnvironment(environment = process.env) { * @returns {NodeJS.ProcessEnv} Isolated environment with file-owned release settings. */ export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT, options = {}) { + const compression = environment.PACKAGE_WINDOWS_COMPRESSION_LEVEL + if (compression !== undefined && compression !== '') { + if (platform !== 'win32' || !['3', '7'].includes(compression)) { + throw new Error('desktop package: PACKAGE_WINDOWS_COMPRESSION_LEVEL requires Windows and a choice of 3 or 7') + } + } + const zipCompression = environment.PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL + if (zipCompression !== undefined) { + if (platform !== 'darwin') throw new Error('desktop package: PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL requires macOS') + if (!['3', '7'].includes(zipCompression)) throw new Error('desktop package: workflow ZIP compression must be 3 or 7') + } const preset = environment.DSH_PACKAGING_PRESET let path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos') if (preset !== undefined) { @@ -53,8 +68,8 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en } const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING const publicPlatformSetting = platform === 'win32' - ? /^DSH_DESKTOP_WINDOWS_(?:SIGNATURE_CACHE_(?:DIR|CONCURRENCY)|PACK_CONCURRENCY)$/u - : /^DSH_DESKTOP_MACOS_(?:SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)$/u + ? /^DSH_DESKTOP_WINDOWS_(?:SIGNATURE_CACHE_(?:DIR|CONCURRENCY)|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u + : /^DSH_DESKTOP_MACOS_(?:SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|DOWNLOAD_PROXY|NOTARIZATION_PROXY)$/u const settings = readSettings(path, name => preset === undefined ? SHARED_SETTING.test(name) || platformSetting.test(name) : /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_(?:ORIGIN|RELEASE_ID))$/u.test(name) || publicPlatformSetting.test(name)) @@ -68,6 +83,10 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en : MACOS_SETTING.test(name) && !publicPlatformSetting.test(name))) } } + if (compression !== undefined && compression !== '') settings.DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL = compression + if (zipCompression !== undefined) { + settings.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL = zipCompression + } const releaseId = environment.PACKAGE_TEST_RELEASE_ID?.trim() if (releaseId) { if (preset !== 'test') throw new Error('desktop package: test release ID override requires the test preset') @@ -134,6 +153,7 @@ function requireReadableFile(environment, name) { * @returns {void} */ export function validateDesktopPackageEnvironment(environment, target, options = {}) { + resolveDesktopTargetBuildPaths(environment, target.platform, target.arch) resolveDesktopAppId(environment) resolveNpmRegistry(environment) if (target.platform !== 'darwin' || !options.unsigned) resolveDesktopPolicyEnvironment(environment) diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 6cf6754801..cf0c19e895 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -70,7 +70,7 @@ export function createElectronBuilderConfig( if (resolvedPlatform === 'win32') installWindowsDirectoryInstaller() const macOSSigning = packagesMacOS && !unsigned ? resolveMacOSSigningEnvironment(env) : undefined if (packagesMacOS && !unsigned && !skipNotarization) resolveMacOSNotarizationEnvironment(env) - const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) + const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch), env) const output = desktopArtifactOutput(buildPaths, { unsigned, skipNotarization }) let primaryRuntimeDestination let dshDestination diff --git a/apps/desktop/scripts/macos-package-settings.d.mts b/apps/desktop/scripts/macos-package-settings.d.mts index 09b991752e..842b01804e 100644 --- a/apps/desktop/scripts/macos-package-settings.d.mts +++ b/apps/desktop/scripts/macos-package-settings.d.mts @@ -3,6 +3,7 @@ /** Validated settings shared by preflight and stage execution. */ export interface MacOSPackageSettings { readonly packConcurrency: number + readonly zipCompressionLevel: number readonly downloadProxy?: string readonly notarizationProxy?: string } diff --git a/apps/desktop/scripts/macos-package-settings.mjs b/apps/desktop/scripts/macos-package-settings.mjs index 1ed93c3377..4d1f28e257 100644 --- a/apps/desktop/scripts/macos-package-settings.mjs +++ b/apps/desktop/scripts/macos-package-settings.mjs @@ -15,14 +15,19 @@ function proxy(environment, name, protocols) { /** * Validate tuning before any build or network operation. * @param {NodeJS.ProcessEnv} environment File-owned packaging settings. - * @returns {{packConcurrency: number, downloadProxy?: string, notarizationProxy?: string}} Resolved settings; empty proxy fields preserve inherited networking. + * @returns {{packConcurrency: number, zipCompressionLevel: number, downloadProxy?: string, notarizationProxy?: string}} Resolved settings; empty proxy fields preserve inherited networking. */ export function resolveMacOSPackageSettings(environment) { const concurrency = environment.DSH_DESKTOP_MACOS_PACK_CONCURRENCY ?? '4' if (!/^[1-9]\d*$/u.test(concurrency) || !Number.isSafeInteger(Number(concurrency))) { throw new Error('desktop package: DSH_DESKTOP_MACOS_PACK_CONCURRENCY must be a positive integer') } + const compression = environment.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL ?? '3' + if (!/^[0-9]$/u.test(compression)) { + throw new Error('desktop package: DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL must be an integer from 0 to 9') + } return { + zipCompressionLevel: Number(compression), packConcurrency: Number(concurrency), downloadProxy: proxy(environment, 'DSH_DESKTOP_MACOS_DOWNLOAD_PROXY', ['http:', 'https:']), notarizationProxy: proxy(environment, 'DSH_DESKTOP_MACOS_NOTARIZATION_PROXY', ['http:']), diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index c3dd201989..b6bc6efb63 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -328,7 +328,7 @@ async function resolveRequestedBuildVersion( const requested = invocation.requestedBuildVersion if (requested === undefined) return productVersion if (requested !== AUTOMATIC_BUILD_VERSION) return validateDesktopBuildVersion(requested, productVersion) - const paths = desktopTargetBuildPaths(invocation.target.name) + const paths = desktopTargetBuildPaths(invocation.target.name, environment) return suggestDesktopBuildVersion({ productVersion, target: invocation.target.name, // Machine presets own packaging, not upload credentials; local auto numbering uses existing artifacts. @@ -370,7 +370,7 @@ async function main(): Promise { version: buildVersion, productVersion, node: process.version, commit: packaged.commit, dirty: packaged.dirty, - }, { parallel: target.platform === 'darwin', secrets }) + }, { parallel: true, secrets }) console.log(`DESKTOP_PACKAGING_RECORD ${run.directory}`) const previousDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR process.env.DSH_DESKTOP_PACKAGING_RUN_DIR = run.directory @@ -380,7 +380,7 @@ async function main(): Promise { await packagingStep(run.directory, 'toolchain', () => requireDesktopToolchain(target.platform, environment), secrets) if (target.platform === 'darwin') { const settings = resolveMacOSPackageSettings(environment) - recordPackagingEvent(run.directory, { type: 'macos-settings', packConcurrency: settings.packConcurrency, + recordPackagingEvent(run.directory, { type: 'macos-settings', packConcurrency: settings.packConcurrency, zipCompressionLevel: settings.zipCompressionLevel, downloadProxyConfigured: settings.downloadProxy !== undefined, notarizationProxyConfigured: settings.notarizationProxy !== undefined }) await packagingStep(run.directory, 'macos-package', () => invocation.unsigned @@ -400,7 +400,7 @@ async function main(): Promise { run.finish(success) } if (success && target.platform === 'darwin' && !invocation.directory && !invocation.prepareOnly) { - const paths = desktopTargetBuildPaths(target.name) + const paths = desktopTargetBuildPaths(target.name, environment) const output = desktopArtifactOutput(paths, invocation) const filename = `deepseek-harness-${buildVersion}-mac-${target.arch}${output.suffix}.dmg` console.log(`DMG: ${join(output.directory, filename)}`) @@ -426,7 +426,7 @@ export async function packageTarget( const mac = target.platform === 'darwin' ? resolveMacOSPackageSettings(environment) : undefined const packConcurrency = mac?.packConcurrency ?? resolveWindowsPackageSettings(environment).packConcurrency const packArguments = ['--concurrency', String(packConcurrency)] - const buildPaths = desktopTargetBuildPaths(target.name) + const buildPaths = desktopTargetBuildPaths(target.name, environment) const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name)) if (!invocation.prepareOnly && !invocation.unsigned && !invocation.skipNotarization) { rmSync(releaseRecordPath, { force: true }) @@ -443,6 +443,9 @@ export async function packageTarget( } const downloadEnv = macOSDownloadEnvironment(targetEnv, mac?.downloadProxy) const electronBuilderEnv = desktopElectronBuilderEnvironment(downloadEnv, invocation.unsigned) + if (target.platform === 'win32') { + electronBuilderEnv.ELECTRON_BUILDER_COMPRESSION_LEVEL = String(resolveWindowsPackageSettings(environment).compressionLevel) + } for (const name of WINDOWS_SIGNING_ENV_NAMES) { if (!invocation.unsigned && environment[name] !== undefined) electronBuilderEnv[name] = environment[name] } @@ -476,36 +479,56 @@ export async function packageTarget( }) } await execute(invocation.unsigned ? ['run', 'build', '--profile', 'local'] : ['run', 'build:official'], buildEnv, REPOSITORY_ROOT) - await execute([ - ...(invocation.unsigned - ? ['exec', 'tsx', 'apps/desktop/scripts/pack-local-packages.ts'] - : ['run', 'release:pack', '--family', 'dsh']), - '--out', buildPaths.packedDsh, ...packArguments, - ], buildEnv, REPOSITORY_ROOT) - await execute([ - '--dir', - 'apps/desktop-host', - 'pack', - '--pack-destination', - buildPaths.packedDsh, - ], buildEnv, REPOSITORY_ROOT) - await execute(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor, ...packArguments], buildEnv, REPOSITORY_ROOT) - rmSync(buildPaths.packedLandlock, { recursive: true, force: true }) - mkdirSync(buildPaths.packedLandlock, { recursive: true }) - await execute(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT) - await execute([ - '--dir', - 'native/system/packages/entry', - 'pack', - '--pack-destination', - buildPaths.packedLandlock, - ], buildEnv, REPOSITORY_ROOT) - await execute(['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], downloadEnv) + let preparationFailure: { error: unknown } | undefined + const executePreparation = async (args: readonly string[], env: NodeJS.ProcessEnv, cwd?: string): Promise => { + if (preparationFailure !== undefined) throw preparationFailure.error + try { await execute(args, env, cwd) } + catch (error) { preparationFailure ??= { error }; throw error } + } + const pack = async (): Promise => { + await executePreparation([ + ...(invocation.unsigned + ? ['exec', 'tsx', 'apps/desktop/scripts/pack-local-packages.ts'] + : ['run', 'release:pack', '--family', 'dsh']), + '--out', buildPaths.packedDsh, ...packArguments, + ], buildEnv, REPOSITORY_ROOT) + await executePreparation([ + '--dir', + 'apps/desktop-host', + 'pack', + '--pack-destination', + buildPaths.packedDsh, + ], buildEnv, REPOSITORY_ROOT) + await executePreparation(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor, ...packArguments], buildEnv, REPOSITORY_ROOT) + rmSync(buildPaths.packedLandlock, { recursive: true, force: true }) + mkdirSync(buildPaths.packedLandlock, { recursive: true }) + await executePreparation(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT) + await executePreparation([ + '--dir', + 'native/system/packages/entry', + 'pack', + '--pack-destination', + buildPaths.packedLandlock, + ], buildEnv, REPOSITORY_ROOT) + } + const prepareRuntime = () => executePreparation( + ['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], downloadEnv) + if (target.platform === 'win32') { + const results = await Promise.allSettled([pack(), prepareRuntime()]) + for (const result of results) if (result.status === 'rejected') throw result.reason + } else { + await pack() + await prepareRuntime() + } if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime'], electronBuilderEnv) await execute(['run', 'prepare:packages'], targetEnv) await execute(['run', 'prepare:dsh', ...(signPrimaryRuntime ? ['--defer-runtime-smoke'] : [])], downloadEnv) if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime', '--dsh'], electronBuilderEnv) if (invocation.prepareOnly) return + const buildMacArtifact = (artifact: DesktopPrepackagedArtifact) => execute( + desktopElectronBuilderArguments(target, false, artifact), artifact.format === 'zip' + ? { ...electronBuilderEnv, ELECTRON_BUILDER_COMPRESSION_LEVEL: String(mac?.zipCompressionLevel) } + : electronBuilderEnv) if (target.platform === 'darwin' && !invocation.unsigned && !invocation.skipNotarization && !invocation.directory) { await execute([ ...desktopElectronBuilderArguments(target, true), @@ -518,7 +541,7 @@ export async function packageTarget( version: resolveDesktopBuildVersion(environment, packageVersion(join(APP_ROOT, 'package.json'), 'desktop package')), artifactsRoot: buildPaths.artifacts, environment: electronBuilderEnv, - }, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv)), undefined, undefined, proxyEvent) + }, buildMacArtifact), undefined, undefined, proxyEvent) } else if (target.platform === 'darwin' && !invocation.unsigned && !invocation.skipNotarization) { await execute([...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false'], electronBuilderEnv) await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv) @@ -526,7 +549,15 @@ export async function packageTarget( await withMacOSNotarizationProxy(mac?.notarizationProxy, () => notarizeMacOS({ appPath, ...resolveMacOSNotarizationEnvironment(environment) }), undefined, undefined, proxyEvent) } else { - await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)) + if (target.platform === 'darwin' && invocation.skipNotarization && !invocation.directory) { + await execute(desktopElectronBuilderArguments(target, true), electronBuilderEnv) + const appPath = join(artifactsRoot, target.arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app') + for (const format of ['dmg', 'zip'] as const) { + await buildMacArtifact({ format, appPath, output: artifactsRoot }) + } + } else { + await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)) + } await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts', ...(invocation.unsigned ? ['--unsigned'] : []), ...(invocation.skipNotarization ? ['--skip-notarization'] : [])], targetEnv) } diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 365c5a45f2..a069888036 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -3,6 +3,7 @@ import { resolveMacOSSignatureCacheDirectory } from './macos-signature-cache-directory.mjs' import { packagingStep } from './packaging-step.mjs' import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join, relative, resolve } from 'node:path' @@ -22,7 +23,7 @@ import { smokePrimaryRuntime } from './prepare-primary-runtime.ts' import { smokePreparedRuntime } from './smoke-prepared-runtime.ts' import { prepareRuntimeManifests } from './prepare-runtime-manifests.ts' import { prepareRuntimePatches, verifyRuntimePatches } from './prepare-runtime-patches.ts' -import { writeDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { writeDesktopRuntime, writeDesktopRuntimeAsync, verifyDesktopRuntime } from '../src/runtime-tree.ts' import { resolveDesktopAppId, resolveMacOSSigningEnvironment, @@ -39,7 +40,7 @@ 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 STORE_ROOT = BUILD_PATHS.pnpmStore const RUNTIME_ROOT = BUILD_PATHS.runtime const PNPM_BUILD_STATE = BUILD_PATHS.dshPnpm const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet @@ -82,6 +83,8 @@ function runPnpm(args: readonly string[]): Promise { PNPM, `--config.registry=${registry}`, `--config.store-dir=${STORE_ROOT}`, + '--config.verify-store-integrity=true', + '--config.package-import-method=copy', '--config.enable-global-virtual-store=false', `--config.userconfig=${userConfig}`, command, @@ -113,6 +116,7 @@ function runPnpm(args: readonly string[]): Promise { async function main(): Promise { try { + console.log(`desktop runtime: pnpm store ${STORE_ROOT}`) await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:reset', async () => { rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true }) rmSync(PNPM_BUILD_STATE, { recursive: true, force: true }) @@ -127,8 +131,10 @@ async function main(): Promise { }) await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:lockfile', () => runPnpm(['install', '--lockfile-only'])) verifyRuntimePatches(BUILD_ROOT, patches) + const lockfile = readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8') + console.log(`desktop runtime: lockfile sha256 ${createHash('sha256').update(lockfile).digest('hex')}`) verifyDesktopCoreLockfile( - readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8'), + lockfile, readDesktopCorePackageSet(BUILD_ROOT, release.version), ) await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:install', () => runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile'])) @@ -164,7 +170,10 @@ async function main(): Promise { } await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:manifests', () => prepareRuntimeManifests(DSH_OUTPUT_ROOT)) await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:primary-smoke', async () => smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime'))) - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:write-descriptor', async () => writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target)) + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:write-descriptor', async () => { + const writeRuntime = target.platform === 'win32' ? writeDesktopRuntimeAsync : writeDesktopRuntime + return writeRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target) + }) const descriptor = await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:verify-before-smoke', () => verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)) if (!process.argv.includes('--defer-runtime-smoke')) { await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:smoke', () => smokePreparedRuntime(DSH_OUTPUT_ROOT, NODE, RUNTIME_ROOT, descriptor)) diff --git a/apps/desktop/scripts/prepare-runtime.ts b/apps/desktop/scripts/prepare-runtime.ts index ba92fef0e6..4e33853272 100644 --- a/apps/desktop/scripts/prepare-runtime.ts +++ b/apps/desktop/scripts/prepare-runtime.ts @@ -53,9 +53,18 @@ async function main(): Promise { const target = desktopTargetPlatform(resolveDesktopBuildTarget()) rmSync(RUNTIME_ROOT, { recursive: true, force: true }) mkdirSync(RUNTIME_ROOT, { recursive: true }) - const nodeVersion = await prepareElectron(target) - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime', + const preparePrimary = () => packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime', () => preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] })) + let nodeVersion: string + if (target.platform === 'win32') { + const [electron, primary] = await Promise.allSettled([prepareElectron(target), preparePrimary()]) + if (electron.status === 'rejected') throw electron.reason + if (primary.status === 'rejected') throw primary.reason + nodeVersion = electron.value + } else { + nodeVersion = await prepareElectron(target) + await preparePrimary() + } const { pnpm } = await readPrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime')) if (pnpm === undefined) throw new Error('desktop runtime: primary-runtime manifest has no pnpm version') await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:cli', async () => { prepareCli(target) }) diff --git a/apps/desktop/scripts/prepare-windows-ci.mjs b/apps/desktop/scripts/prepare-windows-ci.mjs index ac571095f4..b89227885a 100644 --- a/apps/desktop/scripts/prepare-windows-ci.mjs +++ b/apps/desktop/scripts/prepare-windows-ci.mjs @@ -5,6 +5,7 @@ import { execFileSync } from 'node:child_process' import { desktopCiVersion as windowsCiVersion } from './desktop-ci-version.mjs' export { desktopCiVersion as windowsCiVersion } from './desktop-ci-version.mjs' import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs' +import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { const appRoot = resolve(import.meta.dirname, '..') @@ -22,6 +23,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi } } validateDesktopPackageEnvironment(environment, { platform: 'win32', arch: 'x64' }, { unsigned: mode === 'unsigned' }) + console.log(`Windows installer compression level: ${resolveWindowsPackageSettings(environment).compressionLevel}`) if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions') appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 ${mode} installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`) diff --git a/apps/desktop/scripts/run-windows-runner-network-process.ps1 b/apps/desktop/scripts/run-windows-runner-network-process.ps1 new file mode 100644 index 0000000000..5129c6c860 --- /dev/null +++ b/apps/desktop/scripts/run-windows-runner-network-process.ps1 @@ -0,0 +1,138 @@ +# Own one operator-configured network process until the registered runner exits. +param([Parameter(Mandatory=$true)][string]$RunnerRoot, [Parameter(Mandatory=$true)][string]$ConfigRoot, [Parameter(Mandatory=$true)][string]$NetworkRoot, [Parameter(Mandatory=$true)][string]$ReadinessScript, [Parameter(Mandatory=$true)][string]$StartScript, [switch]$CheckOnly) +$ErrorActionPreference = 'Stop' +$network = $NetworkRoot +$lock = $null +$process = $null +$started = $false +$runner = $null +$runnerStarted = $false +$restarts = 0 +$recordPath = Join-Path $network 'process.json' +$code = 1 +try { + foreach ($directory in @($RunnerRoot,$ConfigRoot,$NetworkRoot)) { + if ($directory -notmatch '^[A-Za-z]:[\\/]' -or -not (Test-Path -LiteralPath $directory -PathType Container)) { throw 'An existing absolute local directory is required.' } + } + foreach ($script in @($ReadinessScript,$StartScript)) { + if ($script -notmatch '^[A-Za-z]:[\\/]' -or -not (Test-Path -LiteralPath $script -PathType Leaf)) { throw 'An existing absolute local script is required.' } + } + $lock = [IO.File]::Open((Join-Path $network 'supervisor.lock'), 'OpenOrCreate', 'ReadWrite', 'None') + $launch = Get-Content (Join-Path $network 'launch.json') -Raw | ConvertFrom-Json -AsHashtable + if ($launch -isnot [Collections.IDictionary] -or $launch.Count -ne 3 -or @($launch.Keys | Where-Object { $_ -cnotin @('executable','arguments','restartDelaySeconds') }).Count) { throw 'Invalid network launch configuration.' } + if (($launch.restartDelaySeconds -isnot [long] -and $launch.restartDelaySeconds -isnot [int]) -or $launch.restartDelaySeconds -lt 1 -or $launch.restartDelaySeconds -gt 3600) { throw 'Invalid network restart interval.' } + if ($launch.executable -isnot [string] -or $launch.arguments -isnot [array] -or @($launch.arguments | Where-Object { $_ -isnot [string] -or $_.Contains([char]0) }).Count) { throw 'Invalid network executable or arguments.' } + if (-not [IO.Path]::IsPathFullyQualified($launch.executable) -or -not (Test-Path -LiteralPath $launch.executable -PathType Leaf)) { throw 'Network executable unavailable.' } + $launch.executable = [IO.Path]::GetFullPath($launch.executable) + if (Test-Path -LiteralPath $recordPath) { + $record = Get-Content -LiteralPath $recordPath -Raw | ConvertFrom-Json + if ($record.pid -isnot [long] -and $record.pid -isnot [int]) { throw 'Invalid network process record.' } + if ($record.pid -le 0 -or $record.started -isnot [long] -or $record.started -le 0) { throw 'Invalid network process record.' } + $stale = Get-Process -Id $record.pid -ErrorAction SilentlyContinue + if ($stale) { + try { + if (-not $stale.StartTime) { throw 'Owned network process identity unavailable.' } + if ($stale.StartTime.ToUniversalTime().Ticks -eq $record.started) { + if ($stale.Path -ne $launch.executable) { throw 'Owned network process identity unavailable.' } + $stale.Kill($true) + if (-not $stale.WaitForExit(10000)) { throw 'Owned network process did not stop.' } + } + } finally { $stale.Dispose() } + } + Remove-Item -LiteralPath $recordPath + } + $info = [Diagnostics.ProcessStartInfo]::new($launch.executable) + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.WorkingDirectory = $network + foreach ($name in @('HTTP_PROXY','HTTPS_PROXY','ALL_PROXY','http_proxy','https_proxy','all_proxy')) { $null = $info.Environment.Remove($name) } + # Provider output can include private routes; it must never enter runner logs. + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + foreach ($argument in $launch.arguments) { $info.ArgumentList.Add($argument) } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $info + if (-not $process.Start()) { throw 'Network process did not start.' } + $started = $true + $stdout = $process.StandardOutput.BaseStream.CopyToAsync([IO.Stream]::Null) + $stderr = $process.StandardError.BaseStream.CopyToAsync([IO.Stream]::Null) + @{pid=$process.Id;started=$process.StartTime.ToUniversalTime().Ticks} | ConvertTo-Json | Set-Content -LiteralPath $recordPath + & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File $ReadinessScript -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -CheckOnly + if ($LASTEXITCODE -ne 0 -or $process.HasExited) { throw 'Network readiness failed; listener was not started.' } + if ($CheckOnly) { $code = 0 } else { + $runnerInfo = [Diagnostics.ProcessStartInfo]::new((Join-Path $PSHOME 'pwsh.exe')) + $runnerInfo.UseShellExecute = $false + $runnerInfo.CreateNoWindow = $true + $runnerInfo.RedirectStandardOutput = $true + $runnerInfo.RedirectStandardError = $true + foreach ($argument in @('-NoProfile','-NonInteractive','-File',$StartScript,'-RunnerRoot',$RunnerRoot,'-ConfigRoot',$ConfigRoot)) { $runnerInfo.ArgumentList.Add($argument) } + $runner = [Diagnostics.Process]::new() + $runner.StartInfo = $runnerInfo + if (-not $runner.Start()) { throw 'Runner process did not start.' } + $runnerStarted = $true + $runnerOut = $runner.StandardOutput.BaseStream.CopyToAsync([Console]::OpenStandardOutput()) + $runnerErr = $runner.StandardError.BaseStream.CopyToAsync([Console]::OpenStandardError()) + while (-not $runner.WaitForExit(1000)) { + if ($started -and -not $process.HasExited) { continue } + if ($started) { + Write-Output "Network process exited (code $($process.ExitCode)); runner retained. Inspect local network configuration if recovery repeats." + $started = $false + Remove-Item -LiteralPath $recordPath + $process.Dispose() + $process = $null + } + # The runner's lifetime owns recovery; waiting on it also cancels the retry delay. + if ($runner.WaitForExit($launch.restartDelaySeconds * 1000)) { break } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $info + try { $started = $process.Start() } + catch { + # Launch errors can contain private arguments or paths. + $started = $false + } + if (-not $started) { + $process.Dispose() + $process = $null + Write-Output 'Network process restart failed; runner retained. Inspect the local executable and configuration; recovery will retry.' + continue + } + $stdout = $process.StandardOutput.BaseStream.CopyToAsync([IO.Stream]::Null) + $stderr = $process.StandardError.BaseStream.CopyToAsync([IO.Stream]::Null) + @{pid=$process.Id;started=$process.StartTime.ToUniversalTime().Ticks} | ConvertTo-Json | Set-Content -LiteralPath $recordPath + $restarts++ + Write-Output "Network process restarted (attempt $restarts); runner retained. Connectivity recovery is handled by the runner." + } + $code = $runner.ExitCode + } +} catch { + # Do not expose provider paths, arguments, configuration or raw error messages. + [Console]::Error.WriteLine('Private network supervision failed; inspect local state. No job was retried.') + if ($runnerStarted -and -not $runner.HasExited) { + [Console]::Error.WriteLine('Network recovery stopped; the existing runner remains running until it exits. Inspect local supervision state before maintenance.') + $runner.WaitForExit() + } +} finally { + if ($runnerStarted) { + try { + if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($runnerOut,$runnerErr), 5000)) { throw 'Output drain timed out.' } + } catch { + $code = 1 + [Console]::Error.WriteLine('Runner output drain failed or timed out; output may be incomplete.') + } + } + if ($runner) { $runner.Dispose() } + try { + if ($started) { + if (-not $process.HasExited) { $process.Kill($true) } + if (-not $process.WaitForExit(10000)) { throw 'Network cleanup did not complete.' } + if (Test-Path -LiteralPath $recordPath) { Remove-Item -LiteralPath $recordPath } + } + } catch { + $code = 1 + [Console]::Error.WriteLine('Owned network cleanup failed; inspect local process state before restarting.') + } finally { + if ($process) { $process.Dispose() } + if ($lock) { $lock.Dispose() } + } +} +exit $code diff --git a/apps/desktop/scripts/run-windows-runner-task.ps1 b/apps/desktop/scripts/run-windows-runner-task.ps1 index 339350377a..56371e970c 100644 --- a/apps/desktop/scripts/run-windows-runner-task.ps1 +++ b/apps/desktop/scripts/run-windows-runner-task.ps1 @@ -15,7 +15,13 @@ try { exit 1 } Set-Content -LiteralPath $attemptFile -Value ($attempts + 1) - & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File (Join-Path $PSScriptRoot 'start.ps1') -RunnerRoot $config.RunnerRoot -ConfigRoot $config.ConfigRoot *> $log + $startup = Join-Path $PSScriptRoot 'start.ps1' + if ($config.NetworkRoot) { + if (-not (Test-Path -LiteralPath (Join-Path $config.ConfigRoot 'runner-network.json') -PathType Leaf)) { throw 'Owned network supervision requires runner-network.json.' } + & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File (Join-Path $PSScriptRoot 'run-windows-runner-network-process.ps1') -RunnerRoot $config.RunnerRoot -ConfigRoot $config.ConfigRoot -NetworkRoot $config.NetworkRoot -ReadinessScript $startup -StartScript $startup *> $log + } else { + & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File $startup -RunnerRoot $config.RunnerRoot -ConfigRoot $config.ConfigRoot *> $log + } $code = $LASTEXITCODE Add-Content -LiteralPath $log -Value "Runner launcher exited with code $code." # An unexpected exit is a task failure even when run.cmd hides the listener's nonzero status; the log retains its code. diff --git a/apps/desktop/scripts/sign-primary-runtime.ts b/apps/desktop/scripts/sign-primary-runtime.ts index d9a9b36c25..a15e378625 100644 --- a/apps/desktop/scripts/sign-primary-runtime.ts +++ b/apps/desktop/scripts/sign-primary-runtime.ts @@ -11,7 +11,7 @@ import { withWindowsSigningStage } from './windows-signing-stage.mjs' import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs' import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { smokePrimaryRuntime } from './prepare-primary-runtime.ts' -import { verifyDesktopRuntime, writeDesktopRuntime, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' +import { verifyDesktopRuntime, writeDesktopRuntimeAsync, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' import { smokePreparedRuntime } from './smoke-prepared-runtime.ts' /** @@ -39,7 +39,7 @@ export async function signWindowsDesktopRuntime(root: string, version: string, options: WindowsCodeSigningOptions & { smoke: (descriptor: DesktopRuntimeDescriptor) => Promise }): Promise { const descriptor = await verifyDesktopRuntime(root, version) await signWindowsCode(root, options) - writeDesktopRuntime(root, descriptor.release, descriptor.sharedPackages.map(entry => entry.name)) + await writeDesktopRuntimeAsync(root, descriptor.release, descriptor.sharedPackages.map(entry => entry.name)) const signed = await verifyDesktopRuntime(root, version) await options.smoke(signed) await verifyDesktopRuntime(root, version) diff --git a/apps/desktop/scripts/start-windows-packaging-runner.ps1 b/apps/desktop/scripts/start-windows-packaging-runner.ps1 index 13251a110a..a024296024 100644 --- a/apps/desktop/scripts/start-windows-packaging-runner.ps1 +++ b/apps/desktop/scripts/start-windows-packaging-runner.ps1 @@ -16,6 +16,9 @@ if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw 'ConfigRoot requires a f $env:DSH_PACKAGING_CONFIG_DIR=[IO.Path]::GetFullPath($ConfigRoot) $env:DSH_TELEMETRY_DISABLED='1' $env:POWERSHELL_TELEMETRY_OPTOUT='1' +. (Join-Path $PSScriptRoot 'windows-runner-network.ps1') +$network = Read-RunnerNetwork $ConfigRoot +Set-RunnerNetwork $network foreach($tool in @('pwsh','node','git','tar','python')) { $resolved=Get-Command $tool -CommandType Application -ErrorAction Stop | Select-Object -First 1 Write-Output ('Tool: '+$tool+' -> '+$resolved.Source) @@ -31,6 +34,7 @@ $vswhere=Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/v $visualStudio=& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath if($LASTEXITCODE -ne 0 -or -not $visualStudio){throw 'Visual C++ tools unavailable'} Write-Output 'Visual C++ tools located.' +Wait-RunnerNetwork $network if($CheckOnly){Write-Output 'Runner environment check passed.'; exit 0} if(Get-Process -Name Runner.Listener -ErrorAction SilentlyContinue){throw 'A runner listener is already running; inspect before starting another.'} Set-Location $RunnerRoot diff --git a/apps/desktop/scripts/windows-build-workspace.d.mts b/apps/desktop/scripts/windows-build-workspace.d.mts new file mode 100644 index 0000000000..92f3a3c704 --- /dev/null +++ b/apps/desktop/scripts/windows-build-workspace.d.mts @@ -0,0 +1,19 @@ +/** Identity of a Windows packaging run and its independent build root. */ +export interface WindowsBuildWorkspaceOptions { + sourceDirectory: string + buildRoot: string + runId: string + runAttempt: string + sourceSha: string +} +/** + * Clone the clean selected commit into a new run-owned directory. + * @param options - Original checkout and Actions run identity. + * @returns Checkout and temporary directory owned by this run. + */ +export function prepareWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): { directory: string; temporaryDirectory: string } +/** + * Remove the directory only when its ownership record matches the run. + * @param options - Original checkout and Actions run identity. + */ +export function removeWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): void diff --git a/apps/desktop/scripts/windows-build-workspace.mjs b/apps/desktop/scripts/windows-build-workspace.mjs new file mode 100644 index 0000000000..977dda6618 --- /dev/null +++ b/apps/desktop/scripts/windows-build-workspace.mjs @@ -0,0 +1,96 @@ +/** Create and remove a run-owned checkout for Windows packaging. */ +import { execFileSync } from 'node:child_process' +import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, isAbsolute, join, parse, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +function assertUnlinkedAncestors(directory) { + for (let current = directory; ; current = dirname(current)) { + if (existsSync(current) && lstatSync(current).isSymbolicLink()) { + throw new Error('Packaging workspace ancestors must not be links') + } + if (dirname(current) === current) break + } +} + +function locations(options) { + const { buildRoot, runId, runAttempt, sourceSha } = options + if (!buildRoot || !isAbsolute(buildRoot) || /[\r\n\0]/.test(buildRoot)) { + throw new Error('Packaging build root must be a nonempty absolute directory') + } + const root = resolve(buildRoot) + if (root === parse(root).root) throw new Error('Packaging build root must not be a volume root') + if (!/^[1-9]\d*$/.test(runId) || !/^[1-9]\d*$/.test(runAttempt) || !/^[a-f0-9]{40}$/.test(sourceSha)) { + throw new Error('Packaging workspace requires a run identity and exact commit') + } + assertUnlinkedAncestors(root) + for (let current = root; ; current = dirname(current)) { + if (existsSync(join(current, '.git'))) throw new Error('Packaging build root must be outside existing Git checkouts') + if (dirname(current) === current) break + } + const owner = join(root, `windows-package-${runId}-${runAttempt}`) + assertUnlinkedAncestors(owner) + return { root, owner, directory: join(owner, 'source'), temporaryDirectory: join(owner, 'temp') } +} + +function git(directory, args) { + return execFileSync('git', args, { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() +} + +function ownership(options, paths) { + return JSON.stringify({ version: 1, root: paths.root, source: resolve(options.sourceDirectory), runId: options.runId, runAttempt: options.runAttempt, sourceSha: options.sourceSha }) +} + +/** + * Clone the validated commit into a new run-owned checkout; failures retain diagnostics. + * @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Source and run identity. + * @returns {{directory: string, temporaryDirectory: string}} Checkout and temporary directory owned by this run. + */ +export function prepareWindowsBuildWorkspace(options) { + const paths = locations(options) + if (git(options.sourceDirectory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(options.sourceDirectory, ['status', '--porcelain'])) { + throw new Error('Packaging source must be clean and match the selected commit') + } + mkdirSync(paths.root, { recursive: true }) + mkdirSync(paths.owner) + writeFileSync(join(paths.owner, 'owner.json'), ownership(options, paths), { flag: 'wx' }) + mkdirSync(paths.temporaryDirectory) + git(paths.owner, ['clone', '--no-hardlinks', '--no-checkout', resolve(options.sourceDirectory), paths.directory]) + git(paths.directory, ['checkout', '--detach', options.sourceSha]) + if (git(paths.directory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(paths.directory, ['status', '--porcelain'])) { + throw new Error('Isolated packaging checkout does not match the selected commit') + } + return { directory: paths.directory, temporaryDirectory: paths.temporaryDirectory } +} + +/** + * Remove only the matching run-owned directory after successful artifact upload. + * Transient filesystem failures retry up to three times; persistent failures propagate. + * @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Original source and run identity. + * @returns {void} + */ +export function removeWindowsBuildWorkspace(options) { + const paths = locations(options) + const marker = join(paths.owner, 'owner.json') + const stat = lstatSync(marker) + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8192 || readFileSync(marker, 'utf8') !== ownership(options, paths)) { + throw new Error('Packaging workspace ownership does not match this run') + } + rmSync(paths.owner, { recursive: true, maxRetries: 3, retryDelay: 100 }) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const options = { + sourceDirectory: process.env.GITHUB_WORKSPACE ?? '', + buildRoot: process.env.PACKAGE_BUILD_ROOT ?? '', + runId: process.env.GITHUB_RUN_ID ?? '', + runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? '', + sourceSha: process.env.PACKAGE_SOURCE_SHA ?? '', + } + if (process.argv[2] === '--remove') removeWindowsBuildWorkspace(options) + else { + if (process.argv.length !== 2 || !process.env.GITHUB_OUTPUT) throw new Error('Expected Actions output file and no arguments') + const result = prepareWindowsBuildWorkspace(options) + appendFileSync(process.env.GITHUB_OUTPUT, `directory=${result.directory}\ntemporaryDirectory=${result.temporaryDirectory}\n`) + } +} diff --git a/apps/desktop/scripts/windows-package-settings.d.mts b/apps/desktop/scripts/windows-package-settings.d.mts index cef8b8aa85..b8eb377952 100644 --- a/apps/desktop/scripts/windows-package-settings.d.mts +++ b/apps/desktop/scripts/windows-package-settings.d.mts @@ -1,6 +1,7 @@ /** - * Validate file-owned Windows packaging and cache worker limits before packaging starts. - * @param environment Windows packaging settings loaded from .env.windows. - * @returns Cache workers from one to eight and a positive integer pack limit; both default to four. Invalid values throw. + * Validate worker limits and compression before packaging starts. + * File-owned compression takes precedence over the existing electron-builder environment override. + * @param environment Loaded packaging settings and inherited build environment. + * @returns Cache workers from one to eight and a positive integer pack limit, both defaulting to four; archive compression from zero to nine, defaulting to three. Invalid values throw. */ -export function resolveWindowsPackageSettings(environment: NodeJS.ProcessEnv): { signatureCacheConcurrency: number, packConcurrency: number } +export function resolveWindowsPackageSettings(environment: NodeJS.ProcessEnv): { signatureCacheConcurrency: number, packConcurrency: number, compressionLevel: number } diff --git a/apps/desktop/scripts/windows-package-settings.mjs b/apps/desktop/scripts/windows-package-settings.mjs index cd78e49756..6fc29d1440 100644 --- a/apps/desktop/scripts/windows-package-settings.mjs +++ b/apps/desktop/scripts/windows-package-settings.mjs @@ -1,9 +1,10 @@ -/** Resolve file-owned Windows packaging and signature-cache concurrency. */ +/** Resolve Windows packaging concurrency and archive compression. */ /** - * Validate packaging and cache worker limits without accessing signing hardware. - * @param {NodeJS.ProcessEnv} environment File-owned Windows packaging settings. - * @returns {{signatureCacheConcurrency: number, packConcurrency: number}} Worker limits, each defaulting to four when omitted. + * Validate worker limits and compression without accessing signing hardware. + * File-owned compression takes precedence over the existing electron-builder environment override. + * @param {NodeJS.ProcessEnv} environment Loaded packaging settings and inherited build environment. + * @returns {{signatureCacheConcurrency: number, packConcurrency: number, compressionLevel: number}} Worker limits defaulting to four and archive compression defaulting to three. */ export function resolveWindowsPackageSettings(environment) { const value = environment.DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY ?? '4' @@ -14,5 +15,9 @@ export function resolveWindowsPackageSettings(environment) { if (!/^[1-9]\d*$/u.test(pack) || !Number.isSafeInteger(Number(pack))) { throw new Error('desktop package: DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY must be a positive integer') } - return { signatureCacheConcurrency: Number(value), packConcurrency: Number(pack) } + const compression = environment.DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL ?? environment.ELECTRON_BUILDER_COMPRESSION_LEVEL ?? '3' + if (!/^[0-9]$/u.test(compression)) { + throw new Error('desktop package: DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL must be an integer from 0 to 9') + } + return { signatureCacheConcurrency: Number(value), packConcurrency: Number(pack), compressionLevel: Number(compression) } } diff --git a/apps/desktop/scripts/windows-packaging-priority.ps1 b/apps/desktop/scripts/windows-packaging-priority.ps1 new file mode 100644 index 0000000000..2e926f030b --- /dev/null +++ b/apps/desktop/scripts/windows-packaging-priority.ps1 @@ -0,0 +1,59 @@ +# Normalize only this workflow shell and the children it subsequently launches. +[CmdletBinding()] +param([switch]$InspectOnly, [switch]$BestEffort) + +$ErrorActionPreference = 'Stop' +if (-not $IsWindows) { throw 'Packaging process priority requires Windows.' } + +if (-not ('DshPackagingPriority' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public static class DshPackagingPriority { + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess(); + [DllImport("ntdll.dll")] static extern int NtSetInformationProcess(IntPtr process, int kind, ref int value, int size); + [DllImport("ntdll.dll")] static extern int NtQueryInformationProcess(IntPtr process, int kind, out int value, int size, out int length); + public static int Read(int kind) { + int value, length; + int status = NtQueryInformationProcess(GetCurrentProcess(), kind, out value, 4, out length); + if (status != 0 || length != 4) throw new InvalidOperationException("Cannot read packaging priority: " + status); + return value; + } + public static void Set(int kind, int value) { + int status = NtSetInformationProcess(GetCurrentProcess(), kind, ref value, 4); + if (status != 0) throw new InvalidOperationException("Cannot set packaging priority: " + status); + } +} +'@ +} + +$process = [Diagnostics.Process]::GetCurrentProcess() +try { + $before = [ordered]@{ + cpu = $process.PriorityClass.ToString() + io = [DshPackagingPriority]::Read(33) + memory = [DshPackagingPriority]::Read(39) + } + if (-not $InspectOnly) { + $process.PriorityClass = [Diagnostics.ProcessPriorityClass]::Normal + # PROCESSINFOCLASS: ProcessIoPriority (33), ProcessPagePriority (39). + [DshPackagingPriority]::Set(33, 2) + [DshPackagingPriority]::Set(39, 5) + } + $process.Refresh() + $after = [ordered]@{ + cpu = $process.PriorityClass.ToString() + io = [DshPackagingPriority]::Read(33) + memory = [DshPackagingPriority]::Read(39) + } + [ordered]@{ event = 'packaging-process-priority'; before = $before; after = $after } | ConvertTo-Json -Compress + if (-not $InspectOnly -and ($after.cpu -ne 'Normal' -or $after.io -ne 2 -or $after.memory -ne 5)) { + throw 'Packaging process did not reach normal CPU, I/O and memory priority.' + } +} catch { + if (-not $BestEffort) { throw } + Write-Warning "Packaging priority adjustment unavailable; performance comparison is invalid: $($_.Exception.Message)" + [ordered]@{ event = 'packaging-process-priority'; comparable = $false } | ConvertTo-Json -Compress +} finally { + $process.Dispose() +} diff --git a/apps/desktop/scripts/windows-runner-network.ps1 b/apps/desktop/scripts/windows-runner-network.ps1 new file mode 100644 index 0000000000..9ab2491901 --- /dev/null +++ b/apps/desktop/scripts/windows-runner-network.ps1 @@ -0,0 +1,80 @@ +# Load operator-owned network settings without evaluating code or logging their values. +function Read-RunnerNetwork([string]$ConfigRoot) { + $path = Join-Path $ConfigRoot 'runner-network.json' + if (-not (Test-Path -LiteralPath $path)) { return $null } + try { $config = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -AsHashtable -ErrorAction Stop } + catch { throw 'Cannot read runner-network.json; inspect its JSON locally.' } + $keys = @('proxyUrl','noProxy','serviceName','probeUrl','attempts','timeoutSeconds','retrySeconds') + if ($config -isnot [Collections.IDictionary] -or $config.Count -ne $keys.Count -or @($config.Keys | Where-Object { $_ -cnotin $keys }).Count) { + throw 'runner-network.json requires exactly proxyUrl, noProxy, serviceName, probeUrl, attempts, timeoutSeconds and retrySeconds.' + } + foreach ($key in @('proxyUrl','noProxy','serviceName','probeUrl')) { + if ($config[$key] -isnot [string] -or $config[$key] -match '[\r\n\x00]') { throw 'Invalid network string setting; inspect runner-network.json locally.' } + } + if ([Management.Automation.WildcardPattern]::ContainsWildcardCharacters($config.serviceName)) { throw 'Network service must be an exact name.' } + foreach ($key in @('attempts','timeoutSeconds','retrySeconds')) { + if ($config[$key] -isnot [long] -and $config[$key] -isnot [int]) { throw 'Network budgets must be integers.' } + if ($config[$key] -lt 1 -or $config[$key] -gt 60) { throw 'Network budgets must be between 1 and 60.' } + } + foreach ($key in @('proxyUrl','probeUrl')) { + if ($key -eq 'proxyUrl' -and $config[$key] -eq '') { continue } + $uri = $null + if (-not [Uri]::TryCreate($config[$key], [UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -notin @('http','https') -or $uri.UserInfo -or $uri.Query -or $uri.Fragment) { + throw 'Network URLs must be absolute HTTP(S) URLs without credentials, queries or fragments.' + } + if ($key -eq 'proxyUrl' -and $uri.AbsolutePath -ne '/') { throw 'Proxy URL must not contain a path.' } + } + return $config +} + +# Called after the runner environment is scrubbed; only declared network values reach child jobs. +function Set-RunnerNetwork($Config) { + if ($null -eq $Config) { return } + [Environment]::SetEnvironmentVariable('HTTP_PROXY', $Config.proxyUrl, 'Process') + [Environment]::SetEnvironmentVariable('HTTPS_PROXY', $Config.proxyUrl, 'Process') + [Environment]::SetEnvironmentVariable('NO_PROXY', $Config.noProxy, 'Process') + [Environment]::SetEnvironmentVariable('NODE_USE_ENV_PROXY', '1', 'Process') +} + +# Only probe retries are allowed here. Service recovery belongs to its installed Windows service. +function Wait-RunnerNetwork($Config) { + if ($null -eq $Config) { return } + $info = [Diagnostics.ProcessStartInfo]::new((Get-Command node -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source) + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + $info.Environment['DSH_NETWORK_PROBE_URL'] = $Config.probeUrl + $info.Environment['DSH_NETWORK_PROBE_TIMEOUT'] = [string]($Config.timeoutSeconds * 1000) + $info.ArgumentList.Add('--input-type=module') + $info.ArgumentList.Add('-e') + $info.ArgumentList.Add('try { const r = await fetch(process.env.DSH_NETWORK_PROBE_URL, { method: "HEAD", redirect: "error", signal: AbortSignal.timeout(Number(process.env.DSH_NETWORK_PROBE_TIMEOUT)) }); await r.body?.cancel(); process.exit(r.ok ? 0 : 1); } catch { process.exit(1); }') + for ($attempt = 1; $attempt -le $Config.attempts; $attempt++) { + $ready = $true + if ($Config.serviceName) { + try { $service = Get-Service -Name $Config.serviceName -ErrorAction Stop } + catch { throw 'Configured network service is unavailable; inspect its installation locally.' } + $ready = $service.Status -eq 'Running' + } + if ($ready) { + $process = [Diagnostics.Process]::new() + $process.StartInfo = $info + try { + if (-not $process.Start()) { throw 'Cannot launch network probe.' } + $out = $process.StandardOutput.ReadToEndAsync() + $err = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(($Config.timeoutSeconds + 5) * 1000)) { + $process.Kill($true) + $process.WaitForExit() + $ready = $false + } else { $ready = $process.ExitCode -eq 0 } + $null = $out.GetAwaiter().GetResult() + $null = $err.GetAwaiter().GetResult() + } finally { $process.Dispose() } + } + if ($ready) { Write-Output 'Runner network dependency ready (Node HEAD probe).'; return } + Write-Output "Runner network dependency not ready (probe $attempt/$($Config.attempts))." + if ($attempt -lt $Config.attempts) { Start-Sleep -Seconds $Config.retrySeconds } + } + throw 'Runner network dependency unavailable; runner was not started. Inspect local service and network configuration. No job or signing retry was requested.' +} diff --git a/apps/desktop/scripts/windows-runner-task.ps1 b/apps/desktop/scripts/windows-runner-task.ps1 index 47efba43e0..e940eef988 100644 --- a/apps/desktop/scripts/windows-runner-task.ps1 +++ b/apps/desktop/scripts/windows-runner-task.ps1 @@ -4,13 +4,14 @@ param( [Parameter(Mandatory=$true)][ValidateSet('install','start','stop','status','uninstall')][string]$Action, [Parameter(Mandatory=$true)][string]$RunnerRoot, [string]$ConfigRoot, + [string]$NetworkRoot, [ValidateRange(1,1440)][int]$RestartMinutes = 1, [ValidateRange(1,999)][int]$RestartCount = 10 ) $ErrorActionPreference = 'Stop' if (-not $IsWindows) { throw 'This command requires PowerShell 7 on Windows.' } -if ($Action -ne 'install' -and @('ConfigRoot','RestartMinutes','RestartCount').Where({ $PSBoundParameters.ContainsKey($_) }).Count) { - throw 'ConfigRoot, RestartMinutes and RestartCount apply only to install; reinstall to change them.' +if ($Action -ne 'install' -and @('ConfigRoot','NetworkRoot','RestartMinutes','RestartCount').Where({ $PSBoundParameters.ContainsKey($_) }).Count) { + throw 'ConfigRoot, NetworkRoot, RestartMinutes and RestartCount apply only to install; reinstall to change them.' } if ($RunnerRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'RunnerRoot must be an absolute local directory.' } $RunnerRoot = [IO.Path]::GetFullPath($RunnerRoot).TrimEnd('\','/') @@ -30,7 +31,15 @@ if ($Action -eq 'install') { if (-not $ConfigRoot -or $ConfigRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'Install requires an absolute ConfigRoot.' } $ConfigRoot = [IO.Path]::GetFullPath($ConfigRoot) $pwsh = Join-Path $PSHOME 'pwsh.exe' - & $pwsh -NoProfile -File (Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1') -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -CheckOnly + $startup = Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1' + if ($PSBoundParameters.ContainsKey('NetworkRoot')) { + if ($NetworkRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'NetworkRoot must identify an existing absolute local directory.' } + if (-not (Test-Path -LiteralPath (Join-Path $ConfigRoot 'runner-network.json') -PathType Leaf)) { throw 'Owned network supervision requires runner-network.json.' } + $NetworkRoot = [IO.Path]::GetFullPath($NetworkRoot) + & $pwsh -NoProfile -File (Join-Path $PSScriptRoot 'run-windows-runner-network-process.ps1') -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -NetworkRoot $NetworkRoot -ReadinessScript $startup -StartScript $startup -CheckOnly + } else { + & $pwsh -NoProfile -File $startup -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -CheckOnly + } if ($LASTEXITCODE -ne 0) { throw 'Runner preflight failed; no task was installed.' } if (Test-Path -LiteralPath $serviceDir) { if ((Get-Item -LiteralPath $serviceDir).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Task directory cannot be a link.' } @@ -44,7 +53,10 @@ if ($Action -eq 'install') { if ($LASTEXITCODE -ne 0) { throw 'Runner launcher compilation failed; verify .NET Framework 4.5+ is installed. No task was installed.' } Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1') -Destination (Join-Path $serviceDir 'start.ps1') Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'run-windows-runner-task.ps1') -Destination (Join-Path $serviceDir 'run.ps1') - @{ RunnerRoot = $RunnerRoot; ConfigRoot = $ConfigRoot; TaskName = $taskName; RestartCount = $RestartCount } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $serviceDir 'config.json') -Encoding utf8 + foreach ($helper in @('windows-runner-network.ps1','run-windows-runner-network-process.ps1')) { + Copy-Item -LiteralPath (Join-Path $PSScriptRoot $helper) -Destination (Join-Path $serviceDir $helper) + } + @{ RunnerRoot = $RunnerRoot; ConfigRoot = $ConfigRoot; NetworkRoot = $NetworkRoot; TaskName = $taskName; RestartCount = $RestartCount } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $serviceDir 'config.json') -Encoding utf8 Set-Content -LiteralPath (Join-Path $serviceDir 'attempts.txt') -Value '0' $execution = New-ScheduledTaskAction -Execute $launcher -Argument ('"' + $pwsh + '"') -WorkingDirectory $RunnerRoot $principal = New-ScheduledTaskPrincipal -UserId $sid -LogonType Interactive -RunLevel Limited @@ -52,7 +64,7 @@ if ($Action -eq 'install') { (New-ScheduledTaskTrigger -AtLogOn -User $sid), (New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes($RestartMinutes) -RepetitionInterval (New-TimeSpan -Minutes $RestartMinutes)) ) - $settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries + $settings = New-ScheduledTaskSettingsSet -Priority 4 -MultipleInstances IgnoreNew -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries $settings.Enabled = $false $null = Register-ScheduledTask -TaskName $taskName -TaskPath '\' -Action $execution -Principal $principal -Trigger $trigger -Settings $settings -Description "DSH packaging runner: $RunnerRoot; owner: $sid" Write-Output "Installed disabled task $taskName. Run start while logged in; reboot requires desktop login. Logs: $serviceDir" diff --git a/apps/desktop/scripts/windows-runtime-signature.d.mts b/apps/desktop/scripts/windows-runtime-signature.d.mts index f47e306885..b5624c2577 100644 --- a/apps/desktop/scripts/windows-runtime-signature.d.mts +++ b/apps/desktop/scripts/windows-runtime-signature.d.mts @@ -41,7 +41,7 @@ export interface WindowsCodeSigningOptions { /** * Enumerate PE files by content without following links. * @param root Materialized directory to scan. - * @returns Sorted PE paths; rejects malformed Windows executables and links. + * @returns Sorted PE paths; eight bounded readers reject links or malformed executables and drain open handles before failure. */ export function windowsRuntimeCode(root: string): Promise @@ -49,7 +49,7 @@ export function windowsRuntimeCode(root: string): Promise * Preserve valid signatures and sign unsigned PE files with a supervised signer. * @param root Materialized directory to sign. * @param options Signer, certificate identity and audit sink. - * @returns Resolves after verified parallel cache restores and sequential signing; stops dispatch and drains active restores on failure, without retries. + * @returns Resolves after parallel cache restores, batched target verification and sequential signing; failures drain active restores or inspections without retries. */ export function signWindowsCode(root: string, options: WindowsCodeSigningOptions): Promise diff --git a/apps/desktop/scripts/windows-runtime-signature.mjs b/apps/desktop/scripts/windows-runtime-signature.mjs index 8780f182ca..9f79547b34 100644 --- a/apps/desktop/scripts/windows-runtime-signature.mjs +++ b/apps/desktop/scripts/windows-runtime-signature.mjs @@ -6,6 +6,7 @@ import { promisify } from 'node:util' import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' import { recordPackagingEvent } from './packaging-run.mjs' import { inspectSignaturesBatched } from './windows-signature-batch.mjs' +import { packagingStep } from './packaging-step.mjs' /** * Read Windows trust, timestamp and signer identity using the engine's bundled modules, without accessing the private key. @@ -52,17 +53,32 @@ export async function preserveWindowsRuntimeSignature(path, options) { /** * Enumerate Windows code without following links or treating foreign .node files as PE binaries. * @param {string} root - Owned, materialized runtime directory. - * @returns {Promise} Sorted real PE files; rejects links and malformed Windows executable files. + * @returns {Promise} Sorted real PE files; eight bounded readers reject links or malformed executables and drain open handles before failure. */ export async function windowsRuntimeCode(root) { - const rootStat = await lstat(root) - if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('Windows code: expected a real directory') + return packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'windows:enumerate-code', async () => { + const candidates = [] + async function collect(directory) { + const rootStat = await lstat(directory) + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('Windows code: expected a real directory') + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isSymbolicLink()) throw new Error(`Windows code: directory links are not signable: ${path}`) + if (entry.isDirectory()) await collect(path) + else if (entry.isFile()) candidates.push(path) + } + } + await collect(root) + return inspectPortableExecutables(candidates) + }) +} + +/** Close every active file before returning a sorted inventory or the first inspection failure. */ +async function inspectPortableExecutables(candidates) { const files = [] - for (const entry of await readdir(root, { withFileTypes: true })) { - const path = join(root, entry.name) - if (entry.isSymbolicLink()) throw new Error(`Windows code: directory links are not signable: ${path}`) - if (entry.isDirectory()) { files.push(...await windowsRuntimeCode(path)); continue } - if (!entry.isFile()) continue + let next = 0 + let failure + async function inspect(path) { const file = await open(path, 'r') let portableExecutable = false let windowsCandidate = false @@ -80,11 +96,26 @@ export async function windowsRuntimeCode(root) { if (portableExecutable) files.push(path) else if (windowsCandidate || ['.exe', '.dll', '.pyd'].includes(extname(path).toLowerCase())) throw new Error(`Windows code: invalid PE file: ${path}`) } + async function worker() { + while (failure === undefined && next < candidates.length) { + const path = candidates[next++] + try { await inspect(path) } + catch (error) { failure ??= { error } } + } + } + await Promise.all(Array.from({ length: Math.min(8, candidates.length) }, worker)) + if (failure !== undefined) throw failure.error return files.sort() } /** Drain each public-key verification batch before signing or reporting a failure. */ async function inspectWindowsCode(files, inspect) { + return packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'windows:inspect-signatures', + () => inspectWindowsCodeBatch(files, inspect)) +} + +/** Complete bounded inspections without allowing callers to proceed past an active failed batch. */ +async function inspectWindowsCodeBatch(files, inspect) { if (inspect === inspectWindowsRuntimeSignature) return inspectSignaturesBatched(files) const signatures = [] for (let offset = 0; offset < files.length; offset += 4) { @@ -101,7 +132,7 @@ async function inspectWindowsCode(files, inspect) { * Preserve valid signatures and sign unsigned PE files with a supervised signer. * @param {string} root - Owned final runtime directory. * @param {import('./windows-runtime-signature.mjs').WindowsCodeSigningOptions} options - Supervised signer, certificate identity, audit sink. - * @returns {Promise} Resolves after bounded cache restores and sequential hardware signing, each with verification; failures drain active restores before rejecting. + * @returns {Promise} Resolves after bounded cache restores, batched target verification and sequential hardware signing; failures drain active work before rejecting. */ export async function signWindowsCode(root, options) { const inspect = options.inspect ?? inspectWindowsRuntimeSignature @@ -116,12 +147,15 @@ export async function signWindowsCode(root, options) { else if (signature.status !== 'Valid') throw new Error(`Windows code: refusing ${signature.status} signature: ${path}`) } options.record({ type: 'windows-code-signing-plan', files: files.length, unsigned: unsigned.length }) - async function verifySigned(path) { - const signature = await inspect(path) - if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) { - throw new Error(`Windows code: signing verification failed: ${path}`) + async function verifySigned(paths) { + const verified = await inspectWindowsCode(paths, inspect) + for (const [index, path] of paths.entries()) { + const signature = verified[index] + if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) { + throw new Error(`Windows code: signing verification failed: ${path}`) + } + options.record({ type: 'windows-code-signature-verified', path, ...signature }) } - options.record({ type: 'windows-code-signature-verified', path, ...signature }) } const restored = new Set() if (options.cache) { @@ -132,7 +166,6 @@ export async function signWindowsCode(root, options) { const path = unsigned[next++] try { if (await options.cache.restore({ path, hash: 'sha256', isNest: false })) { - await verifySigned(path) restored.add(path) } } catch (error) { failure ??= { error } } @@ -140,11 +173,12 @@ export async function signWindowsCode(root, options) { } await Promise.all(Array.from({ length: Math.min(options.cache.concurrency, unsigned.length) }, worker)) if (failure !== undefined) throw failure.error + await verifySigned(unsigned.filter(path => restored.has(path))) } for (const path of unsigned) { if (restored.has(path)) continue await options.sign({ path, hash: 'sha256', isNest: false }) - await verifySigned(path) + await verifySigned([path]) } } diff --git a/apps/desktop/scripts/windows-sign.mjs b/apps/desktop/scripts/windows-sign.mjs index 3ebd199615..78459c4c1f 100644 --- a/apps/desktop/scripts/windows-sign.mjs +++ b/apps/desktop/scripts/windows-sign.mjs @@ -135,7 +135,9 @@ export function createRedactedWindowsSigningError(error, path, secrets) { const stderr = record !== undefined && 'stderr' in record ? redactedSigningOutput(record.stderr, secrets).trim() : '' - return new Error(`Windows release signing failed for ${path}${code}${stderr === '' ? '' : `: ${stderr}`}`) + return new Error(`Windows release signing failed for ${path}${code}${stderr === '' ? '' : `: ${stderr}`}. ` + + 'Signing stopped. After replacing the UKey, check the certificate, container, SafeNet provider and user PIN. ' + + 'This error does not establish device lock status or whether PIN retries were consumed; inspect the retained interlock before operator recovery.') } /** diff --git a/apps/desktop/scripts/windows-signing-preflight.ts b/apps/desktop/scripts/windows-signing-preflight.ts index 6076e4cd4e..48d65b7b91 100644 --- a/apps/desktop/scripts/windows-signing-preflight.ts +++ b/apps/desktop/scripts/windows-signing-preflight.ts @@ -8,6 +8,7 @@ import { promisify } from 'node:util' import { createWindowsTokenSigner, scrubWindowsSigningEnvironment } from './windows-sign.mjs' import { inspectWindowsRuntimeSignature, type WindowsRuntimeSignature } from './windows-runtime-signature.mjs' import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs' +import { inspectWindowsTokenIdentity, windowsTokenIdentityFailure } from './windows-token-identity.ts' const PROBE_SOURCE = 'internal static class SigningProbe { private static int Main() { return 0; } }\n' @@ -18,6 +19,7 @@ interface SigningPreflightOptions { compile?: (compiler: string, source: string, output: string, environment: NodeJS.ProcessEnv) => Promise sign?: ReturnType inspect?: (path: string) => Promise + inspectIdentity?: typeof inspectWindowsTokenIdentity } async function compileProbe(compiler: string, source: string, output: string, environment: NodeJS.ProcessEnv): Promise { @@ -28,7 +30,7 @@ async function compileProbe(compiler: string, source: string, output: string, en } /** - * Sign one newly compiled, never-executed probe and verify its timestamp and configured certificate. + * Match public container keys before signing one never-executed probe and verifying its certificate and timestamp. * @param options Supervised evidence directory, file-owned settings and isolated test adapters. * @returns Resolves after one signature and verification; failures retain evidence; timestamp attempts never repeat hardware signing. */ @@ -71,6 +73,12 @@ export async function preflightWindowsSigning(options: SigningPreflightOptions): compilerSha256: createHash('sha256').update(await readFile(compiler)).digest('hex'), signToolSha256: createHash('sha256').update(await readFile(environment.DSH_DESKTOP_WINDOWS_SIGNTOOL!)).digest('hex'), certificateSha256: createHash('sha256').update(certificate).digest('hex') }) + const identity = await (options.inspectIdentity ?? inspectWindowsTokenIdentity)({ + certificate, container: environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER!.trim(), + environment: scrubWindowsSigningEnvironment(environment), + }) + record({ type: 'signing-preflight-public-identity', result: identity, pinSupplied: false }) + if (identity !== 'matched') throw new Error(windowsTokenIdentityFailure(identity)) await (options.compile ?? compileProbe)(compiler, source, output, environment) const inspect = options.inspect ?? inspectWindowsRuntimeSignature if ((await inspect(output)).status !== 'NotSigned') throw new Error('Windows signing preflight requires a new unsigned probe') diff --git a/apps/desktop/scripts/windows-token-identity.ts b/apps/desktop/scripts/windows-token-identity.ts new file mode 100644 index 0000000000..a36ddf1d72 --- /dev/null +++ b/apps/desktop/scripts/windows-token-identity.ts @@ -0,0 +1,87 @@ +/** Compare CSP public material without supplying a PIN or invoking a signing operation. */ +import { execFile } from 'node:child_process' +import { createPublicKey, X509Certificate } from 'node:crypto' +import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' +import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' + +/** Public-data results do not attest physical device presence, authentication or remaining PIN attempts. */ +export type WindowsTokenIdentity = 'matched' | 'certificate-mismatch' | 'provider-unavailable' | 'container-unavailable' | 'unknown' + +interface PublicKeyQuery { + container: string + environment: NodeJS.ProcessEnv +} + +async function queryPublicKeys({ container, environment }: PublicKeyQuery): Promise { + const { NODE_OPTIONS: _options, NODE_PATH: _path, ...safeEnvironment } = scrubWindowsSigningEnvironment(environment) + const { stdout, stderr } = await promisify(execFile)('pwsh', ['-NoProfile', '-NonInteractive', '-File', + fileURLToPath(new URL('./windows-token-public-key.ps1', import.meta.url))], { + env: { ...safeEnvironment, DSH_TOKEN_PUBLIC_CONTAINER: container }, + encoding: 'utf8', windowsHide: true, timeout: 15_000, maxBuffer: 64 * 1024, + }) + if (stderr) throw new Error('Public-key query returned diagnostics') + return stdout +} + +function publicBlobKey(value: string): Buffer { + const bytes = Buffer.from(value, 'base64') + if (bytes.toString('base64') !== value || bytes.length < 20 || bytes[0] !== 6 || bytes[1] !== 2 + || bytes.readUInt16LE(2) !== 0 || ![0x2400, 0xa400].includes(bytes.readUInt32LE(4)) + || bytes.readUInt32LE(8) !== 0x31415352) throw new Error('Invalid RSA public blob') + const bits = bytes.readUInt32LE(12) + const exponent = bytes.readUInt32LE(16) + if (bits < 512 || bits % 8 !== 0 || bytes.length !== 20 + bits / 8 || exponent < 3 || exponent % 2 === 0) { + throw new Error('Invalid RSA public parameters') + } + const exponentBytes = Buffer.alloc(4) + exponentBytes.writeUInt32BE(exponent) + const first = exponentBytes.findIndex(byte => byte !== 0) + return createPublicKey({ format: 'jwk', key: { kty: 'RSA', + n: Buffer.from(bytes.subarray(20)).reverse().toString('base64url'), + e: exponentBytes.subarray(first).toString('base64url') } }).export({ format: 'der', type: 'spki' }) +} + +/** + * Read both CSP public key slots and compare them with the configured certificate; never sign or retry. + * @param options Certificate bytes, container, process environment and optional isolated public-query adapter. + * @returns Matched public information, a specific unavailable/mismatch result, or unknown on incomplete/invalid output. + */ +export async function inspectWindowsTokenIdentity(options: PublicKeyQuery & { + certificate: Uint8Array + query?: (input: PublicKeyQuery) => Promise +}): Promise { + try { + const value: unknown = JSON.parse(await (options.query ?? queryPublicKeys)({ + container: options.container, environment: options.environment, + })) + if (!value || typeof value !== 'object' || !('status' in value)) return 'unknown' + if (value.status === 'provider-unavailable' || value.status === 'container-unavailable') return value.status + if (value.status !== 'public-keys' || !('publicKeys' in value) || !Array.isArray(value.publicKeys) + || value.publicKeys.length < 1 || value.publicKeys.length > 2) return 'unknown' + const keys = value.publicKeys.map((key: unknown) => { + if (typeof key !== 'string') throw new Error('Invalid public key') + return publicBlobKey(key) + }) + const expected = new X509Certificate(options.certificate).publicKey.export({ format: 'der', type: 'spki' }) + return keys.some(key => key.equals(expected)) ? 'matched' : 'certificate-mismatch' + } catch { + // Native failures may include container names or inherited diagnostics; expose only the incomplete result. + return 'unknown' + } +} + +/** + * Explain why public-data validation stopped signing without asserting device lock or authentication status. + * @param result Non-matching public-data result. + * @returns A credential-free operator diagnostic. + */ +export function windowsTokenIdentityFailure(result: Exclude): string { + const reasons = { + 'provider-unavailable': 'The configured eToken provider is unavailable; check the SafeNet driver.', + 'container-unavailable': 'The configured container could not be opened; after replacing the UKey, check its container name and visibility.', + 'certificate-mismatch': 'The returned container public keys do not match the configured certificate; check the certificate and container after replacing the UKey.', + unknown: 'The container public identity could not be confirmed; check that PowerShell 7 (pwsh) can start, then check device visibility and SafeNet public-information support.', + } + return `${reasons[result]} No PIN was supplied and no signing was attempted by this check. Signing stopped; device lock and PIN retry counts are unknown.` +} diff --git a/apps/desktop/scripts/windows-token-public-key.cs b/apps/desktop/scripts/windows-token-public-key.cs new file mode 100644 index 0000000000..2549138c0f --- /dev/null +++ b/apps/desktop/scripts/windows-token-public-key.cs @@ -0,0 +1,94 @@ +// Read only public material through the existing SafeNet CSP. No authentication or key mutation APIs. +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +public static class DshTokenPublicKey +{ + public const string Provider = "eToken Base Cryptographic Provider"; + public const uint PublicContextFlags = 0xF0000040; // CRYPT_VERIFYCONTEXT | CRYPT_SILENT + public const int NoKey = unchecked((int)0x8009000D); + + public interface IApi + { + int Acquire(string container, out IntPtr context); + int GetKey(IntPtr context, uint spec, out IntPtr key); + int Export(IntPtr key, byte[] bytes, ref uint length); + bool Destroy(IntPtr key); + bool Release(IntPtr context); + } + + public sealed class NativeApi : IApi + { + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + static extern bool CryptAcquireContextW(out IntPtr context, string container, string provider, uint type, uint flags); + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool CryptGetUserKey(IntPtr context, uint spec, out IntPtr key); + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool CryptExportKey(IntPtr key, IntPtr exportKey, uint type, uint flags, byte[] bytes, ref uint length); + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool CryptDestroyKey(IntPtr key); + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool CryptReleaseContext(IntPtr context, uint flags); + public int Acquire(string container, out IntPtr context) { + return CryptAcquireContextW(out context, container, Provider, 1, PublicContextFlags) ? 0 : Marshal.GetLastWin32Error(); + } + public int GetKey(IntPtr context, uint spec, out IntPtr key) { + return CryptGetUserKey(context, spec, out key) ? 0 : Marshal.GetLastWin32Error(); + } + public int Export(IntPtr key, byte[] bytes, ref uint length) { + return CryptExportKey(key, IntPtr.Zero, 6, 0, bytes, ref length) ? 0 : Marshal.GetLastWin32Error(); // PUBLICKEYBLOB + } + public bool Destroy(IntPtr key) { return CryptDestroyKey(key); } + public bool Release(IntPtr context) { return CryptReleaseContext(context, 0); } + } + + public sealed class Result + { + public string status = "unknown"; + public string[] publicKeys = new string[0]; + } + + // The adapter seam allows all failure paths to be exercised without loading a hardware provider. + public static Result Read(IApi api, string container) + { + var result = new Result(); + IntPtr context; + int error = api.Acquire(container, out context); + if (error != 0) { + if (error == unchecked((int)0x80090013) || error == unchecked((int)0x80090017) + || error == unchecked((int)0x8009001E)) result.status = "provider-unavailable"; + else if (error == unchecked((int)0x80090016)) result.status = "container-unavailable"; + return result; + } + try { + var keys = new List(); + foreach (uint spec in new uint[] { 2, 1 }) { + IntPtr key; + error = api.GetKey(context, spec, out key); + if (error == NoKey) continue; + if (error != 0) return result; + bool destroyed; + try { + uint length = 0; + if (api.Export(key, null, ref length) != 0 || length < 20 || length > 16384) return result; + var bytes = new byte[length]; + uint capacity = length; + if (api.Export(key, bytes, ref length) != 0 || length < 20 || length > capacity) return result; + keys.Add(Convert.ToBase64String(bytes, 0, (int)length)); + } finally { destroyed = api.Destroy(key); } + if (!destroyed) return result; + } + if (keys.Count > 0) { + result.publicKeys = keys.ToArray(); + result.status = "public-keys"; + } + } finally { + if (!api.Release(context)) { + result.status = "unknown"; + result.publicKeys = new string[0]; + } + } + return result; + } +} diff --git a/apps/desktop/scripts/windows-token-public-key.ps1 b/apps/desktop/scripts/windows-token-public-key.ps1 new file mode 100644 index 0000000000..9adb367c3b --- /dev/null +++ b/apps/desktop/scripts/windows-token-public-key.ps1 @@ -0,0 +1,4 @@ +# Only the container name is needed; the caller removes inherited signing credentials. +$ErrorActionPreference = 'Stop' +Add-Type -Path (Join-Path $PSScriptRoot 'windows-token-public-key.cs') +[DshTokenPublicKey]::Read([DshTokenPublicKey+NativeApi]::new(), $env:DSH_TOKEN_PUBLIC_CONTAINER) | ConvertTo-Json -Compress diff --git a/apps/desktop/src/runtime-tree.ts b/apps/desktop/src/runtime-tree.ts index dd7941d8e8..5bc8b758cf 100644 --- a/apps/desktop/src/runtime-tree.ts +++ b/apps/desktop/src/runtime-tree.ts @@ -89,7 +89,7 @@ export function inventoryDesktopRuntime(root: string): DesktopRuntimeFile[] { .sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) } -async function inventoryRuntimeForVerification(root: string): Promise { +async function inventoryRuntimeConcurrently(root: string): Promise { const entries = runtimeFiles(root) const files: DesktopRuntimeFile[] = [] const remaining = entries.values() @@ -125,6 +125,31 @@ export function writeDesktopRuntime( root: string, release: DesktopRelease, sharedNames: readonly string[], target: { platform: NodeJS.Platform; arch: string } = process, ): DesktopRuntimeDescriptor { + return writeRuntimeDescriptor(root, { + ...runtimeMetadata(root, release, sharedNames, target), files: inventoryDesktopRuntime(root), + }) +} + +/** + * Seal a materialized runtime with bounded concurrent reads, draining readers before failure returns. + * @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 after every final file has been inventoried. + */ +export async function writeDesktopRuntimeAsync( + root: string, release: DesktopRelease, sharedNames: readonly string[], + target: { platform: NodeJS.Platform; arch: string } = process, +): Promise { + const metadata = runtimeMetadata(root, release, sharedNames, target) + return writeRuntimeDescriptor(root, { ...metadata, files: await inventoryRuntimeConcurrently(root) }) +} + +function runtimeMetadata( + root: string, release: DesktopRelease, sharedNames: readonly string[], + target: { platform: NodeJS.Platform; arch: string }, +): Omit { 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}` @@ -134,10 +159,10 @@ export function writeDesktopRuntime( } return { name, version: manifest.version, path } }) - const descriptor: DesktopRuntimeDescriptor = { - schemaVersion: 1, release, platform: target.platform, arch: target.arch, - sharedPackages, files: inventoryDesktopRuntime(root), - } + return { schemaVersion: 1, release, platform: target.platform, arch: target.arch, sharedPackages } +} + +function writeRuntimeDescriptor(root: string, descriptor: DesktopRuntimeDescriptor): DesktopRuntimeDescriptor { writeFileSync(join(root, DESKTOP_RUNTIME_FILE), `${JSON.stringify(descriptor, undefined, 2)}\n`) return descriptor } @@ -203,7 +228,7 @@ export async function verifyDesktopRuntime( throw new Error(`desktop runtime: shared package metadata mismatch for ${entry.name}`) } } - const actual = await inventoryRuntimeForVerification(root) + const actual = await inventoryRuntimeConcurrently(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 diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts index ede5c5e3d3..f9891ebb9c 100644 --- a/apps/desktop/tests/desktop-build-paths.spec.ts +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -1,10 +1,11 @@ -import { join, sep } from 'node:path' +import { join, resolve, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { desktopTargetBuildPaths, desktopTargetPlatform, developmentRuntimeDirectory, resolveDesktopBuildTarget, + resolveDesktopTargetBuildPaths, } from '../scripts/desktop-build-paths.mjs' describe('desktop build paths', () => { @@ -35,11 +36,34 @@ describe('desktop build paths', () => { expect(windows.runtime).toContain(join('targets', 'win-x64', 'runtime')) }) - it('shares only the immutable upstream download cache', () => { + it('shares download and package caches outside target output directories', () => { const arm64 = desktopTargetBuildPaths('mac-arm64') const x64 = desktopTargetBuildPaths('mac-x64') expect(arm64.downloads).toBe(x64.downloads) expect(arm64.downloads).not.toContain(`${sep}targets${sep}`) + expect(arm64.pnpmStore).toBe(x64.pnpmStore) + expect(arm64.pnpmStore).not.toContain(`${sep}targets${sep}`) + }) + + it('retains target outputs while relocating caches through the supplied environment', () => { + const cache = resolve('external-package-cache') + const environment = { DSH_DESKTOP_PACKAGE_CACHE_DIR: cache } + const defaults = desktopTargetBuildPaths('win-x64', {}) + const paths = resolveDesktopTargetBuildPaths(environment, 'win32', 'x64') + expect(paths).toEqual({ ...defaults, downloads: join(cache, 'downloads'), pnpmStore: join(cache, 'pnpm-store') }) + expect(desktopTargetBuildPaths('mac-arm64', environment).pnpmStore).toBe(paths.pnpmStore) + for (const target of ['mac-arm64', 'mac-x64', 'win-x64'] as const) { + const configured = desktopTargetBuildPaths(target, environment) + expect(configured.downloads).toBe(join(cache, 'downloads')) + expect(configured.pnpmStore).toBe(join(cache, 'pnpm-store')) + expect(configured.root).not.toContain(cache) + } + expect(defaults.pnpmStore).toBe(resolve(defaults.root, '../..', 'pnpm-store')) + }) + + it.each(['', ' ', 'relative/cache', './cache', 'cache\0invalid'])('rejects invalid cache directory %j', (directory) => { + expect(() => desktopTargetBuildPaths('win-x64', { DSH_DESKTOP_PACKAGE_CACHE_DIR: directory })) + .toThrow('DSH_DESKTOP_PACKAGE_CACHE_DIR') }) it('resolves the development primary runtime from the build target rather than the host architecture', () => { diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index 6116cb718a..a8d79a2c96 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -23,6 +23,85 @@ async function withDirectory(action: (directory: string) => Promise): Prom } describe('Desktop local packaging configuration', () => { + it('loads file-owned compression and defaults to fast Windows archive compression', async () => { + expect(resolveWindowsPackageSettings({}).compressionLevel).toBe(3) + await withDirectory(async (directory) => { + await writeFile(join(directory, '.env.windows'), 'DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3\n') + const selected = loadDesktopPackageEnvironment('win32', { DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL: '0' }, directory) + expect(resolveWindowsPackageSettings(selected).compressionLevel).toBe(3) + }) + }) + + it.each(['3', '7'])('applies an explicit Windows workflow compression override %s', async (value) => { + await withDirectory(async (directory) => { + await writeFile(join(directory, '.env.windows'), 'DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=5\n') + const selected = loadDesktopPackageEnvironment('win32', { PACKAGE_WINDOWS_COMPRESSION_LEVEL: value }, directory) + expect(resolveWindowsPackageSettings(selected).compressionLevel).toBe(Number(value)) + const unchanged = loadDesktopPackageEnvironment('win32', { PACKAGE_WINDOWS_COMPRESSION_LEVEL: '' }, directory) + expect(resolveWindowsPackageSettings(unchanged).compressionLevel).toBe(5) + }) + }) + + it.each(['0', '5', '7', '9'])('retains explicit Windows file compression %s outside the workflow', async (value) => { + await withDirectory(async (directory) => { + await writeFile(join(directory, '.env.windows'), `DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=${value}\n`) + expect(resolveWindowsPackageSettings(loadDesktopPackageEnvironment('win32', {}, directory)).compressionLevel).toBe(Number(value)) + }) + }) + + it('keeps the legacy builder override below the file-owned Windows setting', () => { + expect(resolveWindowsPackageSettings({ ELECTRON_BUILDER_COMPRESSION_LEVEL: '7' }).compressionLevel).toBe(7) + expect(resolveWindowsPackageSettings({ ELECTRON_BUILDER_COMPRESSION_LEVEL: '7', DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL: '3' }).compressionLevel).toBe(3) + }) + + it.each(['test', 'production'])('uses the same Windows compression defaults and overrides for %s', async (preset) => { + await withDirectory(async (directory) => { + await mkdir(join(directory, 'presets')) + const path = join(directory, 'presets', `${preset}.env`) + const parent = { DSH_PACKAGING_CONFIG_DIR: directory, DSH_PACKAGING_PRESET: preset } + await writeFile(path, `DSH_DESKTOP_AUTO_UPDATE_ENV=${preset}\n`) + const load = (environment: NodeJS.ProcessEnv) => resolveWindowsPackageSettings( + loadDesktopPackageEnvironment('win32', environment, directory, { unsigned: true }), + ).compressionLevel + expect(load(parent)).toBe(3) + await writeFile(path, `DSH_DESKTOP_AUTO_UPDATE_ENV=${preset}\nDSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=9\n`) + expect(load(parent)).toBe(9) + expect(load({ ...parent, PACKAGE_WINDOWS_COMPRESSION_LEVEL: '3' })).toBe(3) + expect(load({ ...parent, PACKAGE_WINDOWS_COMPRESSION_LEVEL: '7' })).toBe(7) + }) + }) + + it.each([' ', '0', '5', '9', '-1', '10', '3.5', '03', 'fast'])('rejects invalid workflow compression %j before reading settings', (value) => { + expect(() => loadDesktopPackageEnvironment('win32', { PACKAGE_WINDOWS_COMPRESSION_LEVEL: value }, 'missing-settings')) + .toThrow('PACKAGE_WINDOWS_COMPRESSION_LEVEL') + }) + + it('rejects a Windows compression override for macOS before reading settings', () => { + expect(() => loadDesktopPackageEnvironment('darwin', { PACKAGE_WINDOWS_COMPRESSION_LEVEL: '3' }, 'missing-settings')) + .toThrow('PACKAGE_WINDOWS_COMPRESSION_LEVEL') + }) + it.each(['', '-1', '10', '3.5', '03', 'fast'])('rejects invalid compression %j before preparation', (value) => { + const selected = { ...RELEASE, DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL: value } + expect(() => { validateDesktopPackageEnvironment(selected, WINDOWS, { unsigned: true }) }) + .toThrow('COMPRESSION_LEVEL') + }) + + it.each(['', ' ', 'relative/cache'])('rejects invalid package caches before any signing mode %j', (directory) => { + for (const target of [WINDOWS, MACOS]) { + for (const options of [{ unsigned: true }, { prepareOnly: true }, {}]) { + expect(() => { validateDesktopPackageEnvironment({ DSH_DESKTOP_PACKAGE_CACHE_DIR: directory }, target, options) }) + .toThrow('DSH_DESKTOP_PACKAGE_CACHE_DIR') + } + } + }) + + it('preserves the machine package cache when loading a release file', async () => { + await withDirectory(async (directory) => { + await writeFile(join(directory, '.env.windows'), '') + expect(loadDesktopPackageEnvironment('win32', { DSH_DESKTOP_PACKAGE_CACHE_DIR: directory }, directory)) + .toEqual({ DSH_DESKTOP_PACKAGE_CACHE_DIR: directory }) + }) + }) it('selects macOS local defaults while excluding inherited release settings and retaining build networking', () => { const parent = { PATH: 'build-tools', HTTPS_PROXY: 'http://proxy.example:8080', DSH_DESKTOP_NPM_REGISTRY: 'https://registry.npmmirror.com', @@ -272,3 +351,40 @@ it('skips Apple credentials but still requires signing credentials for unnotariz }).toThrow(/CSC_KEY_PASSWORD/u) }) }) + +it('takes ZIP compression from the Mac file instead of ambient release settings', async () => { + await withDirectory(async (directory) => { + await writeFile(join(directory, '.env.macos'), 'DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=5\n') + const loaded = loadDesktopPackageEnvironment('darwin', { DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: '9' }, directory) + expect(loaded.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL).toBe('5') + await writeFile(join(directory, '.env.macos'), '') + expect(loadDesktopPackageEnvironment('darwin', { DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: '9' }, directory)) + .not.toHaveProperty('DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL') + }) +}) + +it.each(['', 'relative-cache'])('rejects invalid build cache paths during preflight (%s)', (cache) => { + expect(() => { validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_PACKAGE_CACHE_DIR: cache }, MACOS, { unsigned: true }) }) + .toThrow('DSH_DESKTOP_PACKAGE_CACHE_DIR must be a nonempty absolute directory') +}) + +it.each(['3', '7'])('applies the workflow ZIP choice %s over the Mac file setting', async (level) => { + await withDirectory(async (directory) => { + await writeFile(join(directory, '.env.macos'), 'DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=5\n') + const loaded = loadDesktopPackageEnvironment('darwin', { PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: level }, directory) + expect(loaded.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL).toBe(level) + }) +}) + +it.each(['', '3', '7'])('rejects Mac workflow ZIP choice %j on Windows before reading settings', (level) => { + expect(() => loadDesktopPackageEnvironment('win32', { PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: level }, 'missing-settings')) + .toThrow('PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL requires macOS') +}) + +it.each(['', '0', '9', 'invalid'])('rejects unsupported workflow ZIP choice %j', async (level) => { + await withDirectory(async (directory) => { + await writeFile(join(directory, '.env.macos'), '') + expect(() => loadDesktopPackageEnvironment('darwin', { PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: level }, directory)) + .toThrow('workflow ZIP compression must be 3 or 7') + }) +}) diff --git a/apps/desktop/tests/fixtures/windows-token-public-key-fake.cs b/apps/desktop/tests/fixtures/windows-token-public-key-fake.cs new file mode 100644 index 0000000000..9d2c1342f9 --- /dev/null +++ b/apps/desktop/tests/fixtures/windows-token-public-key-fake.cs @@ -0,0 +1,49 @@ + +public sealed class FakeTokenPublicKey : DshTokenPublicKey.IApi +{ + string scenario; + int releases, destroys, exports, keys; + public FakeTokenPublicKey(string value) { scenario = value; } + public int Acquire(string container, out System.IntPtr context) { + if (container != "fake-container") throw new System.Exception("unexpected container"); + context = new System.IntPtr(1); + if (scenario == "provider") return unchecked((int)0x80090013); + if (scenario == "container") return unchecked((int)0x80090016); + if (scenario == "auth") return unchecked((int)0x80090022); + return 0; + } + public int GetKey(System.IntPtr context, uint spec, out System.IntPtr key) { + keys++; + key = new System.IntPtr(2); + if (scenario == "no-key" || spec == 1) return DshTokenPublicKey.NoKey; + if (scenario == "get-key") return 5; + return 0; + } + public int Export(System.IntPtr key, byte[] bytes, ref uint length) { + exports++; + if (scenario == "export") return 5; + if (scenario == "size") { length = 1000000; return 0; } + length = 24; + return 0; + } + public bool Destroy(System.IntPtr key) { destroys++; return scenario != "destroy"; } + public bool Release(System.IntPtr context) { releases++; return scenario != "release"; } + public static string Test() { + if (DshTokenPublicKey.PublicContextFlags != 0xF0000040) throw new System.Exception("unsafe flags"); + foreach (string scenario in new [] { "ok", "provider", "container", "auth", "no-key", "get-key", "export", "size", "destroy", "release" }) { + var fake = new FakeTokenPublicKey(scenario); + var result = DshTokenPublicKey.Read(fake, "fake-container"); + string expected = scenario == "ok" ? "public-keys" : scenario == "provider" ? "provider-unavailable" + : scenario == "container" ? "container-unavailable" : "unknown"; + if (result.status != expected) throw new System.Exception("wrong outcome: " + scenario); + bool acquired = scenario != "provider" && scenario != "container" && scenario != "auth"; + if (fake.releases != (acquired ? 1 : 0)) throw new System.Exception("context leak: " + scenario); + bool hasKey = acquired && scenario != "no-key" && scenario != "get-key"; + if (fake.destroys != (hasKey ? 1 : 0)) throw new System.Exception("key leak: " + scenario); + if (!acquired && fake.keys != 0) throw new System.Exception("read after failed acquire"); + if (scenario == "ok" && (fake.exports != 2 || fake.keys != 2)) throw new System.Exception("incomplete read"); + if (result.status != "public-keys" && result.publicKeys.Length != 0) throw new System.Exception("partial result escaped"); + } + return "fake CSP cases passed"; + } +} diff --git a/apps/desktop/tests/helpers/windows-signing-corpus.ts b/apps/desktop/tests/helpers/windows-signing-corpus.ts new file mode 100644 index 0000000000..c12b936422 --- /dev/null +++ b/apps/desktop/tests/helpers/windows-signing-corpus.ts @@ -0,0 +1,52 @@ +/** Snapshot operator-selected release files into unsigned, hash-pinned experiment inputs. */ +import { createHash } from 'node:crypto' +import { copyFile, lstat, mkdir, readFile } from 'node:fs/promises' +import { extname, isAbsolute, join } from 'node:path' +import { inspectWindowsRuntimeSignature } from '../../scripts/windows-runtime-signature.mjs' + +/** One immutable unsigned input; its source is never modified or executed. */ +export interface SigningCorpusFile { + readonly path: string + readonly sha256: string + readonly bytes: number +} + +/** + * Copy a nonempty JSON array of absolute paths and SHA-256 digests, then strip signatures only on copies. + * @param manifestPath Operator-owned JSON manifest; every source must be a regular file with the pinned digest. + * @param directory New private directory for retained unsigned templates. + * @param removeSignature Public SignTool removal operation; never receives an original source path. + * @param signal Stops preparation before the next file when aborted. + * @returns Unsigned template digests and sizes in manifest order; rejects invalid signatures or changed inputs. + */ +export async function prepareSigningCorpus( + manifestPath: string, directory: string, removeSignature: (path: string) => Promise, signal: AbortSignal, +): Promise { + const manifest: unknown = JSON.parse(await readFile(manifestPath, 'utf8')) + if (!Array.isArray(manifest) || manifest.length === 0) throw new Error('Signing corpus must be a nonempty JSON array') + const inputs = manifest.map((entry: unknown) => { + if (typeof entry !== 'object' || entry === null || !('path' in entry) || typeof entry.path !== 'string' + || !isAbsolute(entry.path) || !('sha256' in entry) || typeof entry.sha256 !== 'string' + || !/^[a-f0-9]{64}$/u.test(entry.sha256)) throw new Error('Signing corpus requires absolute paths and lowercase SHA-256 digests') + return { path: entry.path, sha256: entry.sha256 } + }) + await mkdir(directory) + const result: SigningCorpusFile[] = [] + for (const [index, input] of inputs.entries()) { + signal.throwIfAborted() + const stat = await lstat(input.path) + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Signing corpus source must be a regular file') + const path = join(directory, `${index}${extname(input.path) || '.exe'}`) + await copyFile(input.path, path) + if (createHash('sha256').update(await readFile(path)).digest('hex') !== input.sha256) { + throw new Error('Signing corpus source differs from its pinned digest') + } + const before = await inspectWindowsRuntimeSignature(path) + if (before.status === 'Valid') await removeSignature(path) + else if (before.status !== 'NotSigned') throw new Error('Signing corpus source has an invalid signature') + if ((await inspectWindowsRuntimeSignature(path)).status !== 'NotSigned') throw new Error('Signing corpus copy must be unsigned') + const content = await readFile(path) + result.push({ path, sha256: createHash('sha256').update(content).digest('hex'), bytes: content.length }) + } + return result +} diff --git a/apps/desktop/tests/helpers/windows-signing-experiment.ts b/apps/desktop/tests/helpers/windows-signing-experiment.ts new file mode 100644 index 0000000000..f9ccac9eda --- /dev/null +++ b/apps/desktop/tests/helpers/windows-signing-experiment.ts @@ -0,0 +1,70 @@ +/** Bounded scheduling for the opt-in signing experiment; production signing does not use this helper. */ + +/** Operations owned by one experiment, with independently bounded completion and hardware work. */ +export interface SigningExperimentOptions { + readonly jobs: 1 | 2 | 4 + readonly hardwareConcurrency: 1 | 2 | 4 + readonly signal?: AbortSignal + /** Complete one item, awaiting the supplied signing operation and all subsequent work before returning. */ + readonly complete: (item: T, sign: (path: string) => Promise) => Promise + /** Perform one hardware request without retrying it. */ + readonly sign: (item: T, path: string) => Promise +} + +/** + * Schedule independent experiment items and stop dispatch on the first failure or cancellation. + * @param items Ordered inputs owned by this batch. + * @param options Completion workers, hardware limit, cancellation and experiment operations. + * @returns Resolves after every item completes; otherwise drains issued work before rejecting with the first reason. + * Cancellation prevents new items and hardware requests but does not terminate an issued native operation. + */ +export async function runSigningExperimentBatch(items: readonly T[], options: SigningExperimentOptions): Promise { + let failure: { reason: unknown } | undefined + let next = 0 + let signing = 0 + const requests = new Set>() + const waiting = new Set<() => void>() + const wake = (): void => { + for (const resume of waiting) resume() + waiting.clear() + } + const stop = (reason: unknown): void => { + failure ??= { reason } + wake() + } + const aborted = (): void => { stop(options.signal?.reason) } + const signItem = async (item: T, path: string): Promise => { + while (failure === undefined && signing >= options.hardwareConcurrency) { + await new Promise((resume) => { waiting.add(resume) }) + } + if (failure !== undefined) throw failure.reason + signing++ + try { await options.sign(item, path) } + catch (error) { stop(error); throw error } + finally { signing--; wake() } + } + const worker = async (): Promise => { + while (failure === undefined && next < items.length) { + const item = items[next++]! + try { + await options.complete(item, (path) => { + const request = signItem(item, path) + requests.add(request) + void request.then(() => requests.delete(request), () => requests.delete(request)) + return request + }) + } + catch (error) { stop(error) } + } + } + options.signal?.addEventListener('abort', aborted, { once: true }) + try { + if (options.signal?.aborted) aborted() + await Promise.all(Array.from({ length: Math.min(options.jobs, items.length) }, worker)) + // A completion failure can leave a previously issued request awaiting its native result. + await Promise.allSettled(requests) + if (failure !== undefined) throw failure.reason + } finally { + options.signal?.removeEventListener('abort', aborted) + } +} diff --git a/apps/desktop/tests/macos-package-settings.spec.ts b/apps/desktop/tests/macos-package-settings.spec.ts index 1daa31c70a..449a453fda 100644 --- a/apps/desktop/tests/macos-package-settings.spec.ts +++ b/apps/desktop/tests/macos-package-settings.spec.ts @@ -2,12 +2,12 @@ import { expect, it } from 'vitest' import { macOSDownloadEnvironment, resolveMacOSPackageSettings } from '../scripts/macos-package-settings.mjs' it('defaults to four workers and keeps download and Apple proxies independent', () => { - expect(resolveMacOSPackageSettings({})).toEqual({ packConcurrency: 4 }) + expect(resolveMacOSPackageSettings({})).toEqual({ packConcurrency: 4, zipCompressionLevel: 3 }) expect(resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_PACK_CONCURRENCY: '2', DSH_DESKTOP_MACOS_DOWNLOAD_PROXY: 'https://download.example:8080', DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: 'http://apple.example:8081', - })).toEqual({ packConcurrency: 2, downloadProxy: 'https://download.example:8080', notarizationProxy: 'http://apple.example:8081' }) - expect(resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_DOWNLOAD_PROXY: ' ', DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: '' })).toEqual({ packConcurrency: 4 }) + })).toEqual({ packConcurrency: 2, zipCompressionLevel: 3, downloadProxy: 'https://download.example:8080', notarizationProxy: 'http://apple.example:8081' }) + expect(resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_DOWNLOAD_PROXY: ' ', DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: '' })).toEqual({ packConcurrency: 4, zipCompressionLevel: 3 }) }) it.each(['', '0', '-1', '1.5', '1e2', 'Infinity', '9007199254740992'])('rejects invalid concurrency %j', (value) => { @@ -32,3 +32,11 @@ it('overrides conflicting child proxy variables while preserving the parent and expect(parent.HTTPS_PROXY).toBe('http://old') expect(parent.NO_PROXY).toBe('*') }) + +it.each(['', '-1', '10', '3.5', '03', 'NaN'])('rejects invalid ZIP compression %j', (value) => { + expect(() => resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: value })).toThrow('ZIP_COMPRESSION_LEVEL') +}) + +it.each(['0', '3', '7', '9'])('accepts ZIP compression %s', (value) => { + expect(resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: value }).zipCompressionLevel).toBe(Number(value)) +}) diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index 9ca7e94e05..37bd38384f 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -1,4 +1,6 @@ import { writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { setImmediate } from 'node:timers/promises' import { afterEach, expect, it, vi } from 'vitest' import { packageTarget, parseDesktopPackageInvocation } from '../scripts/package-target.ts' import { withMacOSNotarizationProxy } from '../scripts/macos-notarization-proxy.ts' @@ -43,6 +45,97 @@ function supervisor(failure?: string) { return { run, stages } } +it.each(['pack', 'runtime'] as const)('overlaps Windows preparation and waits for both before signing when %s finishes first', async (first) => { + const { run, stages } = supervisor() + const pack = Promise.withResolvers() + const runtime = Promise.withResolvers() + const finished = new Set() + run.run.mockImplementation(async (stage) => { + stages.push(stage) + if (stage.startsWith('run release:pack --family dsh ')) await pack.promise + if (stage === 'run prepare:runtime --defer-primary-runtime-smoke') { + await runtime.promise + finished.add('runtime') + } + if (stage.startsWith('--dir native/system/packages/entry pack ')) finished.add('pack') + if (stage === 'run sign:primary-runtime') expect([...finished].sort()).toEqual(['pack', 'runtime']) + }) + const operation = packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), environment, run) + const outcome = operation.then(() => undefined, (error: unknown) => error) + try { + await vi.waitFor(() => { + expect(stages.some(stage => stage.startsWith('run release:pack --family dsh '))).toBe(true) + expect(stages).toContain('run prepare:runtime --defer-primary-runtime-smoke') + }) + expect(stages).not.toContain('run sign:primary-runtime') + ;(first === 'pack' ? pack : runtime).resolve(undefined) + await vi.waitFor(() => { expect(finished.has(first)).toBe(true) }) + expect(stages).not.toContain('run sign:primary-runtime') + expect(writeFileSync).not.toHaveBeenCalled() + ;(first === 'pack' ? runtime : pack).resolve(undefined) + expect(await outcome).toBeUndefined() + expect(stages).toContain('run sign:primary-runtime') + expect(writeFileSync).toHaveBeenCalledOnce() + } finally { + pack.resolve(undefined) + runtime.resolve(undefined) + await outcome + } +}) + +it.each(['pack', 'runtime'] as const)('drains the other Windows preparation after %s fails without starting later packing or signing', async (failed) => { + const { run, stages } = supervisor() + const pack = Promise.withResolvers() + const runtime = Promise.withResolvers() + const failure = new Error(`${failed} preparation failed`) + let settled = false + run.run.mockImplementation(async (stage) => { + stages.push(stage) + if (stage.startsWith('run release:pack --family dsh ')) await pack.promise + if (stage === 'run prepare:runtime --defer-primary-runtime-smoke') await runtime.promise + }) + const outcome = packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), environment, run).then( + () => { settled = true; return undefined }, + (error: unknown) => { settled = true; return error }, + ) + try { + await vi.waitFor(() => { + expect(stages.some(stage => stage.startsWith('run release:pack --family dsh '))).toBe(true) + expect(stages).toContain('run prepare:runtime --defer-primary-runtime-smoke') + }) + ;(failed === 'pack' ? pack : runtime).reject(failure) + await setImmediate() + expect(settled).toBe(false) + expect(writeFileSync).not.toHaveBeenCalled() + ;(failed === 'pack' ? runtime : pack).resolve(undefined) + expect(await outcome).toBe(failure) + expect(stages.filter(stage => stage.startsWith('run release:pack'))).toHaveLength(1) + expect(stages.some(stage => stage.startsWith('--dir apps/desktop-host pack'))).toBe(false) + expect(stages.some(stage => stage.startsWith('--dir native/system'))).toBe(false) + expect(stages.some(stage => stage.startsWith('run sign:primary-runtime'))).toBe(false) + expect(stages.some(stage => stage.startsWith('run prepare:packages') || stage.startsWith('run prepare:dsh'))).toBe(false) + expect(writeFileSync).not.toHaveBeenCalled() + } finally { + pack.resolve(undefined) + runtime.resolve(undefined) + await outcome + } +}) + +it.each([undefined, '3'])('passes the package cache and selected compression to their subprocesses (%s)', async (level) => { + const { run } = supervisor() + const cache = resolve('fixture-package-cache') + await packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), { + ...environment, DSH_DESKTOP_PACKAGE_CACHE_DIR: cache, + ...(level === undefined ? {} : { DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL: level }), + ELECTRON_BUILDER_COMPRESSION_LEVEL: '0', + }, run) + const prepare = run.run.mock.calls.find(([stage]) => stage.startsWith('run prepare:dsh'))! + expect(prepare[3].env.DSH_DESKTOP_PACKAGE_CACHE_DIR).toBe(cache) + const builder = run.run.mock.calls.find(([stage]) => stage.startsWith('exec electron-builder'))! + expect(builder[3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe(level ?? '0') +}) + it.each([undefined, '2'])('uses bounded Windows packing while retaining signing and smoke (workers=%s)', async (workers) => { const { run, stages } = supervisor() const settings = workers === undefined ? environment : { ...environment, DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY: workers } @@ -147,7 +240,10 @@ it.each([ const invocation = parseDesktopPackageInvocation([target, '--unsigned'], target === 'win-x64' ? 'win32' : 'darwin', target === 'win-x64' ? 'x64' : 'arm64') await expect(packageTarget(invocation, environment, run)).rejects.toThrow('stage refused') - expect(stages.at(-1)?.startsWith(failure)).toBe(true) + const failedAt = stages.findIndex(stage => stage.startsWith(failure)) + expect(failedAt).toBeGreaterThanOrEqual(0) + expect(stages.slice(failedAt + 1)).toEqual( + target === 'win-x64' && failure.startsWith('exec tsx') ? ['run prepare:runtime'] : []) expect(stages.some(stage => stage.startsWith('--dir apps/desktop-host pack'))).toBe(false) expect(writeFileSync).not.toHaveBeenCalled() }) @@ -253,7 +349,8 @@ it.each([false, true])('keeps signing and runtime verification when notarization await packageTarget(parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization', ...(directory ? ['--dir'] : [])], 'darwin', 'arm64'), { ...environment, CSC_LINK: 'signing.p12', CSC_KEY_PASSWORD: 'fixture-password', }, run) - expect(stages.at(-2)).toBe(`exec electron-builder --config electron-builder.config.mjs --mac --arm64 --publish never${directory ? ' --dir' : ''}`) + expect(stages).toContain('exec electron-builder --config electron-builder.config.mjs --mac --arm64 --publish never --dir') + if (!directory) expect(stages.at(-2)).toContain('--mac zip') expect(stages.at(-1)).toBe('exec tsx scripts/smoke-packaged-runtime.ts --skip-notarization') expect(run.run.mock.calls.at(-2)![3].env).toMatchObject({ DSH_DESKTOP_UNSIGNED: '0', DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: '1', CSC_LINK: 'signing.p12', @@ -263,6 +360,34 @@ it.each([false, true])('keeps signing and runtime verification when notarization expect(writeFileSync).not.toHaveBeenCalled() }) +it.each(['mac-arm64', 'mac-x64'] as const)('applies ZIP compression without changing DMG settings when skipping notarization for %s', async (target) => { + for (const level of [undefined, '3', '7']) { + const { run } = supervisor() + await packageTarget(parseDesktopPackageInvocation([target, '--skip-notarization'], 'darwin', 'arm64'), { + ...environment, ELECTRON_BUILDER_COMPRESSION_LEVEL: '9', + ...(level === undefined ? {} : { DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: level }), + }, run) + const builders = run.run.mock.calls.filter(call => call[0].startsWith('exec electron-builder')) + expect(builders).toHaveLength(3) + const [app, dmg, zip] = builders + expect(app![0]).toContain('--dir') + expect(dmg![0]).toContain('--mac dmg') + expect(zip![0]).toContain('--mac zip') + expect(app![3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe('9') + expect(dmg![3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe('9') + expect(zip![3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe(level ?? '3') + for (const call of [dmg!, zip!]) { + expect(call[0]).toContain('--prepackaged') + expect(call[0]).toContain('unnotarized-artifacts') + expect(call[3].env.DSH_DESKTOP_MACOS_SKIP_NOTARIZATION).toBe('1') + expect(call[3].env.DSH_DESKTOP_UNSIGNED).toBe('0') + } + } + expect(packageMacOSArtifacts).not.toHaveBeenCalled() + expect(withMacOSNotarizationProxy).not.toHaveBeenCalled() + expect(writeFileSync).not.toHaveBeenCalled() +}) + it('fails debugging packages when runtime verification fails', async () => { const failure = 'exec tsx scripts/smoke-packaged-runtime.ts --skip-notarization' const { run, stages } = supervisor(failure) @@ -271,3 +396,29 @@ it('fails debugging packages when runtime verification fails', async () => { expect(stages.at(-1)).toBe(failure) expect(writeFileSync).not.toHaveBeenCalled() }) + +it('stops unnotarized ZIP packaging when the DMG stage fails', async () => { + const failure = 'exec electron-builder --config electron-builder.config.mjs --mac dmg' + const { run, stages } = supervisor(failure) + await expect(packageTarget(parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization'], 'darwin', 'arm64'), environment, run)) + .rejects.toThrow('stage refused') + expect(stages.some(stage => stage.includes('--mac zip'))).toBe(false) + expect(stages.some(stage => stage.includes('smoke-packaged-runtime'))).toBe(false) + expect(writeFileSync).not.toHaveBeenCalled() +}) + +it.each([undefined, '7'])('applies file-owned compression only to the final notarized ZIP (%s)', async (level) => { + const { run } = supervisor() + vi.mocked(packageMacOSArtifacts).mockImplementationOnce(async (_request, build) => { + await build({ format: 'dmg', appPath: '/fixture/app', output: '/fixture/dmg' }) + await build({ format: 'zip', appPath: '/fixture/app', output: '/fixture/zip' }) + }) + const settings = { ...environment, ELECTRON_BUILDER_COMPRESSION_LEVEL: '9', + ...(level === undefined ? {} : { DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: level }) } + await packageTarget(parseDesktopPackageInvocation(['mac-arm64'], 'darwin', 'arm64'), settings, run) + const zip = run.run.mock.calls.find(call => call[0].includes('--mac zip')) + const dmg = run.run.mock.calls.find(call => call[0].includes('--mac dmg')) + expect(zip?.[3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe(level ?? '3') + expect(dmg?.[3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe('9') + expect(writeFileSync).toHaveBeenCalledOnce() +}) diff --git a/apps/desktop/tests/primary-runtime-signing.spec.ts b/apps/desktop/tests/primary-runtime-signing.spec.ts index 5c7b4aa080..2aae8516c5 100644 --- a/apps/desktop/tests/primary-runtime-signing.spec.ts +++ b/apps/desktop/tests/primary-runtime-signing.spec.ts @@ -65,32 +65,84 @@ it('bounds overlapping cache restores and verifies all hits before serial hardwa expect(sign.mock.calls.map(([request]) => request.path)).toEqual(['c.exe', 'd.exe'].map(name => join(root, name))) }) -it.each(['restore', 'verification', 'audit'])('drains active cache restores after %s failure without dispatching more work', async (failure) => { +it('drains active cache restores after failure without dispatching more work', async () => { const root = await fixture(['a.exe', 'b.exe', 'c.exe']) const entered = Promise.withResolvers() const release = Promise.withResolvers() const failed = Promise.withResolvers() - const restored = new Set() const calls: string[] = [] const sign = vi.fn(async () => {}) let settled = false const operation = signWindowsCode(root, { thumbprint, sign, - record: (event) => { - if (failure === 'audit' && 'type' in event && event.type === 'windows-code-signature-verified') { - failed.resolve(undefined) - throw new Error('audit failed') - } - }, - inspect: async (path) => { - if (!restored.has(path)) return unsigned - if (failure === 'verification' && path.endsWith('a.exe')) { failed.resolve(undefined); throw new Error('verification failed') } - return valid - }, + record: () => {}, + inspect: async () => unsigned, cache: { concurrency: 2, restore: async ({ path }) => { calls.push(path) if (path.endsWith('b.exe')) { entered.resolve(undefined); await release.promise; return false } await entered.promise - if (failure === 'restore') { failed.resolve(undefined); throw new Error('restore failed') } + failed.resolve(undefined) + throw new Error('restore failed') + } }, + }).then(() => { settled = true; return undefined }, (error: unknown) => { settled = true; return error }) + try { + await Promise.race([failed.promise, operation]) + await new Promise(resolve => setImmediate(resolve)) + expect(settled).toBe(false) + expect(sign).not.toHaveBeenCalled() + } finally { release.resolve(undefined); await operation } + expect(await operation).toEqual(new Error('restore failed')) + expect(calls).toEqual(['a.exe', 'b.exe'].map(name => join(root, name))) + expect(sign).not.toHaveBeenCalled() +}) + +it.each(['trust', 'timestamp', 'certificate', 'audit'])('rejects restored target %s failures before hardware signing', async (failure) => { + const root = await fixture(['a.exe', 'b.exe', 'miss.exe']) + const restored = new Set() + let restores = 0 + const sign = vi.fn(async () => {}) + await expect(signWindowsCode(root, { thumbprint, sign, + record: (event) => { + if (failure === 'audit' && 'type' in event && event.type === 'windows-code-signature-verified') throw new Error('audit failed') + }, + inspect: async (path) => { + if (!restored.has(path)) return unsigned + expect(restores).toBe(3) + return { + ...valid, + ...(failure === 'trust' ? { status: 'HashMismatch' } : {}), + ...(failure === 'timestamp' ? { timestamped: false } : {}), + ...(failure === 'certificate' ? { thumbprint: 'B'.repeat(40) } : {}), + } + }, + cache: { concurrency: 2, restore: async ({ path }) => { + restores++ + if (path.endsWith('miss.exe')) return false + restored.add(path) + return true + } }, + })).rejects.toThrow(failure === 'audit' ? 'audit failed' : 'signing verification failed') + expect(restores).toBe(3) + expect(sign).not.toHaveBeenCalled() +}) + +it('drains failed restored-target inspection before returning and never signs misses', async () => { + const root = await fixture(['a.exe', 'b.exe', 'miss.exe']) + const entered = Promise.withResolvers() + const failed = Promise.withResolvers() + const release = Promise.withResolvers() + const restored = new Set() + const sign = vi.fn(async () => {}) + let settled = false + const operation = signWindowsCode(root, { thumbprint, sign, record: () => {}, + inspect: async (path) => { + if (!restored.has(path)) return unsigned + if (path.endsWith('b.exe')) { entered.resolve(undefined); await release.promise; return valid } + await entered.promise + failed.resolve(undefined) + throw new Error('verification failed') + }, + cache: { concurrency: 2, restore: async ({ path }) => { + if (path.endsWith('miss.exe')) return false restored.add(path) return true } }, @@ -101,8 +153,7 @@ it.each(['restore', 'verification', 'audit'])('drains active cache restores afte expect(settled).toBe(false) expect(sign).not.toHaveBeenCalled() } finally { release.resolve(undefined); await operation } - expect(await operation).toEqual(new Error(`${failure} failed`)) - expect(calls).toEqual(['a.exe', 'b.exe'].map(name => join(root, name))) + expect(await operation).toEqual(new Error('verification failed')) expect(sign).not.toHaveBeenCalled() }) diff --git a/apps/desktop/tests/runtime-preparation.spec.ts b/apps/desktop/tests/runtime-preparation.spec.ts index d16dd592c2..5da6e9815d 100644 --- a/apps/desktop/tests/runtime-preparation.spec.ts +++ b/apps/desktop/tests/runtime-preparation.spec.ts @@ -12,6 +12,7 @@ const state = vi.hoisted(() => ({ pnpm: '11.7.0' as string | undefined, failure: undefined as 'electron' | 'primary' | 'cli' | undefined, preparePrimary: vi.fn(), + extractElectron: vi.fn(), commandLink: vi.fn(), })) vi.mock('../scripts/desktop-build-paths.mjs', async importOriginal => ({ @@ -22,9 +23,7 @@ vi.mock('../scripts/desktop-build-paths.mjs', async importOriginal => ({ }), })) vi.mock('@electron/get', () => ({ downloadArtifact: async () => 'electron.zip' })) -vi.mock('extract-zip', () => ({ default: async () => { - if (state.failure === 'electron') throw new Error('Electron extraction failed') -} })) +vi.mock('extract-zip', () => ({ default: state.extractElectron })) vi.mock('node:child_process', async importOriginal => ({ ...await importOriginal(), execFileSync: (file: string) => file === '/usr/libexec/PlistBuddy' ? '12.0\n' : '24.18.1\n', @@ -54,6 +53,9 @@ beforeEach(() => { state.failure = undefined process.argv = [process.execPath, 'prepare-runtime.ts'] vi.stubEnv('DSH_DESKTOP_PACKAGING_RUN_DIR', undefined) + state.extractElectron.mockImplementation(async () => { + if (state.failure === 'electron') throw new Error('Electron extraction failed') + }) state.preparePrimary.mockImplementation(async () => { if (state.failure === 'primary') throw new Error('primary preparation failed') const root = join(state.root, 'runtime', 'primary-runtime') @@ -118,3 +120,94 @@ it.each([ await expect(import('../scripts/prepare-runtime.ts')).rejects.toThrow(error) expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false) }) + +it.each(['electron', 'primary'] as const)('overlaps Windows payloads and waits for %s to finish last before publishing CLI and versions', async (last) => { + state.target = 'win-x64' + const electron = Promise.withResolvers() + const primary = Promise.withResolvers() + const started = new Set() + const finished = new Set() + const preparePrimary = state.preparePrimary.getMockImplementation()! + state.extractElectron.mockImplementation(async () => { + started.add('electron') + await electron.promise + finished.add('electron') + }) + state.preparePrimary.mockImplementation(async () => { + started.add('primary') + await primary.promise + await preparePrimary() + finished.add('primary') + }) + const outcome = import('../scripts/prepare-runtime.ts').then(() => undefined, (error: unknown) => error) + try { + await vi.waitFor(() => { expect([...started].sort()).toEqual(['electron', 'primary']) }, { timeout: 5000 }) + ;(last === 'electron' ? primary : electron).resolve(undefined) + await vi.waitFor(() => { expect(finished.size).toBe(1) }, { timeout: 5000 }) + expect(existsSync(join(state.root, 'runtime', 'cli'))).toBe(false) + expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false) + ;(last === 'electron' ? electron : primary).resolve(undefined) + expect(await outcome).toBeUndefined() + expect(existsSync(join(state.root, 'runtime', 'cli', 'command-manager.js'))).toBe(true) + expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(true) + } finally { + electron.resolve(undefined) + primary.resolve(undefined) + await outcome + } +}) + +it.each(['electron', 'primary'] as const)('drains the sibling after Windows %s failure without publishing CLI or versions', async (failed) => { + state.target = 'win-x64' + const electron = Promise.withResolvers() + const primary = Promise.withResolvers() + const started = new Set() + const completed = new Set() + const failure = new Error(`${failed} payload failed`) + const prepare = async (name: 'electron' | 'primary', barrier: Promise) => { + started.add(name) + try { await barrier } finally { completed.add(name) } + } + state.extractElectron.mockImplementation(() => prepare('electron', electron.promise)) + state.preparePrimary.mockImplementation(() => prepare('primary', primary.promise)) + const outcome = import('../scripts/prepare-runtime.ts').then( + () => ({ error: undefined, completed: [...completed].sort() }), + (error: unknown) => ({ error, completed: [...completed].sort() }), + ) + try { + await vi.waitFor(() => { expect([...started].sort()).toEqual(['electron', 'primary']) }, { timeout: 5000 }) + ;(failed === 'electron' ? electron : primary).reject(failure) + await vi.waitFor(() => { expect(completed.has(failed)).toBe(true) }, { timeout: 5000 }) + expect(existsSync(join(state.root, 'runtime', 'cli'))).toBe(false) + expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false) + ;(failed === 'electron' ? primary : electron).resolve(undefined) + expect(await outcome).toEqual({ error: failure, completed: ['electron', 'primary'] }) + expect(existsSync(join(state.root, 'runtime', 'cli'))).toBe(false) + expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false) + } finally { + electron.resolve(undefined) + primary.resolve(undefined) + await outcome + } +}) + +it.each(['mac-arm64', 'mac-x64'] as const)('keeps %s primary preparation after Electron extraction', async (target) => { + state.target = target + const electron = Promise.withResolvers() + let extracting = false + state.extractElectron.mockImplementation(async () => { + extracting = true + await electron.promise + }) + const outcome = import('../scripts/prepare-runtime.ts').then(() => undefined, (error: unknown) => error) + try { + await vi.waitFor(() => { expect(extracting).toBe(true) }, { timeout: 5000 }) + expect(state.preparePrimary).not.toHaveBeenCalled() + electron.resolve(undefined) + expect(await outcome).toBeUndefined() + expect(state.preparePrimary).toHaveBeenCalledOnce() + } finally { + electron.resolve(undefined) + await outcome + } +}) diff --git a/apps/desktop/tests/runtime-tree.spec.ts b/apps/desktop/tests/runtime-tree.spec.ts index dadc4ca08d..1c8c2c58e1 100644 --- a/apps/desktop/tests/runtime-tree.spec.ts +++ b/apps/desktop/tests/runtime-tree.spec.ts @@ -3,7 +3,7 @@ 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, readDesktopRuntime, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { DESKTOP_RUNTIME_FILE, readDesktopRuntime, runtimePath, verifyDesktopRuntime, writeDesktopRuntime, writeDesktopRuntimeAsync } from '../src/runtime-tree.ts' import { runtimeFixture } from './runtime-fixture.ts' const roots: string[] = [] @@ -15,6 +15,29 @@ function fixture(): string { } afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) +it('seals final file bytes identically with concurrent and synchronous inventory', async () => { + const dsh = join(fixture(), 'dsh') + const original = readDesktopRuntime(dsh) + const names = original.sharedPackages.map(entry => entry.name) + writeFileSync(join(dsh, 'extra.bin'), Buffer.from([0, 1, 255])) + writeFileSync(join(dsh, 'package.json'), '{"type":"module","private":true}\n') + const expected = writeDesktopRuntime(dsh, original.release, names) + const expectedBytes = readFileSync(join(dsh, DESKTOP_RUNTIME_FILE)) + expect(await writeDesktopRuntimeAsync(dsh, original.release, names)).toEqual(expected) + expect(readFileSync(join(dsh, DESKTOP_RUNTIME_FILE))).toEqual(expectedBytes) + expect(await verifyDesktopRuntime(dsh, original.release.version)).toEqual(expected) +}) + +it.each(['manifest', 'link'])('leaves the descriptor unchanged when concurrent sealing rejects a %s', async (failure) => { + const dsh = join(fixture(), 'dsh') + const original = readDesktopRuntime(dsh) + const before = readFileSync(join(dsh, DESKTOP_RUNTIME_FILE)) + if (failure === 'manifest') writeFileSync(join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, 'package.json'), '{}') + else symlinkSync(join(dsh, 'node_modules'), join(dsh, 'outside'), process.platform === 'win32' ? 'junction' : 'dir') + await expect(writeDesktopRuntimeAsync(dsh, original.release, original.sharedPackages.map(entry => entry.name))).rejects.toThrow() + expect(readFileSync(join(dsh, DESKTOP_RUNTIME_FILE))).toEqual(before) +}) + 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') diff --git a/apps/desktop/tests/signing-concurrency/README.i18n.yaml b/apps/desktop/tests/signing-concurrency/README.i18n.yaml new file mode 100644 index 0000000000..ea5ef116aa --- /dev/null +++ b/apps/desktop/tests/signing-concurrency/README.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write apps/desktop/tests/signing-concurrency/README.md +/: + en: cde5fda2a2f4b53f + zh: 19dbeb4af20f26fa +/windows-signing-concurrency-qualification: + en: bd245a9d93c44d46 + zh: eb74f3edc9bb1f97 +/windows-signing-concurrency-qualification/summary: + en: 0d910efcaf9434da + zh: 0e6e179620e82713 +/windows-signing-concurrency-qualification/table-of-contents: + en: 498f69e40952088d + zh: 506c81b0f6879e9a +/windows-signing-concurrency-qualification/retest-on-another-machine: + en: b90878b9b5756cc9 + zh: e69d017153c10660 +/windows-signing-concurrency-qualification/measured-reference: + en: c2d5b47db6ce1e20 + zh: ca1182b064d63081 +/windows-signing-concurrency-qualification/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/apps/desktop/tests/signing-concurrency/README.md b/apps/desktop/tests/signing-concurrency/README.md new file mode 100644 index 0000000000..75295ce696 --- /dev/null +++ b/apps/desktop/tests/signing-concurrency/README.md @@ -0,0 +1,47 @@ +--- +description: "Windows signing concurrency measurements and retesting on replacement runners." +--- + +# Windows signing concurrency qualification + +English | [中文](README.zh.md) + +## Summary + +Use this record when choosing signing concurrency on a replacement Windows runner. Results apply to the measured host, token and software combination; repeat qualification after changing them. Production signing remains serial. + +## Table of Contents + +- [Retest on another machine](#retest-on-another-machine) +- [Measured reference](#measured-reference) + + + +## Retest on another machine + +1. Follow the [operator-approved experiment procedure](../../windows-signing/README.md#verification), using a hash-pinned corpus of real runtime, application and installer files. Compare the same inputs across modes; preserve originals and use isolated copies without signature-cache reuse. +2. Run `--corpus` with `--rounds 2` to compare 1/1, 2/2 and 4/4 completion workers/hardware requests, reversing the second round. Run the same corpus with `--pipeline-control --rounds 2` for the 4/1 control. Retain the procedure's first-failure stop and interlock handling. +3. Record the date, test and artifact versions, corpus count/bytes/largest file, CPU/RAM/disk, token/client/provider/SignTool versions, timestamp service, competing jobs and resource pressure. Keep both batch wall times and failure/retry counts; retain detailed output outside Git without credentials. Select a candidate only from completely verified runs, then validate it in the complete signed release workflow. Similar results favor fewer hardware requests; unexpected variation needs more rounds. + + + +## Measured reference + +On 2026-10-22, the experiment in PR #6675 used retained files from release run 39725804188, version `0.2.1-rc.2.20261022.50.2`. Each batch contained 148 runtime and 8 application/installer targets: 156 files, 849.2 MiB unsigned, largest 277.5 MiB. Deleted transient targets and repeated intermediate signing passes were excluded. + +The host had a Ryzen AI 9 HX 470 (12 cores/24 threads), 32 GiB RAM and a 1 TB UMIS RPJYJ1T24RLS1QWY disk. The operator identified the token as SafeNet 5110; the precise variant/firmware was not verified. Signing used SafeNet Authentication Client 10.9.4482.0, `eToken Base Cryptographic Provider` and the unchanged machine test preset's SignTool/timestamp service. Public signature removal alone used SDK 10.0.26100.0 SignTool. Mode-average host CPU was 7.6–8.9%, minimum sampled free RAM was 9.9 GiB, and no Runner.Worker was sampled; other Node processes shared the host. + +Times below are batch wall seconds, including signing, timestamp completion and all file verification. They exclude preparation, lock waiting, build, cache lookup, package assembly and upload. File-system caches were uncontrolled; these are not cold/hot full-release measurements. + +| Completion workers / hardware requests | Round 1 (s) | Round 2 (s) | Mean (s) | Reduction vs serial | +|---|---:|---:|---:|---:| +| 1 / 1 | 732.3 | 718.5 | 725.4 | — | +| 2 / 2 | 398.3 | 394.6 | 396.5 | 45.3% | +| 4 / 4 | 459.4 | 465.0 | 462.2 | 36.3% | +| 4 / 1, separate control | 427.7 | 428.3 | 428.0 | 41.0% | + +All 1,248 signatures, timestamps and final validations passed, with zero hardware failures/timeouts or timestamp retries. Original files and signature-cache payloads remained unchanged. The 2/2 mode was fastest; 4/4 was slower, while pipeline overlap alone delivered most of the observed benefit. This selects candidates for further release testing, not a production default or evidence of parallel operations inside the token. It does not establish a ten-minute complete release. + +## Dev Note + +None. diff --git a/apps/desktop/tests/signing-concurrency/README.zh.md b/apps/desktop/tests/signing-concurrency/README.zh.md new file mode 100644 index 0000000000..52c0a522d5 --- /dev/null +++ b/apps/desktop/tests/signing-concurrency/README.zh.md @@ -0,0 +1,47 @@ +--- +description: "Windows 签名并发实测结果与更换 runner 后的复测方法。" +--- + +# Windows 签名并发验收 + +[English](README.md) | 中文 + +## 摘要 + +更换 Windows runner 后,使用本记录选择签名并发参数。结果仅适用于实测的主机、UKey 和软件组合;更换后需重新验收。生产签名仍为串行。 + +## 目录 + +- [在另一台机器上复测](#retest-on-another-machine) +- [实测参考](#measured-reference) + + + +## 在另一台机器上复测 + +1. 按[经操作人员授权的实验流程](../../windows-signing/README.zh.md#verification),使用哈希固定的真实 runtime、应用和安装器文件集。各模式使用相同输入,保留原件,使用隔离副本且不复用签名缓存。 +2. 使用 `--corpus` 和 `--rounds 2` 比较完成任务数/硬件请求数为 1/1、2/2、4/4 的模式,第二轮反转顺序。对相同文件集使用 `--pipeline-control --rounds 2` 测量 4/1 对照组。保留流程中的首错停止和 interlock 处理。 +3. 记录日期、测试及产物版本、文件数/总大小/最大文件、CPU/内存/磁盘、UKey/客户端/provider/SignTool 版本、时间戳服务、并行任务和资源压力。保留两轮整批墙钟时间及失败/重试次数;详细输出保存在 Git 外且不含凭据。仅从全部验证通过的结果中选择候选参数,再用完整签名发布流程验收。结果接近时优先减少硬件请求;波动异常时增加轮次。 + + + +## 实测参考 + +2026-10-22,PR #6675 中的实验使用发布 run 39725804188 保留的文件,版本为 `0.2.1-rc.2.20261022.50.2`。每批包含 148 个 runtime 和 8 个应用/安装器签名目标:共 156 个文件,无签名总大小 849.2 MiB,最大文件 277.5 MiB。不含已删除的临时目标及中间产物的重复签名调用。 + +主机使用 Ryzen AI 9 HX 470(12 核/24 线程)、32 GiB 内存和 1 TB UMIS RPJYJ1T24RLS1QWY 磁盘。操作人员确认 UKey 为 SafeNet 5110,未核实具体变体/固件。签名使用 SafeNet Authentication Client 10.9.4482.0、`eToken Base Cryptographic Provider`,以及未变更的机器 test preset 中的 SignTool/时间戳服务。仅公开移除签名使用 SDK 10.0.26100.0 SignTool。各模式主机平均 CPU 为 7.6–8.9%,采样可用内存最低 9.9 GiB,未采样到 Runner.Worker;另有 Node 进程共享主机。 + +下表为整批墙钟秒数,包含签名、时间戳完成及全部文件验证。不含准备、等锁、构建、缓存查询、包组装和上传。文件系统缓存未受控,因此不是完整发布的冷/热缓存测量。 + +| 完成任务数 / 硬件请求数 | 第 1 轮(秒) | 第 2 轮(秒) | 平均(秒) | 相对串行减少 | +|---|---:|---:|---:|---:| +| 1 / 1 | 732.3 | 718.5 | 725.4 | — | +| 2 / 2 | 398.3 | 394.6 | 396.5 | 45.3% | +| 4 / 4 | 459.4 | 465.0 | 462.2 | 36.3% | +| 4 / 1,单独对照组 | 427.7 | 428.3 | 428.0 | 41.0% | + +共 1,248 次签名、时间戳及最终验证全部通过,硬件失败/超时及时间戳重试均为零。原文件和签名缓存内容保持不变。2/2 模式最快,4/4 更慢;仅流水线重叠已获得大部分实测收益。这些结果用于选择后续发布测试的候选参数,不是生产默认值,也不能证明 UKey 内部并行执行。它们不能证明完整发布达到十分钟。 + +## 开发备注 + +无。 diff --git a/apps/desktop/tests/windows-packaging-priority.ps1 b/apps/desktop/tests/windows-packaging-priority.ps1 new file mode 100644 index 0000000000..3a7b6981c6 --- /dev/null +++ b/apps/desktop/tests/windows-packaging-priority.ps1 @@ -0,0 +1,66 @@ +# Exercise priority inheritance in disposable children without changing the test host or runner. +[CmdletBinding()] +param([switch]$Child, [switch]$Failure) +$ErrorActionPreference = 'Stop' +if (-not $IsWindows) { throw 'Requires PowerShell 7 on Windows.' } +$helper = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../scripts/windows-packaging-priority.ps1')) +if ($Failure) { + Add-Type 'public static class DshPackagingPriority { public static int Read(int kind) { throw new System.InvalidOperationException("fixture priority access denied"); } }' + $warnings = @() + $report = & $helper -BestEffort -WarningVariable warnings 3>$null | ConvertFrom-Json + if ($report.comparable -ne $false -or $warnings.Count -ne 1) { throw 'Missing degraded scheduling report.' } + $rejected = $false + try { & $helper } catch { $rejected = $true } + if (-not $rejected) { throw 'Strict priority inspection swallowed failure.' } + exit 0 +} +if ($Child) { + $null = & $helper -InspectOnly + $self = [Diagnostics.Process]::GetCurrentProcess() + try { + $self.PriorityClass = [Diagnostics.ProcessPriorityClass]::BelowNormal + [DshPackagingPriority]::Set(33, 1) + [DshPackagingPriority]::Set(39, 2) + $report = (& $helper | ConvertFrom-Json) + if ($report.before.cpu -ne 'BelowNormal' -or $report.before.io -ne 1 -or $report.before.memory -ne 2) { + throw 'Fixture did not establish background priority.' + } + if ($report.after.cpu -ne 'Normal' -or $report.after.io -ne 2 -or $report.after.memory -ne 5) { + throw 'Packaging shell retained background priority.' + } + $inherited = (& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File $helper -InspectOnly | ConvertFrom-Json) + if ($LASTEXITCODE -ne 0) { throw 'Priority inspection child failed.' } + if ($inherited.before.cpu -ne 'Normal' -or $inherited.before.io -ne 2 -or $inherited.before.memory -ne 5) { + throw 'Packaging child did not inherit normal priority.' + } + } finally { + $self.Dispose() + } + exit 0 +} +$process = [Diagnostics.Process]::new() +$process.StartInfo.FileName = Join-Path $PSHOME 'pwsh.exe' +$process.StartInfo.UseShellExecute = $false +$process.StartInfo.CreateNoWindow = $true +$process.StartInfo.RedirectStandardError = $true +foreach ($argument in @('-NoProfile', '-File', $PSCommandPath, '-Child')) { + $process.StartInfo.ArgumentList.Add($argument) +} +$started = $false +try { + $started = $process.Start() + if (-not $started) { throw 'Could not start priority test.' } + $errorOutput = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(30000)) { throw 'Priority test exceeded 30 seconds.' } + if (-not $errorOutput.Wait(5000)) { throw 'Priority test error stream did not close.' } + if ($process.ExitCode -ne 0) { throw "Priority test failed: $($process.ExitCode): $($errorOutput.Result)" } +} finally { + if ($started -and -not $process.HasExited) { + $process.Kill($true) + $process.WaitForExit() + } + $process.Dispose() +} +& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File $PSCommandPath -Failure +if ($LASTEXITCODE -ne 0) { throw 'Priority failure policy test failed.' } +Write-Output 'Packaging priority normalization, child inheritance and failure policy passed.' diff --git a/apps/desktop/tests/windows-runner-network-process.ps1 b/apps/desktop/tests/windows-runner-network-process.ps1 new file mode 100644 index 0000000000..7b8fe0887b --- /dev/null +++ b/apps/desktop/tests/windows-runner-network-process.ps1 @@ -0,0 +1,140 @@ +# Fake local processes only; no network or signing hardware. +$ErrorActionPreference = 'Stop' +$root = Join-Path ([IO.Path]::GetTempPath()) ('demo-network-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory $root +try { + foreach ($case in @('ready','not-ready','locked','missing-executable','stale','reused','invalid-config','invalid-delay','bad-record','provider-exit','runner-failure','check-only','recovery','task-route')) { + $base = Join-Path $root $case + $runner = Join-Path $base 'runner' + $network = Join-Path $base 'network' + $null = New-Item -ItemType Directory -Path $network,(Join-Path $runner 'dsh-task') -Force + @' +Set-Content (Join-Path $PSScriptRoot 'child-pid') $PID +Write-Output 'PRIVATE-OUTPUT-MUST-NOT-LEAK' +Start-Sleep -Seconds 60 +'@ | Set-Content (Join-Path $network 'fake.ps1') + @' +param([string]$RunnerRoot,[string]$ConfigRoot) +$network = Join-Path (Split-Path $PSScriptRoot) 'network' +$deadline = [datetime]::UtcNow.AddSeconds(10) +while (-not (Test-Path (Join-Path $network 'child-pid'))) { + if ([datetime]::UtcNow -ge $deadline) { exit 9 } + Start-Sleep -Milliseconds 20 +} +if (Test-Path (Join-Path $network 'fail')) { exit 1 } +exit 0 +'@ | Set-Content (Join-Path $runner 'cold-boot-smoke.ps1') + "Set-Content (Join-Path `$PSScriptRoot 'listened') yes; exit 0" | Set-Content (Join-Path $runner 'dsh-task/start-runner.ps1') + if ($case -eq 'runner-failure') { Set-Content (Join-Path $runner 'dsh-task/start-runner.ps1') "Set-Content (Join-Path `$PSScriptRoot 'listened') yes; exit 7" } + if ($case -in @('recovery','task-route')) { + @' +param([string]$RunnerRoot,[string]$ConfigRoot,[switch]$CheckOnly) +$ErrorActionPreference = 'Stop' +$network = Join-Path $ConfigRoot 'network' +if ($CheckOnly) { + $deadline = [datetime]::UtcNow.AddSeconds(30) + while (-not (Test-Path (Join-Path $network 'child-pid'))) { + if ([datetime]::UtcNow -ge $deadline) { exit 9 } + Start-Sleep -Milliseconds 20 + } + exit 0 +} +Set-Content (Join-Path $PSScriptRoot 'listened') $PID +Write-Output 'RUNNER-STDOUT' +[Console]::Error.WriteLine('RUNNER-STDERR') +for ($iteration = 0; $iteration -lt 2; $iteration++) { + $previous = [int](Get-Content (Join-Path $network 'child-pid')) + $child = Get-Process -Id $previous + try { $child.Kill(); $child.WaitForExit() } finally { $child.Dispose() } + $deadline = [datetime]::UtcNow.AddSeconds(30) + do { + if ([datetime]::UtcNow -ge $deadline) { throw 'Network process was not recovered.' } + Start-Sleep -Milliseconds 50 + $next = Get-Content (Join-Path $network 'child-pid') -Raw + } while (-not $next -or [int]$next -eq $previous) + if ([int](Get-Content (Join-Path $PSScriptRoot 'listened')) -ne $PID) { throw 'Runner was replaced during recovery.' } +} +Write-Output 'RUNNER-SURVIVED-RECOVERY' +exit 0 +'@ | Set-Content (Join-Path $runner 'dsh-task/start-runner.ps1') + } + if ($case -eq 'provider-exit') { + Set-Content (Join-Path $network 'fake.ps1') 'Set-Content (Join-Path $PSScriptRoot "child-pid") $PID; exit 0' + @' +$network = Join-Path (Split-Path $PSScriptRoot) 'network' +$deadline = [datetime]::UtcNow.AddSeconds(10) +do { + if ([datetime]::UtcNow -ge $deadline) { throw 'Provider exit barrier timed out.' } + Start-Sleep -Milliseconds 20 + $record = Join-Path $network 'child-pid' +} while (-not (Test-Path $record) -or (Get-Process -Id ([int](Get-Content $record)) -ErrorAction SilentlyContinue)) +exit 0 +'@ | Set-Content (Join-Path $runner 'cold-boot-smoke.ps1') + } + $exe = if ($case -eq 'missing-executable') { Join-Path $network 'missing.exe' } else { Join-Path $PSHOME 'pwsh.exe' } + @{executable=$exe;arguments=@('-NoProfile','-File',(Join-Path $network 'fake.ps1'));restartDelaySeconds=1} | ConvertTo-Json | Set-Content (Join-Path $network 'launch.json') + if ($case -eq 'invalid-config') { Set-Content (Join-Path $network 'launch.json') '{"executable":"PRIVATE-OUTPUT","arguments":[],"unknown":true}' } + if ($case -eq 'invalid-delay') { @{executable=$exe;arguments=@();restartDelaySeconds=0} | ConvertTo-Json | Set-Content (Join-Path $network 'launch.json') } + if ($case -eq 'bad-record') { Set-Content (Join-Path $network 'process.json') '{"pid":0,"started":1}' } + if ($case -eq 'not-ready') { Set-Content (Join-Path $network 'fail') yes } + $held = if ($case -eq 'locked') { [IO.File]::Open((Join-Path $network 'supervisor.lock'),'OpenOrCreate','ReadWrite','None') } else { $null } + $stale = $null + try { + if ($case -in @('stale','reused')) { + $info = [Diagnostics.ProcessStartInfo]::new($exe) + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + foreach ($argument in @('-NoProfile','-Command','Start-Sleep -Seconds 60')) { $info.ArgumentList.Add($argument) } + $stale = [Diagnostics.Process]::Start($info) + @{pid=$stale.Id;started=($stale.StartTime.ToUniversalTime().Ticks + $(if($case -eq 'reused'){1}else{0}))} | ConvertTo-Json | Set-Content (Join-Path $network 'process.json') + } + [string[]]$options = @(if ($case -eq 'check-only') { '-CheckOnly' }) + if ($case -eq 'task-route') { + $taskRoot = Join-Path $runner 'dsh-task' + Copy-Item (Join-Path $PSScriptRoot '../scripts/run-windows-runner-task.ps1') (Join-Path $taskRoot 'run.ps1') + Copy-Item (Join-Path $PSScriptRoot '../scripts/run-windows-runner-network-process.ps1') $taskRoot + Copy-Item (Join-Path $taskRoot 'start-runner.ps1') (Join-Path $taskRoot 'start.ps1') + @{RunnerRoot=$runner;ConfigRoot=$base;NetworkRoot=$network;RestartCount=1} | ConvertTo-Json | Set-Content (Join-Path $taskRoot 'config.json') + Set-Content (Join-Path $taskRoot 'attempts.txt') 0 + Set-Content (Join-Path $base 'runner-network.json') '{}' + & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File (Join-Path $taskRoot 'run.ps1') + if ($LASTEXITCODE -ne 1) { throw 'Task wrapper must retain its unexpected-exit result.' } + $output = Get-Content (Get-ChildItem $taskRoot -Filter 'run-*.log').FullName -Raw + if ([int](Get-Content (Join-Path $taskRoot 'attempts.txt')) -ne 1) { throw 'Network recovery consumed task launch budget.' } + } else { + $output = & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File (Join-Path $PSScriptRoot '../scripts/run-windows-runner-network-process.ps1') -RunnerRoot $runner -ConfigRoot $base -NetworkRoot $network -ReadinessScript (Join-Path $runner 'cold-boot-smoke.ps1') -StartScript (Join-Path $runner 'dsh-task/start-runner.ps1') @options 2>&1 | Out-String + } + if (($LASTEXITCODE -eq 0) -ne ($case -in @('ready','stale','reused','check-only','recovery'))) { throw "Unexpected exit: ${case}: $output" } + if ((Test-Path (Join-Path $runner 'dsh-task/listened')) -ne ($case -in @('ready','stale','reused','runner-failure','recovery','task-route'))) { throw "Listener admission failed: $case" } + if ($case -in @('recovery','task-route')) { + foreach ($expected in @('RUNNER-STDOUT','RUNNER-STDERR','RUNNER-SURVIVED-RECOVERY','Network process exited','Network process restarted (attempt 2)')) { + if (-not $output.Contains($expected)) { throw "Missing recovery evidence: $expected" } + } + } + if ($case -eq 'stale' -and -not $stale.HasExited) { throw 'Owned stale provider was not reaped' } + if ($case -eq 'reused' -and $stale.HasExited) { throw 'A reused PID was killed' } + if ($output.Contains('PRIVATE-OUTPUT')) { throw 'Provider output leaked' } + if (Test-Path (Join-Path $network 'child-pid')) { + $childId = [int](Get-Content (Join-Path $network 'child-pid')) + if (Get-Process -Id $childId -ErrorAction SilentlyContinue) { throw 'Child leaked' } + } + if ($case -ne 'bad-record' -and (Test-Path (Join-Path $network 'process.json'))) { throw 'Ownership record leaked' } + if ($case -eq 'bad-record' -and -not (Test-Path (Join-Path $network 'process.json'))) { throw 'Invalid identity evidence was removed' } + if (-not $held) { + $released = [IO.File]::Open((Join-Path $network 'supervisor.lock'),'OpenOrCreate','ReadWrite','None') + $released.Dispose() + } + Write-Output "PASS: $case" + } finally { + if ($stale) { + if (-not $stale.HasExited) { $stale.Kill(); $stale.WaitForExit() } + $stale.Dispose() + } + if ($held) { $held.Dispose() } + } + } +} finally { + if ([IO.Path]::GetDirectoryName($root).TrimEnd('\') -ne [IO.Path]::GetTempPath().TrimEnd('\')) { throw 'Unsafe cleanup path' } + Remove-Item -LiteralPath $root -Recurse -Force +} +exit 0 diff --git a/apps/desktop/tests/windows-runner-network.ps1 b/apps/desktop/tests/windows-runner-network.ps1 new file mode 100644 index 0000000000..bb9e4fe94e --- /dev/null +++ b/apps/desktop/tests/windows-runner-network.ps1 @@ -0,0 +1,117 @@ +# Isolated proxy fixture; no external requests, runner registration or signing hardware. +param([switch]$CheckStartup) +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot '../scripts/windows-runner-network.ps1') +$root = Join-Path ([IO.Path]::GetTempPath()) ('dsh-network-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $root +$null = New-Item -ItemType Directory -Path (Join-Path $root 'tools') +$pwshLink = Join-Path $root 'tools/pwsh-7.6.6' +if ($CheckStartup) { $null = New-Item -ItemType Junction -Path $pwshLink -Target $PSHOME } +$server = $null +$saved = @{} +foreach ($name in @('HTTP_PROXY','HTTPS_PROXY','NO_PROXY','NODE_USE_ENV_PROXY')) { $saved[$name] = [Environment]::GetEnvironmentVariable($name,'Process') } +try { + if ($null -ne (Read-RunnerNetwork $root)) { throw 'Absent configuration changed behavior.' } + $fixture = Join-Path $root 'proxy.cjs' + @' +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const root = process.argv[2]; +const server = http.createServer((req, res) => { + fs.appendFileSync(path.join(root, 'requests'), req.method + ' ' + req.url + '\n'); + if (req.url.includes('/hang')) return; + res.writeHead(req.url.includes('/fail') ? 503 : 200); + res.end(); +}); +server.on('connect', (req, socket) => { + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + server.emit('connection', socket); +}); +server.listen(0, '127.0.0.1', () => fs.writeFileSync(path.join(root, 'port'), String(server.address().port))); +'@ | Set-Content -LiteralPath $fixture + $info = [Diagnostics.ProcessStartInfo]::new((Get-Command node).Source) + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.ArgumentList.Add($fixture) + $info.ArgumentList.Add($root) + $server = [Diagnostics.Process]::Start($info) + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while (-not (Test-Path (Join-Path $root 'port'))) { + if ($server.HasExited -or [DateTime]::UtcNow -gt $deadline) { throw 'Proxy fixture did not become ready.' } + Start-Sleep -Milliseconds 50 + } + $port = Get-Content (Join-Path $root 'port') -Raw + $config = @{proxyUrl="http://127.0.0.1:$port";noProxy='';serviceName='';probeUrl='http://runner-probe.invalid/ready';attempts=2;timeoutSeconds=2;retrySeconds=1} + function Write-Config { $config | ConvertTo-Json | Set-Content (Join-Path $root 'runner-network.json') } + Write-Config + $parsed = Read-RunnerNetwork $root + Set-RunnerNetwork $parsed + Wait-RunnerNetwork $parsed + if ($env:NODE_USE_ENV_PROXY -ne '1' -or $env:HTTP_PROXY -ne $config.proxyUrl -or $env:HTTPS_PROXY -ne $config.proxyUrl) { throw 'Proxy environment was not applied.' } + # Full startup requires the operator's installed packaging toolchain and persistent PATH. + if ($CheckStartup) { + $output = & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File (Join-Path $PSScriptRoot '../scripts/start-windows-packaging-runner.ps1') -RunnerRoot $root -ConfigRoot $root -CheckOnly 2>&1 | Out-String + if ($LASTEXITCODE -ne 0 -or -not $output.Contains('Runner network dependency ready')) { throw "Startup integration failed: $output" } + if ((Get-Content (Join-Path $root 'requests')).Count -ne 2) { throw 'Startup did not reach the configured proxy.' } + Clear-Content (Join-Path $root 'requests') + Wait-RunnerNetwork $parsed + } + $config.probeUrl = 'http://runner-probe.invalid/fail' + Write-Config + $failed = $false + try { Wait-RunnerNetwork (Read-RunnerNetwork $root) } catch { $failed = $_.Exception.Message.StartsWith('Runner network dependency unavailable;') } + if (-not $failed -or (Get-Content (Join-Path $root 'requests')).Count -ne 3) { throw 'Failed readiness did not stop at its budget.' } + $config.serviceName = 'fixture-service' + $config.probeUrl = 'http://runner-probe.invalid/ready' + $script:serviceChecks = 0 + function Get-Service { + param($Name) + $script:serviceChecks++ + [pscustomobject]@{ Status = if ($script:serviceChecks -eq 1) { 'Stopped' } else { 'Running' } } + } + try { + Wait-RunnerNetwork $config + if ($script:serviceChecks -ne 2 -or (Get-Content (Join-Path $root 'requests')).Count -ne 4) { throw 'Service readiness did not contain probes.' } + function Get-Service { throw 'fake-secret' } + $failure = $null + try { Wait-RunnerNetwork $config } catch { $failure = $_ } + if (-not $failure -or $failure.ToString().Contains('fake-secret') -or (Get-Content (Join-Path $root 'requests')).Count -ne 4) { throw 'Missing service was not contained.' } + } finally { Remove-Item Function:Get-Service } + $config.serviceName = '' + $config.proxyUrl = 'http://127.0.0.1:1' + $config.noProxy = '127.0.0.1' + $config.probeUrl = "http://127.0.0.1:$port/ready" + Set-RunnerNetwork $config + Wait-RunnerNetwork $config + if ((Get-Content (Join-Path $root 'requests')).Count -ne 5) { throw 'NO_PROXY did not bypass the unavailable proxy.' } + $config.probeUrl = "http://127.0.0.1:$port/hang" + $config.attempts = 1 + $config.timeoutSeconds = 1 + $failure = $null + try { Wait-RunnerNetwork $config } catch { $failure = $_ } + if (-not $failure -or (Get-Content (Join-Path $root 'requests')).Count -ne 6) { throw 'Unresponsive endpoint did not terminate its probe.' } + foreach ($invalid in @('extra','credential','budget','type','wildcard','malformed')) { + $bad = $config.Clone() + switch ($invalid) { + 'extra' { $bad.unknown = 'invalid' } + 'credential' { $bad.proxyUrl = 'http://fake-secret:fake-secret@localhost:1' } + 'budget' { $bad.attempts = 0 } + 'type' { $bad.retrySeconds = '2' } + 'wildcard' { $bad.serviceName = '*' } + } + $json = if ($invalid -eq 'malformed') { '{fake-secret' } else { $bad | ConvertTo-Json } + $json | Set-Content (Join-Path $root 'runner-network.json') + $failure = $null + try { $null = Read-RunnerNetwork $root } catch { $failure = $_ } + if (-not $failure -or $failure.ToString().Contains('fake-secret')) { throw 'Invalid configuration accepted or disclosed.' } + } + Write-Output "PASS: absent config, proxy routing, Node proxy opt-in, service readiness, bounded failure and redacted invalid config; startup integration=$CheckStartup." +} finally { + foreach ($name in $saved.Keys) { [Environment]::SetEnvironmentVariable($name,$saved[$name],'Process') } + if ($server) { if (-not $server.HasExited) { $server.Kill($true); $server.WaitForExit() }; $server.Dispose() } + if ($CheckStartup) { [IO.Directory]::Delete($pwshLink) } + if ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($root)).TrimEnd('\') -ne [IO.Path]::GetTempPath().TrimEnd('\')) { throw 'Fixture cleanup escaped the temporary directory.' } + Remove-Item -LiteralPath $root -Recurse -Force +} +exit 0 diff --git a/apps/desktop/tests/windows-runner-task.ps1 b/apps/desktop/tests/windows-runner-task.ps1 index da9a8ff8ac..a01a90c3f4 100644 --- a/apps/desktop/tests/windows-runner-task.ps1 +++ b/apps/desktop/tests/windows-runner-task.ps1 @@ -7,13 +7,18 @@ $source = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../scripts')) $runner = Join-Path $root 'runner with spaces' $null = New-Item -ItemType Directory -Path $runner Set-Content -LiteralPath (Join-Path $runner '.runner') -Value '{}' -foreach ($file in @('windows-runner-task.ps1','run-windows-runner-task.ps1','windows-runner-launcher.cs')) { Copy-Item -LiteralPath (Join-Path $source $file) -Destination $root } +foreach ($file in @('windows-runner-task.ps1','run-windows-runner-task.ps1','windows-runner-launcher.cs','windows-runner-network.ps1','run-windows-runner-network-process.ps1')) { Copy-Item -LiteralPath (Join-Path $source $file) -Destination $root } $manager = Join-Path $root 'windows-runner-task.ps1' @' param([string]$RunnerRoot, [string]$ConfigRoot, [switch]$CheckOnly) if ($CheckOnly) { exit 0 } Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class ConsoleProbe { [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); }' Add-Content -LiteralPath (Join-Path $RunnerRoot 'consoles.txt') -Value ([ConsoleProbe]::GetConsoleWindow().ToInt64()) +Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class SchedulingProbe { [DllImport("ntdll.dll")] static extern int NtQueryInformationProcess(IntPtr handle, int kind, out int value, int size, out int length); public static int Read(IntPtr handle, int kind) { int value, length; int status = NtQueryInformationProcess(handle, kind, out value, 4, out length); if (status != 0) throw new InvalidOperationException("Priority query failed: " + status); return value; } }' +$process = [Diagnostics.Process]::GetCurrentProcess() +try { + @{ Cpu = $process.PriorityClass.ToString(); Io = [SchedulingProbe]::Read($process.Handle, 33); Memory = [SchedulingProbe]::Read($process.Handle, 39) } | ConvertTo-Json -Compress | Add-Content -LiteralPath (Join-Path $RunnerRoot 'priorities.jsonl') +} finally { $process.Dispose() } Add-Content -LiteralPath (Join-Path $RunnerRoot 'starts.txt') -Value $PID if (-not (Test-Path -LiteralPath (Join-Path $RunnerRoot 'failed-once'))) { Set-Content -LiteralPath (Join-Path $RunnerRoot 'failed-once') -Value 'yes' @@ -50,6 +55,7 @@ try { $peOffset = [BitConverter]::ToInt32($image, 0x3c) if ([BitConverter]::ToUInt16($image, $peOffset + 24 + 68) -ne 2) { throw 'Launcher must use the Windows GUI subsystem.' } if ($task.State -ne 'Disabled' -or $task.Settings.ExecutionTimeLimit -ne 'PT0S' -or $task.Settings.MultipleInstances -ne 'IgnoreNew') { throw 'Unexpected task scheduling settings.' } + if ($task.Settings.Priority -ne 4) { throw 'Runner task must use normal CPU, I/O and memory priority.' } Invoke-Manager install -ExpectFailure $runScript = Join-Path $runner 'dsh-task/run.ps1' $originalRun = Get-Content -LiteralPath $runScript -Raw @@ -75,6 +81,8 @@ if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'inherited-writer-star if ([int](Get-Content -LiteralPath (Join-Path $runner 'dsh-task/attempts.txt') -Raw) -ne 2) { throw 'Drain recovery did not consume the normal restart budget.' } $consoles = @(Get-Content -LiteralPath (Join-Path $runner 'consoles.txt')) if ($consoles.Count -ne 2 -or @($consoles | Where-Object { $_ -ne '0' }).Count) { throw 'Initial or recovered runner allocated a console.' } + $priorities = @(Get-Content -LiteralPath (Join-Path $runner 'priorities.jsonl') | ForEach-Object { $_ | ConvertFrom-Json }) + if ($priorities.Count -ne 2 -or @($priorities | Where-Object { $_.Cpu -ne 'Normal' -or $_.Io -ne 2 -or $_.Memory -ne 5 }).Count) { throw 'Initial or recovered runner inherited background scheduling priority.' } $childId = [int]@(Get-Content -LiteralPath $starts)[-1] $child = Get-Process -Id $childId Invoke-Manager start diff --git a/apps/desktop/tests/windows-runtime-code.spec.ts b/apps/desktop/tests/windows-runtime-code.spec.ts new file mode 100644 index 0000000000..d3f1530aaf --- /dev/null +++ b/apps/desktop/tests/windows-runtime-code.spec.ts @@ -0,0 +1,128 @@ +/** PE discovery keeps content checks and drains bounded file reads before failure. */ +import { mkdir, mkdtemp, rm, writeFile, type FileHandle } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { setImmediate } from 'node:timers/promises' +import { afterEach, expect, it, vi } from 'vitest' +import { windowsRuntimeCode } from '../scripts/windows-runtime-signature.mjs' + +const hooks = vi.hoisted(() => ({ + opened: undefined as ((handle: FileHandle) => void) | undefined, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + open: async (...args: Parameters) => { + const handle = await actual.open(...args) + hooks.opened?.(handle) + return handle + }, + } +}) + +const roots: string[] = [] + +async function fixture(entries: Record): Promise { + const root = await mkdtemp(join(tmpdir(), 'windows-runtime-code-')) + roots.push(root) + for (const [name, contents] of Object.entries(entries)) { + const path = join(root, name) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, contents) + } + return root +} + +function executable(): Buffer { + const bytes = Buffer.alloc(128) + bytes.writeUInt16LE(0x5a4d, 0) + bytes.writeUInt32LE(64, 0x3c) + bytes.writeUInt32LE(0x4550, 64) + return bytes +} + +afterEach(async () => { + hooks.opened = undefined + vi.restoreAllMocks() + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + +it('discovers extensionless PE files in nested directories and excludes foreign native modules', async () => { + const root = await fixture({ + 'nested/deeper/extensionless': executable(), + 'nested/runtime.node': executable(), + 'a.exe': executable(), + 'nested/deeper/foreign.node': Buffer.from([0x7f, 0x45, 0x4c, 0x46]), + 'readme.txt': 'ordinary data', + }) + expect(await windowsRuntimeCode(root)).toEqual([ + 'nested/deeper/extensionless', 'nested/runtime.node', 'a.exe', + ].map(name => join(root, name)).sort()) +}) + +it.each([ + ['broken.exe', Buffer.from('not PE')], + ['nested/extensionless', Buffer.from('MZ')], + ['nested/broken.node', Buffer.from('MZ')], + ['nested/deeper/broken.dll', Buffer.alloc(64)], +])('rejects malformed Windows code at %s', async (name, contents) => { + const root = await fixture({ [name]: contents }) + await expect(windowsRuntimeCode(root)).rejects.toThrow(`Windows code: invalid PE file: ${join(root, name)}`) +}) + +it('limits open files to eight and drains pending closes before rejecting a read failure', async () => { + const root = await fixture(Object.fromEntries(Array.from({ length: 24 }, (_, index) => [ + `${String(index).padStart(2, '0')}.txt`, 'ordinary data', + ]))) + const releaseReads = Promise.withResolvers() + const releaseCloses = Promise.withResolvers() + const failedClosed = Promise.withResolvers() + const failure = new Error('controlled PE header read failure') + let opened = 0 + let active = 0 + let peak = 0 + let settled = false + hooks.opened = (handle) => { + const index = opened++ + active++ + peak = Math.max(peak, active) + const close = handle.close.bind(handle) + vi.spyOn(handle, 'read').mockImplementationOnce(async () => { + await releaseReads.promise + if (index === 0) throw failure + return { bytesRead: 0, buffer: Buffer.alloc(64) } + }) + vi.spyOn(handle, 'close').mockImplementationOnce(async () => { + if (index !== 0) await releaseCloses.promise + await close() + active-- + if (index === 0) failedClosed.resolve(undefined) + }) + } + const operation = windowsRuntimeCode(root).then( + (result) => { settled = true; return result }, + (error: unknown) => { settled = true; return error }, + ) + try { + await vi.waitFor(() => { expect(opened).toBe(8) }) + expect(active).toBe(8) + releaseReads.resolve(undefined) + await failedClosed.promise + await setImmediate() + expect(settled).toBe(false) + expect(opened).toBe(8) + expect(active).toBe(7) + releaseCloses.resolve(undefined) + expect(await operation).toBe(failure) + expect(opened).toBe(8) + expect(peak).toBe(8) + expect(active).toBe(0) + } finally { + hooks.opened = undefined + releaseReads.resolve(undefined) + releaseCloses.resolve(undefined) + await operation + } +}) diff --git a/apps/desktop/tests/windows-sign.spec.ts b/apps/desktop/tests/windows-sign.spec.ts index e5d5a7cd3f..3e0ec371a7 100644 --- a/apps/desktop/tests/windows-sign.spec.ts +++ b/apps/desktop/tests/windows-sign.spec.ts @@ -317,7 +317,9 @@ describe('Windows token signing', () => { 'C:\\release\\setup.exe', ['token-secret'], ) - expect(failure.message).toBe('Windows release signing failed for C:\\release\\setup.exe (exit 1): provider rejected ') + expect(failure.message).toContain('Windows release signing failed for C:\\release\\setup.exe (exit 1): provider rejected ') + expect(failure.message).toContain('After replacing the UKey') + expect(failure.message).toContain('does not establish device lock status') expect(failure.message).not.toContain('token-secret') expect(failure).not.toHaveProperty('cause') expect(failure).not.toHaveProperty('cmd') diff --git a/apps/desktop/tests/windows-signing-concurrency.perf.ts b/apps/desktop/tests/windows-signing-concurrency.perf.ts new file mode 100644 index 0000000000..93c1eb3f18 --- /dev/null +++ b/apps/desktop/tests/windows-signing-concurrency.perf.ts @@ -0,0 +1,209 @@ +/** Operator-only comparison of serial signing and bounded concurrent signature completion. */ +import { execFile } from 'node:child_process' +import { createHash, X509Certificate } from 'node:crypto' +import { existsSync } from 'node:fs' +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises' +import { homedir, cpus, totalmem, freemem } from 'node:os' +import { extname, isAbsolute, join, resolve } from 'node:path' +import { parseArgs, promisify } from 'node:util' +import { fileURLToPath } from 'node:url' +import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' +import { createPackagingRun, recordPackagingEvent } from '../scripts/packaging-run.mjs' +import { beginWindowsSigningAttempt } from '../scripts/windows-signing-state.mjs' +import { withWindowsSigningStage } from '../scripts/windows-signing-stage.mjs' +import { inspectWindowsTokenIdentity, windowsTokenIdentityFailure } from '../scripts/windows-token-identity.ts' +import { buildWindowsSigningEnvironment, createWindowsTokenSigner, scrubWindowsSigningEnvironment } from '../scripts/windows-sign.mjs' +import { inspectWindowsRuntimeSignature } from '../scripts/windows-runtime-signature.mjs' +import { completeWindowsSignature, normalizeWindowsSignature } from '../scripts/windows-timestamp.mjs' +import { runSigningExperimentBatch } from './helpers/windows-signing-experiment.ts' +import { prepareSigningCorpus } from './helpers/windows-signing-corpus.ts' + +const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) +const MODES = [ + { name: 'serial', jobs: 1, hardwareConcurrency: 1 }, + { name: 'pipeline', jobs: 2, hardwareConcurrency: 1 }, + { name: 'hardware2', jobs: 2, hardwareConcurrency: 2 }, +] as const +const CORPUS_MODES = [ + { name: 'serial', jobs: 1, hardwareConcurrency: 1 }, + { name: 'hardware2', jobs: 2, hardwareConcurrency: 2 }, + { name: 'hardware4', jobs: 4, hardwareConcurrency: 4 }, +] as const + +/** + * Run an explicitly opted-in hardware experiment, retaining unsigned inputs, signed copies and audit records. + * @param args CLI arguments; omission uses the current process arguments. + * @returns Resolves after all requested batches verify; a failure during a batch retains its interlock and stops later modes. + */ +export async function runWindowsSigningConcurrencyExperiment(args = process.argv.slice(2)): Promise { + const { values } = parseArgs({ args, options: { + execute: { type: 'boolean' }, 'config-root': { type: 'string' }, rounds: { type: 'string', default: '2' }, + corpus: { type: 'string' }, 'pipeline-control': { type: 'boolean' }, 'strip-tool': { type: 'string' }, + } }) + if (!values.execute) { + console.log('Operator hardware experiment: node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root [--rounds 1|2] [--corpus ] [--strip-tool ] [--pipeline-control]') + return + } + if (process.platform !== 'win32' || !values['config-root'] || !isAbsolute(values['config-root']) + || !['1', '2'].includes(values.rounds)) throw new Error('Requires Windows, an absolute config-root and rounds 1 or 2') + if (values.corpus !== undefined && !isAbsolute(values.corpus)) throw new Error('Signing corpus manifest must be an absolute path') + if (values['pipeline-control'] && !values.corpus) throw new Error('Pipeline control requires a real-file corpus') + if (values['strip-tool'] !== undefined && (!values.corpus || !isAbsolute(values['strip-tool']))) throw new Error('Signature stripping override requires a corpus and an absolute SignTool path') + const modes = values['pipeline-control'] ? [{ name: 'pipeline4', jobs: 4, hardwareConcurrency: 1 } as const] + : values.corpus ? CORPUS_MODES : MODES + const environment = loadDesktopPackageEnvironment('win32', { + ...process.env, DSH_PACKAGING_PRESET: 'test', DSH_PACKAGING_CONFIG_DIR: values['config-root'], + }) + const certificateFile = environment.DSH_DESKTOP_WINDOWS_CER_FILE! + const signTool = environment.DSH_DESKTOP_WINDOWS_SIGNTOOL! + const stripTool = values['strip-tool'] ?? signTool + const tokenPin = environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN! + const keyContainer = environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER!.trim() + // Construction validates the same signing inputs; the production serial hook is never invoked here. + createWindowsTokenSigner({ certificateFile, signTool, tokenPin, keyContainer }) + const certificate = await readFile(certificateFile) + const leaf = new X509Certificate(certificate) + if (Date.now() < Date.parse(leaf.validFrom) || Date.now() > Date.parse(leaf.validTo)) { + throw new Error('Signing experiment requires a currently valid public certificate') + } + const thumbprint = leaf.fingerprint.replaceAll(':', '') + const safeEnvironment = scrubWindowsSigningEnvironment(environment) + delete safeEnvironment.NODE_OPTIONS + delete safeEnvironment.NODE_PATH + const run = createPackagingRun(join(APP_ROOT, '.desktop-build', 'qualification', 'signing-concurrency'), { + kind: 'operator-signing-concurrency', certificateSha256: createHash('sha256').update(certificate).digest('hex'), + node: process.version, cpu: cpus()[0]?.model, logicalCpus: cpus().length, memoryBytes: totalmem(), + rounds: Number(values.rounds), corpus: values.corpus ?? null, stripTool, modes, + cache: 'disabled', probeExecuted: false, + }, { parallel: true, secrets: [tokenPin, keyContainer] }) + console.log(`SIGNING_EXPERIMENT_RECORD ${run.directory}`) + const controller = new AbortController() + const abort = (): void => { controller.abort(new Error('Operator interrupted the signing experiment')) } + process.once('SIGINT', abort) + process.once('SIGTERM', abort) + const record = (event: object): void => { recordPackagingEvent(run.directory, event) } + let success = false + try { + await withWindowsSigningStage({ stage: 'operator-signing-concurrency', signal: controller.signal, record }, async () => { + if (existsSync(join(homedir(), '.dsh-desktop-signing', 'attempt.json'))) { + throw new Error('Retained signing interlock requires operator inspection; experiment refused') + } + const identity = await inspectWindowsTokenIdentity({ certificate, container: keyContainer, environment: safeEnvironment }) + record({ type: 'experiment-public-identity', result: identity, pinSupplied: false }) + if (identity !== 'matched') throw new Error(windowsTokenIdentityFailure(identity)) + let templates: string[] = [] + if (values.corpus) { + let removalIndex = 0 + const corpus = await prepareSigningCorpus(values.corpus, join(run.directory, 'unsigned'), async (path) => { + await run.run(`remove-signature-${removalIndex++}`, stripTool, ['remove', '/s', path], + { cwd: run.directory, env: safeEnvironment, timeoutMs: 60_000 }) + }, controller.signal) + await writeFile(join(run.directory, 'corpus.json'), `${JSON.stringify(corpus, null, 2)}\n`, { flag: 'wx' }) + templates = corpus.map(file => file.path) + record({ type: 'experiment-corpus', files: corpus.length, bytes: corpus.reduce((sum, file) => sum + file.bytes, 0) }) + } else { + const source = join(run.directory, 'probe.cs') + await writeFile(source, 'internal static class SigningProbe { private static int Main() { return 0; } }\n', { flag: 'wx' }) + for (const size of [0, 1024 * 1024, 16 * 1024 * 1024]) { + controller.signal.throwIfAborted() + const resource = join(run.directory, `resource-${size}.bin`) + if (size) await writeFile(resource, Buffer.alloc(size, 0x5a), { flag: 'wx' }) + const output = join(run.directory, `unsigned-${size}.exe`) + await run.run(`compile-probe-${size}`, join(environment.SystemRoot ?? 'C:\\Windows', 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'), + ['/nologo', '/target:exe', '/platform:x64', `/out:${output}`, ...(size ? [`/resource:${resource}`] : []), source], + { cwd: run.directory, env: safeEnvironment, timeoutMs: 30_000 }) + if ((await inspectWindowsRuntimeSignature(output)).status !== 'NotSigned') throw new Error('Probe must be unsigned') + templates.push(output, output) + } + } + const results: { round: number; mode: string; elapsedMs: number; files: number }[] = [] + for (let round = 1; round <= Number(values.rounds); round++) { + const order = round % 2 ? modes : [...modes].reverse() + for (const mode of order) { + controller.signal.throwIfAborted() + const directory = join(run.directory, `round-${round}-${mode.name}`) + await mkdir(directory) + const files: string[] = [] + for (const [index, template] of templates.entries()) { + const file = join(directory, `probe-${index}${extname(template)}`) + await copyFile(template, file) + if (Buffer.compare(await readFile(template), await readFile(file)) !== 0) throw new Error('Signing experiment batch copy differs from its template') + files.push(file) + } + record({ type: 'experiment-batch-plan', round, ...mode, files }) + const attempt = beginWindowsSigningAttempt({ runDirectory: run.directory, target: directory }) + let commandIndex = 0 + const started = performance.now() + try { + record({ type: 'experiment-resources', round, mode: mode.name, point: 'start', freeMemoryBytes: freemem(), cpuTimes: cpus().map(cpu => cpu.times), processMemory: process.memoryUsage() }) + await runSigningExperimentBatch(files, { + ...mode, signal: controller.signal, + sign: async (_file, path) => { + controller.signal.throwIfAborted() + const stage = `experiment-sign-${round}-${mode.name}-${commandIndex++}` + record({ type: 'experiment-hardware-start', stage, path }) + const interpreter = environment.ComSpec ?? join(environment.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe') + await run.run(stage, interpreter, + ['/d', '/v:off', '/c', 'windows-sign.cmd'], { + cwd: join(APP_ROOT, 'scripts'), timeoutMs: 60_000, + env: buildWindowsSigningEnvironment(safeEnvironment, { + certificateFile, signTool, tokenPin, keyContainer, path, isNest: false, + }), + }) + record({ type: 'experiment-hardware-exited', stage, path }) + const signature = await inspectWindowsRuntimeSignature(path) + if (signature.status !== 'Valid' || signature.timestamped || signature.thumbprint?.toUpperCase() !== thumbprint.toUpperCase()) { + throw new Error('Primary signature verification failed') + } + record({ type: 'experiment-hardware-verified', stage, path }) + }, + complete: async (path, sign) => { + const fileStarted = performance.now() + await completeWindowsSignature(path, { + thumbprint, sign, inspect: inspectWindowsRuntimeSignature, evidenceDirectory: run.directory, record, + normalize: file => normalizeWindowsSignature(file, signTool, safeEnvironment), + timestamp: async (file) => { + await promisify(execFile)(signTool, ['timestamp', '/v', '/tr', 'http://timestamp.digicert.com', '/td', 'sha256', file], { + env: safeEnvironment, windowsHide: true, timeout: 60_000, + }) + }, + }) + const signature = await inspectWindowsRuntimeSignature(path) + if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== thumbprint.toUpperCase()) { + throw new Error('Published probe signature verification failed') + } + record({ type: 'experiment-file-verified', path, ...signature, elapsedMs: performance.now() - fileStarted, + freeMemoryBytes: freemem(), processMemory: process.memoryUsage(), + sha256: createHash('sha256').update(await readFile(path)).digest('hex') }) + }, + }) + controller.signal.throwIfAborted() + const result = { round, mode: mode.name, elapsedMs: performance.now() - started, files: files.length } + record({ type: 'experiment-resources', round, mode: mode.name, point: 'end', freeMemoryBytes: freemem(), cpuTimes: cpus().map(cpu => cpu.times), processMemory: process.memoryUsage() }) + results.push(result) + record({ type: 'experiment-batch-complete', ...result }) + await writeFile(join(run.directory, 'measurements.json'), `${JSON.stringify(results, null, 2)}\n`, { flush: true }) + attempt.success() + console.log(`SIGNING_EXPERIMENT_SAMPLE ${JSON.stringify(result)}`) + } catch (error) { + attempt.failure(null, 'Signing concurrency experiment failed; inspect retained records before recovery') + throw error + } + } + } + }) + success = true + } finally { + process.removeListener('SIGINT', abort) + process.removeListener('SIGTERM', abort) + run.finish(success) + } +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runWindowsSigningConcurrencyExperiment().catch(() => { + // Child-process errors can embed arguments; the retained supervisor output is already redacted. + console.error('Signing experiment stopped; inspect its retained record. No automatic hardware retry or interlock recovery.') + process.exitCode = 1 + }) +} diff --git a/apps/desktop/tests/windows-signing-concurrency.spec.ts b/apps/desktop/tests/windows-signing-concurrency.spec.ts new file mode 100644 index 0000000000..bb82cd7062 --- /dev/null +++ b/apps/desktop/tests/windows-signing-concurrency.spec.ts @@ -0,0 +1,235 @@ +/** Driver ownership tests replace every native signing and device boundary. */ +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { runWindowsSigningConcurrencyExperiment } from './windows-signing-concurrency.perf.ts' +import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' +import { createPackagingRun } from '../scripts/packaging-run.mjs' +import { beginWindowsSigningAttempt } from '../scripts/windows-signing-state.mjs' +import { inspectWindowsTokenIdentity } from '../scripts/windows-token-identity.ts' +import { prepareSigningCorpus } from './helpers/windows-signing-corpus.ts' + +const state = vi.hoisted(() => ({ + root: '', environment: {} as NodeJS.ProcessEnv, + identity: 'matched', failure: '' as '' | 'primary' | 'final' | 'audit', + signed: new Set(), completed: new Set(), stages: [] as string[], + events: [] as object[], successes: 0, failures: 0, + removals: [] as { executable: string; args: readonly string[]; environment: NodeJS.ProcessEnv }[], +})) + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(() => { throw new Error('Native subprocesses are forbidden in driver tests') }), +})) +vi.mock('node:os', async original => ({ ...await original(), homedir: () => state.root })) +vi.mock('node:crypto', async original => ({ + ...await original(), + X509Certificate: class { + fingerprint = 'A'.repeat(40) + validFrom = 'Jan 1 2000 GMT' + validTo = 'Jan 1 2100 GMT' + }, +})) +vi.mock('../scripts/desktop-package-environment.mjs', () => ({ + loadDesktopPackageEnvironment: vi.fn(() => state.environment), +})) +vi.mock('../scripts/windows-sign.mjs', async original => ({ + ...await original(), + createWindowsTokenSigner: vi.fn(() => { throw new Error('unconfigured constructor') }), +})) +vi.mock('../scripts/windows-token-identity.ts', () => ({ + inspectWindowsTokenIdentity: vi.fn(async () => state.identity), + windowsTokenIdentityFailure: (result: string) => `Identity refused: ${result}`, +})) +vi.mock('./helpers/windows-signing-corpus.ts', () => ({ + prepareSigningCorpus: vi.fn(async () => { + const files = [] + for (let index = 0; index < 6; index++) { + const path = join(state.root, `real-${index}.dll`) + await writeFile(path, 'inert real file') + files.push({ path, bytes: 15, sha256: 'a'.repeat(64) }) + } + return files + }), +})) +vi.mock('../scripts/windows-signing-stage.mjs', () => ({ + withWindowsSigningStage: vi.fn(async (_options: object, operation: () => Promise) => operation()), +})) +vi.mock('../scripts/packaging-run.mjs', () => ({ + createPackagingRun: vi.fn(() => ({ + directory: join(state.root, 'run'), + finish: vi.fn(), + run: vi.fn(async (stage: string, executable: string, args: readonly string[], options: { env: NodeJS.ProcessEnv }) => { + state.stages.push(stage) + if (stage.startsWith('compile-')) { + await writeFile(args.find(arg => arg.startsWith('/out:'))!.slice(5), 'inert probe') + } else if (stage.startsWith('remove-signature-')) { + state.removals.push({ executable, args, environment: options.env }) + } else { + state.signed.add(options.env.DSH_DESKTOP_WINDOWS_SIGN_TARGET!) + } + }), + })), + recordPackagingEvent: (_directory: string, event: object) => { + if (state.failure === 'audit' && 'type' in event && event.type === 'experiment-file-verified') throw new Error('Audit failed') + state.events.push(event) + }, +})) +vi.mock('../scripts/windows-signing-state.mjs', () => ({ + beginWindowsSigningAttempt: vi.fn((options: { runDirectory: string; target: string; stateDirectory?: string }) => { + expect(options.stateDirectory).toBeUndefined() + const lock = join(state.root, '.dsh-desktop-signing', 'attempt.json') + mkdirSync(dirname(lock), { recursive: true }) + writeFileSync(lock, 'batch attempt', { flag: 'wx' }) + return { + success: () => { + const verified = state.events.filter(event => 'type' in event && event.type === 'experiment-file-verified' + && 'path' in event && typeof event.path === 'string' && dirname(event.path) === options.target) + expect(verified).toHaveLength(6) + expect(readFileSync(join(options.runDirectory, 'measurements.json'), 'utf8')).toContain('"files": 6') + unlinkSync(lock) + state.successes++ + }, + failure: () => { state.failures++ }, + } + }), +})) +vi.mock('../scripts/windows-runtime-signature.mjs', () => ({ + inspectWindowsRuntimeSignature: vi.fn(async (path: string) => { + if (!state.signed.has(path)) return { status: 'NotSigned', timestamped: false, thumbprint: null } + const timestamped = state.completed.has(path) + const failed = state.failure === (timestamped ? 'final' : 'primary') + return { status: failed ? 'HashMismatch' : 'Valid', timestamped, thumbprint: 'A'.repeat(40) } + }), +})) +vi.mock('../scripts/windows-timestamp.mjs', () => ({ + completeWindowsSignature: vi.fn(async (path: string, options: { sign: (path: string) => Promise }) => { + await options.sign(path) + state.completed.add(path) + }), + normalizeWindowsSignature: vi.fn(() => { throw new Error('Native normalization is forbidden in driver tests') }), +})) + +const platform = Object.getOwnPropertyDescriptor(process, 'platform')! +beforeEach(async () => { + vi.clearAllMocks() + state.root = await mkdtemp(join(tmpdir(), 'signing-driver-')) + mkdirSync(join(state.root, 'run')) + state.identity = 'matched'; state.failure = ''; state.successes = 0; state.failures = 0 + state.signed.clear(); state.completed.clear(); state.stages = []; state.events = []; state.removals = [] + state.environment = { + DSH_DESKTOP_WINDOWS_CER_FILE: join(state.root, 'certificate.cer'), + DSH_DESKTOP_WINDOWS_SIGNTOOL: 'forbidden-signer', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin', + DSH_DESKTOP_WINDOWS_KEY_CONTAINER: ' fixture-container ', API_KEY: 'fixture-secret', NODE_OPTIONS: 'forbidden-option', + } + await writeFile(state.environment.DSH_DESKTOP_WINDOWS_CER_FILE!, 'inert certificate') + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + const { createWindowsTokenSigner } = await import('../scripts/windows-sign.mjs') + vi.mocked(createWindowsTokenSigner).mockReturnValue(vi.fn(async () => { throw new Error('Production signer is forbidden') })) + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) +afterEach(async () => { + Object.defineProperty(process, 'platform', platform) + vi.useRealTimers() + vi.restoreAllMocks() + await rm(state.root, { recursive: true, force: true, maxRetries: 3 }) +}) + +const execute = () => runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--rounds', '1']) +const lock = () => join(state.root, '.dsh-desktop-signing', 'attempt.json') + +it('requires opt-in before reading configuration or querying a device', async () => { + await runWindowsSigningConcurrencyExperiment([]) + expect(loadDesktopPackageEnvironment).not.toHaveBeenCalled() + expect(inspectWindowsTokenIdentity).not.toHaveBeenCalled() + expect(createPackagingRun).not.toHaveBeenCalled() +}) + +it.each(['1999-01-01', '2101-01-01'])('rejects certificate validity at %s before querying or signing', async (date) => { + vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date(date)) + await expect(execute()).rejects.toThrow('currently valid') + expect(inspectWindowsTokenIdentity).not.toHaveBeenCalled() + expect(beginWindowsSigningAttempt).not.toHaveBeenCalled() + expect(state.stages).toEqual([]) +}) + +it.each(['certificate-mismatch', 'provider-unavailable', 'container-unavailable', 'unknown'])('refuses public identity %s before any signing', async (identity) => { + state.identity = identity + await expect(execute()).rejects.toThrow('Identity refused') + expect(state.stages).toEqual([]) + expect(beginWindowsSigningAttempt).not.toHaveBeenCalled() +}) + +it('retains an existing default-account interlock without accessing the device', async () => { + mkdirSync(dirname(lock()), { recursive: true }); writeFileSync(lock(), 'prior failure') + await expect(execute()).rejects.toThrow('Retained signing interlock') + expect(inspectWindowsTokenIdentity).not.toHaveBeenCalled() + expect(readFileSync(lock(), 'utf8')).toBe('prior failure') +}) + +it('completes all three modes and releases each default-account batch only after recording every verification', async () => { + await execute() + expect(state.successes).toBe(3) + expect(state.failures).toBe(0) + expect(state.signed.size).toBe(18) + expect(existsSync(lock())).toBe(false) + expect(beginWindowsSigningAttempt).toHaveBeenCalledTimes(3) + expect(inspectWindowsTokenIdentity).toHaveBeenCalledWith(expect.objectContaining({ + container: 'fixture-container', environment: {}, + })) + expect(JSON.stringify(state.events)).not.toMatch(/fixture-pin|fixture-secret|fixture-container/u) +}) + +it('uses fresh real-file copies for 1, 2, 4 workers and reverses the second round', async () => { + await runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--corpus', join(state.root, 'manifest.json')]) + expect(state.successes).toBe(6) + expect(state.signed.size).toBe(36) + expect(state.stages.some(stage => stage.startsWith('compile-'))).toBe(false) + expect(prepareSigningCorpus).toHaveBeenCalledOnce() + expect(state.events.filter(event => 'type' in event && event.type === 'experiment-batch-plan')).toEqual([ + expect.objectContaining({ round: 1, jobs: 1, hardwareConcurrency: 1 }), + expect.objectContaining({ round: 1, jobs: 2, hardwareConcurrency: 2 }), + expect.objectContaining({ round: 1, jobs: 4, hardwareConcurrency: 4 }), + expect.objectContaining({ round: 2, jobs: 4, hardwareConcurrency: 4 }), + expect.objectContaining({ round: 2, jobs: 2, hardwareConcurrency: 2 }), + expect.objectContaining({ round: 2, jobs: 1, hardwareConcurrency: 1 }), + ]) + expect(existsSync(lock())).toBe(false) +}) + +it('offers a separate four-job control with only one hardware request', async () => { + await runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--rounds', '1', '--corpus', join(state.root, 'manifest.json'), '--pipeline-control']) + expect(state.successes).toBe(1) + expect(state.events).toContainEqual(expect.objectContaining({ type: 'experiment-batch-plan', jobs: 4, hardwareConcurrency: 1 })) +}) + +it('refuses corpus preparation failure before beginning a hardware attempt', async () => { + vi.mocked(prepareSigningCorpus).mockRejectedValueOnce(new Error('changed input')) + await expect(runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--corpus', join(state.root, 'manifest.json')])).rejects.toThrow('changed input') + expect(beginWindowsSigningAttempt).not.toHaveBeenCalled() + expect(state.signed.size).toBe(0) +}) + +it('uses the stripping override only for public removal on a corpus copy', async () => { + const copy = join(state.root, 'copy.exe') + const stripTool = join(state.root, 'sdk-signtool.exe') + vi.mocked(prepareSigningCorpus).mockImplementationOnce(async (_manifest, _directory, remove) => { + await remove(copy) + throw new Error('stop before hardware') + }) + await expect(runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--corpus', join(state.root, 'manifest.json'), '--strip-tool', stripTool])).rejects.toThrow('stop before hardware') + expect(state.removals).toEqual([{ executable: stripTool, args: ['remove', '/s', copy], environment: {} }]) + expect(beginWindowsSigningAttempt).not.toHaveBeenCalled() +}) + +it.each(['primary', 'final', 'audit'] as const)('retains the batch interlock and stops later modes after %s failure', async (failure) => { + state.failure = failure + await expect(execute()).rejects.toThrow(failure === 'audit' ? 'Audit failed' : 'signature verification failed') + expect(state.successes).toBe(0) + expect(state.failures).toBe(1) + expect(beginWindowsSigningAttempt).toHaveBeenCalledOnce() + expect(state.signed.size).toBe(1) + expect(existsSync(lock())).toBe(true) + expect(state.stages.some(stage => stage.includes('pipeline') || stage.includes('hardware2'))).toBe(false) +}) diff --git a/apps/desktop/tests/windows-signing-corpus.spec.ts b/apps/desktop/tests/windows-signing-corpus.spec.ts new file mode 100644 index 0000000000..1849c2ea7a --- /dev/null +++ b/apps/desktop/tests/windows-signing-corpus.spec.ts @@ -0,0 +1,71 @@ +/** Corpus preparation touches only private copies; public signature inspection is simulated. */ +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { prepareSigningCorpus } from './helpers/windows-signing-corpus.ts' +import { inspectWindowsRuntimeSignature } from '../scripts/windows-runtime-signature.mjs' + +vi.mock('../scripts/windows-runtime-signature.mjs', () => ({ inspectWindowsRuntimeSignature: vi.fn() })) +let root: string +let source: string +let manifest: string +const digest = (data: Buffer | string) => createHash('sha256').update(data).digest('hex') +beforeEach(async () => { + vi.resetAllMocks() + root = await mkdtemp(join(tmpdir(), 'signing-corpus-')) + source = join(root, 'original.dll') + manifest = join(root, 'manifest.json') + await writeFile(source, 'signed original') + await writeFile(manifest, JSON.stringify([{ path: source, sha256: digest('signed original') }])) + vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValue({ status: 'NotSigned', timestamped: false, thumbprint: null }) +}) +afterEach(async () => { await rm(root, { recursive: true, force: true, maxRetries: 3 }) }) + +it('strips only a pinned private copy and preserves the original bytes', async () => { + vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValueOnce({ status: 'Valid', timestamped: true, thumbprint: 'A' }) + const remove = vi.fn(async (path: string) => { expect(path).not.toBe(source); await writeFile(path, 'unsigned copy') }) + const files = await prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal) + expect(remove).toHaveBeenCalledOnce() + expect(files).toEqual([{ path: join(root, 'copies', '0.dll'), sha256: digest('unsigned copy'), bytes: 13 }]) + expect(await readFile(source, 'utf8')).toBe('signed original') +}) + +it('accepts already unsigned input without invoking removal', async () => { + const remove = vi.fn() + const files = await prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal) + expect(remove).not.toHaveBeenCalled() + expect(files[0]?.sha256).toBe(digest('signed original')) +}) + +it.each([[], {}, [{ path: 'relative', sha256: 'a'.repeat(64) }], [{ path: 'ignored', sha256: 'invalid' }]])('rejects malformed manifest %j', async (value) => { + await writeFile(manifest, JSON.stringify(value)) + const remove = vi.fn() + await expect(prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal)).rejects.toThrow('Signing corpus') + expect(remove).not.toHaveBeenCalled() +}) + +it('rejects source content changed since the manifest was created', async () => { + await writeFile(source, 'changed source') + await expect(prepareSigningCorpus(manifest, join(root, 'copies'), vi.fn(), new AbortController().signal)).rejects.toThrow('pinned digest') + expect(inspectWindowsRuntimeSignature).not.toHaveBeenCalled() +}) + +it.each(['HashMismatch', 'NotTrusted'])('rejects invalid source signature %s without removal', async (status) => { + vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValue({ status, timestamped: false, thumbprint: null }) + const remove = vi.fn() + await expect(prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal)).rejects.toThrow('invalid signature') + expect(remove).not.toHaveBeenCalled() +}) + +it('rejects a copy whose signature removal did not finish', async () => { + vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValue({ status: 'Valid', timestamped: true, thumbprint: 'A' }) + await expect(prepareSigningCorpus(manifest, join(root, 'copies'), vi.fn(), new AbortController().signal)).rejects.toThrow('must be unsigned') +}) + +it('stops before copying after cancellation', async () => { + const controller = new AbortController(); controller.abort(new Error('cancelled')) + await expect(prepareSigningCorpus(manifest, join(root, 'copies'), vi.fn(), controller.signal)).rejects.toThrow('cancelled') + expect(inspectWindowsRuntimeSignature).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/windows-signing-experiment.spec.ts b/apps/desktop/tests/windows-signing-experiment.spec.ts new file mode 100644 index 0000000000..d14e9399eb --- /dev/null +++ b/apps/desktop/tests/windows-signing-experiment.spec.ts @@ -0,0 +1,288 @@ +import { expect, it, vi } from 'vitest' +import { runSigningExperimentBatch } from './helpers/windows-signing-experiment.ts' + +function barrier() { + const pending = Promise.withResolvers() + return { promise: pending.promise, resolve: () => { pending.resolve(undefined) } } +} +const observe = (operation: Promise) => operation.then( + () => ({ ok: true as const }), (error: unknown) => ({ ok: false as const, error }), +) + +it('drains four issued hardware calls after failure without dispatching a fifth', async () => { + const allStarted = barrier() + const release = barrier() + const failed = barrier() + const started: number[] = [] + const finished: number[] = [] + const outcome = observe(runSigningExperimentBatch([1, 2, 3, 4, 5, 6], { + jobs: 4, hardwareConcurrency: 4, + sign: async (item) => { + started.push(item) + if (started.length === 4) allStarted.resolve() + await allStarted.promise + if (item === 1) { failed.resolve(); throw new Error('hardware failure') } + await release.promise + finished.push(item) + }, + complete: async (item, sign) => { await sign(`staged-${item}`) }, + })) + let settled = false + void outcome.then(() => { settled = true }) + try { + await failed.promise + expect(started).toEqual([1, 2, 3, 4]) + expect(settled).toBe(false) + } finally { release.resolve() } + expect(await outcome).toEqual({ ok: false, error: new Error('hardware failure') }) + expect(finished.sort()).toEqual([2, 3, 4]) + expect(started).toEqual([1, 2, 3, 4]) +}) + +it('finishes each item before beginning the next in serial mode', async () => { + const events: string[] = [] + await runSigningExperimentBatch([1, 2], { + jobs: 1, hardwareConcurrency: 1, + sign: async (item, path) => { expect(path).toBe(`staged-${item}`); events.push(`sign ${item}`) }, + complete: async (item, sign) => { + events.push(`start ${item}`) + await sign(`staged-${item}`) + events.push(`complete ${item}`) + }, + }) + expect(events).toEqual(['start 1', 'sign 1', 'complete 1', 'start 2', 'sign 2', 'complete 2']) +}) + +it('overlaps completion with the next signature while keeping hardware concurrency at one', async () => { + const firstSigning = barrier() + const releaseFirstSignature = barrier() + const secondStarted = barrier() + const firstPost = barrier() + const secondSigned = barrier() + const releaseCompletion = barrier() + const started: number[] = [] + let active = 0 + let peak = 0 + const outcome = observe(runSigningExperimentBatch([1, 2, 3], { + jobs: 2, hardwareConcurrency: 1, + sign: async (item) => { + active++ + peak = Math.max(peak, active) + try { + if (item === 1) { firstSigning.resolve(); await releaseFirstSignature.promise } + if (item === 2) secondSigned.resolve() + } finally { active-- } + }, + complete: async (item, sign) => { + started.push(item) + if (item === 2) secondStarted.resolve() + await sign(`staged-${item}`) + if (item === 1) firstPost.resolve() + await releaseCompletion.promise + }, + })) + try { + await Promise.all([firstSigning.promise, secondStarted.promise]) + expect(active).toBe(1) + releaseFirstSignature.resolve() + await Promise.all([firstPost.promise, secondSigned.promise]) + expect(started).toEqual([1, 2]) + expect(peak).toBe(1) + } finally { + releaseFirstSignature.resolve() + releaseCompletion.resolve() + expect(await outcome).toEqual({ ok: true }) + } + expect(started).toEqual([1, 2, 3]) +}) + +it.each([2, 4] as const)('allows %i hardware requests but keeps later items queued until a job completes', async (limit) => { + const bothSigning = barrier() + const release = barrier() + const started: number[] = [] + let active = 0 + let peak = 0 + const outcome = observe(runSigningExperimentBatch(Array.from({ length: limit + 1 }, (_, index) => index + 1), { + jobs: limit, hardwareConcurrency: limit, + sign: async () => { + active++ + peak = Math.max(peak, active) + if (active === limit) bothSigning.resolve() + try { await release.promise } finally { active-- } + }, + complete: async (item, sign) => { started.push(item); await sign(`staged-${item}`) }, + })) + try { + await bothSigning.promise + expect(started).toEqual(Array.from({ length: limit }, (_, index) => index + 1)) + expect(active).toBe(limit) + } finally { release.resolve(); expect(await outcome).toEqual({ ok: true }) } + expect(peak).toBe(limit) + expect(active).toBe(0) +}) + +it.each([new Error('hardware failed'), undefined])('stops queued signatures and new items after a hardware rejection (%s)', async (failure) => { + const secondStarted = barrier() + const sign = vi.fn(async () => { await secondStarted.promise; throw failure }) + const started: number[] = [] + const result = await observe(runSigningExperimentBatch([1, 2, 3], { + jobs: 2, hardwareConcurrency: 1, sign, + complete: async (item, request) => { + started.push(item) + if (item === 2) secondStarted.resolve() + await request(`staged-${item}`) + }, + })) + expect(result).toEqual({ ok: false, error: failure }) + expect(sign).toHaveBeenCalledTimes(1) + expect(started).toEqual([1, 2]) +}) + +it('drains concurrent hardware after the first failure and retains that failure', async () => { + const secondSigning = barrier() + const firstFailed = barrier() + const releaseSecond = barrier() + const failure = new Error('first hardware failure') + const calls: number[] = [] + let active = 0 + let settled = false + const outcome = observe(runSigningExperimentBatch([1, 2, 3], { + jobs: 2, hardwareConcurrency: 2, + sign: async (item) => { + calls.push(item) + active++ + try { + if (item === 1) { await secondSigning.promise; throw failure } + secondSigning.resolve() + await releaseSecond.promise + throw new Error('later hardware failure') + } finally { active-- } + }, + complete: async (item, sign) => { + try { await sign(`staged-${item}`) } + catch (error) { if (item === 1) firstFailed.resolve(); throw error } + }, + })).then((result) => { settled = true; return result }) + try { + await firstFailed.promise + expect(settled).toBe(false) + expect(active).toBe(1) + expect(calls).toEqual([1, 2]) + } finally { releaseSecond.resolve(); expect(await outcome).toEqual({ ok: false, error: failure }) } + expect(active).toBe(0) + expect(calls).toEqual([1, 2]) +}) + +it('preserves an undefined completion failure and starts no further work', async () => { + const sign = vi.fn(async () => {}) + const complete = vi.fn(async () => { throw undefined }) + expect(await observe(runSigningExperimentBatch([1, 2], { + jobs: 1, hardwareConcurrency: 1, complete, sign, + }))).toEqual({ ok: false, error: undefined }) + expect(complete).toHaveBeenCalledTimes(1) + expect(sign).not.toHaveBeenCalled() +}) + +it('prevents a waiting signature when completion fails and drains the issued hardware request', async () => { + const secondSigning = barrier() + const releaseSecond = barrier() + const failureObserved = barrier() + const failure = new Error('timestamp failed') + const calls: number[] = [] + let pending: ReturnType | undefined + let settled = false + const outcome = observe(runSigningExperimentBatch([1, 2, 3], { + jobs: 2, hardwareConcurrency: 1, + sign: async (item) => { + calls.push(item) + if (item === 2) { secondSigning.resolve(); await releaseSecond.promise } + }, + complete: async (item, sign) => { + await sign(`staged-${item}`) + if (item === 1) { + await secondSigning.promise + // Failure can interrupt a completion that already owns a queued request. + const waiting = observe(sign(`staged-retry-${item}`)).then((result) => { failureObserved.resolve(); return result }) + pending = waiting + throw failure + } + }, + })).then((result) => { settled = true; return result }) + try { + await failureObserved.promise + expect(settled).toBe(false) + expect(calls).toEqual([1, 2]) + } finally { releaseSecond.resolve(); expect(await outcome).toEqual({ ok: false, error: failure }) } + expect(calls).toEqual([1, 2]) + expect(await pending).toEqual({ ok: false, error: failure }) +}) + +it('drains both completion workers after one fails without starting more items', async () => { + const secondPost = barrier() + const releaseSecondPost = barrier() + const failed = barrier() + const failure = new Error('verification failed') + const started: number[] = [] + let settled = false + const outcome = observe(runSigningExperimentBatch([1, 2, 3], { + jobs: 2, hardwareConcurrency: 2, sign: async () => {}, + complete: async (item, sign) => { + started.push(item) + await sign(`staged-${item}`) + if (item === 1) { await secondPost.promise; failed.resolve(); throw failure } + secondPost.resolve() + await releaseSecondPost.promise + }, + })).then((result) => { settled = true; return result }) + try { + await failed.promise + expect(settled).toBe(false) + expect(started).toEqual([1, 2]) + } finally { releaseSecondPost.resolve(); expect(await outcome).toEqual({ ok: false, error: failure }) } + expect(started).toEqual([1, 2]) +}) + +it('cancels waiting signatures and new items while waiting for issued hardware to finish', async () => { + const controller = new AbortController() + const secondStarted = barrier() + const release = barrier() + const waitingRejected = barrier() + const reason = new Error('operator cancelled') + const calls: number[] = [] + let active = false + let settled = false + const outcome = observe(runSigningExperimentBatch([1, 2, 3], { + jobs: 2, hardwareConcurrency: 1, signal: controller.signal, + sign: async (item) => { + calls.push(item) + active = true + try { await release.promise } finally { active = false } + }, + complete: async (item, sign) => { + if (item === 2) secondStarted.resolve() + try { await sign(`staged-${item}`) } + catch (error) { if (item === 2) waitingRejected.resolve(); throw error } + }, + })).then((result) => { settled = true; return result }) + try { + await secondStarted.promise + controller.abort(reason) + await waitingRejected.promise + expect(active).toBe(true) + expect(settled).toBe(false) + expect(calls).toEqual([1]) + } finally { release.resolve(); expect(await outcome).toEqual({ ok: false, error: reason }) } + expect(active).toBe(false) + expect(calls).toEqual([1]) +}) + +it('does not begin work when cancellation was already requested', async () => { + const reason = new Error('already cancelled') + const complete = vi.fn(async () => {}) + const sign = vi.fn(async () => {}) + expect(await observe(runSigningExperimentBatch([1], { + jobs: 2, hardwareConcurrency: 2, signal: AbortSignal.abort(reason), complete, sign, + }))).toEqual({ ok: false, error: reason }) + expect(complete).not.toHaveBeenCalled() + expect(sign).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/windows-signing-preflight.spec.ts b/apps/desktop/tests/windows-signing-preflight.spec.ts index e560654e80..008b9bfcc0 100644 --- a/apps/desktop/tests/windows-signing-preflight.spec.ts +++ b/apps/desktop/tests/windows-signing-preflight.spec.ts @@ -63,15 +63,32 @@ async function fixture() { sequence.push('compile'); await writeFile(output, 'inert unsigned probe') }) const sign = vi.fn(async () => { sequence.push('sign') }) + const inspectIdentity = vi.fn(async () => 'matched' as const) const inspect = vi.fn(async (_path: string) => { sequence.push('inspect') return sign.mock.calls.length === 0 ? { status: 'NotSigned', timestamped: false, thumbprint: null } : { status: 'Valid', timestamped: true, thumbprint: 'A'.repeat(40) } }) - return { root, compiler, sequence, runDirectory: run.directory, environment, stateDirectory: join(root, 'state'), compile, sign, inspect } + return { root, compiler, sequence, runDirectory: run.directory, environment, stateDirectory: join(root, 'state'), compile, sign, inspect, inspectIdentity } } +it.each(['provider-unavailable', 'container-unavailable', 'certificate-mismatch', 'unknown'] as const) +('stops before compilation and signing on public identity result %s', async (result) => { + const options = await fixture() + const inspectIdentity = vi.fn(async (_input: { environment: NodeJS.ProcessEnv }) => result) + await expect(preflightWindowsSigning({ ...options, inspectIdentity })).rejects.toThrow('No PIN was supplied') + expect(options.compile).not.toHaveBeenCalled() + expect(options.sign).not.toHaveBeenCalled() + expect(inspectIdentity).toHaveBeenCalledOnce() + expect(inspectIdentity.mock.calls[0]![0].environment).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN') + expect(inspectIdentity.mock.calls[0]![0].environment).not.toHaveProperty('DSH_DESKTOP_WINDOWS_SIGNTOOL') + const events = await readFile(join(options.runDirectory, 'events.jsonl'), 'utf8') + expect(events).toContain(`"result":"${result}"`) + expect(events).not.toContain('fixture-pin') + expect(events).not.toContain('fixture-container') +}) + it('compiles one new probe and verifies one production-format signature before success', async () => { const options = await fixture() await preflightWindowsSigning(options) diff --git a/apps/desktop/tests/windows-token-identity.spec.ts b/apps/desktop/tests/windows-token-identity.spec.ts new file mode 100644 index 0000000000..e56cd689b5 --- /dev/null +++ b/apps/desktop/tests/windows-token-identity.spec.ts @@ -0,0 +1,113 @@ +import { execFileSync } from 'node:child_process' +import { X509Certificate } from 'node:crypto' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { rootCertificates } from 'node:tls' +import { expect, it, vi } from 'vitest' +import { inspectWindowsTokenIdentity, windowsTokenIdentityFailure } from '../scripts/windows-token-identity.ts' + +const launch = vi.hoisted(() => vi.fn(async (_file: string, _args: string[], _options: { env: NodeJS.ProcessEnv; timeout: number }) => ({ stdout: '', stderr: '' }))) +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal() + const { promisify } = await import('node:util') + return { ...original, execFile: Object.assign(vi.fn(), { [promisify.custom]: launch }) } +}) + +const certificate = new X509Certificate(rootCertificates.find(pem => new X509Certificate(pem).publicKey.asymmetricKeyType === 'rsa')!) +const jwk = certificate.publicKey.export({ format: 'jwk' }) +const modulus = Buffer.from(jwk.n!, 'base64url') +const exponent = Buffer.from(jwk.e!, 'base64url').reduce((value, byte) => value * 256 + byte, 0) +const blob = Buffer.alloc(20 + modulus.length) +blob[0] = 6; blob[1] = 2 +blob.writeUInt32LE(0x2400, 4); blob.writeUInt32LE(0x31415352, 8) +blob.writeUInt32LE(modulus.length * 8, 12); blob.writeUInt32LE(exponent, 16) +Buffer.from(modulus).reverse().copy(blob, 20) +const input = { certificate: certificate.raw, container: 'fixture-container', environment: {} } + +it('compares the returned public key with the certificate, not the container name', async () => { + const query = vi.fn(async () => JSON.stringify({ status: 'public-keys', publicKeys: [blob.toString('base64')] })) + expect(await inspectWindowsTokenIdentity({ ...input, query })).toBe('matched') + expect(query).toHaveBeenCalledOnce() + const other = Buffer.from(blob) + other[20] = other[20]! ^ 2 + expect(await inspectWindowsTokenIdentity({ ...input, query: async () => JSON.stringify({ + status: 'public-keys', publicKeys: [other.toString('base64')], + }) })).toBe('certificate-mismatch') +}) + +it.each(['provider-unavailable', 'container-unavailable', 'unknown'])('preserves public query result %s', async (status) => { + expect(await inspectWindowsTokenIdentity({ ...input, query: async () => JSON.stringify({ status }) })).toBe(status) +}) + +it.each(['not json', '{}', '{"status":"public-keys","publicKeys":[]}', + '{"status":"public-keys","publicKeys":["bad"]}', '{"status":"matched"}', + JSON.stringify({ status: 'public-keys', publicKeys: [blob.toString('base64'), 'bad'] })]) +('rejects incomplete or malformed public data without accepting a partial match: %s', async (output) => { + expect(await inspectWindowsTokenIdentity({ ...input, query: async () => output })).toBe('unknown') +}) + +it('contains query failures without retrying or exposing diagnostics', async () => { + const query = vi.fn(async () => { throw new Error('private fixture diagnostic') }) + expect(await inspectWindowsTokenIdentity({ ...input, query })).toBe('unknown') + expect(query).toHaveBeenCalledOnce() +}) + +it('launches only the public query with no PIN, signing tool or inherited secrets', async () => { + launch.mockClear() + launch.mockResolvedValueOnce({ stdout: JSON.stringify({ status: 'public-keys', publicKeys: [blob.toString('base64')] }), stderr: '' }) + expect(await inspectWindowsTokenIdentity({ ...input, environment: { + PATH: 'fixture-path', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'must-not-pass', API_KEY: 'must-not-pass', + DSH_DESKTOP_WINDOWS_SIGNTOOL: 'must-not-run', NODE_OPTIONS: 'must-not-pass', NODE_PATH: 'must-not-pass', + } })).toBe('matched') + expect(launch).toHaveBeenCalledOnce() + const [file, args, options] = launch.mock.calls[0]! + expect(file).toBe('pwsh') + expect(args).toContain('-NonInteractive') + expect(args.at(-1)).toMatch(/windows-token-public-key\.ps1$/u) + expect(options.timeout).toBe(15_000) + expect(options.env).toEqual({ PATH: 'fixture-path', DSH_TOKEN_PUBLIC_CONTAINER: 'fixture-container' }) +}) + +it('reports a helper timeout or stderr as unknown without retry', async () => { + launch.mockClear() + launch.mockRejectedValueOnce(new Error('timeout with private diagnostic')) + expect(await inspectWindowsTokenIdentity(input)).toBe('unknown') + expect(launch).toHaveBeenCalledOnce() + launch.mockResolvedValueOnce({ stdout: JSON.stringify({ status: 'public-keys', publicKeys: [blob.toString('base64')] }), stderr: 'unexpected' }) + expect(await inspectWindowsTokenIdentity(input)).toBe('unknown') +}) + +it('reports a missing query launcher without exposing process diagnostics or retrying', async () => { + launch.mockClear() + launch.mockRejectedValueOnce(Object.assign(new Error('private launcher diagnostic'), { code: 'ENOENT' })) + const result = await inspectWindowsTokenIdentity(input) + expect(result).toBe('unknown') + expect(launch).toHaveBeenCalledOnce() + const message = windowsTokenIdentityFailure('unknown') + expect(message).toContain('PowerShell 7 (pwsh) can start') + expect(message).not.toContain('private launcher diagnostic') + expect(message).toContain('no signing was attempted') +}) + +it.skipIf(process.platform !== 'win32')('exercises native control flow with a fake CSP and never constructs the native adapter', () => { + const root = mkdtempSync(join(tmpdir(), 'token-public-test-')) + try { + const source = resolve(import.meta.dirname, '../scripts/windows-token-public-key.cs') + const runner = join(root, 'test.ps1') + const fixture = resolve(import.meta.dirname, 'fixtures/windows-token-public-key-fake.cs') + writeFileSync(runner, `param([string]$Source, [string]$Fixture) +$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition ((Get-Content -LiteralPath $Source -Raw) + (Get-Content -LiteralPath $Fixture -Raw)) +[FakeTokenPublicKey]::Test() +`) + expect(execFileSync('pwsh', ['-NoProfile', '-NonInteractive', '-File', runner, source, fixture], { encoding: 'utf8', timeout: 30_000 })) + .toContain('fake CSP cases passed') + const native = readFileSync(source, 'utf8') + const provider = /Provider = "([^"]+)"/u.exec(native)![1] + expect(readFileSync(resolve(import.meta.dirname, '../scripts/windows-sign.cmd'), 'utf8')).toContain(`/csp "${provider}"`) + expect(native).toContain('CryptAcquireContextW(out context, container, Provider, 1, PublicContextFlags)') + expect(native).toContain('CryptExportKey(key, IntPtr.Zero, 6, 0, bytes, ref length)') + expect(native).not.toMatch(/CryptSign|CryptSetProvParam|CryptGenKey|CryptAcquireCertificatePrivateKey/u) + } finally { rmSync(root, { recursive: true, force: true, maxRetries: 3 }) } +}) diff --git a/apps/desktop/windows-signing/README.i18n.yaml b/apps/desktop/windows-signing/README.i18n.yaml index f64f0ef778..c1354ca9e8 100644 --- a/apps/desktop/windows-signing/README.i18n.yaml +++ b/apps/desktop/windows-signing/README.i18n.yaml @@ -21,23 +21,26 @@ en: bb889e9514678d1c zh: 48cbec1e907d6a2b /windows-signing-and-runner-operations/machine-setup-and-runner-startup/supervised-startup: - en: d8e2ed7bde76ed7e - zh: 475917ecbd158b48 + en: cada81c15a20ecc2 + zh: 80b7a4af7b1e0e5d +/windows-signing-and-runner-operations/machine-setup-and-runner-startup/optional-network-dependency: + en: 60f34f8637b3c252 + zh: 805435964ab20263 /windows-signing-and-runner-operations/signing-configuration: en: 4b5548818375eb9a zh: 8c975adbb1dca9c8 /windows-signing-and-runner-operations/signing-and-timestamp-completion: - en: e42565e00283e3f6 - zh: 584a33f07b50fd5b + en: a8d23c931cafc236 + zh: 28fd4860549cdc7b /windows-signing-and-runner-operations/failure-records-and-operator-recovery: en: 5401b605a2f3a44e zh: fc1f6ee667bcd20d /windows-signing-and-runner-operations/signature-cache-and-concurrent-stages: - en: c2686bf2e9589521 - zh: 58dfecf61e4f95da + en: 6e8208d19ad510e5 + zh: eee42e8938c0f603 /windows-signing-and-runner-operations/verification-limits: - en: 94a471410c84a1c7 - zh: 115228e4ca36581d + en: 53f1fe23f54af84e + zh: d219606367ff2d72 /windows-signing-and-runner-operations/dev-note: en: b75c8458928b72b9 zh: 003ba4e17b4b5269 diff --git a/apps/desktop/windows-signing/README.md b/apps/desktop/windows-signing/README.md index ec8312eddc..f7b48b9b3e 100644 --- a/apps/desktop/windows-signing/README.md +++ b/apps/desktop/windows-signing/README.md @@ -38,14 +38,24 @@ Run `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Roo For the complete registration, label, routing and approval sequence, start with [CI publication setup](../publishing/ci/README.md#setup-sequence). The task installer requires the Windows .NET Framework 4.5+ compiler at `%SystemRoot%/Microsoft.NET/Framework64/v4.0.30319/csc.exe`. It compiles a GUI executable into `runner/dsh-task/launcher.exe`; this executable starts PowerShell with no console and waits for it to exit. The output and errors of `run.ps1` itself go to uniquely named `launcher-*.out.log` and `launcher-*.err.log`; filenames include UTC time, PID and a random identifier. Launcher failures disable periodic starts, including failures before the retry budget can be read. Task result `2` indicates launcher failure; `3` means its error log could not be written; `4` means disabling the task also failed and an operator must disable it in Task Scheduler. After the direct child exits, output draining is limited to five seconds. A timeout returns `5` with a `launcher-*.drain.log` diagnostic: output may be incomplete, but periodic recovery continues within the existing restart budget. Descendant processes may outlive the exited runner; draining logs does not terminate them. The recorded stop target is the launcher process, so stop includes the launcher and its descendants. Normal runner output remains in `run-*.log`. -Run `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot -ConfigRoot /packaging` as the runner's Windows user, using its installed PowerShell 7. The installer checks the environment and copies startup scripts into `runner/dsh-task`; registration, tools and secrets remain separate. It registers a disabled, limited-permission interactive Task Scheduler task without storing an account password. For `-Action start` or `-Action status`, pass only `-RunnerRoot`; configuration and retry options apply only to installation. The task starts after this user's desktop login, including after reboot, and stops on logout. It does not provide unattended boot or unlock a signing Token. +Run `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot -ConfigRoot /packaging` as the runner's Windows user, using its installed PowerShell 7. The installer checks the environment and copies startup scripts into `runner/dsh-task`; registration, tools and secrets remain separate. It registers a disabled, limited-permission interactive Task Scheduler task without storing an account password. Task priority 4 gives the runner and its children normal CPU, I/O and memory priority; the scheduler default 7 lowers all three and slows dependency installation. Existing tasks retain their settings: while the runner is idle, stop, uninstall and reinstall the task before starting it again. For `-Action start` or `-Action status`, pass only `-RunnerRoot`; configuration and retry options apply only to installation. The task starts after this user's desktop login, including after reboot, and stops on logout. It does not provide unattended boot or unlock a signing Token. The task permits one instance and has no execution time limit. A repeating trigger checks every `-RestartMinutes` minutes (default `1`) and restarts an exited launcher, including an unexpected zero exit. The persisted budget permits the initial launch plus `-RestartCount` restarts (default `10`) between explicit starts. Every desktop login also consumes this budget; `status` shows the remaining launches. After the budget is exhausted, login alone cannot restart the runner. Starting an already running task does not reset the budget. Missing or invalid budget/configuration files disable automatic starts until repaired. GitHub's launcher owns runner updates; task recovery does not rerun a failed workflow or clear signing interlocks. After retries are exhausted, inspect `runner/dsh-task/run-*.log`, the runner's `_diag` logs and `status`, correct the cause, then run `start`. A running task alone does not prove connectivity: verify `Connected to GitHub` and `Listening for Jobs`, then verify assignment in Actions. Both `run-*.log` and `launcher-*.log` are retained for operator-managed cleanup. -Before maintenance, pause new workflow dispatch and wait for the runner to become idle. `-Action stop` refuses an active worker for this runner, disables future starts and stops its task process tree; the local check cannot prevent a concurrent GitHub assignment. `-Action uninstall` requires a stopped, disabled task and retains registration, configuration and logs. Pulling repository changes does not update the installed copy. To upgrade it, record the configuration directory and retry settings, then run `stop`, `uninstall`, `install` with those settings, and `start` from the updated checkout. Confirm listening and job assignment again. When migrating a manually launched runner, stop its existing launcher only after it is idle, then start the task; never run both launchers together. +Before maintenance, pause new workflow dispatch and wait for the runner to become idle. `-Action stop` refuses an active worker for this runner, disables future starts and stops its task process tree; the local check cannot prevent a concurrent GitHub assignment. `-Action uninstall` requires a stopped, disabled task and retains registration, configuration and logs. Pulling repository changes does not update the installed copy. To upgrade it, record the original -ConfigRoot, optional -NetworkRoot and retry settings, then run `stop`, `uninstall`, `install` with those settings, and `start` from the updated checkout. Confirm listening and job assignment again. When migrating a manually launched runner, stop its existing launcher only after it is idle, then start the task; never run both launchers together. Operators can run `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1` on Windows to exercise installation, console-free initial launch and automatic recovery, duplicate-start prevention, stop, child-process teardown, containment of startup/logging failures and uninstall against an isolated fake runner. This opt-in test creates a temporary task and retains diagnostic files; it does not contact GitHub or access signing hardware. `pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` checks the GUI executable, child console absence, exit status, diagnostic output and bounded draining with an inherited writer without Task Scheduler; Windows CI runs it. Real-machine acceptance additionally checks an idle runner coming online without a visible Terminal window, an Actions job being assigned, and login after an operator-approved reboot. Signed packaging and logout/reboot acceptance require separate operator verification. +### Optional network dependency + +Without `runner-network.json` in `ConfigRoot`, startup retains its scrubbed direct-network behavior. To configure a network dependency, create that operator-owned JSON file outside the checkout with exactly `proxyUrl`, `noProxy`, `serviceName`, `probeUrl`, `attempts`, `timeoutSeconds` and `retrySeconds`. The first four fields are strings; the three budgets are integers from 1 through 60. Use an empty `proxyUrl` for direct access and an empty `serviceName` when no Windows service owns the dependency. URLs must use HTTP(S) without credentials, queries or fragments; the proxy URL cannot include a path. Startup applies `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and `NODE_USE_ENV_PROXY` after environment scrubbing, then requires a successful Node HEAD probe before admitting the runner. Service readiness checks never start or stop a Windows service. + +For a dedicated child process, add `-NetworkRoot ` to task installation. That directory must contain `launch.json` with exactly an absolute `executable` filename, an `arguments` string array and an integer `restartDelaySeconds` from 1 through 3600. This mode requires `runner-network.json`; set its `serviceName` to empty. Give the child its own data directory, listener bindings and executable identity, distinct from applications managed by desktop software; separate ports alone do not prevent another manager from terminating a process by executable name. The scripts do not alter desktop network settings. Keep actual provider names, paths, addresses, ports and arguments in machine configuration, never in repository files or shared logs. Restrict access to both configuration directories to the runner operator. + +Installation starts the dependency for readiness checking and stops it before registering the task. While the runner lives, the supervisor restarts only an exited owned network process after the configured delay, including repeated launch failures; it does not restart the runner or retry jobs or signing. Exit, restart and launch-failure messages appear in `run-*.log`; provider output is discarded. Restart messages establish process creation, not network availability: confirm reconnection in runner logs. A live but unresponsive dependency requires operator diagnosis. The supervisor exclusively locks `NetworkRoot`, verifies PID, start time and executable before reclaiming a stale child, and stops its child when the runner exits. Unrecoverable supervision errors leave the active runner alive until it exits and report that recovery has stopped. Preserve `NetworkRoot` when reinstalling; pulling changes alone does not replace installed helpers. + +Windows CI runs `pwsh -NoProfile -File apps/desktop/tests/windows-runner-network.ps1` and `pwsh -NoProfile -File apps/desktop/tests/windows-runner-network-process.ps1` with isolated local fixtures. On a configured packaging machine, add `-CheckStartup` to the first command to verify network configuration after the actual startup environment scrub. These tests use no signing hardware or external network endpoints. + ## Signing configuration @@ -66,6 +76,8 @@ Follow the [version and build instructions](../packaging/README.md#release-versi Signed Windows builds run a supervised signing preflight before compilation or dependency preparation. Static configuration, certificate validity, audit storage, compiler availability and any retained signing interlock are checked without token access. The local .NET Framework C# compiler creates a small private probe; the production signer signs it once, and verification requires the configured certificate and a timestamp before building continues. The probe is never executed. The overall 60-second preflight deadline includes timestamp attempts; deadline expiry, hardware signing errors and verification failures stop the run without another hardware attempt. Success proves the current signing path works, not that the PIN was independently authenticated: SafeNet may reuse login state. Do not log out or repeat authentication to test the PIN. `--check`, preparation-only and `--unsigned` modes do not run this hardware preflight; unsigned artifacts remain ineligible for release upload. Automated regression tests use a fake signer; release operators qualify real hardware separately. +Public identity queries require PowerShell 7 (`pwsh`) on the build account’s `PATH`; an unavailable or failed query process stops signing with an unknown identity result. Before compiling the probe, a separate noninteractive process reads public keys from the fixed eToken CSP using `CRYPT_VERIFYCONTEXT | CRYPT_SILENT`. It receives the container name but no PIN or signing credentials, and has a 15-second deadline within the existing preflight deadline. Both RSA key slots must be read completely (an absent slot is allowed); at least one public key must match the configured certificate. Provider/container access failure, unsupported public reads, malformed output, timeout or mismatch stops before hardware signing. A container-access error does not establish that the device or container is absent. Public information can be cached by middleware: a match does not authenticate the device, validate the PIN or establish remaining retries. This path is covered by a simulated CSP; the installed SafeNet version still requires separate operator qualification. No failure automatically retries authentication or clears the existing interlock. + Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, without a timestamp. The hook then completes a DigiCert SHA-256 RFC 3161 timestamp on isolated copies without signing credentials. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. Timestamp completion retries only a normally exited timestamp command returning failure or warning, at most three times with one- and two-second delays. Each attempt starts from the same verified primary signature. Launch errors, uncertain termination and verification failures stop immediately. SignTool operates on short private paths; publication copies verified bytes to the target volume before atomic replacement. Final Windows trust, certificate, timestamp and normalized full-file equality are required. Exhaustion stops packaging and preserves evidence without another hardware call. See the [signature completion decision](../../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md). @@ -88,9 +100,11 @@ Runtime signing shares complete signed files across the current Windows account' Preflight, primary-runtime signing, application-runtime signing and artifact creation each hold the account's signing-stage lock until their supervised child processes settle. Other builds wait before entering those stages; compilation and preparation do not hold the lock. Runtime signing workers own their locks, so terminating only the outer packaging process does not unlock a surviving cache user. Migration and maintenance acquire the same lock; older or external signing commands do not participate and must be scheduled separately. Stage waiting is outside the preflight deadline. Closing the stage handle releases ordinary contention without removing the separate hardware-attempt interlock; a retained hardware failure still requires operator recovery. -Cache-hit copying, hashing and per-file trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). All restores and their post-verification finish before cache misses enter serial hardware signing. A restore or verification failure stops new dispatch and drains active work before releasing the stage lock; hardware signing, runtime smoke checks and final integrity validation remain required. +Cache-hit copying, hashing and per-file candidate trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). After all restores finish, bounded PowerShell batches verify every restored target's trust, timestamp and certificate before cache misses enter serial hardware signing. A restore failure stops new restores and drains active workers; a target-verification failure drains its inspection batch and prevents hardware signing. Runtime smoke checks and final integrity validation remain required. -Windows packaging reads `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY` from `.env.windows` or the selected preset: a positive integer, defaulting to `4`. Packaging batches first-party and vendor tarballs with bounded workers, then validates every payload and records publication order. Setting the limit to `1` selects serial packing; standalone `release:pack` defaults to `1`. +PE discovery reads every regular file's header with at most eight open files, rejects links and malformed Windows executables, and drains active reads and closes before reporting failure. Packaging records `windows:enumerate-code` and `windows:inspect-signatures` as nested phases of signing and artifact verification; their timings are included in the enclosing stages. + +Windows packaging reads `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY` from `.env.windows` or the selected preset: a positive integer, defaulting to `4`. Packaging batches first-party and vendor tarballs with bounded workers, then validates every payload and records publication order. After the complete build succeeds, tarball packing overlaps Electron and primary-runtime preparation in separate output directories. Both must finish successfully before runtime signing; preparation failure stops new packing commands and drains active processes before returning. Setting the limit to `1` selects serial tarball workers; standalone `release:pack` defaults to `1`. Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. Concurrent timings sum per-file work rather than stage wall time. @@ -100,7 +114,13 @@ Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect st ## Verification limits -Real hardware and installation evidence is owned by the [Desktop qualification record](../tests/README.md) and [installed-update procedure](../tests/installed-update/README.md). This document does not authorize PIN retries, automatic interlock deletion, device initialization or changes to the accepted isolation policy. +Signing performance evidence and replacement-runner retesting are owned by the [signing concurrency qualification](../tests/signing-concurrency/README.md). Update and installation evidence is owned by the [Desktop qualification record](../tests/README.md) and [installed-update procedure](../tests/installed-update/README.md). This document does not authorize PIN retries, automatic interlock deletion, device initialization or changes to the accepted isolation policy. + +For operator-approved concurrency experiments, run `node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root --rounds 2`. Without `--execute`, it only prints usage. The default compares serial completion, two completion workers with one hardware request, and two concurrent hardware requests on six synthetic PE copies per batch. The second round reverses mode order. + +Add `--corpus ` to compare 1, 2 and 4 workers on real release files. The manifest is a nonempty JSON array of objects with an absolute `path` and its lowercase hexadecimal `sha256`. Preparation copies each pinned file, verifies its signature and removes any valid signature from the copy; invalid signatures or changed bytes stop preparation. Signature removal requires SignTool with `remove /s`; `--strip-tool ` overrides only that public preparation tool when the configured version lacks support. Hardware signing and timestamping still use the machine preset. Every batch uses fresh, byte-checked copies of the unsigned templates, preserving manifest order. `--pipeline-control` with a corpus instead runs four completion workers with one hardware request. Original files and release caches remain unchanged. + +The experiment uses the machine test preset, checks public identity, holds the account signing-stage lock and retains one hardware-attempt interlock per batch. The first failure stops dispatch and drains issued work; up to four hardware calls may already be in flight. Only complete signature, timestamp and file verification permits normal interlock release. Files are never executed. Records remain under `.desktop-build/qualification/signing-concurrency`, including batch and per-file elapsed times, batch endpoint host CPU counters, free memory and driver-process memory samples. These memory samples exclude child processes and do not establish peak memory. Timings exclude input preparation, lock waiting and package assembly. Replaying ready-made artifacts also excludes build dependencies between them; the experiment does not enable production concurrency or establish full-release performance. ## Dev Note diff --git a/apps/desktop/windows-signing/README.zh.md b/apps/desktop/windows-signing/README.zh.md index 3b957c5f57..0b7ff50cd2 100644 --- a/apps/desktop/windows-signing/README.zh.md +++ b/apps/desktop/windows-signing/README.zh.md @@ -38,14 +38,24 @@ description: "配置和维护 Windows 打包 runner、签名凭据、EV Token 完整的注册、标签、路由和审批配置顺序见 [CI 发布配置](../publishing/ci/README.zh.md#setup-sequence)。任务安装器需要 Windows .NET Framework 4.5+ 编译器,路径为 `%SystemRoot%/Microsoft.NET/Framework64/v4.0.30319/csc.exe`。它将 GUI 可执行文件编译到 `runner/dsh-task/launcher.exe`;该程序不分配控制台地启动 PowerShell,并等待其退出。`run.ps1` 自身的输出和错误分别写入名称唯一的 `launcher-*.out.log` 和 `launcher-*.err.log`,文件名包含 UTC 时间、PID 和随机标识。启动器失败时禁用周期启动,包括尚未读取重试预算时发生的失败。任务结果 `2` 表示启动器失败,`3` 表示无法写入错误日志,`4` 表示禁用任务也失败,需要操作者在计划任务中手动禁用。直接子进程退出后,最多等待五秒排空输出;超时返回 `5`,将诊断写入 `launcher-*.drain.log`:输出可能不完整,但周期恢复仍受原有重试预算约束。后代进程可能在 runner 退出后继续运行;日志排空不会终止它们。记录的停止目标是启动器进程,因此 stop 覆盖启动器及其后代。正常 runner 输出仍保存在 `run-*.log`。 -以 runner 的 Windows 用户运行 `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot -ConfigRoot /packaging`,使用该机器安装的 PowerShell 7。安装器检查环境,并将启动脚本复制到 `runner/dsh-task`;注册、工具和凭据仍单独管理。它注册一个初始禁用、权限受限的交互式计划任务,不保存账户密码。执行 `-Action start` 或 `-Action status` 时只传 `-RunnerRoot`;配置目录及重试选项仅适用于安装。任务在该用户登录桌面后启动,重启后也需要登录,注销时停止。它不提供无人值守开机,也不解锁签名 Token。 +以 runner 的 Windows 用户运行 `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot -ConfigRoot /packaging`,使用该机器安装的 PowerShell 7。安装器检查环境,并将启动脚本复制到 `runner/dsh-task`;注册、工具和凭据仍单独管理。它注册一个初始禁用、权限受限的交互式计划任务,不保存账户密码。任务优先级 4 让 runner 及子进程使用普通 CPU、I/O 和内存优先级;计划任务默认值 7 会降低三者的优先级,使依赖安装变慢。已有任务保留原设置:在 runner 空闲时停止、卸载并重新安装任务,再启动。执行 `-Action start` 或 `-Action status` 时只传 `-RunnerRoot`;配置目录及重试选项仅适用于安装。任务在该用户登录桌面后启动,重启后也需要登录,注销时停止。它不提供无人值守开机,也不解锁签名 Token。 任务只允许一个实例,且没有执行时限。周期触发器每隔 `-RestartMinutes` 分钟(默认 `1`)检查一次,并恢复已退出的启动器,包括意外的零退出码。持久预算允许首次启动及 `-RestartCount` 次重启(默认 `10`),直到再次显式启动。每次桌面登录也会消耗预算,`status` 显示剩余启动次数;预算耗尽后,仅登录不能重启 runner。对正在运行的任务执行 start 不会重置预算。预算或配置文件缺失、无效时,自动启动会禁用,需修复后恢复。GitHub 启动器负责 runner 更新;任务恢复不会重跑失败的 workflow,也不会清除签名互锁。重试耗尽后,检查 `runner/dsh-task/run-*.log`、runner 的 `_diag` 日志和 `status`,修复原因后运行 `start`。任务运行不等于连接成功:确认出现 `Connected to GitHub` 和 `Listening for Jobs`,再到 Actions 确认任务分配。`run-*.log` 和 `launcher-*.log` 均保留,由操作者管理清理。 -维护前暂停新 workflow 的触发,等待 runner 空闲。`-Action stop` 在该 runner 存在活跃 worker 时拒绝停止,否则禁用后续启动并停止任务进程树;本地检查无法阻止 GitHub 同时分配任务。`-Action uninstall` 要求任务已停止并禁用,保留注册、配置和日志。拉取仓库更新不会更新已安装的副本。升级时先记录配置目录和重试设置,再从更新后的 checkout 依次执行 `stop`、`uninstall`、携带原设置的 `install` 和 `start`,最后重新确认监听与 job 分配。迁移手动启动的 runner 时,只在空闲后停止原启动器,再启动计划任务;不要同时运行两个启动器。 +维护前暂停新 workflow 的触发,等待 runner 空闲。`-Action stop` 在该 runner 存在活跃 worker 时拒绝停止,否则禁用后续启动并停止任务进程树;本地检查无法阻止 GitHub 同时分配任务。`-Action uninstall` 要求任务已停止并禁用,保留注册、配置和日志。拉取仓库更新不会更新已安装的副本。升级时先记录原来的 -ConfigRoot、可选的 -NetworkRoot 和重试设置,再从更新后的 checkout 依次执行 `stop`、`uninstall`、携带原设置的 `install` 和 `start`,最后重新确认监听与 job 分配。迁移手动启动的 runner 时,只在空闲后停止原启动器,再启动计划任务;不要同时运行两个启动器。 操作者可在 Windows 上运行 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1`,使用隔离的假 runner 验证安装、无控制台的首次启动与自动恢复、防止重复启动、停止、子进程退出、启动/日志失败时的禁用处理和卸载。此显式运行的测试创建临时任务并保留诊断文件,不连接 GitHub 或访问签名硬件。`pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` 无需计划任务,检查 GUI 可执行文件、子进程无控制台、退出状态、诊断输出以及存在继承管道写端时的有界排空;Windows CI 会执行该测试。真实机器验收还需检查空闲 runner 上线且不出现 Terminal 窗口、Actions job 分配,以及经操作者批准的重启后登录。签名打包和注销/重启验收需要操作者单独验证。 +### 可选网络依赖 + +`ConfigRoot` 中没有 `runner-network.json` 时,启动仍使用清理继承环境后的直连行为。要配置网络依赖,请在 checkout 外创建操作者管理的该 JSON 文件,且只包含 `proxyUrl`、`noProxy`、`serviceName`、`probeUrl`、`attempts`、`timeoutSeconds` 和 `retrySeconds`。前四个字段为字符串,后三个预算为 1 至 60 的整数。直连时将 `proxyUrl` 留空,没有 Windows 服务管理依赖时将 `serviceName` 留空。URL 必须使用 HTTP(S),不能包含凭据、查询参数或片段;代理 URL 不能包含路径。启动脚本在清理环境后设置 `HTTP_PROXY`、`HTTPS_PROXY`、`NO_PROXY` 和 `NODE_USE_ENV_PROXY`,并要求 Node HEAD 探测成功才允许启动 runner。服务就绪检查不会启动或停止 Windows 服务。 + +若使用专用子进程,在安装任务时添加 `-NetworkRoot `。该目录必须包含 `launch.json`,且只包含绝对路径的 `executable` 文件名、字符串数组 `arguments`,以及 1 至 3600 的整数 `restartDelaySeconds`。此模式要求存在 `runner-network.json`,其中 `serviceName` 应留空。为子进程分配独立的数据目录、监听绑定和可执行文件标识,与桌面软件管理的应用分离;仅使用不同端口无法防止其他管理器按可执行文件名终止进程。脚本不会修改桌面网络设置。实际服务商名称、路径、地址、端口和参数只保存在机器配置中,不得写入仓库文件或共享日志。将两个配置目录的访问权限限制为 runner 操作者。 + +安装时会启动依赖进行就绪检查,并在注册任务前停止它。runner 存活期间,守卫仅在自己拥有的网络进程退出后,按配置间隔重新启动它;启动连续失败时仍会重试,但不会重启 runner、重跑任务或重试签名。退出、重启和启动失败消息写入 `run-*.log`,依赖进程的输出会被丢弃。重启消息只表明进程已创建,不代表网络可用;请在 runner 日志中确认重新连接。进程仍存活但无响应时需要操作者排查。守卫独占锁定 `NetworkRoot`,回收残留子进程前核对 PID、启动时间和可执行文件,并在 runner 退出时停止子进程。不可恢复的守卫错误会保留活动 runner 直到其退出,并报告恢复已停止。重新安装时保留 `NetworkRoot`;仅拉取代码不会替换已安装的辅助脚本。 + +Windows CI 使用隔离的本地 fixture 运行 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-network.ps1` 和 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-network-process.ps1`。在已配置的打包机器上,给第一条命令添加 `-CheckStartup`,可验证实际启动环境清理后的网络配置。这些测试不会使用签名硬件或外部网络端点。 + ## 签名配置 @@ -66,6 +76,8 @@ PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检的整体 60 秒期限包含时间戳尝试;超时、硬件签名报错或验签失败都会停止本轮流程,不再次调用硬件。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。 +公开身份查询要求构建账户的 `PATH` 中有 PowerShell 7(`pwsh`);查询进程不可用或失败时,以身份未知结果停止签名。编译探针前,独立非交互进程使用 `CRYPT_VERIFYCONTEXT | CRYPT_SILENT` 从固定的 eToken CSP 读取公钥。该进程只接收容器名,不接收 PIN 或签名凭据;15 秒期限计入已有预检总期限。两个 RSA 密钥槽必须完整读取(允许槽不存在),且至少一个公钥与配置证书匹配。提供程序或容器访问失败、不支持公开读取、输出损坏、超时或不匹配时,在硬件签名前停止。容器访问错误不能证明设备或容器不存在。中间件可能缓存公开信息,因此匹配不代表设备已认证、PIN 正确或已确认剩余尝试次数。此路径已由模拟 CSP 覆盖,当前 SafeNet 版本仍需操作人员单独验收。任何失败都不会自动重试认证或清除已有保护锁。 + 打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`和 SHA-256 文件摘要,不请求时间戳。随后钩子在隔离副本上完成 DigiCert SHA-256 RFC 3161 时间戳,不传递签名凭据。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。 时间戳处理仅对正常退出但返回失败或警告的时间戳命令重试,最多尝试三次,间隔为一秒和两秒。每次均从同一份已验证的主签名开始。启动错误、终止状态不确定或验签失败会立即停止。SignTool 使用短的私有路径;发布时先把已验证字节复制到目标卷,再原子替换。最终必须通过 Windows 信任、证书、时间戳和规范化全文件相等检查。尝试耗尽后停止打包并保留证据,不再次调用硬件。参见[签名完成决策](../../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md)。 @@ -88,9 +100,11 @@ Windows 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `.desktop-build/pac 预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。 -缓存命中的复制、摘要和逐文件信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复及后置验签完成后,未命中项才进入串行硬件签名。恢复或验签失败会停止派发新任务,并在释放阶段锁前等待在途工作结束;硬件签名、运行时 smoke 检查和最终完整性校验仍须执行。 +缓存命中的复制、摘要和逐文件候选信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复完成后,有界 PowerShell 批次逐项验证恢复目标的信任、时间戳和证书,通过后未命中项才进入串行硬件签名。恢复失败会停止派发新的恢复任务并等待在途工作结束;目标验签失败会等待当前检查批次结束并阻止硬件签名。运行时 smoke 检查和最终完整性校验仍须执行。 -Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY`:必须为正整数,默认 `4`。Windows 使用有界 worker 批量打包第一方和 vendor tarball,随后校验每个产物并记录发布顺序。设为 `1` 时串行打包;独立的 `release:pack` 默认使用 `1`。 +PE 发现会读取每个普通文件的头部,同时最多打开八个文件,拒绝链接和损坏的 Windows 可执行文件,并在报告失败前等待在途读取和关闭结束。打包将 `windows:enumerate-code` 与 `windows:inspect-signatures` 记录为签名和产物验证的嵌套阶段;其耗时已包含在外层阶段中。 + +Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY`:必须为正整数,默认 `4`。Windows 使用有界 worker 批量打包第一方和 vendor tarball,随后校验每个产物并记录发布顺序。完整构建成功后,tarball 打包与 Electron、主要运行时准备在各自输出目录中并行执行。两者都成功结束后才开始运行时签名;准备失败会停止新的打包命令,并等待在途进程结束后返回。设为 `1` 时 tarball worker 串行执行;独立的 `release:pack` 默认使用 `1`。 每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。 @@ -100,7 +114,13 @@ Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK ## 验证范围 -真实硬件与安装证据由[Desktop 验证记录](../tests/README.zh.md)和[已安装更新流程](../tests/installed-update/README.zh.md)维护。本文档不授权 PIN 重试、自动删除 interlock、设备初始化或改变已接受的隔离策略。 +签名性能证据与更换 runner 后的复测由[签名并发验收](../tests/signing-concurrency/README.zh.md)维护。更新与安装证据由[Desktop 验证记录](../tests/README.zh.md)和[已安装更新流程](../tests/installed-update/README.zh.md)维护。本文档不授权 PIN 重试、自动删除 interlock、设备初始化或改变已接受的隔离策略。 + +经操作人员授权后,可运行 `node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root <打包配置目录的绝对路径> --rounds 2` 做并发实验。不加 `--execute` 只打印用法。默认比较完全串行、两个完成任务共用单路硬件签名,以及两路硬件签名,每批使用六个合成 PE 副本。第二轮反转模式顺序。 + +加上 `--corpus <清单 JSON 的绝对路径>` 后,改用真实发布文件比较 1、2、4 个并发任务。清单是非空 JSON 数组,每项包含绝对路径 `path` 及对应的小写十六进制 `sha256`。准备阶段复制哈希固定的文件,验证签名并仅移除副本上的有效签名;签名无效或字节变化会中止准备。移除签名要求 SignTool 支持 `remove /s`;配置中的版本不支持时,可用 `--strip-tool ` 仅覆盖这个公开准备工具。硬件签名和时间戳仍使用机器 preset。每批使用经过字节比对的全新无签名模板副本,保持清单顺序。配合语料使用 `--pipeline-control` 时,改为四个完成任务共用一个硬件请求。原文件和发布缓存保持不变。 + +实验读取机器 test preset,核对公开身份,持有账户签名阶段锁,每批保留一条硬件尝试保护记录。首错停止派发并排空已发出的工作;最多可能已有四条硬件请求在途。只有全部签名、时间戳和文件验证成功才正常释放保护记录。文件从不执行。记录保留在 `.desktop-build/qualification/signing-concurrency`,包含整批及单文件耗时、批次起止时的主机 CPU 计数、可用内存和驱动进程内存采样。内存采样不含子进程,不能用于确定峰值内存。计时不含输入准备、等锁和包组装。重放现成产物还排除了它们之间的构建依赖;实验不启用生产并发,也不代表完整发布性能。 ## 开发备注 diff --git a/apps/plugin-market-generator/package.json b/apps/plugin-market-generator/package.json index b013bc93ff..b9b31d1912 100644 --- a/apps/plugin-market-generator/package.json +++ b/apps/plugin-market-generator/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-market-generator", "description": "Reference generator of plugin market index files from a list of npm packages and Git repositories", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "private": true, "type": "module", "scripts": { diff --git a/apps/web/package.json b/apps/web/package.json index b5ec233d82..b967ed8b39 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index 401508bc2c..87f9288d42 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -288,8 +288,11 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass }) mock.unary('session/attachment', (request: unknown) => { request = recordValue(request, 'request') - const attachmentId = recordString(request, 'attachmentId') - return attachments.get(attachmentId) ?? { + const attachment = recordValue(request, 'attachment') + const attachmentId = recordString(attachment, 'attachmentId') + const stored = attachments.get(attachmentId) + if (stored !== undefined) return ok({ attachment, data: recordString(recordValue(stored, 'value'), 'data') }) + return { ok: false, error: { code: 'session/attachment-invalid', diff --git a/apps/web/tests/bundle-platform.e2e.ts b/apps/web/tests/bundle-platform.e2e.ts new file mode 100644 index 0000000000..55d22be94d --- /dev/null +++ b/apps/web/tests/bundle-platform.e2e.ts @@ -0,0 +1,62 @@ +/** Unsupported Host platforms leave the profile and running PTC configuration untouched. */ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser } from 'playwright' +import type {} from '@deepseek-ai/dsh-ptc-runtime' +import { expect, it, onTestFinished } from 'vitest' +import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts' + +it('disables unsupported bundles, shows the Host restriction, and refuses direct activation without changing PTC', async () => { + const root = await mkdtemp(join(tmpdir(), 'bundle-platform-web-')) + const owned: { scaffold?: WebScaffold; browser?: Browser } = {} + onTestFinished(async () => { + try { await owned.browser?.close() } finally { + try { await owned.scaffold?.close() } finally { await rm(root, { recursive: true, force: true }) } + } + }) + const name = 'host-platform-only' + await writeFile(join(root, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { + patch: './cordis.patch.yml', platforms: [process.platform === 'darwin' ? 'win32' : 'darwin'], + } } })) + await writeFile(join(root, 'cordis.patch.yml'), '- id: ptc-runtime\n config:\n executionMode: persistent-in-memory\n') + const scaffold = owned.scaffold = await launchWebScaffold({ profile: { packages: [{ dir: root }] } }) + const browser = owned.browser = await chromium.launch() + const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + await page.goto(scaffold.authenticatedUrl) + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() + const reason = '此插件不支持运行 DSH 的主机操作系统' + const card = page.locator(`[data-plugin-package="${name}"]`) + await card.getByText(reason, { exact: true }).waitFor() + expect(await card.getByRole('switch').isEnabled()).toBe(false) + expect(await card.getByRole('switch').getAttribute('aria-checked')).toBe('false') + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/bundle-platform/unsupported-platform.expected.md', import.meta.url)), + await captureStableAria(page, `[data-plugin-package="${name}"] p[role="status"]`, scaffold.workspaceCwd), webSnapshotMode()) + const manifest = join(scaffold.harnessHome, 'profiles/scaffold/package.json') + const before = await readFile(manifest, 'utf8') + const runtimeId = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'ptc-runtime')?.fiber?.uid + const beforeRuntime = runtimeId() + expect(beforeRuntime).toBeTypeOf('number') + expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body') + const names = [name] + await page.locator('[data-plugin-panel]').getByRole('button', { name: '更多', exact: true }).click() + const cua = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]') + expect(await cua.count()).toBe(0) + if (process.platform !== 'darwin') { + expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle')) + .toMatchObject({ listedByDefault: false, enabled: false, error: { code: 'unsupported-platform' } }) + names.push('@deepseek-ai/dsh-cua-bundle') + } + for (const bundle of names) { + expect(await scaffold.ctx.pluginManager.setBundleEnabled(bundle, true)).toMatchObject({ + changed: false, application: 'failed', error: { code: 'unsupported-platform' }, + }) + } + expect(await readFile(manifest, 'utf8')).toBe(before) + expect(runtimeId()).toBe(beforeRuntime) + expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body') + expect(scaffold.ctx.get('computerUse')).toBeUndefined() +}) diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index dec6749ab9..25cbcf590d 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -307,15 +307,17 @@ describe('web e2e: long Chat interaction contract', () => { await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 }) .toBe(expectedUserText) + const existing = new Set(scaffold.ctx.agents.list().map(agent => agent.id)) await turnTailRow.hover() await turnTailRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click() await expect.poll( - () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)), + () => scaffold.ctx.agents.list().find(agent => !existing.has(agent.id)), { timeout: 15_000 }, ).toBeDefined() const child = scaffold.ctx.agents.list() - .find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)) + .find(agent => !existing.has(agent.id)) if (child === undefined) throw new Error('message branch did not create a child session') + expect(child.session.header.parentSession).toBeUndefined() expect(child.session.inheritedEventCount).toBe(boundary.seq + 1) expect(child.session.snapshotEvents().some(event => carries(event, branchAssistantMarker))).toBe(true) expect(child.session.snapshotEvents().some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false) diff --git a/apps/web/tests/creation-recommendations.e2e.ts b/apps/web/tests/creation-recommendations.e2e.ts new file mode 100644 index 0000000000..119a2ef3f7 --- /dev/null +++ b/apps/web/tests/creation-recommendations.e2e.ts @@ -0,0 +1,130 @@ +/** The completed answer offers creation through a propose_action card; choosing a location does not start it. */ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + fixtureUserPrompts, launchWebScaffold, watchConsole, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage } from './support.ts' + +const directory = fileURLToPath(new URL('../../../snapshots/web/creation-recommendations', import.meta.url)) +const fixture = join(directory, 'session.v4.jsonl') + +describe('creation recommendation after an explicit wish', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + + beforeAll(async () => { + scaffold = await launchWebScaffold({ replayFixture: fixture, compareReplaySession: true, + profile: { packages: [{ dir: fileURLToPath(new URL('../../../packages/experimental/personalized-recommendations', import.meta.url)), enabled: true }] }, + }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + }) + afterAll(async () => { await browser?.close(); await scaffold?.close() }) + + it('shows one card below the answer, lets the user change location, and restores a dismissal', async () => { + const tripwire = watchConsole(page) + await page.setViewportSize({ width: 620, height: 900 }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const input = page.locator('[data-composer-input]').first() + const settled = scaffold.whenTurnSettled() + await input.fill(fixtureUserPrompts(await readFile(fixture, 'utf8'))[0]!) + await input.press('Enter') + await settled + const card = page.locator('[data-action-card]') + await card.waitFor({ state: 'visible' }) + expect(await card.count()).toBe(1) + expect(await page.locator('[data-turn-tail] [data-action-card]').count()).toBe(1) + const answer = page.getByText('I can create a persistent counter with increment and reset controls. Choose its location below.', { exact: true }) + const answerBox = await answer.boundingBox() + const cardBox = await card.boundingBox() + expect(answerBox).not.toBeNull() + expect(cardBox!.y).toBeGreaterThanOrEqual(answerBox!.y + answerBox!.height) + expect(await card.getByRole('button', { name: 'Create', exact: true }).isEnabled()).toBe(true) + await compareOrRefreshGolden(join(directory, 'offered.expected.md'), await captureStableAria(page, '[data-action-card]', scaffold.workspaceCwd), scaffold.mode) + const info = card.getByRole('button', { name: 'How creation works and token usage', exact: true }) + expect(await info.getAttribute('aria-expanded')).toBe('true') + expect(await card.getByRole('note').innerText()).toContain('large number of tokens') + await info.focus() + await info.press('Space') + expect(await card.getByRole('note').count()).toBe(0) + const infoWarnings = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await card.waitFor({ state: 'visible' }) + acknowledgeReloadConnectionLoss(tripwire, infoWarnings) + expect(await info.getAttribute('aria-expanded')).toBe('false') + await info.focus() + await info.press('Enter') + expect(await info.getAttribute('aria-expanded')).toBe('true') + expect(await card.getByRole('note').innerText()).toContain('large number of tokens') + expect(await card.getByRole('note').innerText()).toContain('keep chatting here') + expect(await card.getByRole('button', { name: 'Create', exact: true }).isEnabled()).toBe(true) + expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + await compareOrRefreshGolden(join(directory, 'info.expected.md'), await captureStableAria(page, '[data-action-card]', scaffold.workspaceCwd), scaffold.mode) + await info.press('Space') + expect(await info.getAttribute('aria-expanded')).toBe('false') + expect(await card.getByRole('note').count()).toBe(0) + const expand = card.getByRole('button', { name: 'Show more', exact: true }) + const plan = page.locator(`[id="${await expand.getAttribute('aria-controls')}"]`) + const compactHeight = await plan.evaluate(element => element.clientHeight) + const previewText = await plan.innerText() + expect(previewText.endsWith('…')).toBe(true) + // The preview keeps about 260 Latin-letter widths before the ellipsis; a trailing space may be trimmed. + const previewLength = Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(previewText)).length + expect(previewLength).toBeGreaterThan(200) + expect(previewLength).toBeLessThanOrEqual(261) + await expand.click() + expect(await plan.evaluate(element => element.clientHeight)).toBeGreaterThan(compactHeight) + expect(await card.getByRole('button', { name: 'Create', exact: true }).isEnabled()).toBe(true) + await compareOrRefreshGolden(join(directory, 'expanded.expected.md'), await captureStableAria(page, '[data-action-card]', scaffold.workspaceCwd), scaffold.mode) + await card.getByRole('button', { name: 'Show less', exact: true }).click() + const location = card.getByRole('button', { name: 'Add to: Right sidebar', exact: true }) + await location.click() + expect(await page.getByRole('menuitem').count()).toBe(4) + await compareOrRefreshGolden(join(directory, 'location-menu.expected.md'), await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), scaffold.mode) + await page.getByRole('menuitem').first().press('Escape') + expect(await page.getByRole('menu').count()).toBe(0) + expect(await location.evaluate(element => element === document.activeElement)).toBe(true) + await location.click() + await page.getByRole('menuitem', { name: /Write your own/ }).click() + const custom = card.getByRole('textbox', { name: 'Where should it appear?', exact: true }) + expect(await card.getByRole('button', { name: 'Create', exact: true }).isEnabled()).toBe(false) + await custom.fill('A left sidebar entry that opens a full-page calendar') + expect(await card.getByRole('button', { name: 'Create', exact: true }).isEnabled()).toBe(true) + await compareOrRefreshGolden(join(directory, 'custom-location.expected.md'), await captureStableAria(page, '[data-action-card]', scaffold.workspaceCwd), scaffold.mode) + await card.getByRole('button', { name: 'Add to: Write your own', exact: true }).click() + await page.getByRole('menuitem', { name: /Session tab/ }).click() + expect(await card.getByRole('button', { name: 'Add to: Session tab', exact: true }).isVisible()).toBe(true) + expect(await custom.count()).toBe(0) + await compareOrRefreshGolden(join(directory, 'options.expected.md'), await captureStableAria(page, '[data-action-card]', scaffold.workspaceCwd), scaffold.mode) + expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + await card.getByRole('button', { name: 'Not now', exact: true }).click() + await card.getByRole('status').getByText('Dismissed', { exact: true }).waitFor() + const warnings = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await card.getByRole('status').getByText('Dismissed', { exact: true }).waitFor() + acknowledgeReloadConnectionLoss(tripwire, warnings) + expect(await card.getByRole('button', { name: 'Create', exact: true }).count()).toBe(0) + expect(await card.getByRole('button', { name: 'Show more', exact: true }).isVisible()).toBe(true) + expect((await scaffold.ctx.pluginManager.listBundles()).some(bundle => bundle.name.startsWith('@dsh-local/view-'))).toBe(false) + await compareOrRefreshGolden(join(directory, 'dismissed.expected.md'), await captureStableAria(page, '[data-action-card]', scaffold.workspaceCwd), scaffold.mode) + const statusSettled = scaffold.whenTurnSettled() + await input.fill(fixtureUserPrompts(await readFile(fixture, 'utf8'))[1]!) + await input.press('Enter') + await statusSettled + await page.getByText('The counter proposal was dismissed. Creation never started.', { exact: true }).waitFor() + expect(await card.count()).toBe(1) + expect(await card.getByRole('button', { name: 'Create', exact: true }).count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + if (scaffold.mode === 'replay') await assertFixtureInventory(directory, [ + 'session.v4.jsonl', 'offered.expected.md', 'info.expected.md', 'expanded.expected.md', 'options.expected.md', 'dismissed.expected.md', 'location-menu.expected.md', 'custom-location.expected.md', 'system-prompt.expected.md', 'tool-schemas.expected.json', + ]) + }) +}) diff --git a/apps/web/tests/cua-optin.e2e.ts b/apps/web/tests/cua-optin.e2e.ts index f08cd4a54b..d775874a78 100644 --- a/apps/web/tests/cua-optin.e2e.ts +++ b/apps/web/tests/cua-optin.e2e.ts @@ -1,4 +1,4 @@ -/** Optional CUA uses existing plugin activation and offers a separate persistent PTC action. */ +/** CUA follows PTC Agent scopes and changes the default only from its detail page. */ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -8,52 +8,74 @@ import { readProfileManifest } from '@deepseek-ai/dsh-app-boot' import { SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-ptc-runtime' import type {} from '@deepseek-ai/dsh-agent-preset-registry' +import { load } from 'js-yaml' import { expect, it, vi } from 'vitest' import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, webSnapshotMode, type WebScaffold } from './scaffold.ts' import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts' -it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserving default %s until the explicit persistent-runtime action', async (selectedDefault) => { +// The shipped bundle admits only macOS Hosts; unsupported Hosts are exercised in bundle-platform.e2e.ts. +it.skipIf(process.platform !== 'darwin').each([ + { selectedDefault: undefined, codingTools: false }, + { selectedDefault: undefined, codingTools: true }, + { selectedDefault: 'minimal', codingTools: true }, +] as const)('preserves Coding Tools=$codingTools and default=$selectedDefault across CUA toggles', async ({ selectedDefault, codingTools }) => { const directory = await mkdtemp(join(tmpdir(), 'cua-optin-web-')) let ownedScaffold: WebScaffold | undefined let browser: Browser | undefined try { const patch = join(directory, 'native-fixture.yml') - // The executable cannot answer native permission queries, so setup must offer retry in the detail page. - await writeFile(patch, JSON.stringify([{ id: 'cua', disabled: false, config: { helperPath: process.execPath } }])) - // Install the test provider explicitly so the helper override also exercises session wiring on Linux. - const scaffold = ownedScaffold = await launchWebScaffold({ - profile: { packages: [{ dir: fileURLToPath(new URL('../../../packages/experimental/cua-macos', import.meta.url)), enabled: false }] }, - harnessHome: join(directory, 'home'), extraOverlayPath: [ - fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), patch, - ] }) + // Permission inspection fails against this non-helper executable; activation still permits dismissing setup. + await writeFile(patch, JSON.stringify([{ id: 'cua', config: { helperPath: process.execPath } }])) + const harnessHome = join(directory, 'home') + const scaffold = ownedScaffold = await launchWebScaffold({ harnessHome, extraOverlayPath: [ + fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), patch, + ] }) const initialDefault = selectedDefault ?? 'standard' if (selectedDefault !== undefined) await scaffold.ctx.settings.update('agent-preset-registry', { selectedDefault }) + await scaffold.ctx.settings.update('ui-settings', { enabled: codingTools }) + const settingsPath = join(harnessHome, 'profiles/scaffold/cordis.patch.yml') + const beforeEnable = await readFile(settingsPath, 'utf8') browser = await chromium.launch() const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) - await page.goto(scaffold.authenticatedUrl) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) - const originalAgent = scaffold.ctx.agents.list()[0]! - const ptcAgent = (await scaffold.ctx.agents.create({ sessionId: SessionId('cua-existing-ptc'), - setup: async (ctx) => { await scaffold.ctx.agentPresets.mount(ctx, 'ptc') }, - })).agent - const runtimeFiber = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'ptc-runtime')?.fiber - const originalRuntime = scaffold.ctx.ptcRuntime - const originalRuntimeFiber = runtimeFiber() - expect(originalRuntimeFiber).toBeDefined() - expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body') - const settingsPath = join(scaffold.harnessHome, 'profiles/scaffold/cordis.patch.yml') - const before = await readFile(settingsPath, 'utf8') - const selected = () => readProfileManifest('scaffold', join(scaffold.harnessHome, 'profiles/scaffold')).dsh?.profile?.bundles await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() const plugins = page.locator('[data-plugin-panel]') await plugins.getByRole('button', { name: '更多', exact: true }).waitFor() expect(await page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').count()).toBe(0) await plugins.getByRole('button', { name: '更多', exact: true }).click() const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]') - expect(await card.getByText('实验性', { exact: true }).count()).toBe(1) - const toggle = card.getByRole('switch', { name: '启用 Computer Use', exact: true }) - expect(await toggle.getAttribute('aria-checked')).toBe('false') - expect(await card.textContent()).toContain('请切换到 PTC 模式') + expect(await card.count()).toBe(0) + expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle')) + .toMatchObject({ listedByDefault: false, enabled: false, installed: false }) + await plugins.getByRole('button', { name: '返回插件', exact: true }).click() + const offerCua = async () => { + await plugins.getByRole('button', { name: '添加插件', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '添加插件', exact: true }) + await dialog.getByRole('textbox', { name: '包名或地址', exact: true }).fill('@deepseek-ai/dsh-cua-bundle') + await dialog.getByRole('button', { name: '安装', exact: true }).click() + const ready = page.getByRole('dialog', { name: '插件已随 DSH 提供', exact: true }) + await ready.waitFor() + expect(await ready.getByText('无需下载,启用即可使用', { exact: true }).count()).toBe(1) + return ready.getByRole('button', { name: '立即启用', exact: true }) + } + const enableNow = await offerCua() + let toggle = card.getByRole('switch', { name: '启用 Computer Use', exact: true }) + const originalAgent = scaffold.ctx.agents.list()[0]! + const ptcAgent = (await scaffold.ctx.agents.create({ + sessionId: SessionId('cua-existing-ptc'), + setup: async (ctx) => { await scaffold.ctx.agentPresets.mount(ctx, 'ptc') }, + })).agent + expect(scaffold.ctx.tools.schemas(ptcAgent).some(tool => tool.name === 'cua')).toBe(false) + const originalRuntime = scaffold.ctx.ptcRuntime + expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault) + expect(originalRuntime.programFormat).toBe('function-body') + expect(scaffold.ctx.get('computerUse')).toBeUndefined() + const profileManifest = () => readProfileManifest('scaffold', join(scaffold.harnessHome, 'profiles/scaffold')) + const selected = () => profileManifest().dsh?.profile?.bundles + const dependencies = profileManifest().dependencies + expect(selected()).not.toContain('@deepseek-ai/dsh-cua-bundle') + const enabling = Promise.withResolvers() const setEnabled = scaffold.ctx.pluginManager.setBundleEnabled.bind(scaffold.ctx.pluginManager) let applying: ReturnType | undefined @@ -63,11 +85,12 @@ it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserv return applying }) try { - await toggle.click() - await card.getByRole('status').getByText('正在应用 Computer Use 的更改', { exact: true }).waitFor() - expect(await toggle.isEnabled()).toBe(false) + await enableNow.click() + await expect.poll(() => enableNow.isEnabled()).toBe(false) + expect(await enableNow.getAttribute('aria-busy')).toBe('true') + expect(await card.count()).toBe(0) await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/enabling.expected.md', import.meta.url)), - await captureStableAria(page, '[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]', scaffold.workspaceCwd), webSnapshotMode()) + await captureStableAria(page, '[data-install-phase="done"] button[aria-busy]', scaffold.workspaceCwd), webSnapshotMode()) } finally { enabling.resolve(undefined) enableCall.mockRestore() @@ -79,70 +102,147 @@ it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserv const setup = page.getByRole('dialog', { name: '使用 Computer Use 前需要设置', exact: true }) await setup.waitFor() expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1) - await setup.getByRole('button', { name: '稍后', exact: true }).click() - expect(selected()).toContain('@deepseek-ai/dsh-cua-bundle') - expect(await readFile(settingsPath, 'utf8')).toBe(before) - expect(runtimeFiber() === originalRuntimeFiber).toBe(true) - expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body') + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/setup.expected.md', import.meta.url)), + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode()) + expect(await setup.getByRole('button', { name: '设为 PTC', exact: true }).count()).toBe(0) expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault) + expect(selected()).toContain('@deepseek-ai/dsh-cua-bundle') + expect(profileManifest().dependencies).toEqual(dependencies) + expect(await card.getByText('实验性', { exact: true }).count()).toBe(1) + expect(await card.textContent()).not.toContain('将 PTC 模式改为可持久化') + expect(await card.textContent()).toContain('请切换到 PTC 模式') + expect(await card.textContent()).toContain('Windows 支持正在开发中') + expect(await card.textContent()).not.toContain('Swift') + const description = await card.getByRole('button', { name: '查看 Computer Use', exact: true }).evaluate((button) => { + const element = document.getElementById(button.getAttribute('aria-describedby')!)! + const style = getComputedStyle(element) + return { lines: style.webkitLineClamp, height: element.getBoundingClientRect().height, + lineHeight: Number.parseFloat(style.lineHeight) } + }) + expect(description.lines).toBe('1') + expect(description.height).toBeLessThanOrEqual(description.lineHeight + 1) + expect(scaffold.ctx.get('computerUse')).toBeDefined() expect(scaffold.ctx.agents.get(originalAgent.id) === originalAgent).toBe(true) + expect(scaffold.ctx.agents.get(ptcAgent.id) === ptcAgent).toBe(true) + expect(scaffold.ctx.agentPresets.composedPreset(originalAgent.ctx)).toBe(initialDefault) expect(scaffold.ctx.tools.schemas(originalAgent).some(tool => tool.name === 'cua')).toBe(false) expect(scaffold.ctx.tools.schemas(ptcAgent).some(tool => tool.name === 'cua')).toBe(true) expect((await scaffold.ctx.systemPrompt.assemble({ scope: ptcAgent })).tools.map(tool => tool.name)).toEqual(['run_code']) - const fresh = await scaffold.ctx.agents.create({ sessionId: SessionId('cua-new-ptc'), - setup: async (ctx) => { await scaffold.ctx.agentPresets.mount(ctx, 'ptc') }, + for (const preset of ['ptc', 'standard', 'ptc', initialDefault]) { + await scaffold.ctx.agentPresets.select(originalAgent, preset) + expect(scaffold.ctx.tools.schemas(originalAgent).some(tool => tool.name === 'cua')).toBe(preset === 'ptc') + } + const child = await scaffold.ctx.agents.create({ + sessionId: SessionId('cua-child'), parentAgent: ptcAgent, + setup: (ctx) => { scaffold.ctx.agentPresets.composeFrom(ctx, ptcAgent.ctx) }, }) - expect(scaffold.ctx.tools.schemas(fresh.agent).some(tool => tool.name === 'cua')).toBe(true) - await toggle.click() - await toggle.and(page.locator('[aria-checked="false"]')).waitFor({ state: 'visible' }) - expect(scaffold.ctx.get('computerUse')).toBeUndefined() - for (const agent of [ptcAgent, fresh.agent]) expect(scaffold.ctx.tools.schemas(agent).some(tool => tool.name === 'cua')).toBe(false) - expect(runtimeFiber() === originalRuntimeFiber).toBe(true) + expect(scaffold.ctx.tools.schemas(child.agent).some(tool => tool.name === 'cua')).toBe(true) + await child.dispose() + expect(scaffold.ctx.tools.schemas(child.agent).some(tool => tool.name === 'cua')).toBe(false) + expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault) expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body') - expect(await readFile(settingsPath, 'utf8')).toBe(before) - await card.getByRole('button', { name: '查看 Computer Use', exact: true }).click() - const detailToggle = page.locator('[data-plugin-panel]').getByRole('switch', { name: '启用 Computer Use', exact: true }) - await detailToggle.click() - await detailToggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' }) - const panel = page.locator('[data-cua-settings]') - await panel.waitFor() - const defaultSetting = panel.getByRole('group', { name: '新会话默认模式', exact: true }) - await expect.poll(() => defaultSetting.getByRole('button', { name: '设为 PTC', exact: true }).isEnabled()).toBe(true) - await defaultSetting.getByRole('button', { name: '设为 PTC', exact: true }).click() - await expect.poll(() => scaffold.ctx.agentPresets.defaultId).toBe('ptc') - expect(scaffold.ctx.agentPresets.composedPreset(originalAgent.ctx)).toBe(initialDefault) - expect(runtimeFiber() === originalRuntimeFiber).toBe(true) - expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body') - const confirmed = await scaffold.ctx.agents.create({ sessionId: SessionId('cua-confirmed-default'), - setup: async (ctx) => { await scaffold.ctx.agentPresets.mount(ctx) }, - }) - expect(scaffold.ctx.tools.schemas(confirmed.agent).some(tool => tool.name === 'cua')).toBe(true) - const saved = await readFile(settingsPath, 'utf8') - expect(saved).toContain('selectedDefault: ptc') + await setup.waitFor() + expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1) + await setup.getByRole('button', { name: '稍后', exact: true }).click() + await setup.waitFor({ state: 'hidden' }) + let saved = await readFile(settingsPath, 'utf8') + expect(saved).toBe(beforeEnable) + expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: codingTools }) expect(saved).not.toContain('executionMode:') - const persistent = panel.getByRole('group', { name: 'PTC 执行方式', exact: true }) + if (selectedDefault === undefined) { + expect(load(saved)).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'agent-preset-registry' })])) + } else { + expect(load(saved)).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'agent-preset-registry', config: { default: 'standard', selectedDefault } }), + ])) + } + const freshDefault = (await scaffold.ctx.agents.create({ + sessionId: SessionId('cua-new-default'), + setup: async (ctx) => { await scaffold.ctx.agentPresets.mount(ctx) }, + })).agent + expect(scaffold.ctx.agentPresets.composedPreset(freshDefault.ctx)).toBe(initialDefault) + expect(scaffold.ctx.tools.schemas(freshDefault).some(tool => tool.name === 'cua')).toBe(false) + const fresh = (await scaffold.ctx.agents.create({ + sessionId: SessionId('cua-new-ptc'), + setup: async (ctx) => { await scaffold.ctx.agentPresets.mount(ctx, 'ptc') }, + })).agent + expect(scaffold.ctx.agentPresets.composedPreset(fresh.ctx)).toBe('ptc') + expect(scaffold.ctx.tools.schemas(fresh).some(tool => tool.name === 'cua')).toBe(true) + await card.getByRole('button', { name: '查看 Computer Use', exact: true }).click() + toggle = page.locator('[data-plugin-panel]').getByRole('switch', { name: '启用 Computer Use', exact: true }) + const persistent = page.getByRole('group', { name: 'PTC 执行方式', exact: true }) + const beforePersistent = scaffold.ctx.ptcRuntime await persistent.getByRole('button', { name: '设为持久化', exact: true }).click() await persistent.getByRole('button', { name: '已设为持久化', exact: true }).waitFor() expect(scaffold.ctx.ptcRuntime.programFormat).toBe('repl') - expect(scaffold.ctx.ptcRuntime === originalRuntime).toBe(false) - const persistentRuntimeFiber = runtimeFiber() - const runtime = scaffold.ctx.ptcRuntime, owner = {} + expect(scaffold.ctx.ptcRuntime === beforePersistent).toBe(false) + saved = await readFile(settingsPath, 'utf8') + expect(saved).toContain('executionMode: persistent-in-memory') + const runtime = scaffold.ctx.ptcRuntime + const runtimeFiber = [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'ptc-runtime')!.fiber + const owner = {} const program = 'globalThis.counter = (globalThis.counter ?? 0) + 1' expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(1) - const configured = await readFile(settingsPath, 'utf8') - expect(configured).toContain('executionMode: persistent-in-memory') - expect(scaffold.ctx.agentPresets.defaultId).toBe('ptc') - expect(scaffold.ctx.agentPresets.composedPreset(originalAgent.ctx)).toBe(initialDefault) - await detailToggle.click() - await detailToggle.and(page.locator('[aria-checked="false"]')).waitFor({ state: 'visible' }) - expect(await readFile(settingsPath, 'utf8')).toBe(configured) - expect(runtimeFiber() === persistentRuntimeFiber).toBe(true) + await toggle.click() + await toggle.waitFor({ state: 'hidden' }) + await card.waitFor({ state: 'hidden' }) + expect(scaffold.ctx.get('computerUse')).toBeUndefined() + expect([...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'ptc-runtime')!.fiber === runtimeFiber).toBe(true) + expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault) + expect(scaffold.ctx.agents.get(ptcAgent.id) === ptcAgent).toBe(true) + for (const agent of [ptcAgent, fresh]) { + expect(scaffold.ctx.tools.schemas(agent).some(tool => tool.name === 'cua')).toBe(false) + expect((await scaffold.ctx.systemPrompt.assemble({ scope: agent })).tools.map(tool => tool.name)).toEqual(['run_code']) + } expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(2) - await detailToggle.click() - await detailToggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' }) - expect(await readFile(settingsPath, 'utf8')).toBe(configured) - expect(runtimeFiber() === persistentRuntimeFiber).toBe(true) + expect(await readFile(settingsPath, 'utf8')).toBe(saved) + await (await offerCua()).click() + await toggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' }) + await setup.waitFor() + expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1) + expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault) + await setup.getByRole('button', { name: '前往设置', exact: true }).click() + await setup.waitFor({ state: 'hidden' }) + await page.getByRole('region', { name: '给 Computer Use 授权', exact: true }).getByRole('alert').waitFor() + if (!codingTools) { + expect(await readFile(settingsPath, 'utf8')).toBe(saved) + expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: false }) + expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault) + expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(3) + await runtime.release(owner) + return + } + const defaultSetting = page.getByRole('group', { name: '新会话默认模式', exact: true }) + await expect.poll(() => defaultSetting.getByRole('button', { name: '设为 PTC', exact: true }).isEnabled()).toBe(true) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/default-mode.expected.md', import.meta.url)), + await captureStableAria(page, '[role="group"][aria-label="新会话默认模式"]', scaffold.workspaceCwd), webSnapshotMode()) + await defaultSetting.getByRole('button', { name: '设为 PTC', exact: true }).click() + await expect.poll(() => scaffold.ctx.agentPresets.defaultId).toBe('ptc') + expect(scaffold.ctx.agentPresets.composedPreset(originalAgent.ctx)).toBe(initialDefault) + expect(scaffold.ctx.agentPresets.composedPreset(freshDefault.ctx)).toBe(initialDefault) + const confirmed = (await scaffold.ctx.agents.create({ + sessionId: SessionId('cua-confirmed-default'), + setup: async (ctx) => { await scaffold.ctx.agentPresets.mount(ctx) }, + })).agent + expect(scaffold.ctx.agentPresets.composedPreset(confirmed.ctx)).toBe('ptc') + expect(scaffold.ctx.tools.schemas(confirmed).some(tool => tool.name === 'cua')).toBe(true) + expect([...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'ptc-runtime')!.fiber === runtimeFiber).toBe(true) + expect(scaffold.ctx.tools.schemas(ptcAgent).some(tool => tool.name === 'cua')).toBe(true) expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(3) + const confirmedSettings = await readFile(settingsPath, 'utf8') + expect(load(confirmedSettings)).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'agent-preset-registry', config: { default: 'standard', selectedDefault: 'ptc' } }), + ])) + await toggle.click() + await toggle.waitFor({ state: 'hidden' }) + await card.waitFor({ state: 'hidden' }) + await (await offerCua()).click() + await toggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' }) + await setup.waitFor() + expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1) + expect(await setup.getByRole('button', { name: '设为 PTC', exact: true }).count()).toBe(0) + expect(await readFile(settingsPath, 'utf8')).toBe(confirmedSettings) + expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: codingTools }) await runtime.release(owner) } finally { try { await browser?.close() } finally { diff --git a/apps/web/tests/cua-permissions.e2e.ts b/apps/web/tests/cua-permissions.e2e.ts index fa8f95a4f8..4835557ce8 100644 --- a/apps/web/tests/cua-permissions.e2e.ts +++ b/apps/web/tests/cua-permissions.e2e.ts @@ -1,5 +1,5 @@ /** Built permission UI talks to the real provider; the isolated helper substitutes only macOS privilege observations. */ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -33,17 +33,16 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC' } await writeFile(state, JSON.stringify({ accessibility: false, screenRecording: false, launchApplication })) await writeFile(join(root, 'observe-helper'), '') - const fixtureBundle = join(root, 'fixture-bundle') - await mkdir(fixtureBundle) - await writeFile(join(fixtureBundle, 'package.json'), JSON.stringify({ name: 'cua-permission-fixture', version: '1.0.0', - dsh: { bundle: { patch: 'cordis.patch.yml' } } })) - await writeFile(join(fixtureBundle, 'cordis.patch.yml'), `- id: cua\n config: ${JSON.stringify({ helperPath: helper })}\n`) + const fixturePatch = join(root, 'native-fixture.yml') + // Keep the external helper override above bundle order changes when CUA is re-enabled. + await writeFile(fixturePatch, JSON.stringify([{ id: 'cua', config: { helperPath: helper } }])) const scaffold = await launchWebScaffold({ - profile: { packages: [{ dir: bundle, enabled: true }, { dir: fixtureBundle, enabled: true }] }, + profile: { packages: [{ dir: bundle, enabled: true }] }, + extraOverlayPath: fixturePatch, }) resources.scaffold = scaffold - await scaffold.ctx.settings.mutate('cua', [{ op: 'set', path: ['allowedApps'], - value: [{ id: 'a'.repeat(64), name: 'Fixture', executablePath: '/Applications/Fixture.app' }] }]) + expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle')) + .toMatchObject({ listedByDefault: false, enabled: true, installed: true }) const browser = await chromium.launch() resources.browser = browser const page = await newEnglishPage(browser), tripwire = watchConsole(page) @@ -53,7 +52,6 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr const agent = scaffold.ctx.agents.list()[0]! await scaffold.ctx.agentPresets.select(agent, 'ptc') scaffold.ctx.approval.setPolicy(agent, 'never') - await scaffold.ctx.settings.mutate('cua', [{ op: 'set', path: ['allowedApps'], value: [] }]) // Application consent remains independent of the session's tool-approval policy. const grant = scaffold.ctx.tools.execute({ agent, signal: new AbortController().signal, calls: [{ type: 'tool-call', id: ToolCallId('cua-app-approval'), name: 'run_code', @@ -82,7 +80,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr const plugins = page.locator('[data-plugin-panel]') const openDetails = page.getByRole('button', { name: 'View Computer use', exact: true }) await openDetails.scrollIntoViewIfNeeded() - expect(await plugins.evaluate(element => element.scrollTop)).toBeGreaterThan(0) + expect(await plugins.locator('[data-plugin-body]').evaluate(element => element.scrollTop)).toBeGreaterThan(0) await openDetails.click() const panel = page.locator('[data-cua-settings]') const accessibility = panel.getByRole('group', { name: 'Accessibility', exact: true }) @@ -145,7 +143,10 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr await panel.getByRole('button', { name: 'Allow asking again', exact: true }).click() await panel.getByText('Fixture', { exact: true }).waitFor({ state: 'hidden' }) const saved = await readFile(join(scaffold.harnessHome, 'profiles/scaffold/cordis.patch.yml'), 'utf8') - expect(saved).toContain('allowedApps: []') + expect(saved).not.toContain('allowedApps:') + const files = await readdir(join(scaffold.workspaceCwd, '.dsh-storages')) + expect(files.some(file => file.startsWith('cua_permissions_') && file.endsWith('.json'))).toBe(true) + expect((await scaffold.ctx.cuaAccess.applications()).allowedApps).toEqual([]) const events = (await readFile(audit, 'utf8')).trim().split('\n').map(line => JSON.parse(line) as { event: string; method: string; params: object }) .filter(record => record.event === 'call').map(({ method, params }) => ({ method, params })) expect(events).toContainEqual({ method: 'requestPermission', params: { permission: 'accessibility' } }) @@ -194,10 +195,15 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr await trace.evaluate((value) => { value.layouts.length = 0; value.documents.length = 0 }) for (let cycle = 0; cycle < 3; cycle++) { await toggle.click() + await expect.poll(async () => (await scaffold.ctx.pluginManager.listBundles()) + .find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle')) + .toMatchObject({ enabled: false, installed: true }) await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false') await expect.poll(() => toggle.isEnabled(), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe(true) await toggle.click() await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true') + const setup = page.getByRole('dialog', { name: 'Set up computer use before use', exact: true }) + await setup.getByRole('button', { name: 'Later', exact: true }).click() await expect.poll(() => panel.getAttribute('aria-busy'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false') expect(await trace.evaluate(value => value.errors)).toEqual([]) } @@ -219,7 +225,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr await diagnostic.waitFor() const diagnosticText = await diagnostic.innerText() const height = await panel.evaluate(element => element.getBoundingClientRect().height) - const rowToggle = plugins.getByRole('switch', { name: 'Enable component @deepseek-ai/dsh-experimental-cua-macos', exact: true }) + const rowToggle = plugins.getByRole('switch', { name: 'Enable component macOS Computer Use provider', exact: true }) const release = Promise.withResolvers() const changeRow = scaffold.ctx.pluginManager.setPluginEnabled.bind(scaffold.ctx.pluginManager) let changing: ReturnType | undefined diff --git a/apps/web/tests/expected/bundle-platform/unsupported-platform.expected.md b/apps/web/tests/expected/bundle-platform/unsupported-platform.expected.md new file mode 100644 index 0000000000..b6aa94aa05 --- /dev/null +++ b/apps/web/tests/expected/bundle-platform/unsupported-platform.expected.md @@ -0,0 +1 @@ +- status: 此插件不支持运行 DSH 的主机操作系统 diff --git a/apps/web/tests/expected/cua-permissions/application-approval.expected.md b/apps/web/tests/expected/cua-permissions/application-approval.expected.md index fc9007a938..05dfb4a011 100644 --- a/apps/web/tests/expected/cua-permissions/application-approval.expected.md +++ b/apps/web/tests/expected/cua-permissions/application-approval.expected.md @@ -2,7 +2,7 @@ - group "Approval details": - strong: Fixture - text: Allow computer use to launch, read, capture and control this application? - - paragraph: Session access lasts until DSH restarts or computer use is disabled. + - paragraph: Session access applies only to this conversation. - button "Reject" - button "Allow for this session" - button "Always allow" diff --git a/apps/web/tests/expected/cua-permissions/default-mode.expected.md b/apps/web/tests/expected/cua-permissions/default-mode.expected.md new file mode 100644 index 0000000000..36996c4d69 --- /dev/null +++ b/apps/web/tests/expected/cua-permissions/default-mode.expected.md @@ -0,0 +1,4 @@ +- group "新会话默认模式": + - strong: 新会话默认模式 + - paragraph: Computer Use 仅在 PTC 模式中可用。 + - button "设为 PTC" diff --git a/apps/web/tests/expected/cua-permissions/enabling.expected.md b/apps/web/tests/expected/cua-permissions/enabling.expected.md index b0e8e66529..b9d6557e3e 100644 --- a/apps/web/tests/expected/cua-permissions/enabling.expected.md +++ b/apps/web/tests/expected/cua-permissions/enabling.expected.md @@ -1,5 +1 @@ -- listitem: - - button "查看 Computer Use": Computer Use - - text: 实验性 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。 - - status: 正在应用 Computer Use 的更改 - - switch "启用 Computer Use" [disabled] +- button "立即启用" [disabled] diff --git a/apps/web/tests/expected/cua-permissions/setup.expected.md b/apps/web/tests/expected/cua-permissions/setup.expected.md new file mode 100644 index 0000000000..0ee5870bf3 --- /dev/null +++ b/apps/web/tests/expected/cua-permissions/setup.expected.md @@ -0,0 +1,6 @@ +- dialog "使用 Computer Use 前需要设置": + - heading "使用 Computer Use 前需要设置" [level=2] + - button "关闭" + - paragraph: Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。 + - button "稍后" + - button "前往设置" diff --git a/apps/web/tests/fork-mid-turn.e2e.ts b/apps/web/tests/fork-mid-turn.e2e.ts index 2ffa330007..15c8d67512 100644 --- a/apps/web/tests/fork-mid-turn.e2e.ts +++ b/apps/web/tests/fork-mid-turn.e2e.ts @@ -114,7 +114,7 @@ describe('web e2e: exact-boundary fork seeds branch closers and continues', () = const child = scaffold.ctx.sessions.get(childId) if (child === undefined) throw new Error('fork did not publish a live child session') - expect(child.header.parentSession).toBe(SessionId(SEED_ID)) + expect(child.header.parentSession).toBeUndefined() // inheritedEventCount counts only the copied parent prefix; the closers are child work. expect(child.inheritedEventCount).toBe(forkAt + 1) expect(child.snapshotEvents()[forkAt]?.type).toBe('tool/call') diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 2f96305714..405e70b57f 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -371,13 +371,15 @@ describe('web e2e: message IconActions and clocks on settled history', () => { it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork')) // The second answer is followed by another completed user turn in the source. + const existing = new Set(scaffold.ctx.agents.list().map(agent => agent.id)) await page.getByRole('button', { name: 'Branch into a new conversation' }).nth(1).click() await expect.poll( - () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)), + () => scaffold.ctx.agents.list().find(agent => !existing.has(agent.id)), { timeout: 15_000 }, ).toBeDefined() const child = scaffold.ctx.agents.list().find(agent => - agent.session.header.parentSession === SessionId(SEED_ID))! + !existing.has(agent.id))! + expect(child.session.header.parentSession).toBeUndefined() expect(child.inbox.nextTurn).toEqual([]) expect(child.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced' && event.data.inserted.some(message => message.content.some(part => @@ -405,11 +407,13 @@ describe('web e2e: message IconActions and clocks on settled history', () => { const buttonBox = await actionButton.boundingBox() if (buttonBox === null) throw new Error('fork source row action has no layout box') await page.mouse.click(buttonBox.x + buttonBox.width / 2, buttonBox.y + buttonBox.height / 2) + const beforeNested = new Set(scaffold.ctx.agents.list().map(agent => agent.id)) await page.getByRole('menuitem', { name: 'Fork session' }).click() await expect.poll( - () => scaffold.ctx.agents.list().filter(agent => agent.session.header.parentSession !== undefined).length, + () => scaffold.ctx.agents.list().filter(agent => !beforeNested.has(agent.id)).length, { timeout: 15_000 }, - ).toBe(2) + ).toBe(1) + expect(scaffold.ctx.agents.list().find(agent => !beforeNested.has(agent.id))!.session.header.parentSession).toBeUndefined() await expect.poll( () => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }, @@ -445,8 +449,10 @@ describe('web e2e: message IconActions and clocks on settled history', () => { expect(scaffold.ctx.agents.get(id)).toBeUndefined() await row.hover() await row.locator('button[aria-label^="Session actions for "]').click() + const existing = new Set(scaffold.ctx.agents.list().map(agent => agent.id)) await page.getByRole('menuitem', { name: 'Fork session', exact: true }).click() - await expect.poll(() => scaffold.ctx.agents.list().some(agent => agent.session.header.parentSession === id)).toBe(true) + await expect.poll(() => scaffold.ctx.agents.list().some(agent => !existing.has(agent.id))).toBe(true) + expect(scaffold.ctx.agents.list().find(agent => !existing.has(agent.id))!.session.header.parentSession).toBeUndefined() expect(scaffold.ctx.agents.get(id)).toBeUndefined() expect(await row.getAttribute('aria-selected')).not.toBe('true') }) @@ -481,6 +487,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { if (path === '/api/session/fork') forkRequests.push(path) } page.on('request', trackFork) + const existing = new Set(scaffold.ctx.agents.list().map(agent => agent.id)) try { await row.hover() await row.locator('button[aria-label^="Session actions for "]').click() @@ -501,7 +508,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { expect(await readdir(directory)).toContain(`session.v${SESSION_FORMAT_VERSION}.jsonl.zstd`) expect(await readFile(predecessor)).toEqual(source) expect(forkRequests).toEqual(['/api/session/fork']) - expect(scaffold.ctx.agents.list().filter(agent => agent.session.header.parentSession === id)).toEqual([]) + expect(scaffold.ctx.agents.list().filter(agent => !existing.has(agent.id) && agent.id !== id)).toEqual([]) } finally { page.off('request', trackFork) } diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index bec53293a7..ee65497459 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -1,11 +1,12 @@ import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' +import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { AgentHandle } from '@deepseek-ai/dsh-agent' +import type { SessionAttachmentValue } from '@deepseek-ai/dsh-api-session-controller' import { ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm' import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' @@ -18,11 +19,15 @@ import { compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, + parseSeedFixture, + rawSessionLog, + renderSeedFixture, + seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' +import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/minimal-preset', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') @@ -322,6 +327,38 @@ describe('minimal image reading', () => { let browser: Browser | undefined let page: Page | undefined + async function openSession(id: SessionId): Promise { + if (browser === undefined) throw new Error('image scenario browser has not started') + await page?.close() + page = await newEnglishPage(browser) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[role="treeitem"]').first().click() + await page.locator(`[data-row-key="session:${id}"]`).click() + return page + } + + async function imageCard(current: Page): Promise { + const card = current.locator('[data-tool="read_image"]').first() + await card.waitFor({ state: 'attached' }) + await expandOwningTurnProcess(current, card) + const row = card.locator('[data-disclosure-row]') + if (await row.getAttribute('aria-expanded') === 'false') await row.click() + return card + } + + function interruptedTurn(turn: number, text: string): string { + return [ + { type: 'turn/start', data: { turn } }, + { type: 'step/start', data: { turn, step: 1 } }, + { + type: 'user/message', surfaceOp: 'append', + data: createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }), + }, + { type: 'step/end', data: { turn, step: 1 } }, + { type: 'turn/end', data: { turn, reason: { kind: 'interrupted' } } }, + ].map(event => JSON.stringify(event)).join('\n') + } + beforeAll(async () => { scaffold = await launchWebScaffold({ replayFixture: join(snapshotDir, 'session.v4.jsonl'), compareReplaySession: true, paceMs: 10, @@ -337,6 +374,7 @@ describe('minimal image reading', () => { }) handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } })) await handle.agent.whenIdle() + if (MODE !== 'record') browser = await chromium.launch() }) afterAll(async () => { @@ -363,21 +401,10 @@ describe('minimal image reading', () => { it.skipIf(MODE === 'record')('renders the existing image tool card and preview', async () => { onTestFailed(() => { if (page !== undefined) void saveFailureShot(page, 'web-minimal-read-image') }) - browser = await chromium.launch() - page = await newEnglishPage(browser) + page = await openSession(handle.agent.session.id) const tripwire = watchConsole(page) - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - await page.locator('[role="treeitem"]').first().click() - await page.locator('[role="treeitem"]').nth(1).click() await page.getByText('MINIMAL_IMAGE_OK', { exact: true }).waitFor() - await page.locator('[data-turn-process]').click() - const group = page.locator('[data-chat-group-key]').first() - await group.locator('[data-process-activity]').click() - const card = group.locator('[data-tool="read_image"]').first() - const row = card.locator('[data-disclosure-row]') - await row.click() - await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true') + const card = await imageCard(page) const preview = card.locator('img').first() await preview.waitFor() await expect.poll(() => preview.evaluate(img => img instanceof HTMLImageElement && img.naturalWidth)).toBe(1) @@ -387,9 +414,143 @@ describe('minimal image reading', () => { expect(tripwire.warnings).toEqual([]) }) + it.skipIf(MODE === 'record')('reads image bytes and preserves the supplied reference with another Session as the gateway scope', async () => { + const image = handle.agent.session.deriveMessages().flatMap(message => message.content).find(block => block.type === 'image') + if (image?.type !== 'image') throw new Error('minimal read_image produced no durable image') + const unrelated = await scaffold.ctx.agents.create({ sessionId: SessionId('minimal-image-unrelated') }) + try { + expect(unrelated.agent.session.deriveMessages().flatMap(message => message.content).some(block => block.type === 'image')).toBe(false) + const read = async (attachment: unknown, rpcId: string) => { + const response = await scaffold.hostFetch('/api/session/attachment', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId, method: 'session/attachment', + payload: { args: { request: { sessionId: unrelated.agent.session.id, attachment } } }, + }), + }) + expect(response.ok).toBe(true) + return await response.json() as { result: { ok: boolean; value?: SessionAttachmentValue; error?: { code: string } } } + } + const reference = { ...image.attachment, name: 'caller-preview.png' } + const body = await read(reference, 'minimal-image-unrelated') + const stored = await scaffold.ctx.attachments.readImage(image.attachment) + expect(body.result).toEqual({ + ok: true, + value: { attachment: reference, data: Buffer.from(stored.data).toString('base64') }, + }) + const mismatches = [ + { mediaType: 'image/jpeg' }, { bytes: reference.bytes + 1 }, + { width: reference.width + 1 }, { height: reference.height + 1 }, + ] + for (const [index, mismatch] of mismatches.entries()) { + const rejected = await read({ ...reference, ...mismatch }, `minimal-image-mismatch-${index}`) + expect(rejected.result).toMatchObject({ + ok: false, error: { code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_CORRUPT' } }, + }) + expect(rejected.result.value).toBeUndefined() + } + const invalidFields = [ + { mediaType: 'image/svg+xml' }, { bytes: String(reference.bytes) }, + { width: String(reference.width) }, { height: String(reference.height) }, + ] + for (const [index, invalid] of invalidFields.entries()) { + const rejected = await read({ ...reference, ...invalid }, `minimal-image-schema-${index}`) + expect(rejected.result).toMatchObject({ ok: false, error: { code: 'gateway/input-invalid' } }) + expect(rejected.result.value).toBeUndefined() + } + } finally { + await unrelated.dispose() + } + }) + + it.skipIf(MODE === 'record')('loads persisted images after opening, paging older history, and forking', async () => { + onTestFailed(() => { if (page !== undefined) void saveFailureShot(page, 'web-historical-read-image') }) + const { headerLine, events } = parseSeedFixture(rawSessionLog(handle.agent.session)) + const recorded = renderSeedFixture(headerLine, events) + // User-only interrupted turns move the recorded image beyond the 50-message initial page without inventing model output. + const laterTurns = Array.from({ length: 52 }, (_, index) => + interruptedTurn(index + 2, `Later image-history message ${index + 2}`)) + const historicalId = SessionId('minimal-image-cold') + await seedSession(scaffold, `${recorded}${laterTurns[0]}\n`, historicalId, 'minimal') + expect(scaffold.ctx.agents.get(historicalId)).toBeUndefined() + page = await openSession(historicalId) + let card = await imageCard(page) + await expect.poll(() => card.locator('img').first().evaluate(img => img instanceof HTMLImageElement && img.naturalWidth)).toBe(1) + + const pagedId = await seedSession(scaffold, `${recorded}${laterTurns.join('\n')}\n`, 'minimal-image-paged', 'minimal') + expect(scaffold.ctx.agents.get(pagedId)).toBeUndefined() + page = await openSession(pagedId) + await page.getByText('Later image-history message 53', { exact: true }).waitFor() + expect(await page.locator('[data-tool="read_image"]').count()).toBe(0) + await page.getByRole('button', { name: 'Load earlier', exact: true }).click() + card = await imageCard(page) + await expect.poll(() => card.locator('img').first().evaluate(img => img instanceof HTMLImageElement && img.naturalWidth)).toBe(1) + + const response = await scaffold.hostFetch('/api/session/fork', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: 'minimal-image-fork', method: 'session/fork', + payload: { args: { request: { sessionId: historicalId } } }, + }), + }) + expect(response.ok).toBe(true) + const body = await response.json() as { result: { ok: boolean; value?: { sessionId: string } } } + expect(body.result.ok).toBe(true) + if (body.result.value === undefined) throw new Error('image fork did not return a Session ID') + page = await openSession(SessionId(body.result.value.sessionId)) + card = await imageCard(page) + await expect.poll(() => card.locator('img').first().evaluate(img => img instanceof HTMLImageElement && img.naturalWidth)).toBe(1) + }) + + it.skipIf(MODE === 'record')('ends loading with a retryable failure when the persisted image is missing', async () => { + onTestFailed(() => { if (page !== undefined) void saveFailureShot(page, 'web-missing-read-image') }) + const image = handle.agent.session.deriveMessages().flatMap(message => message.content).find(block => block.type === 'image') + if (image?.type !== 'image') throw new Error('minimal read_image produced no durable image') + // The blue pixel owns a different storage object from the recorded red pixel. + const missingImage = await scaffold.ctx.attachments.saveImage({ + data: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYPgPAAEDAQAIicLsAAAAAElFTkSuQmCC', 'base64'), + mediaType: 'image/png', name: 'red.png', + }) + expect(missingImage.attachmentId).not.toBe(image.attachment.attachmentId) + const { headerLine, events } = parseSeedFixture(rawSessionLog(handle.agent.session)) + const missingEvents = events.map(event => event.type === 'tool/result' + ? { + ...event, + data: { + ...event.data, + message: { + ...event.data.message, + content: event.data.message.content.map(block => block.type === 'image' + ? { ...block, attachment: missingImage } + : block), + }, + }, + } + : event) + const missingId = await seedSession(scaffold, + `${renderSeedFixture(headerLine, missingEvents)}${interruptedTurn(2, 'Check the missing image')}\n`, + 'minimal-image-missing', 'minimal') + const stored = await scaffold.ctx.attachments.readImage(missingImage) + const path = scaffold.ctx.attachments.imageHostPath(missingImage) + if (path === undefined) throw new Error('local attachment store did not return the stored image path') + await unlink(path) + try { + page = await openSession(missingId) + const card = await imageCard(page) + await card.getByRole('button', { name: 'Image failed to load; click to retry', exact: true }).waitFor() + expect(await card.getByText('Loading image…', { exact: true }).count()).toBe(0) + expect(await card.locator('[aria-busy="true"]').count()).toBe(0) + expect(await card.locator('img').count()).toBe(0) + await compareOrRefreshGolden(join(snapshotDir, 'missing-image.expected.md'), + await captureStableAria(page, '[data-tool="read_image"]', scaffold.workspaceCwd), MODE) + } finally { + await scaffold.ctx.attachments.saveImage({ data: stored.data, mediaType: stored.ref.mediaType }) + } + }) + it('keeps its snapshot inventory closed', async () => { await assertFixtureInventory(snapshotDir, [ - 'session.v4.jsonl', 'system-prompt.expected.md', 'tool-schemas.expected.json', 'ui.expected.md', + 'session.v4.jsonl', 'system-prompt.expected.md', 'tool-schemas.expected.json', 'ui.expected.md', 'missing-image.expected.md', ]) }) }) diff --git a/apps/web/tests/plan-control-row.e2e.ts b/apps/web/tests/plan-control-row.e2e.ts index 99146362a1..c32960a14e 100644 --- a/apps/web/tests/plan-control-row.e2e.ts +++ b/apps/web/tests/plan-control-row.e2e.ts @@ -256,6 +256,27 @@ describe('web e2e: plan chip click area at the narrow viewport', () => { expect(tripwire.warnings).toEqual([]) }, 200_000) + // The suite above only reaches the icon through the Plan chip's width. This + // case keeps the row otherwise empty: a long model name must still exceed + // the row above the mobile minimum, so a row-relative width cap cannot hide + // the demand behind a shrunken, ellipsized name. + it('collapses the model text on a narrow composer without another control', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-model-collapse-no-plan')) + await page.locator('[data-composer-input]').first().waitFor({ timeout: 10_000 }) + await resizeControls(page, 1680) + await selectModel(page, LONG_MODEL) + await expectControlLayout(page, false) + + await resizeControls(page, 440) + await expectControlLayout(page, true) + const trigger = page.getByRole('button', { name: /Select model/ }) + expect(await trigger.getAttribute('title')).toContain(LONG_MODEL) + expect(await trigger.getAttribute('aria-label')).toContain(LONG_MODEL) + + await resizeControls(page, 1680) + await expectControlLayout(page, false) + }, 120_000) + it('keeps the snapshot inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl']) }) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index a85297938f..14d32cb9ac 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -849,7 +849,7 @@ describe('web e2e: plugin manager', () => { expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count()) .toBe(bundles.filter(bundle => bundle.name !== '@deepseek-ai/dsh-experimental-terminal-bundle' && bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length) - for (const name of ['@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle']) { + for (const name of ['@deepseek-ai/dsh-cua-bundle', '@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle']) { expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)) .toMatchObject({ enabled: false, optional: true, availability: 'installation', listedByDefault: false }) expect(await panel.locator(`[data-plugin-package="${name}"]`).count()).toBe(0) @@ -863,8 +863,8 @@ describe('web e2e: plugin manager', () => { ['@deepseek-ai/dsh-experimental-voice-input-bundle', 1], ['@deepseek-ai/dsh-experimental-plugin-market-bundle', 1], ['@deepseek-ai/dsh-experimental-auto-review', 1], + ['@deepseek-ai/dsh-personalized-recommendations', 1], ['@deepseek-ai/dsh-experimental-inspector-profile', 1], - ['@deepseek-ai/dsh-cua-bundle', 1], ['@deepseek-ai/dsh-experimental-tool-worktree', 1], ['@deepseek-ai/dsh-experimental-cot-translation-bundle', 1], ['@deepseek-ai/dsh-experimental-session-titles-bundle', 1], diff --git a/apps/web/tests/plugin-recommendations.e2e.ts b/apps/web/tests/plugin-recommendations.e2e.ts new file mode 100644 index 0000000000..f45bddb05a --- /dev/null +++ b/apps/web/tests/plugin-recommendations.e2e.ts @@ -0,0 +1,95 @@ +/** Replay model output through the shipped Web profile and decide its real propose_action card. */ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import type {} from '@deepseek-ai/dsh-plugin-manager' +import { + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + fixtureUserPrompts, launchWebScaffold, watchConsole, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage } from './support.ts' + +const directory = fileURLToPath(new URL('../../../snapshots/web/plugin-recommendations', import.meta.url)) +const fixture = join(directory, 'session.v4.jsonl') +const voiceBundle = '@deepseek-ai/dsh-experimental-voice-input-bundle' + +describe('official plugin recommendation in a conversation', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let restoreCatalog: (() => void) | undefined + + beforeAll(async () => { + scaffold = await launchWebScaffold({ replayFixture: fixture, compareReplaySession: true, + profile: { packages: [{ dir: fileURLToPath(new URL('../../../packages/experimental/personalized-recommendations', import.meta.url)), enabled: true }] }, + }) + // The recorded conversation exercises voice input; unrelated installed plugins vary by Host. + // Keep the real inventory read so accidental enablement still changes the observed selection. + const listBundles = scaffold.ctx.pluginManager.listBundles.bind(scaffold.ctx.pluginManager) + const catalog = vi.spyOn(scaffold.ctx.pluginManager, 'listBundles') + .mockImplementation(async () => (await listBundles()).filter(bundle => bundle.name === voiceBundle)) + restoreCatalog = () => { catalog.mockRestore() } + browser = await chromium.launch() + page = await newEnglishPage(browser) + }) + + afterAll(async () => { + try { await browser?.close() } finally { + try { await scaffold?.close() } finally { restoreCatalog?.() } + } + }) + + it('offers the propose_action call as one card below the answer, and reports a dismissal on the next request', async () => { + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const input = page.locator('[data-composer-input]').first() + const settled = scaffold.whenTurnSettled() + const prompts = fixtureUserPrompts(await readFile(fixture, 'utf8')) + await input.fill(prompts[0]!) + await input.press('Enter') + await settled + const card = page.locator('[data-action-card]') + await card.waitFor({ state: 'visible' }) + expect(await card.count()).toBe(1) + expect(await page.locator('[data-turn-tail] [data-action-card][data-action="enable_official_plugin"]').count()).toBe(1) + const answer = page.getByText('Voice input transcribes your recordings locally into the composer. You can enable it below.', { exact: true }) + const answerBox = await answer.boundingBox() + const cardBox = await card.boundingBox() + expect(answerBox).not.toBeNull() + expect(cardBox!.y).toBeGreaterThanOrEqual(answerBox!.y + answerBox!.height) + expect(await page.getByText('enable_official_plugin').count()).toBe(0) + await card.getByText('Voice input', { exact: true }).waitFor() + expect(await card.getByRole('button', { name: 'Enable', exact: true }).isEnabled()).toBe(true) + expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === voiceBundle)?.enabled).toBe(false) + await compareOrRefreshGolden(join(directory, 'offered.expected.md'), await captureStableAria(page, '[data-action-card]', scaffold.workspaceCwd), scaffold.mode) + await page.setViewportSize({ width: 620, height: 900 }) + expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + await card.getByRole('button', { name: 'Not now', exact: true }).click() + await card.getByRole('status').getByText('Dismissed', { exact: true }).waitFor() + const warnings = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await card.getByRole('status').getByText('Dismissed', { exact: true }).waitFor() + acknowledgeReloadConnectionLoss(tripwire, warnings) + expect(await card.count()).toBe(1) + expect(await card.getByRole('button').count()).toBe(1) + expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === voiceBundle)?.enabled).toBe(false) + await compareOrRefreshGolden(join(directory, 'dismissed.expected.md'), await captureStableAria(page, '[data-action-card]', scaffold.workspaceCwd), scaffold.mode) + const statusSettled = scaffold.whenTurnSettled() + await input.fill(prompts[1]!) + await input.press('Enter') + await statusSettled + await page.getByText('Voice input is not enabled: you dismissed the card.', { exact: true }).waitFor() + expect(await card.count()).toBe(1) + await card.getByRole('button', { name: 'View details: Voice input', exact: true }).click() + await page.locator(`[data-plugin-detail="${voiceBundle}"]`).waitFor({ state: 'visible' }) + expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === voiceBundle)?.enabled).toBe(false) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + if (scaffold.mode === 'replay') await assertFixtureInventory(directory, [ + 'session.v4.jsonl', 'offered.expected.md', 'dismissed.expected.md', 'system-prompt.expected.md', 'tool-schemas.expected.json', + ]) + }) +}) diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts index 3ca55f2f85..302aea3fdc 100644 --- a/apps/web/tests/preset-migration.snapshot.ts +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -85,11 +85,11 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t await expect(readFile(childSuccessor)).rejects.toMatchObject({ code: 'ENOENT' }) const signal = new AbortController().signal const address = { kind: 'subagent', parentSessionId: id, childSessionId: childId, mode: 'one-shot' } as const - const childPage = await scaffold.ctx.sessionController.page({ address, throughSeq: childRows.length - 1 }, signal) + const childPage = await scaffold.ctx.sessionController.events({ address, throughSeq: childRows.length - 1 }, signal) expect(childPage.records.map(record => record.event.type)).toEqual(childRows.map(row => row['type'])) - await expect(scaffold.ctx.sessionController.page({ address: { ...address, childSessionId: brokenId, mode: 'unknown' }, throughSeq: 1 }, signal)) + await expect(scaffold.ctx.sessionController.events({ address: { ...address, childSessionId: brokenId, mode: 'unknown' }, throughSeq: 1 }, signal)) .rejects.toThrow() - await expect(scaffold.ctx.sessionController.page({ address: { kind: 'session', sessionId: id }, throughSeq: rows.length - 1 }, signal)) + await expect(scaffold.ctx.sessionController.events({ address: { kind: 'session', sessionId: id }, throughSeq: rows.length - 1 }, signal)) .resolves.toMatchObject({ hasMore: false }) const childWriter = await scaffold.ctx.sessionPersistence.open(childId, 'write') await childWriter.close() diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 80aa4603f6..daa7944c36 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -43,6 +43,7 @@ import { import { VFS_EXAMPLE_SESSION_IDS, VFS_EXAMPLE_REASONING, + VFS_EXAMPLE_SPACING_TITLE, VFS_EXAMPLE_WRAPPED_COMMAND, buildVfsExampleFiles, } from '../../../packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts' @@ -470,6 +471,9 @@ async function bootPreview(site: Site, manifest: ImageManifest, browser: Browser const sessions = page.getByRole('tree', { name: 'Sessions' }) const showcase = sessions.getByRole('treeitem').filter({ hasText: SHOWCASE_TITLE }) await expect.poll(() => showcase.count(), { timeout: 15_000 }).toBe(1) + // The current-generation spacing sample ships beside the showcase and must be listed cold. + const spacingSample = sessions.getByRole('treeitem').filter({ hasText: VFS_EXAMPLE_SPACING_TITLE }) + await expect.poll(() => spacingSample.count(), { timeout: 15_000 }).toBe(1) // The packed deployment ships the Schedule rows, and the preview storage // seeds the showcase Session with active tasks. expect(await showcase.locator('[data-session-schedule-mark]').count()).toBe(1) @@ -697,22 +701,17 @@ async function toggleOptionalBundles(page: Page): Promise { }) expect(result.optional.map(entry => entry.name).sort()).toEqual([...OPTIONAL_BUNDLES].sort()) expect(result.optional.filter(entry => entry.enabled || entry.availability !== 'installation')).toEqual([]) - expect(result.optional.filter(entry => entry.error !== null)).toEqual([]) + expect(result.optional.filter(entry => entry.error !== null)).toEqual([ + expect.objectContaining({ name: '@deepseek-ai/dsh-cua-bundle', error: { code: 'unsupported-platform' } }), + ]) // Metadata resolves through the Worker's Node internal modules: every optional bundle shows its title and icon. expect(result.optional.filter(entry => entry.metaError !== null || entry.title !== 'object' || entry.icon !== 'string')).toEqual([]) expect(result.inventoryErrors).toEqual([]) console.info(`preview boot: optional bundle toggles ${JSON.stringify(result.toggles.map(({ name, enable, disable, error }) => ({ name, enable, disable, error })))}`) - const cua = result.toggles.find(toggle => toggle.name === '@deepseek-ai/dsh-cua-bundle')! - expect(cua).toMatchObject({ - enable: 'applied', disable: 'applied', - error: null, - restored: true, + expect(result.toggles.find(toggle => toggle.name === '@deepseek-ai/dsh-cua-bundle')).toMatchObject({ + enable: 'failed', disable: 'applied', error: { code: 'unsupported-platform' }, + changed: 0, inactive: [], restored: true, }) - expect(cua.changed).toBeGreaterThan(0) - expect(cua.inactive).toEqual([]) - expect(cua.plugins).toContainEqual(expect.objectContaining({ - moduleName: '@deepseek-ai/dsh-experimental-cua-macos', enabled: false, fiberPhase: null, - })) const supported = result.toggles.filter(toggle => toggle.name !== '@deepseek-ai/dsh-cua-bundle') expect(supported.filter(toggle => toggle.enable !== 'applied' || toggle.disable !== 'applied') .map(({ name, enable, disable }) => ({ name, enable, disable }))).toEqual([]) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 4ce50f5075..6a0c089a9a 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -1209,7 +1209,7 @@ async function assertReplaySession( let expectedPath = fixturePath const userPrompts = fixtureUserPrompts(expected) const candidates = sessions.filter((session) => { - if (session.header.parentSession !== undefined) return false + if (session.header.origin === 'subagent' || session.header.isSeeded) return false const actual = session.snapshotEvents().flatMap((event) => { if (event.type !== 'user/message' || event.data.source.kind !== 'user') return [] const text = event.data.content.filter(block => block.type === 'text').map(block => block.text).join('') diff --git a/apps/web/tests/shortcuts-workspace.e2e.ts b/apps/web/tests/shortcuts-workspace.e2e.ts index db74374ae7..410fa3ce84 100644 --- a/apps/web/tests/shortcuts-workspace.e2e.ts +++ b/apps/web/tests/shortcuts-workspace.e2e.ts @@ -93,14 +93,17 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => { await bind('Fork session') const original = await readPersistedEvents(scaffold, sourceId) const lastEnd = original.findLastIndex(event => event.type === 'turn/end') + const existing = new Set(scaffold.ctx.agents.list().map(agent => agent.id)) await page.keyboard.press('Meta+Shift+Comma') - await expect.poll(() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === sourceId)).toBeDefined() - const child = scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === sourceId)! + await expect.poll(() => scaffold.ctx.agents.list().find(agent => !existing.has(agent.id))).toBeDefined() + const child = scaffold.ctx.agents.list().find(agent => !existing.has(agent.id))! + expect(child.session.header.parentSession).toBeUndefined() const childId = child.session.header.id await expect.poll(async () => (await readPersistedEvents(scaffold, childId)).some(event => event.type === 'session/title' && event.data.title === 'T4 source (1)')).toBe(true) const copied = await readPersistedEvents(scaffold, childId) expect(copied.slice(0, lastEnd + 1)).toEqual(original.slice(0, lastEnd + 1)) const sourceRow = page.getByRole('treeitem').filter({ has: page.getByText('T4 source', { exact: true }) }) + expect(child.session.header.parentSession).toBeUndefined() const childRow = page.getByRole('treeitem').filter({ has: page.getByText('T4 source (1)', { exact: true }) }) await childRow.waitFor() expect(await sourceRow.getAttribute('aria-selected')).toBe('true') @@ -218,6 +221,7 @@ it.skipIf(mode === 'record')('explains a first-turn fork refusal and retries fro await connectFreshWorkspace(page, scaffold.workspaceCwd) const source = scaffold.ctx.agents.list().find(agent => !before.has(agent.id)) if (source === undefined) throw new Error('The workspace did not create a Session') + const existing = new Set(scaffold.ctx.agents.list().map(agent => agent.id)) source.session.append('turn/start', { turn: 1 }) const user = source.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'The first turn has not ended.' }], source: { kind: 'user' }, @@ -240,7 +244,7 @@ it.skipIf(mode === 'record')('explains a first-turn fork refusal and retries fro await compareOrRefreshGolden(join(root, 'fork-unavailable.expected.md'), await unavailable.ariaSnapshot(), mode) expect(await selected.textContent()).toContain('First turn') - expect(scaffold.ctx.agents.list().filter(agent => agent.session.header.parentSession === source.id)).toHaveLength(0) + expect(scaffold.ctx.agents.list().filter(agent => !existing.has(agent.id))).toHaveLength(0) expect(forkWarnings).toEqual([]) source.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -252,8 +256,9 @@ it.skipIf(mode === 'record')('explains a first-turn fork refusal and retries fro await scaffold.ctx.sessions.flush(source.session) await page.getByText('The second turn has not ended.', { exact: true }).waitFor() await page.keyboard.press('Meta+Shift+Comma') - await expect.poll(() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === source.id)).toBeDefined() - const child = scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === source.id)! + await expect.poll(() => scaffold.ctx.agents.list().find(agent => !existing.has(agent.id))).toBeDefined() + const child = scaffold.ctx.agents.list().find(agent => !existing.has(agent.id))! + expect(child.session.header.parentSession).toBeUndefined() const childRow = page.getByRole('treeitem').filter({ has: page.getByText('First turn (1)', { exact: true }) }) await childRow.waitFor() expect(await childRow.getAttribute('aria-selected')).toBe('false') diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 7a5f0bd147..365d275118 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -23,6 +23,8 @@ // this client-registered project must not also hold them (one program // cannot see both sides of the cordis Context merges). "exclude": [ + "tests/creation-recommendations.e2e.ts", + "tests/plugin-recommendations.e2e.ts", "tests/japanese-plugin-metadata.e2e.ts", "tests/additional-locales.e2e.ts", "tests/rich-text-quotes.e2e.ts", @@ -92,6 +94,7 @@ "tests/plugin-sticky-motion.e2e.ts", "tests/entry-customization.e2e.ts", "tests/cua-optin.e2e.ts", + "tests/bundle-platform.e2e.ts", "tests/cua-permissions.e2e.ts", "tests/schedule-header.e2e.ts", "tests/schedule-phone.e2e.ts", diff --git a/benchmarks/session-open/session-open.worker.ts b/benchmarks/session-open/session-open.worker.ts index 5e0b163aa1..6788e3f382 100644 --- a/benchmarks/session-open/session-open.worker.ts +++ b/benchmarks/session-open/session-open.worker.ts @@ -29,7 +29,8 @@ import SessionTitleService from '@deepseek-ai/dsh-session-title' import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline' import TokenMeter from '@deepseek-ai/dsh-token-meter' // These Host-only adapters have no public Node export and are compiled into the benchmark worker. -import { SessionHistoryController } from '../../packages/api/session-controller/src/history.ts' +import { HistoryController } from '../../packages/api/session-controller/src/history/controller.ts' +import { WebController } from '../../packages/api/session-controller/src/web/controller.ts' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' import { SYNTHETIC_SESSION_ID } from './session-open.constants.ts' @@ -151,7 +152,7 @@ class SessionBenchmarkHost { private constructor( private readonly ctx: Context, private readonly scenario: SessionOpenBenchmarkScenario, - private readonly history: SessionHistoryController | undefined, + private readonly history: WebController | undefined, ) {} static async create(root: string, scenario: SessionOpenBenchmarkScenario): Promise { @@ -165,10 +166,10 @@ class SessionBenchmarkHost { } await installProjectionSet(ctx) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' }) - let history: SessionHistoryController | undefined + let history: WebController | undefined if (scenario === 'first-history') { new BenchmarkSessionQuery(ctx) - history = new SessionHistoryController(ctx, (observation) => { + history = new WebController(ctx, new HistoryController(ctx), (observation) => { // First-history ends at snapshot delivery; Agent-resume owns live activation and retention. observation[Symbol.dispose]() }) diff --git a/benchmarks/support/scaling-report.ts b/benchmarks/support/scaling-report.ts index 876e197222..6345dde870 100644 --- a/benchmarks/support/scaling-report.ts +++ b/benchmarks/support/scaling-report.ts @@ -102,13 +102,27 @@ function appendRecords(records: readonly (TimingRecord | MemoryRecord)[]): void appendFileSync(RESULTS_PATH, records.map(record => JSON.stringify(record) + '\n').join('')) } -/** Vitest global setup: discard results of an earlier run. */ +/** + * Which invocation of a phased run this process is. + * @returns `first` for the invocation that clears earlier results, `last` for the one that renders the report, and `all` for a single-invocation run. + * @throws If the value is not a known phase. + */ +function reportPhase(): 'first' | 'last' | 'all' { + const raw = process.env.DSH_BENCH_REPORT_PHASE + if (raw === undefined || raw === '' || raw === 'all') return 'all' + if (raw === 'first' || raw === 'last') return raw + throw new Error(`DSH_BENCH_REPORT_PHASE must be "first", "last", or "all", got ${JSON.stringify(raw)}.`) +} + +/** Vitest global setup: discard results of an earlier run unless this phase continues one. */ export function setup(): void { + if (reportPhase() === 'last') return rmSync(REPORT_DIRECTORY, { recursive: true, force: true }) } -/** Vitest global teardown: render the report when any case recorded a timing. */ +/** Vitest global teardown: render the report when a case recorded a timing and this phase owns the report. */ export function teardown(): void { + if (reportPhase() === 'first') return if (!existsSync(RESULTS_PATH)) return const records = readFileSync(RESULTS_PATH, 'utf8').split('\n').filter(line => line !== '').map(line => JSON.parse(line) as TimingRecord | MemoryRecord) const cpuModel = cpus()[0]?.model.trim() ?? 'unknown CPU' diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 6ea6996144..f84e67f384 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -6,8 +6,8 @@ en: 735eb4f22d5f6f71 zh: 697eef269b133039 /api-gateway/programming-model: - en: 78eb70d6c2ce6608 - zh: 826f0ccc206b7299 + en: 008d81750e966b5d + zh: c31ca07da77ffdcc /api-gateway/component-responsibilities: en: fe382a9bcc768cc2 zh: 78e3d50d59780c3d diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 103822c4aa..e5ccfb7ca6 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -12,7 +12,7 @@ Business services use `@Remote` or `@RemoteScope` to select the methods exposed `@RemoteScope(key)` first resolves an identity to a scoped Context through `ctx.typert.contexts`, then obtains the service from that Context and invokes the method. It applies when the method itself depends on scoped composition and does not need to receive objects such as `Agent` explicitly. -`@Remote` accepts an export name or `{ name?, mode?, resources? }`, and `@RemoteScope(key, option?)` accepts an export name, as in `@RemoteScope('agent', 'current')` below, or `{ name?, resources? }` as its second argument. `resources` holds per-kind readers that return the identities of the resources one call touches for access control; each reader receives the method's wire arguments as its own parameters, with types inferred from the decorated method. Most methods declare no readers, because the Gateway also reads identities from lookup parameters and scoped Contexts that declare a `resource`, and from the default fields in `typert.resources`; the [Typert subsystem](subsystems/typert.md) owns that order. +`@Remote` accepts an export name or `{ name?, mode?, resources? }`, and `@RemoteScope(key, option?)` accepts an export name, as in `@RemoteScope('agent', 'current')` below, or `{ name?, resources? }` as its second argument. `resources` holds per-kind readers that return the identities of the resources one call touches for access control; each reader receives the method's wire arguments as its own parameters, with types inferred from the decorated method. Most methods declare no readers, because the Gateway also reads identities from lookup parameters and scoped Contexts that declare a `resource`, and from the default fields or resolvers in `typert.resources`; the [Typert subsystem](subsystems/typert.md) owns that order. Services normally extend `TypertRemoteService` so the constructor explicitly binds the Cordis service key and default Remote namespace. A service that already has another base class can instead declare `readonly typertRemote = bindTypertRemote(this, serviceKey)`; both forms leave an inspectable public binding and do not depend on the compiler injecting a symbol into the constructor. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 8e2df4acbe..fb8201be74 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -14,7 +14,7 @@ `@RemoteScope(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得服务并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。 -`@Remote` 接受导出名或 `{ name?, mode?, resources? }`;`@RemoteScope(key, option?)` 的第二个参数接受导出名(如下文的 `@RemoteScope('agent', 'current')`)或 `{ name?, resources? }`。`resources` 按资源种类给出读取函数,返回一次调用所访问资源的身份,供访问控制使用;每个读取函数以方法的 wire 参数作为自身参数,类型从被装饰方法推断。多数方法不声明读取函数,因为 Gateway 还会从声明了 `resource` 的 lookup 参数与作用域 Context 以及 `typert.resources` 中的默认字段读取身份;[Typert 子系统](subsystems/typert.zh.md)负责该顺序。 +`@Remote` 接受导出名或 `{ name?, mode?, resources? }`;`@RemoteScope(key, option?)` 的第二个参数接受导出名(如下文的 `@RemoteScope('agent', 'current')`)或 `{ name?, resources? }`。`resources` 按资源种类给出读取函数,返回一次调用所访问资源的身份,供访问控制使用;每个读取函数以方法的 wire 参数作为自身参数,类型从被装饰方法推断。多数方法不声明读取函数,因为 Gateway 还会从声明了 `resource` 的 lookup 参数与作用域 Context 以及 `typert.resources` 中的默认字段或 resolver 读取身份;[Typert 子系统](subsystems/typert.zh.md)负责该顺序。 服务通常继承 `TypertRemoteService`,让 Cordis 服务 key 与默认 Remote namespace 在构造器中显式绑定。已有其他基类的服务可以改为声明 `readonly typertRemote = bindTypertRemote(this, serviceKey)`;两种方式都会留下可检查的公开 binding,不依赖编译器向构造函数注入 symbol。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index eb4a96bc94..c8543cdc36 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -33,5 +33,5 @@ en: a5bc20c3f2a1b370 zh: 91773a281410b2f4 /deepseek-harness-architecture/where-new-behavior-goes: - en: 9b2eb0539a9cd0d6 - zh: 4f575277b91d4e5c + en: ee73b1861378b099 + zh: 9a182be609485f67 diff --git a/docs/architecture.md b/docs/architecture.md index 93d431ada3..e6bede8d0e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -162,7 +162,7 @@ New behavior attaches to a documented extension point. Changing the loop itself | Add durable session state | extend `SessionEventMap`; render and replay from the log | | Generate session titles | register the sole `ctx.sessionTitle` provider | | Manage a same-session objective | use `ctx.goals`; continue through `agent/*` | -| Fork a session at a turn boundary | `ctx.agents.create({ sessionId, seed, meta: { parentSession, seedLength } })` — only agent-loop-published sessions persist | +| Fork at a turn boundary | `ctx.agents.create({ sessionId, seed, inheritedEventCount, meta: { isSeeded: true } })` — only agent-loop-published sessions persist | | Store sessions in a new backend | implement `SessionPersistence` (`create`/`open`/`stat`/`list`/`export`) over the shared handle scaffolding | | Scope a registration to one agent | use that agent's `agent.ctx` | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 687bf69911..8863c8047c 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -166,7 +166,7 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统 | 添加持久会话状态 | 扩展 `SessionEventMap`;从日志渲染和回放 | | 生成会话标题 | 注册唯一的 `ctx.sessionTitle` 提供方 | | 管理同会话目标 | 使用 `ctx.goals`;通过 `agent/*` 续跑 | -| 在轮次边界 fork 会话 | `ctx.agents.create({ sessionId, seed, meta: { parentSession, seedLength } })`——只有经 agent-loop 发布的会话才会持久化 | +| 在轮次边界 fork 会话 | `ctx.agents.create({ sessionId, seed, inheritedEventCount, meta: { isSeeded: true } })`——只有经 agent-loop 发布的会话才会持久化 | | 在新后端存储会话 | 基于共享的句柄脚手架实现 `SessionPersistence`(`create`/`open`/`stat`/`list`/`export`) | | 将注册项限定到单个 agent | 使用该 agent 的 `agent.ctx` | diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index e2cd201a0d..fba6972189 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: f2546ac3454d220d - zh: 642a473c0405e81f + en: 506d61dfa8c71186 + zh: 46f05c4ef2f5c322 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 07f93f002f..d28f3f3aee 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -7,6 +7,13 @@ A service can be a core spine service, a swappable capability seam, a bundle/com ```mermaid flowchart LR + pkg_action_cards["action-cards"] + svc_actionCards["ctx.actionCards
Optional action cards"] + pkg_creation_recommendations["creation-recommendations"] + pkg_client_ui_action_cards["client-ui-action-cards"] + pkg_plugin_recommendations["plugin-recommendations"] + svc_pluginRecommendations["ctx.pluginRecommendations
Explicit-need plugin recommendations"] + pkg_client_ui_plugin_recommendations["client-ui-plugin-recommendations"] pkg_headless["headless"] svc_headlessStartup["ctx.headlessStartup
Headless invocation options"] pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"] @@ -298,6 +305,7 @@ flowchart LR pkg_cordis_host_runner["cordis-host-runner"] svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] + pkg_action_cards --> svc_actionCards pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop @@ -388,6 +396,7 @@ flowchart LR pkg_plugin_manager --> svc_pluginManager pkg_plugin_market --> svc_pluginMarket pkg_plugin_package_inventory_deepseek --> svc_deepseekLlmApiExtensions + pkg_plugin_recommendations --> svc_pluginRecommendations pkg_ptc_runtime --> svc_ptcRuntime pkg_ptc_runtime_node --> svc_ptcRuntime pkg_pwsh_local --> svc_shell @@ -457,6 +466,8 @@ flowchart LR pkg_working_directory --> svc_workingDirectory pkg_workspace --> svc_workspaceRegistry pkg_workspace_changes --> svc_workspaceChanges + svc_actionCards --> pkg_client_ui_action_cards + svc_actionCards --> pkg_creation_recommendations svc_agentDefaultModel --> pkg_api_session_controller svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_base @@ -515,6 +526,7 @@ flowchart LR svc_pluginManager --> pkg_ui_settings_plugin_inventory svc_pluginMarket --> pkg_client_ui_plugin_market svc_pluginMarket --> pkg_plugin_market + svc_pluginRecommendations --> pkg_client_ui_plugin_recommendations svc_pluginRegistryProbe --> pkg_client_ui_plugin_manager svc_profileContext --> pkg_plugin_manager svc_ptcRuntime --> pkg_tool_executor_ptc @@ -619,6 +631,8 @@ flowchart LR | ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note | | --- | --- | --- | --- | --- | --- | --- | +| `ctx.actionCards` | `service` | [`action-cards`](../packages/experimental/action-cards) | - | [`creation-recommendations`](../packages/experimental/creation-recommendations), [`client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | - | Owns the card tool, card state, user actions and status notices; card kinds register their actions and handlers. | +| `ctx.pluginRecommendations` | `service` | [`plugin-recommendations`](../packages/experimental/plugin-recommendations) | - | [`client-ui-plugin-recommendations`](../packages/experimental/client-ui-plugin-recommendations) | - | Reads the current catalog text of an official plugin card and enables the plugin through the profile plugin manager after a click. | | `ctx.headlessStartup` | `core` | [`headless`](../packages/bundle/headless) | - | [`headless`](../packages/bundle/headless) | - | Supplies parsed task and run options to the headless runner and its sandbox-policy configuration. | | `ctx.cotTranslation` | `service` | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | - | - | - | Serves reasoning translations, reuses saved results, and joins ordinary Session activation for uncached inactive Sessions. | | `ctx.translator` | `service` | [`experimental-translator`](../packages/experimental/translator) | - | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | - | Resolves Google or Bing requests and retains shared experimental records through the active Session writer. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 1b10ace20d..8a2963f3cc 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -9,6 +9,13 @@ ```mermaid flowchart LR + pkg_action_cards["action-cards"] + svc_actionCards["ctx.actionCards
Optional action cards"] + pkg_creation_recommendations["creation-recommendations"] + pkg_client_ui_action_cards["client-ui-action-cards"] + pkg_plugin_recommendations["plugin-recommendations"] + svc_pluginRecommendations["ctx.pluginRecommendations
Explicit-need plugin recommendations"] + pkg_client_ui_plugin_recommendations["client-ui-plugin-recommendations"] pkg_headless["headless"] svc_headlessStartup["ctx.headlessStartup
Headless invocation options"] pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"] @@ -300,6 +307,7 @@ flowchart LR pkg_cordis_host_runner["cordis-host-runner"] svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] + pkg_action_cards --> svc_actionCards pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop @@ -390,6 +398,7 @@ flowchart LR pkg_plugin_manager --> svc_pluginManager pkg_plugin_market --> svc_pluginMarket pkg_plugin_package_inventory_deepseek --> svc_deepseekLlmApiExtensions + pkg_plugin_recommendations --> svc_pluginRecommendations pkg_ptc_runtime --> svc_ptcRuntime pkg_ptc_runtime_node --> svc_ptcRuntime pkg_pwsh_local --> svc_shell @@ -459,6 +468,8 @@ flowchart LR pkg_working_directory --> svc_workingDirectory pkg_workspace --> svc_workspaceRegistry pkg_workspace_changes --> svc_workspaceChanges + svc_actionCards --> pkg_client_ui_action_cards + svc_actionCards --> pkg_creation_recommendations svc_agentDefaultModel --> pkg_api_session_controller svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_base @@ -517,6 +528,7 @@ flowchart LR svc_pluginManager --> pkg_ui_settings_plugin_inventory svc_pluginMarket --> pkg_client_ui_plugin_market svc_pluginMarket --> pkg_plugin_market + svc_pluginRecommendations --> pkg_client_ui_plugin_recommendations svc_pluginRegistryProbe --> pkg_client_ui_plugin_manager svc_profileContext --> pkg_plugin_manager svc_ptcRuntime --> pkg_tool_executor_ptc @@ -621,6 +633,8 @@ flowchart LR | ctx 键 | 角色 | 所属包 | 实现 | 直接消费方 | 配套插件 | 说明 | | --- | --- | --- | --- | --- | --- | --- | +| `ctx.actionCards` | `service` | [`action-cards`](../packages/experimental/action-cards) | - | [`creation-recommendations`](../packages/experimental/creation-recommendations), [`client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | - | 负责卡片工具、卡片状态、用户操作和状态通知;各卡片种类注册自己的动作和处理器。 | +| `ctx.pluginRecommendations` | `service` | [`plugin-recommendations`](../packages/experimental/plugin-recommendations) | - | [`client-ui-plugin-recommendations`](../packages/experimental/client-ui-plugin-recommendations) | - | 读取官方插件卡片的当前目录文本,并在用户点击后通过配置插件管理器启用该插件。 | | `ctx.headlessStartup` | `core` | [`headless`](../packages/bundle/headless) | - | [`headless`](../packages/bundle/headless) | - | 将解析后的任务和运行选项提供给无界面运行器及其 sandbox-policy 配置。 | | `ctx.cotTranslation` | `service` | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | - | - | - | 为展开的思考内容提供有界的 Session 翻译请求,不改变模型可见历史。 | | `ctx.translator` | `service` | [`experimental-translator`](../packages/experimental/translator) | - | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | - | 解析 Google 或 Bing 请求,并可选保留统一的实验性 Session 记录。 | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4cd093d44a..a696996deb 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -33,6 +33,21 @@ export interface AcpConfig { ``` + + + +## `@deepseek-ai/dsh-action-cards` + +- `inject`: `tools` · `agents` · `sessionProjections` · `remoteAccess` +- `refs`: `z` (`zod`) +- `source`: [`packages/experimental/action-cards/src/index.ts:23`](../packages/experimental/action-cards/src/index.ts) + +```ts config-catalog +/** Validated card limits after schema defaults. */ +export type Config = z.infer +``` + + @@ -188,7 +203,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-gateway` - `inject`: `typert` · `remoteAccess` -- `source`: [`packages/api/gateway/src/index.ts:156`](../packages/api/gateway/src/index.ts) +- `source`: [`packages/api/gateway/src/index.ts:158`](../packages/api/gateway/src/index.ts) ```ts config-catalog /** Gateway transport configuration. */ @@ -227,8 +242,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts) +- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `typertGateway` · `workspaceRegistry` +- `source`: [`packages/api/session-controller/src/index.ts:110`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ @@ -2475,7 +2490,7 @@ export interface PlanModeConfig { - `inject`: `loader` · `profileContext` - `refs`: [`NpmFileSource`](subsystems/boot.md) -- `source`: [`packages/boot/plugin-manager/src/index.ts:53`](../packages/boot/plugin-manager/src/index.ts) +- `source`: [`packages/boot/plugin-manager/src/index.ts:54`](../packages/boot/plugin-manager/src/index.ts) ```ts config-catalog /** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ @@ -3727,7 +3742,7 @@ export type ShellDialect = 'bash' | 'pwsh' ## `@deepseek-ai/dsh-time-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/time-context/src/index.ts:47`](../packages/context/time-context/src/index.ts) +- `source`: [`packages/context/time-context/src/index.ts:52`](../packages/context/time-context/src/index.ts) ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -3826,7 +3841,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash-persistent` - `inject`: `tools` · `terminals` · `workingDirectory` -- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:445`](../packages/shell/tool-bash-persistent/src/index.ts) +- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:447`](../packages/shell/tool-bash-persistent/src/index.ts) ```ts config-catalog /** Configuration for the persistent Bash tool. */ @@ -4689,6 +4704,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-client-locale` | — | [`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts) | | `@deepseek-ai/dsh-client-modules` | `loader` | [`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts) | | `@deepseek-ai/dsh-client-resources` | — | [`packages/client/resources/src/index.ts`](../packages/client/resources/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-action-cards` | — | [`packages/experimental/client-ui-action-cards/src/index.ts`](../packages/experimental/client-ui-action-cards/src/index.ts) | | `@deepseek-ai/dsh-client-ui-agent-preset` | — | [`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts) | | `@deepseek-ai/dsh-client-ui-approval` | — | [`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts) | | `@deepseek-ai/dsh-client-ui-attachment` | — | [`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts) | @@ -4697,6 +4713,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-client-ui-commands` | — | [`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts) | | `@deepseek-ai/dsh-client-ui-conversation` | — | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) | | `@deepseek-ai/dsh-client-ui-cordis` | — | [`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-creation-recommendations` | — | [`packages/experimental/client-ui-creation-recommendations/src/index.ts`](../packages/experimental/client-ui-creation-recommendations/src/index.ts) | | `@deepseek-ai/dsh-client-ui-deepseek-model-label-1017` | — | [`packages/client/ui-deepseek-model-label-1017/src/index.ts`](../packages/client/ui-deepseek-model-label-1017/src/index.ts) | | `@deepseek-ai/dsh-client-ui-deliverables` | `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` · `workspaceChanges` | [`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts) | | `@deepseek-ai/dsh-client-ui-directory-picker-browse` | — | [`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts) | @@ -4713,6 +4730,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-client-ui-permission-presets` | — | [`packages/client/ui-permission-presets/src/index.ts`](../packages/client/ui-permission-presets/src/index.ts) | | `@deepseek-ai/dsh-client-ui-plan` | — | [`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts) | | `@deepseek-ai/dsh-client-ui-plugin-market` | — | [`packages/client/ui-plugin-market/src/index.ts`](../packages/client/ui-plugin-market/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-plugin-recommendations` | — | [`packages/experimental/client-ui-plugin-recommendations/src/index.ts`](../packages/experimental/client-ui-plugin-recommendations/src/index.ts) | | `@deepseek-ai/dsh-client-ui-reference` | — | [`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts) | | `@deepseek-ai/dsh-client-ui-renderer` | — | [`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts) | | `@deepseek-ai/dsh-client-ui-schedule` | — | [`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts) | @@ -4748,6 +4766,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-computer-use` | — | [`packages/computer-use/computer-use/src/index.ts`](../packages/computer-use/computer-use/src/index.ts) | | `@deepseek-ai/dsh-config-editor` | `loader` · `profileContext` | [`packages/boot/config-editor/src/index.ts`](../packages/boot/config-editor/src/index.ts) | | `@deepseek-ai/dsh-cordis-client-runner` | — | [`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts) | +| `@deepseek-ai/dsh-creation-recommendations` | `tools` · `pluginManager` · `agents` · `subagents` · `profileContext` · `skills` · `actionCards` | [`packages/experimental/creation-recommendations/src/index.ts`](../packages/experimental/creation-recommendations/src/index.ts) | | `@deepseek-ai/dsh-cua` | — | [`packages/experimental/cua/src/index.ts`](../packages/experimental/cua/src/index.ts) | | `@deepseek-ai/dsh-deepseek-llm-api-extensions` | — | [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts) | | `@deepseek-ai/dsh-deliverable-file-references` | `systemPrompt` | [`packages/deliverables/deliverable-file-references/src/index.ts`](../packages/deliverables/deliverable-file-references/src/index.ts) | @@ -4770,6 +4789,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-mcp-resources` | `tools` | [`packages/mcp/mcp-resources/src/index.ts`](../packages/mcp/mcp-resources/src/index.ts) | | `@deepseek-ai/dsh-message-quotes` | — | [`packages/session/message-quotes/src/index.ts`](../packages/session/message-quotes/src/index.ts) | | `@deepseek-ai/dsh-otel` | — | [`packages/telemetry/otel/src/index.ts`](../packages/telemetry/otel/src/index.ts) | +| `@deepseek-ai/dsh-plugin-recommendations` | `pluginManager` · `agents` · `skills` · `actionCards` | [`packages/experimental/plugin-recommendations/src/index.ts`](../packages/experimental/plugin-recommendations/src/index.ts) | | `@deepseek-ai/dsh-sandbox-ssh` | `ssh` | [`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts) | | `@deepseek-ai/dsh-session` | — | [`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts) | | `@deepseek-ai/dsh-session-checkpoint-policy` | `llm` · `sessionPersistence` · `sessions` · `tools` | [`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts) | @@ -4847,6 +4867,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. | `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) | | `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-inspector-profile` | — | [`packages/experimental/inspector-profile/src/index.ts`](../packages/experimental/inspector-profile/src/index.ts) | +| `@deepseek-ai/dsh-experimental-liveshare-protocol` | — | [`packages/experimental/liveshare-protocol/src/index.ts`](../packages/experimental/liveshare-protocol/src/index.ts) | | `@deepseek-ai/dsh-experimental-plugin-market-bundle` | — | [`packages/experimental/plugin-market-bundle/src/index.ts`](../packages/experimental/plugin-market-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-ralph-bundle` | — | [`packages/experimental/ralph-bundle/src/index.ts`](../packages/experimental/ralph-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-session-search` | — | [`packages/experimental/session-search/src/index.ts`](../packages/experimental/session-search/src/index.ts) | @@ -4867,6 +4888,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. | `@deepseek-ai/dsh-official-bundles` | — | [`packages/util/official-bundles/src/index.ts`](../packages/util/official-bundles/src/index.ts) | | `@deepseek-ai/dsh-output-retention` | — | [`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts) | | `@deepseek-ai/dsh-package-manifest` | — | [`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts) | +| `@deepseek-ai/dsh-personalized-recommendations` | — | [`packages/experimental/personalized-recommendations/src/index.ts`](../packages/experimental/personalized-recommendations/src/index.ts) | | `@deepseek-ai/dsh-plugin-detail` | — | [`packages/util/plugin-detail/src/index.ts`](../packages/util/plugin-detail/src/index.ts) | | `@deepseek-ai/dsh-plugin-market-index` | — | [`packages/util/plugin-market-index/src/index.ts`](../packages/util/plugin-market-index/src/index.ts) | | `@deepseek-ai/dsh-remote-mock` | — | [`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 6e33f667ba..552c94902b 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -35,6 +35,21 @@ export interface AcpConfig { ``` + + + +## `@deepseek-ai/dsh-action-cards` + +- `inject`: `tools` · `agents` · `sessionProjections` · `remoteAccess` +- `refs`: `z` (`zod`) +- `source`: [`packages/experimental/action-cards/src/index.ts:23`](../packages/experimental/action-cards/src/index.ts) + +```ts config-catalog +/** Validated card limits after schema defaults. */ +export type Config = z.infer +``` + + @@ -190,7 +205,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-gateway` - `inject`: `typert` · `remoteAccess` -- `source`: [`packages/api/gateway/src/index.ts:156`](../packages/api/gateway/src/index.ts) +- `source`: [`packages/api/gateway/src/index.ts:158`](../packages/api/gateway/src/index.ts) ```ts config-catalog /** Gateway transport configuration. */ @@ -229,8 +244,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts) +- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `typertGateway` · `workspaceRegistry` +- `source`: [`packages/api/session-controller/src/index.ts:110`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ @@ -2477,7 +2492,7 @@ export interface PlanModeConfig { - `inject`: `loader` · `profileContext` - `refs`: [`NpmFileSource`](subsystems/boot.zh.md) -- `source`: [`packages/boot/plugin-manager/src/index.ts:53`](../packages/boot/plugin-manager/src/index.ts) +- `source`: [`packages/boot/plugin-manager/src/index.ts:54`](../packages/boot/plugin-manager/src/index.ts) ```ts config-catalog /** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ @@ -3729,7 +3744,7 @@ export type ShellDialect = 'bash' | 'pwsh' ## `@deepseek-ai/dsh-time-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/time-context/src/index.ts:47`](../packages/context/time-context/src/index.ts) +- `source`: [`packages/context/time-context/src/index.ts:52`](../packages/context/time-context/src/index.ts) ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -3828,7 +3843,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash-persistent` - `inject`: `tools` · `terminals` · `workingDirectory` -- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:445`](../packages/shell/tool-bash-persistent/src/index.ts) +- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:447`](../packages/shell/tool-bash-persistent/src/index.ts) ```ts config-catalog /** Configuration for the persistent Bash tool. */ @@ -4691,6 +4706,7 @@ export interface Config { | `@deepseek-ai/dsh-client-locale` | — | [`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts) | | `@deepseek-ai/dsh-client-modules` | `loader` | [`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts) | | `@deepseek-ai/dsh-client-resources` | — | [`packages/client/resources/src/index.ts`](../packages/client/resources/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-action-cards` | — | [`packages/experimental/client-ui-action-cards/src/index.ts`](../packages/experimental/client-ui-action-cards/src/index.ts) | | `@deepseek-ai/dsh-client-ui-agent-preset` | — | [`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts) | | `@deepseek-ai/dsh-client-ui-approval` | — | [`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts) | | `@deepseek-ai/dsh-client-ui-attachment` | — | [`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts) | @@ -4699,6 +4715,7 @@ export interface Config { | `@deepseek-ai/dsh-client-ui-commands` | — | [`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts) | | `@deepseek-ai/dsh-client-ui-conversation` | — | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) | | `@deepseek-ai/dsh-client-ui-cordis` | — | [`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-creation-recommendations` | — | [`packages/experimental/client-ui-creation-recommendations/src/index.ts`](../packages/experimental/client-ui-creation-recommendations/src/index.ts) | | `@deepseek-ai/dsh-client-ui-deepseek-model-label-1017` | — | [`packages/client/ui-deepseek-model-label-1017/src/index.ts`](../packages/client/ui-deepseek-model-label-1017/src/index.ts) | | `@deepseek-ai/dsh-client-ui-deliverables` | `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` · `workspaceChanges` | [`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts) | | `@deepseek-ai/dsh-client-ui-directory-picker-browse` | — | [`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts) | @@ -4715,6 +4732,7 @@ export interface Config { | `@deepseek-ai/dsh-client-ui-permission-presets` | — | [`packages/client/ui-permission-presets/src/index.ts`](../packages/client/ui-permission-presets/src/index.ts) | | `@deepseek-ai/dsh-client-ui-plan` | — | [`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts) | | `@deepseek-ai/dsh-client-ui-plugin-market` | — | [`packages/client/ui-plugin-market/src/index.ts`](../packages/client/ui-plugin-market/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-plugin-recommendations` | — | [`packages/experimental/client-ui-plugin-recommendations/src/index.ts`](../packages/experimental/client-ui-plugin-recommendations/src/index.ts) | | `@deepseek-ai/dsh-client-ui-reference` | — | [`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts) | | `@deepseek-ai/dsh-client-ui-renderer` | — | [`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts) | | `@deepseek-ai/dsh-client-ui-schedule` | — | [`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts) | @@ -4750,6 +4768,7 @@ export interface Config { | `@deepseek-ai/dsh-computer-use` | — | [`packages/computer-use/computer-use/src/index.ts`](../packages/computer-use/computer-use/src/index.ts) | | `@deepseek-ai/dsh-config-editor` | `loader` · `profileContext` | [`packages/boot/config-editor/src/index.ts`](../packages/boot/config-editor/src/index.ts) | | `@deepseek-ai/dsh-cordis-client-runner` | — | [`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts) | +| `@deepseek-ai/dsh-creation-recommendations` | `tools` · `pluginManager` · `agents` · `subagents` · `profileContext` · `skills` · `actionCards` | [`packages/experimental/creation-recommendations/src/index.ts`](../packages/experimental/creation-recommendations/src/index.ts) | | `@deepseek-ai/dsh-cua` | — | [`packages/experimental/cua/src/index.ts`](../packages/experimental/cua/src/index.ts) | | `@deepseek-ai/dsh-deepseek-llm-api-extensions` | — | [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts) | | `@deepseek-ai/dsh-deliverable-file-references` | `systemPrompt` | [`packages/deliverables/deliverable-file-references/src/index.ts`](../packages/deliverables/deliverable-file-references/src/index.ts) | @@ -4772,6 +4791,7 @@ export interface Config { | `@deepseek-ai/dsh-mcp-resources` | `tools` | [`packages/mcp/mcp-resources/src/index.ts`](../packages/mcp/mcp-resources/src/index.ts) | | `@deepseek-ai/dsh-message-quotes` | — | [`packages/session/message-quotes/src/index.ts`](../packages/session/message-quotes/src/index.ts) | | `@deepseek-ai/dsh-otel` | — | [`packages/telemetry/otel/src/index.ts`](../packages/telemetry/otel/src/index.ts) | +| `@deepseek-ai/dsh-plugin-recommendations` | `pluginManager` · `agents` · `skills` · `actionCards` | [`packages/experimental/plugin-recommendations/src/index.ts`](../packages/experimental/plugin-recommendations/src/index.ts) | | `@deepseek-ai/dsh-sandbox-ssh` | `ssh` | [`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts) | | `@deepseek-ai/dsh-session` | — | [`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts) | | `@deepseek-ai/dsh-session-checkpoint-policy` | `llm` · `sessionPersistence` · `sessions` · `tools` | [`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts) | @@ -4849,6 +4869,7 @@ export interface Config { | `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) | | `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-inspector-profile` | — | [`packages/experimental/inspector-profile/src/index.ts`](../packages/experimental/inspector-profile/src/index.ts) | +| `@deepseek-ai/dsh-experimental-liveshare-protocol` | — | [`packages/experimental/liveshare-protocol/src/index.ts`](../packages/experimental/liveshare-protocol/src/index.ts) | | `@deepseek-ai/dsh-experimental-plugin-market-bundle` | — | [`packages/experimental/plugin-market-bundle/src/index.ts`](../packages/experimental/plugin-market-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-ralph-bundle` | — | [`packages/experimental/ralph-bundle/src/index.ts`](../packages/experimental/ralph-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-session-search` | — | [`packages/experimental/session-search/src/index.ts`](../packages/experimental/session-search/src/index.ts) | @@ -4869,6 +4890,7 @@ export interface Config { | `@deepseek-ai/dsh-official-bundles` | — | [`packages/util/official-bundles/src/index.ts`](../packages/util/official-bundles/src/index.ts) | | `@deepseek-ai/dsh-output-retention` | — | [`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts) | | `@deepseek-ai/dsh-package-manifest` | — | [`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts) | +| `@deepseek-ai/dsh-personalized-recommendations` | — | [`packages/experimental/personalized-recommendations/src/index.ts`](../packages/experimental/personalized-recommendations/src/index.ts) | | `@deepseek-ai/dsh-plugin-detail` | — | [`packages/util/plugin-detail/src/index.ts`](../packages/util/plugin-detail/src/index.ts) | | `@deepseek-ai/dsh-plugin-market-index` | — | [`packages/util/plugin-market-index/src/index.ts`](../packages/util/plugin-market-index/src/index.ts) | | `@deepseek-ai/dsh-remote-mock` | — | [`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts) | diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 8e7eb9fca0..999b732bc7 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -21,8 +21,8 @@ en: 3221d1076554001f zh: 29f166be6a5c04d9 /development-guide/contributor-reference/typescript-project-layout: - en: d411ac945820742a - zh: a4515722a107beae + en: 12810e1c6ca8b8e4 + zh: b505d34cdf27856d /development-guide/contributor-reference/environment-variables: en: dba2c72ee6ffa930 zh: b65c27fbb9129ec8 diff --git a/docs/development.md b/docs/development.md index f78e090fb1..76e34201cb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -66,7 +66,7 @@ The repository uses isolated Host and Client aggregates. An ordinary package is | `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No | | `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No | -Host aggregate type checks run with a 6 GiB Node old-space limit across build, release, and Windows diagnostic commands. This is a per-compiler limit; concurrent build steps need additional memory. +Host aggregate type checks run with a 8 GiB Node old-space limit across build, release, and Windows diagnostic commands. This is a per-compiler limit; concurrent build steps need additional memory. Host and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow: @@ -74,9 +74,9 @@ Host and Client stay two aggregate programs because both sides declaration-merge - A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. - A new package is registered in exactly one aggregate; only the split packages above carry both leaf configs, and the shared leaves are registered in both aggregates because each side must type-check the same source. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase. -Six packages split Host and Client tsconfigs: `api/remotes`, `api/gateway`, `api/session-controller`, `api/workspace-controller`, `client/connection`, and `session-query/session-log-export`. `api/remotes`' Host entry participates in the Host Typert graph while its Client entry imports generated `/remote` declarations; `session-log-export` keeps Node archive production out of its browser controller. Each split package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf; it discovers split packages from the presence of both leaf configs, so a new split joins the gate automatically. The [`api-remotes` README](../packages/api/remotes/README.md) and [`session-log-export` README](../packages/session-query/session-log-export/README.md) explain their splits. +Seven packages split Host and Client tsconfigs: `api/remotes`, `api/gateway`, `api/session-controller`, `api/workspace-controller`, `client/connection`, `session-query/session-log-export`, and `experimental/liveshare-protocol`. `api/remotes`' Host entry participates in the Host Typert graph while its Client entry imports generated `/remote` declarations; `session-log-export` keeps Node archive production out of its browser controller; `liveshare-protocol` keeps the Node wire codecs on its Host leaf and the browser viewer carrier on its Client leaf. Each split package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf; it discovers split packages from the presence of both leaf configs, so a new split joins the gate automatically. The [`api-remotes` README](../packages/api/remotes/README.md) and [`session-log-export` README](../packages/session-query/session-log-export/README.md) explain their splits. -`pnpm run build:artifacts` emits every artifact in the generated dependency order, checking and compiling only the package references of each compiler face: +`pnpm run build:artifacts` emits every artifact, checking and compiling only the package references of each compiler face. Client compilation waits for Host and Desktop bundling to finish: split compiler faces rewrite shared `lib/types` files read by those bundles. Client bundling follows Client compilation. The artifact order is: ```sh pnpm run build:native-system diff --git a/docs/development.zh.md b/docs/development.zh.md index fe800d2250..ac61221c52 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -70,7 +70,7 @@ pnpm run typecheck | `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 | | `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` 包 extends。 | 否 | -构建、发布和 Windows 诊断命令中的 Host aggregate 类型检查为 Node 老生代堆设置 6 GiB 上限。这是单个编译进程的上限;并发构建步骤还需要额外内存。 +构建、发布和 Windows 诊断命令中的 Host aggregate 类型检查为 Node 老生代堆设置 8 GiB 上限。这是单个编译进程的上限;并发构建步骤还需要额外内存。 Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律: @@ -78,9 +78,9 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 - 构造全仓 `ts.Program` 的脚本显式以 `tsconfig.host.json` 或 `tsconfig.client.json` 为种子——根 solution 永不作为种子,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。 - 新包只登记进一个 aggregate;只有上述拆分包同时携带两个 leaf 配置,共享 leaf 因两侧需要对同一份源码做类型检查而登记进两个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client 插件的两份运行时产物都在 Client 构建阶段生成。 -拆分 Host/Client tsconfig 的包有六个:`api/remotes`、`api/gateway`、`api/session-controller`、`api/workspace-controller`、`client/connection` 与 `session-query/session-log-export`。`api/remotes` 的 Host 入口进入 Host Typert 图,而 Client 入口导入生成的 `/remote` 声明;`session-log-export` 则让 Node archive 生产代码不进入浏览器 controller。每个拆分包根 `tsconfig.json` 因此只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。[`api-remotes` README](../packages/api/remotes/README.zh.md) 与 [`session-log-export` README](../packages/session-query/session-log-export/README.zh.md)分别说明其拆分。 +拆分 Host/Client tsconfig 的包有七个:`api/remotes`、`api/gateway`、`api/session-controller`、`api/workspace-controller`、`client/connection`、`session-query/session-log-export` 与 `experimental/liveshare-protocol`。`api/remotes` 的 Host 入口进入 Host Typert 图,而 Client 入口导入生成的 `/remote` 声明;`session-log-export` 则让 Node archive 生产代码不进入浏览器 controller;`liveshare-protocol` 把 Node 线路编解码放在 Host leaf、浏览器查看页载体放在 Client leaf。每个拆分包根 `tsconfig.json` 因此只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。[`api-remotes` README](../packages/api/remotes/README.zh.md) 与 [`session-log-export` README](../packages/session-query/session-log-export/README.zh.md)分别说明其拆分。 -`pnpm run build:artifacts` 按生成依赖顺序产出全部产物,只检查并编译各 compiler face 的包 reference: +`pnpm run build:artifacts` 产出全部产物,只检查并编译各 compiler face 的包 reference。Client 编译等待 Host 和 Desktop 的 bundle 完成:拆分 compiler face 会重写这些 bundle 步骤读取的共享 `lib/types` 文件。Client bundle 在 Client 编译后执行。产物步骤顺序为: ```sh pnpm run build:native-system diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 9899d7369f..7cb4b30b18 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -13,21 +13,21 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:199`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:689`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:669`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:696`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:675`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:682`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | @@ -83,7 +83,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | +| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | @@ -108,7 +108,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `internal/service` | - | `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) | | `internal/update` | - | [`app-boot`](../packages/boot/app-boot) | -| `loader/volatile-update` | - | [`cua`](../packages/experimental/cua), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` | +| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` | | `slots/changed` | `ui-renderer` (`emit`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index f0d2d89c66..1e88e78ad2 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -15,21 +15,21 @@ | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:199`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:689`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:669`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:696`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:675`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:682`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | @@ -85,7 +85,7 @@ | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | +| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | @@ -110,7 +110,7 @@ | `internal/service` | - | `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) | | `internal/update` | - | [`app-boot`](../packages/boot/app-boot) | -| `loader/volatile-update` | - | [`cua`](../packages/experimental/cua), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` | +| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` | | `slots/changed` | `ui-renderer` (`emit`) | - | diff --git a/docs/experimental-persistence-catalog.i18n.yaml b/docs/experimental-persistence-catalog.i18n.yaml index a65825ab71..7dd24c2aa4 100644 --- a/docs/experimental-persistence-catalog.i18n.yaml +++ b/docs/experimental-persistence-catalog.i18n.yaml @@ -6,5 +6,5 @@ en: a11f106978c9dce7 zh: a11f106978c9dce7 /experimental-plugin-record-catalog: - en: 84dbc9c06ab92877 - zh: b82ef2a46292ab0e + en: cb931db3b4bcbe33 + zh: aca9cfecfdee34c0 diff --git a/docs/experimental-persistence-catalog.md b/docs/experimental-persistence-catalog.md index 0868849c51..b6d3a73de4 100644 --- a/docs/experimental-persistence-catalog.md +++ b/docs/experimental-persistence-catalog.md @@ -13,5 +13,6 @@ Shared Session event declarations and resolved schemas appear in the [Session pe | Record | Owner | Purpose | Payload and source | |---|---|---|---| +| `plugin:action-cards/decision` | `@deepseek-ai/dsh-action-cards` | Dismissals and kind-defined progress of action cards. | ActionCardDecision · [`packages/experimental/action-cards/src/types.ts`](../packages/experimental/action-cards/src/types.ts) | | `plugin:translator/request` | `@deepseek-ai/dsh-experimental-translator` | Provider-independent translation input, retained before dispatch. | TranslationRequestRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | | `plugin:translator/result` | `@deepseek-ai/dsh-experimental-translator` | Successful translation, retained before it is returned to the consumer. | TranslationResultRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | diff --git a/docs/experimental-persistence-catalog.zh.md b/docs/experimental-persistence-catalog.zh.md index 32cb1d0533..c32cdcabd0 100644 --- a/docs/experimental-persistence-catalog.zh.md +++ b/docs/experimental-persistence-catalog.zh.md @@ -13,5 +13,6 @@ | 记录 | 所属包 | 用途 | 载荷与源码 | |---|---|---|---| +| `plugin:action-cards/decision` | `@deepseek-ai/dsh-action-cards` | Dismissals and kind-defined progress of action cards. | ActionCardDecision · [`packages/experimental/action-cards/src/types.ts`](../packages/experimental/action-cards/src/types.ts) | | `plugin:translator/request` | `@deepseek-ai/dsh-experimental-translator` | Provider-independent translation input, retained before dispatch. | TranslationRequestRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | | `plugin:translator/result` | `@deepseek-ai/dsh-experimental-translator` | Successful translation, retained before it is returned to the consumer. | TranslationResultRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 492f4ed711..f17471eb34 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -254,6 +254,11 @@ flowchart TD pkg_office_to_pdf["office-to-pdf"] end subgraph group_experimental["packages/experimental"] + pkg_action_cards["action-cards"] + pkg_client_ui_action_cards["client-ui-action-cards"] + pkg_client_ui_creation_recommendations["client-ui-creation-recommendations"] + pkg_client_ui_plugin_recommendations["client-ui-plugin-recommendations"] + pkg_creation_recommendations["creation-recommendations"] pkg_cua["cua"] pkg_cua_bundle["cua-bundle"] pkg_experimental_agent_team["experimental-agent-team"] @@ -276,6 +281,7 @@ flowchart TD pkg_experimental_cua_macos["experimental-cua-macos"] pkg_experimental_inspector["experimental-inspector"] pkg_experimental_inspector_profile["experimental-inspector-profile"] + pkg_experimental_liveshare_protocol["experimental-liveshare-protocol"] pkg_experimental_plugin_market_bundle["experimental-plugin-market-bundle"] pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"] pkg_experimental_ralph_bundle["experimental-ralph-bundle"] @@ -297,6 +303,8 @@ flowchart TD pkg_hook_protocol["hook-protocol"] pkg_hooks_claude_code["hooks-claude-code"] pkg_hooks_codex["hooks-codex"] + pkg_personalized_recommendations["personalized-recommendations"] + pkg_plugin_recommendations["plugin-recommendations"] pkg_session_title_all_prompts_llm["session-title-all-prompts-llm"] pkg_skill_badge["skill-badge"] pkg_tool_ralph["tool-ralph"] @@ -515,6 +523,9 @@ flowchart TD pkg_experimental_webworker_runtime --> pkg_client_connection pkg_experimental_webworker_runtime --> pkg_client_modules pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_plugin_recommendations --> pkg_llm + pkg_plugin_recommendations --> pkg_skill + pkg_plugin_recommendations --> pkg_typert_protocol pkg_skill_badge --> pkg_skill pkg_host_directory_picker_auto --> pkg_client_connection pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse @@ -924,6 +935,14 @@ flowchart TD pkg_workspace_changes --> pkg_session pkg_workspace_changes --> pkg_subprocess pkg_workspace_changes --> pkg_tools + pkg_action_cards --> pkg_llm + pkg_action_cards --> pkg_session + pkg_action_cards --> pkg_tools + pkg_action_cards --> pkg_typert_protocol + pkg_creation_recommendations --> pkg_llm + pkg_creation_recommendations --> pkg_session + pkg_creation_recommendations --> pkg_skill + pkg_creation_recommendations --> pkg_tools pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_agent pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_browser_use pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_system_prompt @@ -1560,6 +1579,9 @@ flowchart TD | [`client-web`](../packages/client/web) | `client` | — | | [`credentials`](../packages/credentials/credentials) | `credentials` | — | | [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — | +| [`client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | `experimental` | — | +| [`client-ui-creation-recommendations`](../packages/experimental/client-ui-creation-recommendations) | `experimental` | — | +| [`client-ui-plugin-recommendations`](../packages/experimental/client-ui-plugin-recommendations) | `experimental` | — | | [`cua-bundle`](../packages/experimental/cua-bundle) | `experimental` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-badge-skill-bundle`](../packages/experimental/badge-skill-bundle) | `experimental` | — | @@ -1569,6 +1591,7 @@ flowchart TD | [`experimental-cot-translation-bundle`](../packages/experimental/cot-translation-bundle) | `experimental` | — | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | — | | [`experimental-inspector-profile`](../packages/experimental/inspector-profile) | `experimental` | — | +| [`experimental-liveshare-protocol`](../packages/experimental/liveshare-protocol) | `experimental` | — | | [`experimental-plugin-market-bundle`](../packages/experimental/plugin-market-bundle) | `experimental` | — | | [`experimental-ralph-bundle`](../packages/experimental/ralph-bundle) | `experimental` | — | | [`experimental-session-inspector`](../packages/experimental/session-inspector) | `experimental` | — | @@ -1577,6 +1600,7 @@ flowchart TD | [`experimental-terminal-bundle`](../packages/experimental/terminal-bundle) | `experimental` | — | | [`experimental-voice-input-bundle`](../packages/experimental/voice-input-bundle) | `experimental` | — | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — | +| [`personalized-recommendations`](../packages/experimental/personalized-recommendations) | `experimental` | — | | [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | — | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | — | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | — | @@ -1628,6 +1652,7 @@ flowchart TD | [`deliverable-file-references`](../packages/deliverables/deliverable-file-references) | `deliverables` | [`system-prompt`](../packages/core/system-prompt) | | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | +| [`plugin-recommendations`](../packages/experimental/plugin-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`typert-protocol`](../packages/typert/protocol) | | [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | @@ -1730,6 +1755,8 @@ flowchart TD | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-present`](../packages/deliverables/tool-present) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | +| [`action-cards`](../packages/experimental/action-cards) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`creation-recommendations`](../packages/experimental/creation-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 9c086c9ac7..baf721dc22 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -256,6 +256,11 @@ flowchart TD pkg_office_to_pdf["office-to-pdf"] end subgraph group_experimental["packages/experimental"] + pkg_action_cards["action-cards"] + pkg_client_ui_action_cards["client-ui-action-cards"] + pkg_client_ui_creation_recommendations["client-ui-creation-recommendations"] + pkg_client_ui_plugin_recommendations["client-ui-plugin-recommendations"] + pkg_creation_recommendations["creation-recommendations"] pkg_cua["cua"] pkg_cua_bundle["cua-bundle"] pkg_experimental_agent_team["experimental-agent-team"] @@ -278,6 +283,7 @@ flowchart TD pkg_experimental_cua_macos["experimental-cua-macos"] pkg_experimental_inspector["experimental-inspector"] pkg_experimental_inspector_profile["experimental-inspector-profile"] + pkg_experimental_liveshare_protocol["experimental-liveshare-protocol"] pkg_experimental_plugin_market_bundle["experimental-plugin-market-bundle"] pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"] pkg_experimental_ralph_bundle["experimental-ralph-bundle"] @@ -299,6 +305,8 @@ flowchart TD pkg_hook_protocol["hook-protocol"] pkg_hooks_claude_code["hooks-claude-code"] pkg_hooks_codex["hooks-codex"] + pkg_personalized_recommendations["personalized-recommendations"] + pkg_plugin_recommendations["plugin-recommendations"] pkg_session_title_all_prompts_llm["session-title-all-prompts-llm"] pkg_skill_badge["skill-badge"] pkg_tool_ralph["tool-ralph"] @@ -517,6 +525,9 @@ flowchart TD pkg_experimental_webworker_runtime --> pkg_client_connection pkg_experimental_webworker_runtime --> pkg_client_modules pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_plugin_recommendations --> pkg_llm + pkg_plugin_recommendations --> pkg_skill + pkg_plugin_recommendations --> pkg_typert_protocol pkg_skill_badge --> pkg_skill pkg_host_directory_picker_auto --> pkg_client_connection pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse @@ -926,6 +937,14 @@ flowchart TD pkg_workspace_changes --> pkg_session pkg_workspace_changes --> pkg_subprocess pkg_workspace_changes --> pkg_tools + pkg_action_cards --> pkg_llm + pkg_action_cards --> pkg_session + pkg_action_cards --> pkg_tools + pkg_action_cards --> pkg_typert_protocol + pkg_creation_recommendations --> pkg_llm + pkg_creation_recommendations --> pkg_session + pkg_creation_recommendations --> pkg_skill + pkg_creation_recommendations --> pkg_tools pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_agent pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_browser_use pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_system_prompt @@ -1562,6 +1581,9 @@ flowchart TD | [`client-web`](../packages/client/web) | `client` | — | | [`credentials`](../packages/credentials/credentials) | `credentials` | — | | [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — | +| [`client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | `experimental` | — | +| [`client-ui-creation-recommendations`](../packages/experimental/client-ui-creation-recommendations) | `experimental` | — | +| [`client-ui-plugin-recommendations`](../packages/experimental/client-ui-plugin-recommendations) | `experimental` | — | | [`cua-bundle`](../packages/experimental/cua-bundle) | `experimental` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-badge-skill-bundle`](../packages/experimental/badge-skill-bundle) | `experimental` | — | @@ -1571,6 +1593,7 @@ flowchart TD | [`experimental-cot-translation-bundle`](../packages/experimental/cot-translation-bundle) | `experimental` | — | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | — | | [`experimental-inspector-profile`](../packages/experimental/inspector-profile) | `experimental` | — | +| [`experimental-liveshare-protocol`](../packages/experimental/liveshare-protocol) | `experimental` | — | | [`experimental-plugin-market-bundle`](../packages/experimental/plugin-market-bundle) | `experimental` | — | | [`experimental-ralph-bundle`](../packages/experimental/ralph-bundle) | `experimental` | — | | [`experimental-session-inspector`](../packages/experimental/session-inspector) | `experimental` | — | @@ -1579,6 +1602,7 @@ flowchart TD | [`experimental-terminal-bundle`](../packages/experimental/terminal-bundle) | `experimental` | — | | [`experimental-voice-input-bundle`](../packages/experimental/voice-input-bundle) | `experimental` | — | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — | +| [`personalized-recommendations`](../packages/experimental/personalized-recommendations) | `experimental` | — | | [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | — | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | — | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | — | @@ -1630,6 +1654,7 @@ flowchart TD | [`deliverable-file-references`](../packages/deliverables/deliverable-file-references) | `deliverables` | [`system-prompt`](../packages/core/system-prompt) | | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | +| [`plugin-recommendations`](../packages/experimental/plugin-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`typert-protocol`](../packages/typert/protocol) | | [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | @@ -1732,6 +1757,8 @@ flowchart TD | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-present`](../packages/deliverables/tool-present) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | +| [`action-cards`](../packages/experimental/action-cards) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`creation-recommendations`](../packages/experimental/creation-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index c345f7e1b7..4ec686df63 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -9,11 +9,11 @@ en: 1387c4505dde2faf zh: 48570fa1c13d6910 /session-persistence-event-catalog/persistence-type-fingerprints: - en: bcff48893d904af8 - zh: f82e75c91c39adb1 + en: 3e24343c4745793d + zh: 90f808fd6b79c5a3 /session-persistence-event-catalog/event-envelope: - en: 981db6b1928eda50 - zh: d77bc66cf678099d + en: 56414ffd00cdc32a + zh: 6dc11eb013e95e41 /session-persistence-event-catalog/events: en: f4776ba3d0fd53ce zh: e86769f50a69055a @@ -45,11 +45,11 @@ en: 44426b6cd0dc01cf zh: 44426b6cd0dc01cf /session-persistence-event-catalog/events/assistant/assistant-attempt-log-only: - en: 4e684124f71bd73b - zh: 445ad50a3a4fb635 + en: f330b2521ca12383 + zh: cdc6912a9244677d /session-persistence-event-catalog/events/assistant/assistant-message-surface: - en: 87d9bb62e2aea4b9 - zh: 68ef3b05d4aad7d3 + en: 95916897e677eee6 + zh: e1ea8b17e5af9f35 /session-persistence-event-catalog/events/command: en: 2cecb5b0ee955732 zh: 2cecb5b0ee955732 @@ -84,8 +84,8 @@ en: 3344204b1858edb1 zh: 3344204b1858edb1 /session-persistence-event-catalog/events/developer/developer-message-surface: - en: 927f25c5dbd69c82 - zh: 9c0331f6e61d7e2b + en: fd716cfc669a05a0 + zh: 8693a334958ab3cf /session-persistence-event-catalog/events/feedback: en: f50dbac6867fa64d zh: f50dbac6867fa64d @@ -159,11 +159,11 @@ en: a8df82440ebdf5f8 zh: a8df82440ebdf5f8 /session-persistence-event-catalog/events/request/request-context-log-only: - en: 9167d83823675b3e - zh: dfe9095419cf76f4 + en: 22abf9a3f69d498f + zh: 94055a136b641207 /session-persistence-event-catalog/events/request/request-header-log-only: - en: 6d9e26081d1e3aec - zh: 36d30d0bff197d1e + en: 6c506b420662b60e + zh: 823513802c2cc394 /session-persistence-event-catalog/events/sandbox: en: ec841ab8439335b0 zh: ec841ab8439335b0 @@ -180,8 +180,8 @@ en: a1ac8f2660a4f61e zh: a1ac8f2660a4f61e /session-persistence-event-catalog/events/session/session-end-seed-log-only: - en: f285c670d1bf0e71 - zh: 14ec5689a5943156 + en: 20645a60e921f012 + zh: 7141cafe3a0e71d7 /session-persistence-event-catalog/events/session/session-title-log-only: en: 6c6a27578dff5828 zh: 9a5add2bc0df2d0a @@ -198,11 +198,11 @@ en: bd0dcbbc352b771a zh: bd0dcbbc352b771a /session-persistence-event-catalog/events/step/step-end-log-only: - en: 24bc99ca25809609 - zh: 420384e753feba64 + en: b30d8571ba611386 + zh: 42e45b49142c306c /session-persistence-event-catalog/events/step/step-start-log-only: - en: 269a493d00348184 - zh: 80feab63a0431c60 + en: 161a661514649037 + zh: 5a2e3ec5af378746 /session-persistence-event-catalog/events/subagent: en: 9630752e97ddbfbc zh: 9630752e97ddbfbc @@ -219,8 +219,8 @@ en: 8cac41d2fb575975 zh: 8cac41d2fb575975 /session-persistence-event-catalog/events/system/system-message-surface: - en: 23aedcadb0c95a51 - zh: f58e5195075ddb21 + en: f08085060f967358 + zh: 4d77a8636d9bfef5 /session-persistence-event-catalog/events/team: en: a640800a38b3c874 zh: a640800a38b3c874 @@ -246,8 +246,8 @@ en: 28a24430609494b2 zh: 28a24430609494b2 /session-persistence-event-catalog/events/tool/tool-call-log-only: - en: b5dfd3b4c54281c3 - zh: 3ac01ceab4a36d06 + en: 2edbe249bcd484fc + zh: fa838ad8325507b4 /session-persistence-event-catalog/events/tool/tool-ptc-dispatch-log-only: en: a62b38c00afed655 zh: 793bef93e856eafa @@ -255,8 +255,8 @@ en: da841b1ed3b22c87 zh: 4c9a4d4bc5bd76b6 /session-persistence-event-catalog/events/tool/tool-result-surface: - en: 722a3bf051b384eb - zh: 01e1258be37a82ce + en: e5fdf9592ffa86b4 + zh: 7455727a8f208a1c /session-persistence-event-catalog/events/tool-workflow: en: 1eac7deac6af15f3 zh: 1eac7deac6af15f3 @@ -276,17 +276,17 @@ en: d7e8c999a0851a0c zh: d7e8c999a0851a0c /session-persistence-event-catalog/events/turn/turn-end-log-only: - en: 38d2280f449d3112 - zh: 0f7f05475ea514a3 + en: 7d5bacee7392ed18 + zh: 65b8eb8ab9b10ccd /session-persistence-event-catalog/events/turn/turn-start-log-only: - en: 853b7f23d092c48a - zh: 53e312793b609198 + en: 1b5d3369f6178575 + zh: 34550bedca73336a /session-persistence-event-catalog/events/user: en: d864a88c03e8971c zh: d864a88c03e8971c /session-persistence-event-catalog/events/user/user-message-surface: - en: ba681b5ec685d6e8 - zh: e4b14b585d2b5804 + en: 836d2e0de5f33816 + zh: 67055e83e820f5f5 /session-persistence-event-catalog/events/web: en: d278748103cf1640 zh: d278748103cf1640 @@ -309,8 +309,11 @@ en: 3493ecf0993e5397 zh: ec95688f593a4ed4 /session-persistence-event-catalog/resolved-persistence-types/aborted: - en: d716fb9ee9baa564 - zh: d716fb9ee9baa564 + en: 40049fd36471e4b2 + zh: 40049fd36471e4b2 +/session-persistence-event-catalog/resolved-persistence-types/action-cards: + en: 629f821b50a38bc6 + zh: 629f821b50a38bc6 /session-persistence-event-catalog/resolved-persistence-types/active: en: 6b343e390ae7d7a7 zh: 6b343e390ae7d7a7 @@ -981,8 +984,8 @@ en: e393deed01de3665 zh: 0fa98ad93c22ce79 /session-persistence-event-catalog/resolved-persistence-types/commandsource: - en: 53feeeb70547e7df - zh: 99d9e901d9b4afe5 + en: 459a6f8c8800d3e5 + zh: f1b2626c08c62b4d /session-persistence-event-catalog/resolved-persistence-types/compactioncheckpointsource: en: 87f2aaf2b23a5249 zh: 3d6c5ed6603bee31 @@ -1005,14 +1008,14 @@ en: a6a2e38fdd6d127a zh: 1fe82356d89f5769 /session-persistence-event-catalog/resolved-persistence-types/deepseeksearchllmrequest: - en: 128c0ecc1897eb68 - zh: e4c67c92752bcc2a + en: 0e286bae74e78505 + zh: b1c9da5aa56d5a81 /session-persistence-event-catalog/resolved-persistence-types/developermessage: - en: 997b5db27df00446 - zh: 07e5197ddd7bd59b + en: 3d8a393be23efcde + zh: f95f337d816dbbe7 /session-persistence-event-catalog/resolved-persistence-types/epochheader: - en: c804cb9e2e31c5ec - zh: 3ed7ac59cd1441b7 + en: 3bdc8ecd0bdf0c9c + zh: 0fe5b75c174ebab4 /session-persistence-event-catalog/resolved-persistence-types/everyscheduledispatchchange: en: 54c487394ce0db1d zh: 48d7c9affbe841cb @@ -1119,11 +1122,11 @@ en: 6dbe099c62f7c226 zh: 6fd788f63c038c91 /session-persistence-event-catalog/resolved-persistence-types/llmretrystartedeventdata: - en: c2d9edd8d9f492b7 - zh: f59c687ac691f7ce + en: 3d2f7c88259b6446 + zh: 58bee68c335d6d4f /session-persistence-event-catalog/resolved-persistence-types/message: - en: 594707e4894819a8 - zh: 268c4bfd5c4c28f2 + en: 76d13d39fe22f7bd + zh: b1e19cfe90a6d6b6 /session-persistence-event-catalog/resolved-persistence-types/messagefeedbackdelete: en: c582953270d4ca44 zh: 966ccfe2e7660a2a @@ -1134,14 +1137,14 @@ en: f58575a1fc96df42 zh: 9f0c6ed9033e5c9d /session-persistence-event-catalog/resolved-persistence-types/messagefeedbackrating: - en: 674296d54c1914d0 - zh: 3d71ab319514be25 + en: 887549d6401dd116 + zh: 30198672b67dac6b /session-persistence-event-catalog/resolved-persistence-types/messagesource: - en: 3fab179fe8d58c31 - zh: 5328c9161ff908a9 + en: 2f21414632f8d184 + zh: d85f66e001fd814c /session-persistence-event-catalog/resolved-persistence-types/message~2: - en: 8bd12543fcdcda5f - zh: d4c6b17083948856 + en: 87e95476b4985e5e + zh: 0e0322e005b3e995 /session-persistence-event-catalog/resolved-persistence-types/modelmessagesource: en: 5f9154ca34235ab9 zh: a833105910ebfa82 @@ -1179,11 +1182,11 @@ en: 488050ba0b6438b4 zh: 2bf607b5ad51e39d /session-persistence-event-catalog/resolved-persistence-types/requestcontext: - en: 00058b6c2220cdcb - zh: a3426a8c69123e2e + en: 544039047a28b6bb + zh: c691f0bb1e23db08 /session-persistence-event-catalog/resolved-persistence-types/requestheaderreason: - en: 8e563a11c123255f - zh: ebf891f6a3cb93d3 + en: 1f691c223f54ab2c + zh: f88fef3ee9745c91 /session-persistence-event-catalog/resolved-persistence-types/sandboxmode: en: c9fa5343ea99733b zh: d6de41351c57a73f @@ -1203,11 +1206,11 @@ en: 9a54c4478ad3b620 zh: 02c4c1fd7e252770 /session-persistence-event-catalog/resolved-persistence-types/sessiontitleeventdata: - en: bf7325029c9338d6 - zh: e5f98271511e5318 + en: c0104327b481ae54 + zh: b4624d112bee37f1 /session-persistence-event-catalog/resolved-persistence-types/sessiontitlellmrequesteventdata: - en: 89dcb9a9b957c126 - zh: 132aeb7dfd435290 + en: 2e40681d575da503 + zh: 57cbccb16548e2da /session-persistence-event-catalog/resolved-persistence-types/sessiontitlemodelidentity: en: 9531468264499498 zh: 9da23c0ad2776563 @@ -1236,8 +1239,8 @@ en: d34acab659e74957 zh: 25cabc6c9fb44a7e /session-persistence-event-catalog/resolved-persistence-types/surfaceop: - en: a62be7c2cb8a4af7 - zh: 616fd1ac4bd3995a + en: 737806b6ec54263d + zh: b4e44293950ddf8c /session-persistence-event-catalog/resolved-persistence-types/systemmessage: en: 94876079d6696df9 zh: d25c7ad9f5054eb7 @@ -1311,17 +1314,17 @@ en: 54090e169899c573 zh: 530ea8d0d0f8cf68 /session-persistence-event-catalog/resolved-persistence-types/turnendcancelcause: - en: 4dc89c072fe5d0d1 - zh: 4ed2c9e9e8b4f7e2 + en: 778debd61e0284a2 + zh: 2cc82ad9e80f18b1 /session-persistence-event-catalog/resolved-persistence-types/turnendreason: - en: 344a7207a4199a32 - zh: d500af39e83289cf + en: 933770adc9ee2780 + zh: 8d8462c33ed6029e /session-persistence-event-catalog/resolved-persistence-types/usermessage: - en: bd3aedd570ded4d2 - zh: f020d0816d3a6c1a + en: 79b3ac6c731859aa + zh: 81058fe7edf61bdb /session-persistence-event-catalog/resolved-persistence-types/usermessage~2: - en: b99d66ffe277f0bc - zh: eacc9c1f62ae66c1 + en: 8e699c0217638bbe + zh: a2047db24c25217b /session-persistence-event-catalog/resolved-persistence-types/workflowagentoutcome: en: d0a3f9aab18c3de5 zh: 76ac69a835ca4e29 @@ -1347,8 +1350,8 @@ en: 2786fef560851bb4 zh: 5d787475356b2606 /session-persistence-event-catalog/resolved-persistence-types/string: - en: a9d7dc010ada133d - zh: 9bd682adee72d04e + en: 1e12d380c4988d40 + zh: 14a781104e00f360 /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1410,8 +1413,8 @@ en: a117573a5d58cb3c zh: d5ad59e17a3a815d /session-persistence-event-catalog/resolved-persistence-types/arguments-callid-name-step: - en: 036101ee99efbf72 - zh: a324d7f9f671bab0 + en: 1cd530a4f45fcb4f + zh: 0bf7c0f53d10a5ec /session-persistence-event-catalog/resolved-persistence-types/callid-files-turn: en: 5f4d3f66ed86a14d zh: 7c729360fa1279e6 @@ -1443,8 +1446,8 @@ en: a1f188c9058cf621 zh: eb43c0f045fb9afb /session-persistence-event-catalog/resolved-persistence-types/code-name-reason: - en: 17649d71411cb827 - zh: abb3138d38c76459 + en: 25df2af19bc9c46d + zh: ebb6ecbeee25c9be /session-persistence-event-catalog/resolved-persistence-types/commandid-kind-sourceeventseq-text: en: 87b03e1c73d2a326 zh: d7b9f06863cad891 @@ -1485,14 +1488,14 @@ en: 4b7a8793663d3fef zh: 249459c825d330c3 /session-persistence-event-catalog/resolved-persistence-types/endseq-op-startseq: - en: 1a533265eb56a8b8 - zh: 57480f4b53713570 + en: 766772dfbbb2e164 + zh: 09973d26e68004cb /session-persistence-event-catalog/resolved-persistence-types/error-kind-error: - en: 8d0448ef77e0da08 - zh: 25770329c9db129a + en: e0833e1b1cdb29d0 + zh: 6244880c79509016 /session-persistence-event-catalog/resolved-persistence-types/error-message-meta-step: - en: 9ffa836bf88fd7b4 - zh: 7ca8bc7ebefeadb8 + en: d019b2909673d2a7 + zh: cc16e9c055ea603d /session-persistence-event-catalog/resolved-persistence-types/failure-kind-aborted: en: 349cd90f2598fd12 zh: 1eda02d520ad5d76 @@ -1500,11 +1503,11 @@ en: 51af619ed8532a43 zh: a5ad878c63d0e046 /session-persistence-event-catalog/resolved-persistence-types/header-reason-startsseries: - en: 0b1ca5c765d716d1 - zh: dd939095c250c4eb + en: e4a915fcd4cca68b + zh: 04a70157c20a2f80 /session-persistence-event-catalog/resolved-persistence-types/headerseq-message-step-turn: - en: 3d16abb7b9581429 - zh: dc959157864261d8 + en: 0f367699daf0d4de + zh: 58b980af0f365374 /session-persistence-event-catalog/resolved-persistence-types/height-width: en: a5df50710083ac25 zh: aad3ccf6f6de70e4 @@ -1521,29 +1524,50 @@ en: 41f73c58a77dfa28 zh: af77f4aee3cb4a40 /session-persistence-event-catalog/resolved-persistence-types/inherited: - en: 6ce6ddac6d815805 - zh: f7196176e902c965 + en: 0e761849881f1b90 + zh: 62f72209fb941192 /session-persistence-event-catalog/resolved-persistence-types/inserted-outcome-removedcount-start: - en: 346d884a7a74e48d - zh: 646ac1021f9ca920 + en: 1750502af87d698c + zh: 382a3356f0a61546 /session-persistence-event-catalog/resolved-persistence-types/interrupted-message-step-stream: - en: 89fb3f919d181c86 - zh: 4380624236500687 + en: 8e86cd838e3e4a49 + zh: f5fec2a8adf1f3cc /session-persistence-event-catalog/resolved-persistence-types/kind-aborted-reason: - en: 48424cbed3fbc269 - zh: 2ce88c04ad002104 + en: d2b0ef9a4e2b9be5 + zh: 44ad13fbbf836ac7 +/session-persistence-event-catalog/resolved-persistence-types/kind-action-cards: + en: 8d057f0bd0b88fc6 + zh: 500d1e3d54e6d7c4 +/session-persistence-event-catalog/resolved-persistence-types/kind-action-cards-form-catalog: + en: 14e9dd01c49f91e0 + zh: 4a7e4e4aeeb15982 +/session-persistence-event-catalog/resolved-persistence-types/kind-action-cards-form-instructions: + en: f59ec0c37dc8f9b6 + zh: debc915ed98581fb +/session-persistence-event-catalog/resolved-persistence-types/kind-action-cards-form-notice: + en: ef0a456530698cca + zh: 23f2ace5345eb847 +/session-persistence-event-catalog/resolved-persistence-types/kind-action-cards-form-recall: + en: 0b607f9577e3a3c5 + zh: 237ed72b7a4154df +/session-persistence-event-catalog/resolved-persistence-types/kind-action-cards-form-relay: + en: 712bc9ad7c51a8fe + zh: f230dc9c09bdbf3c +/session-persistence-event-catalog/resolved-persistence-types/kind-action-cards-form-snapshot: + en: 4e030b06ef900819 + zh: 27532bbabe118206 /session-persistence-event-catalog/resolved-persistence-types/kind-blocked: - en: 2fad818b19823c48 - zh: a6b33dee21c3fdc9 + en: 9db831e991ef17ce + zh: 332002f65d191b68 /session-persistence-event-catalog/resolved-persistence-types/kind-completed: - en: e8f76b61a39ebeb0 - zh: e61a304be00b4490 + en: 9affe5d5dac75c57 + zh: 8db62dcb41e804c8 /session-persistence-event-catalog/resolved-persistence-types/kind-cordis-host-runner: en: fb7b8207a6ef3b81 zh: 9d6f112181d90701 /session-persistence-event-catalog/resolved-persistence-types/kind-disposed: - en: e3c9527803bc6de3 - zh: 375c82ce2ac5282b + en: 9a3ce668c21d6c90 + zh: f661c7ce62c44a60 /session-persistence-event-catalog/resolved-persistence-types/kind-dsh-session-title-llm: en: 428529e5dedc761d zh: 3ca0b54c9c521ea0 @@ -1569,11 +1593,11 @@ en: 78e34e6d0d70339e zh: 072127e40cfb9686 /session-persistence-event-catalog/resolved-persistence-types/kind-forked: - en: e2588043ee1a8d50 - zh: d87a5bff12b9190f + en: 4663f6c1a2634822 + zh: 3a2e4f61195759a0 /session-persistence-event-catalog/resolved-persistence-types/kind-hook: - en: 78278a869370695f - zh: 6cdc47f1876f3b23 + en: 669685ab52bb7b68 + zh: 94325f99ba96dec5 /session-persistence-event-catalog/resolved-persistence-types/kind-hooks-claude-code: en: 5eb383a67f11ac4d zh: 1943747cb4995dbc @@ -1617,14 +1641,14 @@ en: a8a338be3b91008f zh: c7632c42c869c2f6 /session-persistence-event-catalog/resolved-persistence-types/kind-interrupted: - en: 452b4a96d072a9b9 - zh: d63c8ab84bd37dbb + en: 8f5585c1e90d6ca3 + zh: 78329cf995882344 /session-persistence-event-catalog/resolved-persistence-types/kind-legacy: - en: c6f68741f8631d4f - zh: 090b58f2b86c02d0 + en: 673110c18ee48238 + zh: e8dd81d68e1a4ee0 /session-persistence-event-catalog/resolved-persistence-types/kind-max-tokens: - en: f85b7a64a6a5f1f7 - zh: 3f4ad5183e1020fd + en: 337ebe760096729a + zh: bd35a401103a3071 /session-persistence-event-catalog/resolved-persistence-types/kind-model-selection: en: a5165486dca91116 zh: b7051ca8167806ef @@ -1647,8 +1671,8 @@ en: 509f9a91aedb2da1 zh: db1071678bfb98d1 /session-persistence-event-catalog/resolved-persistence-types/kind-parent: - en: 7a3ac2238405913d - zh: b249a4d6d0508d40 + en: 5ab43c283829de53 + zh: 5ed4e90922a3a88b /session-persistence-event-catalog/resolved-persistence-types/kind-plan-mode: en: 9758304800b717df zh: dccf14f3a5d0d861 @@ -1833,8 +1857,8 @@ en: 5a0bbf4c76266ac5 zh: 0b20854f90f9d74d /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: 81620af099525ee9 - zh: 812db2fdf9e5d309 + en: e98b716228feb157 + zh: 2bb593ae9cd0ce0d /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 @@ -1872,8 +1896,8 @@ en: 82b0989dad6bbfc0 zh: 0789b63f1d59b2ed /session-persistence-event-catalog/resolved-persistence-types/message-step-turn: - en: 762219ab10a2d9b1 - zh: ce31f3b385595b96 + en: 86fe91fe83eeb08a + zh: 2ebf2f1904829753 /session-persistence-event-catalog/resolved-persistence-types/message-teamid-version: en: f778769acb264dd5 zh: eba9b81d04e9f57b @@ -1890,8 +1914,8 @@ en: 8ebd37343b638773 zh: bf7ffa9a60ca42a8 /session-persistence-event-catalog/resolved-persistence-types/reason-turn: - en: 2d45d5c5e3959d12 - zh: df4d0d7fca4e26bc + en: b8d80b4b1e6441ad + zh: d5c52e1bfe560297 /session-persistence-event-catalog/resolved-persistence-types/role-user: en: 04b34b8ffe7e7764 zh: a792d5371516fd9d @@ -1902,11 +1926,11 @@ en: 7a2ab7b62244f43b zh: 1084e3f8876feebc /session-persistence-event-catalog/resolved-persistence-types/step-stream-turn: - en: d911fd8591d66386 - zh: cba2966a909b9293 + en: 59f11c6d124335cb + zh: 5bbdd6c88612fcff /session-persistence-event-catalog/resolved-persistence-types/step-turn: - en: 4bbf63488cce88c9 - zh: 46b40433a749c133 + en: 8f9e774f89087efa + zh: 72474e7e556e2469 /session-persistence-event-catalog/resolved-persistence-types/targets: en: 5360af614fe753e3 zh: 96427c489cc71de6 @@ -1917,14 +1941,14 @@ en: 8ab873d30e062fd5 zh: a138832c6e299ebb /session-persistence-event-catalog/resolved-persistence-types/turn: - en: ea32b4165dbfb182 - zh: 57f48af06cfa201c + en: 374b8adee8d571fa + zh: b21057efc58e6490 /session-persistence-event-catalog/resolved-persistence-types/type-agent-preset-selected: - en: c8c731ee23bed85a - zh: f4ffc80f7828701d + en: 6a59f1cd472759e9 + zh: 1db67f4f97207efc /session-persistence-event-catalog/resolved-persistence-types/type-agent-inbox-spliced: - en: 62dfe9447efaceb1 - zh: 076454de19b5d7d7 + en: 6c276128e62a5432 + zh: 4ff5e93d255caece /session-persistence-event-catalog/resolved-persistence-types/type-approval-asked: en: 9c77f18b0dad22f0 zh: 8894cf2878fe90db @@ -1968,11 +1992,11 @@ en: ba73fb10b7e2ffc9 zh: 8d5c97e63e5f16da /session-persistence-event-catalog/resolved-persistence-types/type-deliverables-presented: - en: a4d9eab2928e9b30 - zh: b36702c866518273 + en: 10d95ce64c285e64 + zh: 0ddb26f1fa16629c /session-persistence-event-catalog/resolved-persistence-types/type-developer-message: - en: a9e6e24b98390923 - zh: 65addae898d8aaec + en: 78d421c83506ebe9 + zh: 2e9399ba2d360627 /session-persistence-event-catalog/resolved-persistence-types/type-disabled: en: 585627a50ece4e5f zh: 157837aee3da0ac3 @@ -2046,11 +2070,11 @@ en: acf7e21e4d21ebff zh: fb83a853634d204a /session-persistence-event-catalog/resolved-persistence-types/type-session-title: - en: b99fbacda0db1abd - zh: 9e75ce6b1d174d48 + en: 26d99fabe31f7fa3 + zh: 0a44b5c8f7f6488c /session-persistence-event-catalog/resolved-persistence-types/type-session-title-llm-request: - en: 7ba30b98d8e3f653 - zh: ca68b01ab4b6ba3d + en: bbbb345651600abf + zh: e3b29e05fecb136a /session-persistence-event-catalog/resolved-persistence-types/type-step-end: en: 7fc233c4a569dae2 zh: bc2b3fed4a397c00 @@ -2127,11 +2151,11 @@ en: 1b045de96172be4b zh: 8a15c6f35b576b37 /session-persistence-event-catalog/resolved-persistence-types/type-usage: - en: 8eec3bb6eee5085b - zh: 04bb3281fd7bd95c + en: ebb801c4444061fb + zh: f2d6531de88ee16e /session-persistence-event-catalog/resolved-persistence-types/type-user-message: - en: d6d9401f19a66ee8 - zh: 569fdddf887660a2 + en: 536f875df13987d0 + zh: 857a381433a4217f /session-persistence-event-catalog/resolved-persistence-types/type-web-deepseek-search-llm-request: en: 99c8837344178a47 zh: bf746f5678346596 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 7edaa463de..8dbd3621ac 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -23,7 +23,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) | | `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) | | `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) | -| `event:agent/inbox/spliced` | event | `0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034) | +| `event:agent/inbox/spliced` | event | `b36fd842cd004b532ce96fd5d6f5be28e017eda043131aa3cf5e69edc436108e` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-b36fd842cd004b532ce96fd5d6f5be28e017eda043131aa3cf5e69edc436108e) | | `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) | | `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) | | `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) | @@ -36,7 +36,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) | | `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) | | `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) | -| `event:developer/message` | event | `97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c` | [`{ type: "developer/message" }`](#persistence-type-sha256-97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c) | +| `event:developer/message` | event | `5ef58f2a1bdc576c1f12fa1f4da1920ece020b13c0f991a578cddcbeb28bc5be` | [`{ type: "developer/message" }`](#persistence-type-sha256-5ef58f2a1bdc576c1f12fa1f4da1920ece020b13c0f991a578cddcbeb28bc5be) | | `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) | | `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) | | `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) | @@ -58,7 +58,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | -| `event:session/title-llm-request` | event | `79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056) | +| `event:session/title-llm-request` | event | `efa1a091017ce533e38b0ef3e14d11a5c102ff1793229b18dad719a4268d9adc` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-efa1a091017ce533e38b0ef3e14d11a5c102ff1793229b18dad719a4268d9adc) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | | `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) | @@ -80,7 +80,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) | | `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) | | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) | -| `event:user/message` | event | `2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8` | [`{ type: "user/message" }`](#persistence-type-sha256-2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8) | +| `event:user/message` | event | `58b122df513aeaf7ddc5783dbb1fd844e2979bb273964e30ae351a8db6510019` | [`{ type: "user/message" }`](#persistence-type-sha256-58b122df513aeaf7ddc5783dbb1fd844e2979bb273964e30ae351a8db6510019) | | `event:web/deepseek-search-llm-request` | event | `0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52) | | `event:working-directory/change` | event | `124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3` | [`{ type: "working-directory/change" }`](#persistence-type-sha256-124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3) | | `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) | @@ -160,7 +160,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:433`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:441`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:464`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:495`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:439`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:447`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:470`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:501`](../packages/core/session/src/types.ts) ## Events @@ -290,7 +290,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:41`](../packages/inter 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] } ``` -Source: [`packages/core/session/src/types.ts:357`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:363`](../packages/core/session/src/types.ts) @@ -320,7 +320,7 @@ Source: [`packages/core/session/src/types.ts:357`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:349`](../packages/core/session/src/types.ts) ### `command/*` @@ -508,7 +508,7 @@ Source: [`packages/deliverables/tool-present/src/types.ts:15`](../packages/deliv } ``` -Source: [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) ### `feedback/*` @@ -753,7 +753,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:410`](../packages/core/session/src/types.ts) @@ -772,7 +772,7 @@ Source: [`packages/core/session/src/types.ts:404`](../packages/core/session/src/ } ``` -Source: [`packages/core/session/src/types.ts:392`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:398`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -849,7 +849,7 @@ Source: [`packages/schedule/schedule/src/types.ts:371`](../packages/schedule/sch 'session/end-seed': { inherited?: true } ``` -Source: [`packages/core/session/src/types.ts:429`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:435`](../packages/core/session/src/types.ts) @@ -911,7 +911,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:81`](../packages/se 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:307`](../packages/core/session/src/types.ts) @@ -922,7 +922,7 @@ Source: [`packages/core/session/src/types.ts:303`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -987,8 +987,10 @@ Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:17`](../p * The rendered system prompt on the model-visible surface. The loop appends * the first one as surface node 0 before the step's first `user/message`. * A prepared in-history route can append nonempty changes in a continuing - * series. An incapable route or new series normalizes text to the first system - * node. Normalization empties nonempty later nodes, then rewrites the head if + * series. An incapable route, or a new series whose effective text differs, + * normalizes text to the first system node; a series start whose latest + * surviving node already holds the rendering leaves it in place. + * Normalization empties nonempty later nodes, then rewrites the head if * needed, through logged per-node replacements. An empty rendering always * clears all active system nodes, leaving no older instructions model-visible. * Empty later nodes are dormant and project to no message; an empty head with @@ -998,7 +1000,7 @@ Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:17`](../p 'system/message': { turn: number; step: number; message: SystemMessage } ``` -Source: [`packages/core/session/src/types.ts:332`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) ### `team/*` @@ -1091,7 +1093,7 @@ Source: [`packages/todo/tool-todo/src/types.ts:33`](../packages/todo/tool-todo/s Types: [ToolCallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:363`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:369`](../packages/core/session/src/types.ts) @@ -1172,7 +1174,7 @@ Source: [`packages/core/tool-executor-ptc/src/types.ts:45`](../packages/core/too } ``` -Source: [`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:383`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1252,7 +1254,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) @@ -1268,7 +1270,7 @@ Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) ### `user/*` @@ -1287,7 +1289,7 @@ Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) ### `web/*` @@ -1347,6 +1349,14 @@ SHA-256: `1e0b0b66b6f0c53fc72e52289d06b4708d776f1ca9df4c86a22bff621d403cf0` `"aborted"` + + +### `"action-cards"` + +SHA-256: `05205074b24d8340cfd7995f6cb64779c7f1241490954700115601e264178877` + +`"action-cards"` + ### `"active"` @@ -3222,7 +3232,7 @@ Array of [`AssistantStreamRecord`](#persistence-type-sha256-33341aac09312300de5a SHA-256: `ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05` -Sources: [`packages/core/session/src/types.ts:191`](../packages/core/session/src/types.ts) · [`packages/interaction/commands/src/types.ts:75`](../packages/interaction/commands/src/types.ts) · [`packages/interaction/commands/src/types.ts:79`](../packages/interaction/commands/src/types.ts) · [`packages/llm/llm/src/message.ts:111`](../packages/llm/llm/src/message.ts) · [`packages/session/session-title/src/types.ts:35`](../packages/session/session-title/src/types.ts) +Sources: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/types.ts) · [`packages/interaction/commands/src/types.ts:75`](../packages/interaction/commands/src/types.ts) · [`packages/interaction/commands/src/types.ts:79`](../packages/interaction/commands/src/types.ts) · [`packages/llm/llm/src/message.ts:111`](../packages/llm/llm/src/message.ts) · [`packages/session/session-title/src/types.ts:35`](../packages/session/session-title/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -3365,7 +3375,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web | `body` | required | [`{ max_tokens, messages, model, thinking?, … }`](#persistence-type-sha256-9c001ecb5e1d3c3894410db2a1e383842e5f8454fb52182338686158abc1b3ee) | | `endpoint` | required | `string` | - + @@ -3373,7 +3383,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web ### `DeveloperMessage` -SHA-256: `0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7` +SHA-256: `7580e7d96dc8475fffe813a42af499665de3d4517fcdb3bc2337d0340e98d616` Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts) @@ -3382,11 +3392,11 @@ Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message | `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | required | `string` | | `role` | required | `"developer"` | -| `source` | required | [`MessageSource`](#persistence-type-sha256-5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8) | +| `source` | required | [`MessageSource`](#persistence-type-sha256-7a5b68bd1a5118c827df9e2466acc5e4d16b83e3f97a2304d992958647592690) | Source compatibility: `source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`. -Attribution-only additions: `tmux-context`, `user-question-reply`, `working-directory`. +Attribution-only additions: `action-cards`, `tmux-context`, `user-question-reply`, `working-directory`. @@ -3398,7 +3408,7 @@ Attribution-only additions: `tmux-context`, `user-question-reply`, `working-dire SHA-256: `9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7` -Sources: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4081,7 +4091,7 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr | `step` | required | `number` | | `turn` | required | `number` | - + @@ -4089,17 +4099,17 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr ### `Message` -SHA-256: `040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b` +SHA-256: `cda79b839d8cc0c7a7bd09dd067de04925f6e9037080d878b65aaa5a383f117c` Sources: [`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts) One of: - [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731) -- [`DeveloperMessage`](#persistence-type-sha256-0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7) -- [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) -- [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be) +- [`DeveloperMessage`](#persistence-type-sha256-7580e7d96dc8475fffe813a42af499665de3d4517fcdb3bc2337d0340e98d616) +- [`UserMessage`](#persistence-type-sha256-b782058cef83caf3eb44a16adeef5657e4d5cdff2d50655aba418c4490670a69) - [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) +- [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be) @@ -4174,7 +4184,7 @@ One of: - `"negative"` - `"positive"` - + @@ -4182,12 +4192,19 @@ One of: ### `MessageSource` -SHA-256: `5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8` +SHA-256: `7a5b68bd1a5118c827df9e2466acc5e4d16b83e3f97a2304d992958647592690` Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts) | kind | Form property | Other required fields | Full definition | |---|---|---|---| +| `action-cards` | `"catalog"` | `through`: `number` | [`{ kind: "action-cards", form: "catalog" }`](#persistence-type-sha256-dd00cdccd0ba487b92362ef67db58cd264f48c1bfd368291424a1da9a899489a) | +| `action-cards` | `"instructions"` | `through`: `number` | [`{ kind: "action-cards", form: "instructions" }`](#persistence-type-sha256-ba2a1a8f194b1bb21f270713398f6643e206e8ff5f7f26f6d36a88e3f0f0ab10) | +| `action-cards` | `"notice"` | `summary`: `string`; `through`: `number` | [`{ kind: "action-cards", form: "notice" }`](#persistence-type-sha256-e7f4cbb3a57f4cbdb61ea41e95bcba626ad1fff6bb9b1e063b1d50a32da6e864) | +| `action-cards` | `"recall"` | `through`: `number` | [`{ kind: "action-cards", form: "recall" }`](#persistence-type-sha256-b613f83572fe2fbf7259c72308cafd008f4fceea897749128a48bcd158f290aa) | +| `action-cards` | `"relay"` | `through`: `number` | [`{ kind: "action-cards", form: "relay" }`](#persistence-type-sha256-45f005e6bbb7645248be792b715a74bc7fdf5a67f5381655d3a75dcc7aa2112e) | +| `action-cards` | `"snapshot"` | `sections`: [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262); `through`: `number` | [`{ kind: "action-cards", form: "snapshot" }`](#persistence-type-sha256-3247e566f4340029325b70e4366b56fa9719977f1e39033b769a2ec221ab22a9) | +| `action-cards` | not declared | `through`: `number` | [`{ kind: "action-cards" }`](#persistence-type-sha256-dde0cdf9d426768b649b5a9783d2144e7f900c35ef65229cd89036dbf411d1b0) | | `agent-instructions` | `"instructions"` | `changes`: [`AgentInstructionChange[]`](#persistence-type-sha256-2ab12a0e345658f9342816d773fe610f049982bcad82631f43b9205abe43e5ee) | [`AgentInstructionSource`](#persistence-type-sha256-6f22a9d02b9a1f1157f3b0e7bdccd7e7b12b085c4efdb709dfc74a465cd4e590) | | `agent-message` | `"relay"` | `senderSessionId`: `string` | [`AgentMessageSource`](#persistence-type-sha256-4a9bef9f27a942520cc2279ca7dd3082556e4bff543f66b5c0333e1bbed1cd86) | | `compact-checkpoint` | not declared | `compactionId`: `string` | [`CompactionCheckpointSource`](#persistence-type-sha256-31907de9563751822af6d9be02bafaf26faa098bbd1cb412524134d577cc1648) | @@ -4300,13 +4317,13 @@ Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message | `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) | | `working-directory` | not declared | none | [`{ kind: "working-directory" }`](#persistence-type-sha256-2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5) | - + ### `Message[]` -SHA-256: `a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f` +SHA-256: `78b9204976ab9faaf133c3488b73d1dfb6f1a4bb6c0903e3473cd41a5e1e39e1` -Array of [`Message`](#persistence-type-sha256-040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b). +Array of [`Message`](#persistence-type-sha256-cda79b839d8cc0c7a7bd09dd067de04925f6e9037080d878b65aaa5a383f117c). @@ -4528,7 +4545,7 @@ Sources: [`packages/llm/llm/src/types.ts:448`](../packages/llm/llm/src/types.ts) SHA-256: `b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081` -Sources: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:260`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4547,7 +4564,7 @@ Sources: [`packages/core/session/src/types.ts:256`](../packages/core/session/src SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6` -Sources: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) One of: @@ -4690,7 +4707,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/ | `source` | required | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) | | `title` | required | `string` | - + @@ -4698,7 +4715,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/ ### `SessionTitleLlmRequestEventData` -SHA-256: `00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f` +SHA-256: `a3e299483348f27a4787a36bb3f29cd86024c65336abc10bb3d687468302c4cf` Sources: [`packages/session/session-title-llm/src/index.ts:37`](../packages/session/session-title-llm/src/index.ts) @@ -4706,7 +4723,7 @@ Sources: [`packages/session/session-title-llm/src/index.ts:37`](../packages/sess |---|---|---| | `maxTokens` | required | `number` | | `messageSeqs` | required | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | -| `messages` | required | [`Message[]`](#persistence-type-sha256-a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f) | +| `messages` | required | [`Message[]`](#persistence-type-sha256-78b9204976ab9faaf133c3488b73d1dfb6f1a4bb6c0903e3473cd41a5e1e39e1) | | `reasoningEffort` | optional | `string` | | `route` | required | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) | | `system` | required | `string` | @@ -4885,7 +4902,7 @@ Sources: [`packages/subagent/subagent/src/continuation-messages.ts:29`](../packa SHA-256: `335e242de1fcc17b6ca920fc420d71bec2d76e53e37955c00948b65ab77f05c5` -Sources: [`packages/core/session/src/types.ts:464`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:470`](../packages/core/session/src/types.ts) One of: @@ -5330,7 +5347,7 @@ Sources: [`packages/workflow/tool-workflow/src/types.ts:14`](../packages/workflo SHA-256: `1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0` -Sources: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:201`](../packages/core/session/src/types.ts) | kind | Form property | Other required fields | Full definition | |---|---|---|---| @@ -5350,7 +5367,7 @@ Sources: [`packages/core/session/src/types.ts:197`](../packages/core/session/src SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` -Sources: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) | kind | Form property | Other required fields | Full definition | |---|---|---|---| @@ -5362,7 +5379,7 @@ Sources: [`packages/core/session/src/types.ts:234`](../packages/core/session/src | `interrupted` | not declared | none | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | not declared | none | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | - + @@ -5370,7 +5387,7 @@ Sources: [`packages/core/session/src/types.ts:234`](../packages/core/session/src ### `UserMessage` -SHA-256: `2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711` +SHA-256: `b782058cef83caf3eb44a16adeef5657e4d5cdff2d50655aba418c4490670a69` Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts) @@ -5379,19 +5396,19 @@ Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message | `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | required | `string` | | `role` | required | `"user"` | -| `source` | required | [`MessageSource`](#persistence-type-sha256-5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8) | +| `source` | required | [`MessageSource`](#persistence-type-sha256-7a5b68bd1a5118c827df9e2466acc5e4d16b83e3f97a2304d992958647592690) | Source compatibility: `source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`. -Attribution-only additions: `tmux-context`, `user-question-reply`, `working-directory`. +Attribution-only additions: `action-cards`, `tmux-context`, `user-question-reply`, `working-directory`. - + ### `UserMessage[]` -SHA-256: `454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020` +SHA-256: `5d00abf938272f22eb47199e9d40801e8d8ec4c88e2b138b15aa2a5a5bf87520` -Array of [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711). +Array of [`UserMessage`](#persistence-type-sha256-b782058cef83caf3eb44a16adeef5657e4d5cdff2d50655aba418c4490670a69). @@ -5583,7 +5600,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:386`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -5820,7 +5837,7 @@ Sources: [`packages/interaction/commands/src/types.ts:105`](../packages/interact SHA-256: `e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1` -Sources: [`packages/core/session/src/types.ts:363`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:369`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5979,7 +5996,7 @@ SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09` SHA-256: `a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735` -Sources: [`packages/core/session/src/types.ts:385`](../packages/core/session/src/types.ts) · [`packages/core/tool-executor-ptc/src/types.ts:27`](../packages/core/tool-executor-ptc/src/types.ts) +Sources: [`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) · [`packages/core/tool-executor-ptc/src/types.ts:27`](../packages/core/tool-executor-ptc/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6199,7 +6216,7 @@ Sources: [`packages/compaction/compaction/src/types.ts:38`](../packages/compacti SHA-256: `bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825` -Sources: [`packages/core/session/src/types.ts:466`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:472`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6213,7 +6230,7 @@ Sources: [`packages/core/session/src/types.ts:466`](../packages/core/session/src SHA-256: `79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985` -Sources: [`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:218`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6226,7 +6243,7 @@ Sources: [`packages/core/session/src/types.ts:214`](../packages/core/session/src SHA-256: `5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777` -Sources: [`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:383`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6268,7 +6285,7 @@ Sources: [`packages/llm/llm/src/types.ts:161`](../packages/llm/llm/src/types.ts) SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` -Sources: [`packages/core/session/src/types.ts:392`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:398`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6276,18 +6293,18 @@ Sources: [`packages/core/session/src/types.ts:392`](../packages/core/session/src | `reason` | required | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) | | `startsSeries` | optional | `true` | - + ### `{ headerSeq?, message, step, turn }` -SHA-256: `f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb` +SHA-256: `7404c6123fdaa591474a37979a5a8ae70d1387af7c9f09bfe568b218935a974d` -Sources: [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| | `headerSeq` | optional | `number` | -| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7) | +| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-7580e7d96dc8475fffe813a42af499665de3d4517fcdb3bc2337d0340e98d616) | | `step` | required | `number` | | `turn` | required | `number` | @@ -6365,23 +6382,23 @@ SHA-256: `e3e77b26f0148755a505f5b8ea843a3be117827d4703bb5754d9dc8ddbef521f` SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3` -Sources: [`packages/core/session/src/types.ts:429`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:435`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| | `inherited` | optional | `true` | - + ### `{ inserted, outcome?, removedCount?, start, … }` -SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42` +SHA-256: `b2a948a5340a9341fa3bab99507c7090c5b0456de72ec28b5b157b8df3f1d640` Sources: [`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/types.ts) | Property | Presence | Type | |---|---|---| -| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020) | +| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-5d00abf938272f22eb47199e9d40801e8d8ec4c88e2b138b15aa2a5a5bf87520) | | `outcome` | optional | `"canceled"` | | `removedCount` | optional | `number` | | `start` | required | `number` | @@ -6393,7 +6410,7 @@ Sources: [`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/typ SHA-256: `1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d` -Sources: [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:349`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6410,20 +6427,105 @@ Sources: [`packages/core/session/src/types.ts:343`](../packages/core/session/src SHA-256: `0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3` -Sources: [`packages/core/session/src/types.ts:205`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:209`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| | `kind` | required | `"aborted"` | | `reason` | required | [`TurnEndCancelCause`](#persistence-type-sha256-1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0) | + + +### `{ kind: "action-cards" }` + +SHA-256: `dde0cdf9d426768b649b5a9783d2144e7f900c35ef65229cd89036dbf411d1b0` + +| Property | Presence | Type | +|---|---|---| +| `kind` | required | `"action-cards"` | +| `through` | required | `number` | + + + +### `{ kind: "action-cards", form: "catalog" }` + +SHA-256: `dd00cdccd0ba487b92362ef67db58cd264f48c1bfd368291424a1da9a899489a` + +| Property | Presence | Type | +|---|---|---| +| `form` | required | `"catalog"` | +| `kind` | required | `"action-cards"` | +| `through` | required | `number` | + + + +### `{ kind: "action-cards", form: "instructions" }` + +SHA-256: `ba2a1a8f194b1bb21f270713398f6643e206e8ff5f7f26f6d36a88e3f0f0ab10` + +| Property | Presence | Type | +|---|---|---| +| `form` | required | `"instructions"` | +| `kind` | required | `"action-cards"` | +| `through` | required | `number` | + + + +### `{ kind: "action-cards", form: "notice" }` + +SHA-256: `e7f4cbb3a57f4cbdb61ea41e95bcba626ad1fff6bb9b1e063b1d50a32da6e864` + +| Property | Presence | Type | +|---|---|---| +| `form` | required | `"notice"` | +| `kind` | required | `"action-cards"` | +| `summary` | required | `string` | +| `through` | required | `number` | + + + +### `{ kind: "action-cards", form: "recall" }` + +SHA-256: `b613f83572fe2fbf7259c72308cafd008f4fceea897749128a48bcd158f290aa` + +| Property | Presence | Type | +|---|---|---| +| `form` | required | `"recall"` | +| `kind` | required | `"action-cards"` | +| `through` | required | `number` | + + + +### `{ kind: "action-cards", form: "relay" }` + +SHA-256: `45f005e6bbb7645248be792b715a74bc7fdf5a67f5381655d3a75dcc7aa2112e` + +| Property | Presence | Type | +|---|---|---| +| `form` | required | `"relay"` | +| `kind` | required | `"action-cards"` | +| `through` | required | `number` | + + + +### `{ kind: "action-cards", form: "snapshot" }` + +SHA-256: `3247e566f4340029325b70e4366b56fa9719977f1e39033b769a2ec221ab22a9` + +| Property | Presence | Type | +|---|---|---| +| `form` | required | `"snapshot"` | +| `kind` | required | `"action-cards"` | +| `sections` | required | [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | +| `through` | required | `number` | + ### `{ kind: "blocked" }` SHA-256: `023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4` -Sources: [`packages/core/session/src/types.ts:207`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:211`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6435,7 +6537,7 @@ Sources: [`packages/core/session/src/types.ts:207`](../packages/core/session/src SHA-256: `b6ef9c6210be8350cd465f485cf520031fffa6bf3809c61a1e713e834c5982d0` -Sources: [`packages/core/session/src/types.ts:203`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:207`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6459,7 +6561,7 @@ Sources: [`packages/extensions/cordis-host-runner/src/index.ts:14`](../packages/ SHA-256: `4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db` -Sources: [`packages/core/session/src/types.ts:194`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:198`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6561,7 +6663,7 @@ Sources: [`packages/session/session-title/src/types.ts:29`](../packages/session/ SHA-256: `ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7` -Sources: [`packages/core/session/src/types.ts:230`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6573,7 +6675,7 @@ Sources: [`packages/core/session/src/types.ts:230`](../packages/core/session/src SHA-256: `6c5a37d42ebdb12db4410699259335f1e149e3a0260425684c00b153afc8990d` -Sources: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6742,7 +6844,7 @@ SHA-256: `88841202942c7683b3b7e5ea6a796047ec326b6701ac23e58cc236d85613b9fa` SHA-256: `56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23` -Sources: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6754,7 +6856,7 @@ Sources: [`packages/core/session/src/types.ts:223`](../packages/core/session/src SHA-256: `eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c` -Sources: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:201`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6766,7 +6868,7 @@ Sources: [`packages/core/session/src/types.ts:197`](../packages/core/session/src SHA-256: `ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7` -Sources: [`packages/core/session/src/types.ts:216`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) +Sources: [`packages/core/session/src/types.ts:220`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6856,7 +6958,7 @@ SHA-256: `52c6113cb0e53f345324bf84f01928bea5d5df57ee80bc0dd65407b8bc5a3734` SHA-256: `6c03546143cdf6ae88f1745ac98c5139efa1588d1415249bbc5f3104d32ab800` -Sources: [`packages/core/session/src/types.ts:192`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7554,7 +7656,7 @@ Sources: [`packages/core/tools/src/index.ts:28`](../packages/core/tools/src/inde SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -Sources: [`packages/api/session-controller/src/types.ts:389`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:395`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7721,7 +7823,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:230`](../packages/exper SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` -Sources: [`packages/core/session/src/types.ts:332`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7802,7 +7904,7 @@ Sources: [`packages/interaction/permission-presets/src/index.ts:53`](../packages SHA-256: `e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654` -Sources: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7856,7 +7958,7 @@ Sources: [`packages/compaction/compaction/src/types.ts:82`](../packages/compacti SHA-256: `54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0` -Sources: [`packages/core/session/src/types.ts:357`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:363`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7870,7 +7972,7 @@ Sources: [`packages/core/session/src/types.ts:357`](../packages/core/session/src SHA-256: `ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545` -Sources: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:307`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7921,7 +8023,7 @@ Sources: [`packages/todo/tool-todo/src/types.ts:33`](../packages/todo/tool-todo/ SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d` -Sources: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) +Sources: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7943,17 +8045,17 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | `time` | required | `number` | | `type` | required | `"agent-preset/selected"` | - + ### `{ type: "agent/inbox/spliced" }` -SHA-256: `0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034` +SHA-256: `b36fd842cd004b532ce96fd5d6f5be28e017eda043131aa3cf5e69edc436108e` | Property | Presence | Type | |---|---|---| -| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42) | +| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-b2a948a5340a9341fa3bab99507c7090c5b0456de72ec28b5b157b8df3f1d640) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -8194,17 +8296,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | `time` | required | `number` | | `type` | required | `"deliverables/presented"` | - + ### `{ type: "developer/message" }` -SHA-256: `97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c` +SHA-256: `5ef58f2a1bdc576c1f12fa1f4da1920ece020b13c0f991a578cddcbeb28bc5be` | Property | Presence | Type | |---|---|---| -| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb) | +| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-7404c6123fdaa591474a37979a5a8ae70d1387af7c9f09bfe568b218935a974d) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | @@ -8604,17 +8706,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | `time` | required | `number` | | `type` | required | `"session/title"` | - + ### `{ type: "session/title-llm-request" }` -SHA-256: `79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056` +SHA-256: `efa1a091017ce533e38b0ef3e14d11a5c102ff1793229b18dad719a4268d9adc` | Property | Presence | Type | |---|---|---| -| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f) | +| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-a3e299483348f27a4787a36bb3f29cd86024c65336abc10bb3d687468302c4cf) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -9037,17 +9139,17 @@ Sources: [`packages/llm/llm/src/types.ts:476`](../packages/llm/llm/src/types.ts) | `type` | required | `"usage"` | | `usage` | required | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) | - + ### `{ type: "user/message" }` -SHA-256: `2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8` +SHA-256: `58b122df513aeaf7ddc5783dbb1fd844e2979bb273964e30ae351a8db6510019` | Property | Presence | Type | |---|---|---| -| `data` | required | [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) | +| `data` | required | [`UserMessage`](#persistence-type-sha256-b782058cef83caf3eb44a16adeef5657e4d5cdff2d50655aba418c4490670a69) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 1d0d3e0bea..e28c70c102 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -25,7 +25,7 @@ | `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) | | `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) | | `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) | -| `event:agent/inbox/spliced` | event | `0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034) | +| `event:agent/inbox/spliced` | event | `b36fd842cd004b532ce96fd5d6f5be28e017eda043131aa3cf5e69edc436108e` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-b36fd842cd004b532ce96fd5d6f5be28e017eda043131aa3cf5e69edc436108e) | | `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) | | `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) | | `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) | @@ -38,7 +38,7 @@ | `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) | | `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) | | `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) | -| `event:developer/message` | event | `97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c` | [`{ type: "developer/message" }`](#persistence-type-sha256-97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c) | +| `event:developer/message` | event | `5ef58f2a1bdc576c1f12fa1f4da1920ece020b13c0f991a578cddcbeb28bc5be` | [`{ type: "developer/message" }`](#persistence-type-sha256-5ef58f2a1bdc576c1f12fa1f4da1920ece020b13c0f991a578cddcbeb28bc5be) | | `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) | | `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) | | `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) | @@ -60,7 +60,7 @@ | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | -| `event:session/title-llm-request` | event | `79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056) | +| `event:session/title-llm-request` | event | `efa1a091017ce533e38b0ef3e14d11a5c102ff1793229b18dad719a4268d9adc` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-efa1a091017ce533e38b0ef3e14d11a5c102ff1793229b18dad719a4268d9adc) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | | `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) | @@ -82,7 +82,7 @@ | `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) | | `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) | | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) | -| `event:user/message` | event | `2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8` | [`{ type: "user/message" }`](#persistence-type-sha256-2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8) | +| `event:user/message` | event | `58b122df513aeaf7ddc5783dbb1fd844e2979bb273964e30ae351a8db6510019` | [`{ type: "user/message" }`](#persistence-type-sha256-58b122df513aeaf7ddc5783dbb1fd844e2979bb273964e30ae351a8db6510019) | | `event:web/deepseek-search-llm-request` | event | `0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52) | | `event:working-directory/change` | event | `124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3` | [`{ type: "working-directory/change" }`](#persistence-type-sha256-124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3) | | `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) | @@ -162,7 +162,7 @@ export type SessionEvent = { }[T] ``` -来源:[`packages/core/session/src/types.ts:433`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:441`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:464`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:495`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:439`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:447`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:470`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:501`](../packages/core/session/src/types.ts) ## 事件 @@ -292,7 +292,7 @@ export type SessionEvent = { 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] } ``` -来源:[`packages/core/session/src/types.ts:357`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:363`](../packages/core/session/src/types.ts) @@ -322,7 +322,7 @@ export type SessionEvent = { 类型:[TokenUsage](subsystems/llm-streaming.zh.md) -来源:[`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:349`](../packages/core/session/src/types.ts) ### `command/*` @@ -510,7 +510,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) ### `feedback/*` @@ -755,7 +755,7 @@ export type SessionEvent = { 'request/context': RequestContext ``` -来源:[`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:410`](../packages/core/session/src/types.ts) @@ -774,7 +774,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:392`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:398`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -851,7 +851,7 @@ export type SessionEvent = { 'session/end-seed': { inherited?: true } ``` -来源:[`packages/core/session/src/types.ts:429`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:435`](../packages/core/session/src/types.ts) @@ -913,7 +913,7 @@ export type SessionEvent = { 'step/end': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:307`](../packages/core/session/src/types.ts) @@ -924,7 +924,7 @@ export type SessionEvent = { 'step/start': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -989,8 +989,10 @@ export type SessionEvent = { * The rendered system prompt on the model-visible surface. The loop appends * the first one as surface node 0 before the step's first `user/message`. * A prepared in-history route can append nonempty changes in a continuing - * series. An incapable route or new series normalizes text to the first system - * node. Normalization empties nonempty later nodes, then rewrites the head if + * series. An incapable route, or a new series whose effective text differs, + * normalizes text to the first system node; a series start whose latest + * surviving node already holds the rendering leaves it in place. + * Normalization empties nonempty later nodes, then rewrites the head if * needed, through logged per-node replacements. An empty rendering always * clears all active system nodes, leaving no older instructions model-visible. * Empty later nodes are dormant and project to no message; an empty head with @@ -1000,7 +1002,7 @@ export type SessionEvent = { 'system/message': { turn: number; step: number; message: SystemMessage } ``` -来源:[`packages/core/session/src/types.ts:332`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) ### `team/*` @@ -1093,7 +1095,7 @@ export type SessionEvent = { 类型:[ToolCallId](subsystems/core.zh.md) -来源:[`packages/core/session/src/types.ts:363`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:369`](../packages/core/session/src/types.ts) @@ -1174,7 +1176,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:383`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1254,7 +1256,7 @@ export type SessionEvent = { 类型:[TurnEndReason](subsystems/session.zh.md) -来源:[`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) @@ -1270,7 +1272,7 @@ export type SessionEvent = { 'turn/start': { turn: number } ``` -来源:[`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) ### `user/*` @@ -1289,7 +1291,7 @@ export type SessionEvent = { 'user/message': UserMessage ``` -来源:[`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) ### `web/*` @@ -1349,6 +1351,14 @@ SHA-256: `1e0b0b66b6f0c53fc72e52289d06b4708d776f1ca9df4c86a22bff621d403cf0` `"aborted"` + + +### `"action-cards"` + +SHA-256: `05205074b24d8340cfd7995f6cb64779c7f1241490954700115601e264178877` + +`"action-cards"` + ### `"active"` @@ -3224,7 +3234,7 @@ SHA-256: `84bbfdce7d2eab5b0c53e72ff7406db856c8bfd621b965b72fd86598ba493bf7` SHA-256: `ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05` -来源:[`packages/core/session/src/types.ts:191`](../packages/core/session/src/types.ts) · [`packages/interaction/commands/src/types.ts:75`](../packages/interaction/commands/src/types.ts) · [`packages/interaction/commands/src/types.ts:79`](../packages/interaction/commands/src/types.ts) · [`packages/llm/llm/src/message.ts:111`](../packages/llm/llm/src/message.ts) · [`packages/session/session-title/src/types.ts:35`](../packages/session/session-title/src/types.ts) +来源:[`packages/core/session/src/types.ts:195`](../packages/core/session/src/types.ts) · [`packages/interaction/commands/src/types.ts:75`](../packages/interaction/commands/src/types.ts) · [`packages/interaction/commands/src/types.ts:79`](../packages/interaction/commands/src/types.ts) · [`packages/llm/llm/src/message.ts:111`](../packages/llm/llm/src/message.ts) · [`packages/session/session-title/src/types.ts:35`](../packages/session/session-title/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -3367,7 +3377,7 @@ SHA-256: `abd6ca62a5ab166553848324a23fef90eed7f59c8021bd63e7ea5bc6fd666b0c` | `body` | 必需 | [`{ max_tokens, messages, model, thinking?, … }`](#persistence-type-sha256-9c001ecb5e1d3c3894410db2a1e383842e5f8454fb52182338686158abc1b3ee) | | `endpoint` | 必需 | `string` | - + @@ -3375,7 +3385,7 @@ SHA-256: `abd6ca62a5ab166553848324a23fef90eed7f59c8021bd63e7ea5bc6fd666b0c` ### `DeveloperMessage` -SHA-256: `0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7` +SHA-256: `7580e7d96dc8475fffe813a42af499665de3d4517fcdb3bc2337d0340e98d616` 来源:[`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts) @@ -3384,11 +3394,11 @@ SHA-256: `0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7` | `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | 必需 | `string` | | `role` | 必需 | `"developer"` | -| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8) | +| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-7a5b68bd1a5118c827df9e2466acc5e4d16b83e3f97a2304d992958647592690) | 消息源兼容策略:`source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`. -仅表示归属的新增 kind:`tmux-context`, `user-question-reply`, `working-directory`. +仅表示归属的新增 kind:`action-cards`, `tmux-context`, `user-question-reply`, `working-directory`. @@ -3400,7 +3410,7 @@ SHA-256: `0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7` SHA-256: `9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7` -来源:[`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4083,7 +4093,7 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20` | `step` | 必需 | `number` | | `turn` | 必需 | `number` | - + @@ -4091,17 +4101,17 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20` ### `Message` -SHA-256: `040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b` +SHA-256: `cda79b839d8cc0c7a7bd09dd067de04925f6e9037080d878b65aaa5a383f117c` 来源:[`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts) 以下类型之一: - [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731) -- [`DeveloperMessage`](#persistence-type-sha256-0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7) -- [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) -- [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be) +- [`DeveloperMessage`](#persistence-type-sha256-7580e7d96dc8475fffe813a42af499665de3d4517fcdb3bc2337d0340e98d616) +- [`UserMessage`](#persistence-type-sha256-b782058cef83caf3eb44a16adeef5657e4d5cdff2d50655aba418c4490670a69) - [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) +- [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be) @@ -4176,7 +4186,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2` - `"negative"` - `"positive"` - + @@ -4184,12 +4194,19 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2` ### `MessageSource` -SHA-256: `5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8` +SHA-256: `7a5b68bd1a5118c827df9e2466acc5e4d16b83e3f97a2304d992958647592690` 来源:[`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts) | kind | form 属性 | 其他必需字段 | 完整定义 | |---|---|---|---| +| `action-cards` | `"catalog"` | `through`: `number` | [`{ kind: "action-cards", form: "catalog" }`](#persistence-type-sha256-dd00cdccd0ba487b92362ef67db58cd264f48c1bfd368291424a1da9a899489a) | +| `action-cards` | `"instructions"` | `through`: `number` | [`{ kind: "action-cards", form: "instructions" }`](#persistence-type-sha256-ba2a1a8f194b1bb21f270713398f6643e206e8ff5f7f26f6d36a88e3f0f0ab10) | +| `action-cards` | `"notice"` | `summary`: `string`; `through`: `number` | [`{ kind: "action-cards", form: "notice" }`](#persistence-type-sha256-e7f4cbb3a57f4cbdb61ea41e95bcba626ad1fff6bb9b1e063b1d50a32da6e864) | +| `action-cards` | `"recall"` | `through`: `number` | [`{ kind: "action-cards", form: "recall" }`](#persistence-type-sha256-b613f83572fe2fbf7259c72308cafd008f4fceea897749128a48bcd158f290aa) | +| `action-cards` | `"relay"` | `through`: `number` | [`{ kind: "action-cards", form: "relay" }`](#persistence-type-sha256-45f005e6bbb7645248be792b715a74bc7fdf5a67f5381655d3a75dcc7aa2112e) | +| `action-cards` | `"snapshot"` | `sections`: [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262); `through`: `number` | [`{ kind: "action-cards", form: "snapshot" }`](#persistence-type-sha256-3247e566f4340029325b70e4366b56fa9719977f1e39033b769a2ec221ab22a9) | +| `action-cards` | 未声明 | `through`: `number` | [`{ kind: "action-cards" }`](#persistence-type-sha256-dde0cdf9d426768b649b5a9783d2144e7f900c35ef65229cd89036dbf411d1b0) | | `agent-instructions` | `"instructions"` | `changes`: [`AgentInstructionChange[]`](#persistence-type-sha256-2ab12a0e345658f9342816d773fe610f049982bcad82631f43b9205abe43e5ee) | [`AgentInstructionSource`](#persistence-type-sha256-6f22a9d02b9a1f1157f3b0e7bdccd7e7b12b085c4efdb709dfc74a465cd4e590) | | `agent-message` | `"relay"` | `senderSessionId`: `string` | [`AgentMessageSource`](#persistence-type-sha256-4a9bef9f27a942520cc2279ca7dd3082556e4bff543f66b5c0333e1bbed1cd86) | | `compact-checkpoint` | 未声明 | `compactionId`: `string` | [`CompactionCheckpointSource`](#persistence-type-sha256-31907de9563751822af6d9be02bafaf26faa098bbd1cb412524134d577cc1648) | @@ -4302,13 +4319,13 @@ SHA-256: `5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8` | `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) | | `working-directory` | 未声明 | 无 | [`{ kind: "working-directory" }`](#persistence-type-sha256-2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5) | - + ### `Message[]` -SHA-256: `a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f` +SHA-256: `78b9204976ab9faaf133c3488b73d1dfb6f1a4bb6c0903e3473cd41a5e1e39e1` -[`Message`](#persistence-type-sha256-040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b) 的数组。 +[`Message`](#persistence-type-sha256-cda79b839d8cc0c7a7bd09dd067de04925f6e9037080d878b65aaa5a383f117c) 的数组。 @@ -4530,7 +4547,7 @@ SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555` SHA-256: `b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081` -来源:[`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:260`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4549,7 +4566,7 @@ SHA-256: `b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081` SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6` -来源:[`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) 以下类型之一: @@ -4692,7 +4709,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4` | `source` | 必需 | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) | | `title` | 必需 | `string` | - + @@ -4700,7 +4717,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4` ### `SessionTitleLlmRequestEventData` -SHA-256: `00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f` +SHA-256: `a3e299483348f27a4787a36bb3f29cd86024c65336abc10bb3d687468302c4cf` 来源:[`packages/session/session-title-llm/src/index.ts:37`](../packages/session/session-title-llm/src/index.ts) @@ -4708,7 +4725,7 @@ SHA-256: `00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f` |---|---|---| | `maxTokens` | 必需 | `number` | | `messageSeqs` | 必需 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | -| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f) | +| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-78b9204976ab9faaf133c3488b73d1dfb6f1a4bb6c0903e3473cd41a5e1e39e1) | | `reasoningEffort` | 可选 | `string` | | `route` | 必需 | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) | | `system` | 必需 | `string` | @@ -4887,7 +4904,7 @@ SHA-256: `4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a` SHA-256: `335e242de1fcc17b6ca920fc420d71bec2d76e53e37955c00948b65ab77f05c5` -来源:[`packages/core/session/src/types.ts:464`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:470`](../packages/core/session/src/types.ts) 以下类型之一: @@ -5332,7 +5349,7 @@ SHA-256: `a6da9ad5d42ae83a6833a8288b076688e8e0801633ea36cbdd8a30a6d9f3160c` SHA-256: `1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0` -来源:[`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:201`](../packages/core/session/src/types.ts) | kind | form 属性 | 其他必需字段 | 完整定义 | |---|---|---|---| @@ -5352,7 +5369,7 @@ SHA-256: `1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0` SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` -来源:[`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) | kind | form 属性 | 其他必需字段 | 完整定义 | |---|---|---|---| @@ -5364,7 +5381,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` | `interrupted` | 未声明 | 无 | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | 未声明 | 无 | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | - + @@ -5372,7 +5389,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` ### `UserMessage` -SHA-256: `2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711` +SHA-256: `b782058cef83caf3eb44a16adeef5657e4d5cdff2d50655aba418c4490670a69` 来源:[`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts) @@ -5381,19 +5398,19 @@ SHA-256: `2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711` | `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | 必需 | `string` | | `role` | 必需 | `"user"` | -| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8) | +| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-7a5b68bd1a5118c827df9e2466acc5e4d16b83e3f97a2304d992958647592690) | 消息源兼容策略:`source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`. -仅表示归属的新增 kind:`tmux-context`, `user-question-reply`, `working-directory`. +仅表示归属的新增 kind:`action-cards`, `tmux-context`, `user-question-reply`, `working-directory`. - + ### `UserMessage[]` -SHA-256: `454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020` +SHA-256: `5d00abf938272f22eb47199e9d40801e8d8ec4c88e2b138b15aa2a5a5bf87520` -[`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) 的数组。 +[`UserMessage`](#persistence-type-sha256-b782058cef83caf3eb44a16adeef5657e4d5cdff2d50655aba418c4490670a69) 的数组。 @@ -5585,7 +5602,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:386`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -5822,7 +5839,7 @@ SHA-256: `a853902cfab417b8f08aa51ad6855fea46f34ed4588a74e47403035111d0fb86` SHA-256: `e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1` -来源:[`packages/core/session/src/types.ts:363`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:369`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5981,7 +5998,7 @@ SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09` SHA-256: `a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735` -来源:[`packages/core/session/src/types.ts:385`](../packages/core/session/src/types.ts) · [`packages/core/tool-executor-ptc/src/types.ts:27`](../packages/core/tool-executor-ptc/src/types.ts) +来源:[`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) · [`packages/core/tool-executor-ptc/src/types.ts:27`](../packages/core/tool-executor-ptc/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6201,7 +6218,7 @@ SHA-256: `9e41386b3a0c9572b0d63078492ebb3997da7d3a830d44e0259418bd02f4bcb2` SHA-256: `bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825` -来源:[`packages/core/session/src/types.ts:466`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:472`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6215,7 +6232,7 @@ SHA-256: `bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825` SHA-256: `79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985` -来源:[`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:218`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6228,7 +6245,7 @@ SHA-256: `79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985` SHA-256: `5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777` -来源:[`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:383`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6270,7 +6287,7 @@ SHA-256: `693f4952fb5565da96c06bace8924b098ac2fbc915fde0ff8019701183abc057` SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` -来源:[`packages/core/session/src/types.ts:392`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:398`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6278,18 +6295,18 @@ SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` | `reason` | 必需 | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) | | `startsSeries` | 可选 | `true` | - + ### `{ headerSeq?, message, step, turn }` -SHA-256: `f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb` +SHA-256: `7404c6123fdaa591474a37979a5a8ae70d1387af7c9f09bfe568b218935a974d` -来源:[`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `headerSeq` | 可选 | `number` | -| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7) | +| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-7580e7d96dc8475fffe813a42af499665de3d4517fcdb3bc2337d0340e98d616) | | `step` | 必需 | `number` | | `turn` | 必需 | `number` | @@ -6367,23 +6384,23 @@ SHA-256: `e3e77b26f0148755a505f5b8ea843a3be117827d4703bb5754d9dc8ddbef521f` SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3` -来源:[`packages/core/session/src/types.ts:429`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:435`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `inherited` | 可选 | `true` | - + ### `{ inserted, outcome?, removedCount?, start, … }` -SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42` +SHA-256: `b2a948a5340a9341fa3bab99507c7090c5b0456de72ec28b5b157b8df3f1d640` 来源:[`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| -| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020) | +| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-5d00abf938272f22eb47199e9d40801e8d8ec4c88e2b138b15aa2a5a5bf87520) | | `outcome` | 可选 | `"canceled"` | | `removedCount` | 可选 | `number` | | `start` | 必需 | `number` | @@ -6395,7 +6412,7 @@ SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42` SHA-256: `1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d` -来源:[`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:349`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6412,20 +6429,105 @@ SHA-256: `1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d` SHA-256: `0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3` -来源:[`packages/core/session/src/types.ts:205`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:209`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `kind` | 必需 | `"aborted"` | | `reason` | 必需 | [`TurnEndCancelCause`](#persistence-type-sha256-1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0) | + + +### `{ kind: "action-cards" }` + +SHA-256: `dde0cdf9d426768b649b5a9783d2144e7f900c35ef65229cd89036dbf411d1b0` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `kind` | 必需 | `"action-cards"` | +| `through` | 必需 | `number` | + + + +### `{ kind: "action-cards", form: "catalog" }` + +SHA-256: `dd00cdccd0ba487b92362ef67db58cd264f48c1bfd368291424a1da9a899489a` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `form` | 必需 | `"catalog"` | +| `kind` | 必需 | `"action-cards"` | +| `through` | 必需 | `number` | + + + +### `{ kind: "action-cards", form: "instructions" }` + +SHA-256: `ba2a1a8f194b1bb21f270713398f6643e206e8ff5f7f26f6d36a88e3f0f0ab10` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `form` | 必需 | `"instructions"` | +| `kind` | 必需 | `"action-cards"` | +| `through` | 必需 | `number` | + + + +### `{ kind: "action-cards", form: "notice" }` + +SHA-256: `e7f4cbb3a57f4cbdb61ea41e95bcba626ad1fff6bb9b1e063b1d50a32da6e864` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `form` | 必需 | `"notice"` | +| `kind` | 必需 | `"action-cards"` | +| `summary` | 必需 | `string` | +| `through` | 必需 | `number` | + + + +### `{ kind: "action-cards", form: "recall" }` + +SHA-256: `b613f83572fe2fbf7259c72308cafd008f4fceea897749128a48bcd158f290aa` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `form` | 必需 | `"recall"` | +| `kind` | 必需 | `"action-cards"` | +| `through` | 必需 | `number` | + + + +### `{ kind: "action-cards", form: "relay" }` + +SHA-256: `45f005e6bbb7645248be792b715a74bc7fdf5a67f5381655d3a75dcc7aa2112e` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `form` | 必需 | `"relay"` | +| `kind` | 必需 | `"action-cards"` | +| `through` | 必需 | `number` | + + + +### `{ kind: "action-cards", form: "snapshot" }` + +SHA-256: `3247e566f4340029325b70e4366b56fa9719977f1e39033b769a2ec221ab22a9` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `form` | 必需 | `"snapshot"` | +| `kind` | 必需 | `"action-cards"` | +| `sections` | 必需 | [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | +| `through` | 必需 | `number` | + ### `{ kind: "blocked" }` SHA-256: `023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4` -来源:[`packages/core/session/src/types.ts:207`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:211`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6437,7 +6539,7 @@ SHA-256: `023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4` SHA-256: `b6ef9c6210be8350cd465f485cf520031fffa6bf3809c61a1e713e834c5982d0` -来源:[`packages/core/session/src/types.ts:203`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:207`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6461,7 +6563,7 @@ SHA-256: `02b2c23f2d94b028c9588a8faace3fce1217405db346da3389368b495457bf79` SHA-256: `4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db` -来源:[`packages/core/session/src/types.ts:194`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:198`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6563,7 +6665,7 @@ SHA-256: `1b5649238876e28811e80f10ec67a00c197ad3babd4ecc61a22b09064b88c597` SHA-256: `ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7` -来源:[`packages/core/session/src/types.ts:230`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6575,7 +6677,7 @@ SHA-256: `ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7` SHA-256: `6c5a37d42ebdb12db4410699259335f1e149e3a0260425684c00b153afc8990d` -来源:[`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6744,7 +6846,7 @@ SHA-256: `88841202942c7683b3b7e5ea6a796047ec326b6701ac23e58cc236d85613b9fa` SHA-256: `56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23` -来源:[`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6756,7 +6858,7 @@ SHA-256: `56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23` SHA-256: `eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c` -来源:[`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:201`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6768,7 +6870,7 @@ SHA-256: `eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c` SHA-256: `ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7` -来源:[`packages/core/session/src/types.ts:216`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) +来源:[`packages/core/session/src/types.ts:220`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6858,7 +6960,7 @@ SHA-256: `52c6113cb0e53f345324bf84f01928bea5d5df57ee80bc0dd65407b8bc5a3734` SHA-256: `6c03546143cdf6ae88f1745ac98c5139efa1588d1415249bbc5f3104d32ab800` -来源:[`packages/core/session/src/types.ts:192`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7556,7 +7658,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -来源:[`packages/api/session-controller/src/types.ts:389`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:395`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7723,7 +7825,7 @@ SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` -来源:[`packages/core/session/src/types.ts:332`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7804,7 +7906,7 @@ SHA-256: `8a9c990773f798eac10ca8878e6bcc531ee080bc589cac956730e462cc6a09ad` SHA-256: `e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654` -来源:[`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7858,7 +7960,7 @@ SHA-256: `5dd766ccd1702a1142ad8b4bd44f2242f8e8a6752669ad3de0a5d224329b1438` SHA-256: `54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0` -来源:[`packages/core/session/src/types.ts:357`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:363`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7872,7 +7974,7 @@ SHA-256: `54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0` SHA-256: `ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545` -来源:[`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:307`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7923,7 +8025,7 @@ SHA-256: `79ddc907312f97cf9553d4841e8b3fc937097bd8d97900a790efad3c05255e99` SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d` -来源:[`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) +来源:[`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7945,17 +8047,17 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | `time` | 必需 | `number` | | `type` | 必需 | `"agent-preset/selected"` | - + ### `{ type: "agent/inbox/spliced" }` -SHA-256: `0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034` +SHA-256: `b36fd842cd004b532ce96fd5d6f5be28e017eda043131aa3cf5e69edc436108e` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42) | +| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-b2a948a5340a9341fa3bab99507c7090c5b0456de72ec28b5b157b8df3f1d640) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -8196,17 +8298,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | `time` | 必需 | `number` | | `type` | 必需 | `"deliverables/presented"` | - + ### `{ type: "developer/message" }` -SHA-256: `97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c` +SHA-256: `5ef58f2a1bdc576c1f12fa1f4da1920ece020b13c0f991a578cddcbeb28bc5be` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb) | +| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-7404c6123fdaa591474a37979a5a8ae70d1387af7c9f09bfe568b218935a974d) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | @@ -8606,17 +8708,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | `time` | 必需 | `number` | | `type` | 必需 | `"session/title"` | - + ### `{ type: "session/title-llm-request" }` -SHA-256: `79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056` +SHA-256: `efa1a091017ce533e38b0ef3e14d11a5c102ff1793229b18dad719a4268d9adc` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f) | +| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-a3e299483348f27a4787a36bb3f29cd86024c65336abc10bb3d687468302c4cf) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -9039,17 +9141,17 @@ SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515` | `type` | 必需 | `"usage"` | | `usage` | 必需 | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) | - + ### `{ type: "user/message" }` -SHA-256: `2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8` +SHA-256: `58b122df513aeaf7ddc5783dbb1fd844e2979bb273964e30ae351a8db6510019` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) | +| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-b782058cef83caf3eb44a16adeef5657e4d5cdff2d50655aba418c4490670a69) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | diff --git a/docs/persistence-changes/2026-10-22-action-card-status.i18n.yaml b/docs/persistence-changes/2026-10-22-action-card-status.i18n.yaml new file mode 100644 index 0000000000..80b7c81132 --- /dev/null +++ b/docs/persistence-changes/2026-10-22-action-card-status.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-10-22-action-card-status.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/persistence-changes/2026-10-22-action-card-status.md +/: + en: 3d5a424ae7d7e46b + zh: c22a8e4a62d8cc1a +/2026-10-22-action-card-status: + en: f580a2fb64e941db + zh: 0e34217155210da6 +/2026-10-22-action-card-status/summary: + en: 3da2594bf2891e0e + zh: 5bc7d279d99400aa +/2026-10-22-action-card-status/table-of-contents: + en: 6cf314a069ceb451 + zh: 9adff678e9ecab3b +/2026-10-22-action-card-status/declaration: + en: 6907136f28e48c90 + zh: 02f8298485f3d8e7 +/2026-10-22-action-card-status/compatibility: + en: fbf4d1ada55f35fe + zh: bd5ce9a23c55fac6 +/2026-10-22-action-card-status/verification: + en: 91043c4f0b19e7bc + zh: ee1c541133475bb8 +/2026-10-22-action-card-status/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-10-22-action-card-status.md b/docs/persistence-changes/2026-10-22-action-card-status.md new file mode 100644 index 0000000000..4c62efb5ce --- /dev/null +++ b/docs/persistence-changes/2026-10-22-action-card-status.md @@ -0,0 +1,60 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-10-22-action-card-status + +English | [中文](2026-10-22-action-card-status.zh.md) + +## Summary + +Adds action-card status attribution to logged model context. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-22-action-card-status +baseline: false +changes: + - root: "event:agent/inbox/spliced" + previous: "2026-10-13-user-rpc-principal" + after: "b36fd842cd004b532ce96fd5d6f5be28e017eda043131aa3cf5e69edc436108e" + decision: same-version + - root: "event:developer/message" + previous: "2026-10-13-user-rpc-principal" + after: "5ef58f2a1bdc576c1f12fa1f4da1920ece020b13c0f991a578cddcbeb28bc5be" + decision: same-version + - root: "event:session/title-llm-request" + previous: "2026-10-13-user-rpc-principal" + after: "efa1a091017ce533e38b0ef3e14d11a5c102ff1793229b18dad719a4268d9adc" + decision: same-version + - root: "event:user/message" + previous: "2026-10-13-user-rpc-principal" + after: "58b122df513aeaf7ddc5783dbb1fd844e2979bb273964e30ae351a8db6510019" + decision: same-version +``` + + +## Compatibility + +Existing messages and records are unchanged. Readers without the action-card plugin preserve the new action-cards source kind and its JSON metadata and derive the recorded text as ordinary context. Only the producer uses the through sequence, to acknowledge delivered decisions; it grants no authority and does not change replay interpretation. + + +## Verification + +pnpm exec vitest run on the action-cards, creation-recommendations, client-ui-action-cards and client-ui-creation-recommendations tests: 120 tests passed with 100% coverage of their sources. The Web creation-recommendations replay logs the status notice of a dismissed card before the next answer. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-10-22-action-card-status.schema.json b/docs/persistence-changes/2026-10-22-action-card-status.schema.json new file mode 100644 index 0000000000..9aa642699a --- /dev/null +++ b/docs/persistence-changes/2026-10-22-action-card-status.schema.json @@ -0,0 +1,249 @@ +{ + "formatVersion": 2, + "roots": [ + {"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"b36fd842cd004b532ce96fd5d6f5be28e017eda043131aa3cf5e69edc436108e"}, + {"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"5ef58f2a1bdc576c1f12fa1f4da1920ece020b13c0f991a578cddcbeb28bc5be"}, + {"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"efa1a091017ce533e38b0ef3e14d11a5c102ff1793229b18dad719a4268d9adc"}, + {"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"58b122df513aeaf7ddc5783dbb1fd844e2979bb273964e30ae351a8db6510019"} + ], + "types": [], + "nodes": { + "0203243b72fc867a": {"kind":"object","properties":[{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "022623b9ef98ec47": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "02b2c23f2d94b028": {"kind":"object","properties":[{"name":"kind","type":"c5687b1313206a6f","optional":false}],"indices":[]}, + "05205074b24d8340": {"kind":"literal","value":"action-cards"}, + "0657d3ca5ac4e7bf": {"kind":"primitive","type":"number"}, + "07e1a58c58b593f5": {"kind":"object","properties":[{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "09655b34d7f3cf1e": {"kind":"literal","value":"system-prompt"}, + "09f3f8dc417f2782": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"8af841fc9b265ade","optional":false},{"name":"source","type":"65a8400e451e667e","optional":false}],"indices":[]}, + "0ed8d1108fe7a5e1": {"kind":"object","properties":[{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "0eeb17e1df89b1eb": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "0f46153645c29784": {"kind":"object","properties":[{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "0fe52387f6505270": {"kind":"literal","value":"plan-mode"}, + "11638dc58a31496f": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "12d5808fac1996b9": {"kind":"object","properties":[{"name":"attachment","type":"b0b0eb1b16490f6b","optional":false},{"name":"offloaded","type":"dacf3d5f3e4ff619","optional":true},{"name":"type","type":"fb8b6a2e53cb9cf4","optional":false}],"indices":[]}, + "1379f0b72275994d": {"kind":"literal","value":"developer/message"}, + "16d0507cb94b4cfc": {"kind":"literal","value":"relay"}, + "1922cd79a70806c1": {"kind":"literal","value":"skill-invocation"}, + "1938c8c20f1cba7c": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "1e709346005826eb": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "1f0133217eeeffd8": {"kind":"literal","value":"tool-call"}, + "1f1385dbc3e875c0": {"kind":"literal","value":"repeat-tool-reminder"}, + "1fdad624334d3548": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "2152caf1a71e588e": {"kind":"object","properties":[{"name":"kind","type":"2e4debca12f2c449","optional":false},{"name":"messageId","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderId","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderName","type":"4cd48f3e5108bd6e","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "2235d957bf4b7c6c": {"kind":"object","properties":[{"name":"kind","type":"7b1ce60193fa8b69","optional":false}],"indices":[]}, + "224aa11d4d5f88c4": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "23af3759678a30de": {"kind":"literal","value":"dsh-session-title-llm"}, + "24b10f7cdae64928": {"kind":"literal","value":"runtime-context"}, + "24ce31aa528272e5": {"kind":"object","properties":[{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "260523b412c19ad8": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "2765e9119edf5816": {"kind":"literal","value":"model"}, + "277b61f9c6f81704": {"kind":"literal","value":"file"}, + "28936c614bd4e7e2": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "290f32bfd81ff85a": {"kind":"object","properties":[{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "297fbd8067d7815a": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "2aad1179f9576314": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "2ab12a0e345658f9": {"kind":"array","element":"83da72c6857601c7"}, + "2e4debca12f2c449": {"kind":"literal","value":"team-message"}, + "2f1e6a7ebace43d7": {"kind":"union","types":["3f74ba74acd10644","58f94390b982f8a5","de30b1fa12c75f81","ebb768d85ec87fb6"]}, + "2f3f201c8846e19c": {"kind":"literal","value":"tool-goal"}, + "312aee2f0c42aabb": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "31907de956375182": {"kind":"object","properties":[{"name":"compactionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"6b23ed9ce1b397e6","optional":false},{"name":"sourceCommandId","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, + "3247e566f4340029": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "335e242de1fcc17b": {"kind":"union","types":["bcf0caf62d964b2f","e438a9d4fb883b3e"]}, + "383cf73ae4748e5b": {"kind":"literal","value":"recall"}, + "3b04790e08b35241": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "3b4f2c9ef2c1217e": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "3bd652ebfa8726b3": {"kind":"union","types":["0657d3ca5ac4e7bf","938f7c7357bc403e"]}, + "3bdb8dc6989655b0": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "3c2535ca0a2062f1": {"kind":"literal","value":"reasoning"}, + "3e2ece4967cfebe9": {"kind":"literal","value":"next-step"}, + "3f74ba74acd10644": {"kind":"literal","value":"image/jpeg"}, + "408927267422a7b2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "43e311235aa537df": {"kind":"object","properties":[{"name":"toolName","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"83d6916021eede8f","optional":false}],"indices":[]}, + "45f005e6bbb76452": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "4991dfc415c9efeb": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "49dab019d00e8c26": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "4a9bef9f27a94252": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"82470020b5bfcb1a","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "4b259f7f8f0a44ad": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"isError","type":"aa93010d0e521ad7","optional":true},{"name":"role","type":"a13d8f379222ece8","optional":false},{"name":"source","type":"d9c462b9f62fdd3e","optional":false},{"name":"toolCallId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "4cd48f3e5108bd6e": {"kind":"primitive","type":"string"}, + "4e44c9118152f682": {"kind":"literal","value":"text"}, + "4e88776858652856": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"4e44c9118152f682","optional":false}],"indices":[]}, + "4e97c3d85c0fc817": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bf16d2e98a7f1389","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "4edec1b770e8ec4c": {"kind":"literal","value":"ptc-mode"}, + "4f91f187baeabef5": {"kind":"object","properties":[{"name":"capturedFormatVersion","type":"0657d3ca5ac4e7bf","optional":true},{"name":"capturedThroughSeq","type":"3bd652ebfa8726b3","optional":false},{"name":"compacted","type":"aa93010d0e521ad7","optional":false},{"name":"inputIndex","type":"0657d3ca5ac4e7bf","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"omittedBytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"omittedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"originalMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retainedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"truncated","type":"aa93010d0e521ad7","optional":false}],"indices":[]}, + "52c6113cb0e53f34": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "53cf1704ae0709c7": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "5654685d7557ec74": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "58015da1c1950a66": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "587bb11da730e06a": {"kind":"literal","value":"session/title-llm-request"}, + "589d0d91239681f8": {"kind":"literal","value":"developer"}, + "58b122df513aeaf7": {"kind":"object","properties":[{"name":"data","type":"b782058cef83caf3","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]}, + "58f94390b982f8a5": {"kind":"literal","value":"image/png"}, + "5bb69b88517f6214": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "5bc66269cfaf614c": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "5cdbf342b7748d15": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "5d00abf938272f22": {"kind":"array","element":"b782058cef83caf3"}, + "5d03ba38734809bc": {"kind":"array","element":"0657d3ca5ac4e7bf"}, + "5d7a64ed6aac5551": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "5ef58f2a1bdc576c": {"kind":"object","properties":[{"name":"data","type":"7404c6123fdaa591","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]}, + "6085488d223aeffd": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "6292bcbedaa97fea": {"kind":"object","properties":[{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "62ecb62dc0fe1b5e": {"kind":"literal","value":"next-turn"}, + "65a8400e451e667e": {"kind":"object","properties":[{"name":"kind","type":"09655b34d7f3cf1e","optional":false}],"indices":[]}, + "672c4aa2a9b736e3": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"1922cd79a70806c1","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "68f44f59e91c4139": {"kind":"literal","value":"user"}, + "6b23ed9ce1b397e6": {"kind":"literal","value":"compact-checkpoint"}, + "6ccb6bc423fb3952": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "6dacb1584f2bfbe2": {"kind":"object","properties":[{"name":"clientTimeZone","type":"4cd48f3e5108bd6e","optional":true},{"name":"kind","type":"68f44f59e91c4139","optional":false},{"name":"principal","type":"deebfc7c62177ea8","optional":true},{"name":"rpcId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "6ea608bdc8a7a63d": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "6ebcc2dbec9f2dab": {"kind":"literal","value":"skill-catalog"}, + "6f22a9d02b9a1f11": {"kind":"object","properties":[{"name":"baseline","type":"dacf3d5f3e4ff619","optional":true},{"name":"baselineIdentity","type":"4cd48f3e5108bd6e","optional":true},{"name":"changes","type":"2ab12a0e345658f9","optional":false},{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"fbd2c74b346a1595","optional":false}],"indices":[]}, + "6f44f32be9bcd9a8": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "6f9e00383836275e": {"kind":"literal","value":"snapshot"}, + "719c51f67074f970": {"kind":"object","properties":[{"name":"kind","type":"2765e9119edf5816","optional":false},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"replayState","type":"bbc5696e3f13c758","optional":true}],"indices":[]}, + "7382c4fc95fe8c5d": {"kind":"literal","value":"remove"}, + "7404c6123fdaa591": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"7580e7d96dc8475f","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "7554565be416b034": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "756b5e23a5cb2c7f": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "7580e7d96dc8475f": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"7a5b68bd1a5118c8","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, + "78a9be340090b735": {"kind":"literal","value":"tmux-context"}, + "78b9204976ab9faa": {"kind":"array","element":"cda79b839d8cc0c7"}, + "7a5b68bd1a5118c8": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","1938c8c20f1cba7c","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","3247e566f4340029","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","45f005e6bbb76452","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","4e97c3d85c0fc817","52c6113cb0e53f34","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5bb69b88517f6214","5bc66269cfaf614c","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","672c4aa2a9b736e3","6ccb6bc423fb3952","6dacb1584f2bfbe2","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f324cea3916c051","8fcb0b44a575e998","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b613f83572fe2fbf","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba2a1a8f194b1bb2","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","cb1c8959e1726f82","cda2345a9be9d139","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd00cdccd0ba487b","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","dde0cdf9d426768b","deaae5cd30987d4f","e7f4cbb3a57f4cbd","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","fc2ae6962f9f8801","fcf81d3c58c13593","fe1a7acdeed4d0f5"]}, + "7b1ce60193fa8b69": {"kind":"literal","value":"working-directory"}, + "7df62978a2b7de85": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "7eaef7a763ba5b94": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "7f13860ee89a2d35": {"kind":"literal","value":"session-reference"}, + "7f959241c24128b4": {"kind":"object","properties":[{"name":"goalId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"b815d7b91cdd9fc6","optional":false},{"name":"revision","type":"0657d3ca5ac4e7bf","optional":false},{"name":"round","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "8064c03c87c357fd": {"kind":"union","types":["12d5808fac1996b9","43e311235aa537df","4e88776858652856","814d9434e1ddf907","8e611bf46821e500","c1f0f17b6493b85d","f2da0f27c1e6add5"]}, + "80677c8c8b804189": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "8067b0d232c350f1": {"kind":"literal","value":"time-context"}, + "814d9434e1ddf907": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"3c2535ca0a2062f1","optional":false}],"indices":[]}, + "82470020b5bfcb1a": {"kind":"literal","value":"agent-message"}, + "825ca23416b1ea9d": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "829a2fb3be6efd4b": {"kind":"literal","value":"agent/inbox/spliced"}, + "83079c8a3f733ac3": {"kind":"object","properties":[{"name":"description","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "8338ee303e2bd448": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "83d6916021eede8f": {"kind":"literal","value":"tool-removal"}, + "83da72c6857601c7": {"kind":"object","properties":[{"name":"action","type":"904bf499d98be4a3","optional":false},{"name":"digest","type":"4cd48f3e5108bd6e","optional":true},{"name":"path","type":"4cd48f3e5108bd6e","optional":false},{"name":"scope","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "854db3efdfb0cd39": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "85d6d96ff8842974": {"kind":"literal","value":"answered"}, + "85f9b505b7bdff1f": {"kind":"literal","value":"tool-addition"}, + "85fe96bf15d5c755": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "8620189c8e335827": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "86e1e384495ee09a": {"kind":"object","properties":[{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"text","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "87b1a33564b9a4d6": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "88841202942c7683": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "8a046b52331959aa": {"kind":"object","properties":[{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "8af841fc9b265ade": {"kind":"literal","value":"system"}, + "8c1c4f72755a78ea": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "8c627d7a45fa406c": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "8e611bf46821e500": {"kind":"object","properties":[{"name":"tool","type":"fcf427290c6213c2","optional":true},{"name":"toolName","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"85f9b505b7bdff1f","optional":false}],"indices":[]}, + "8f324cea3916c051": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "8fcb0b44a575e998": {"kind":"object","properties":[{"name":"kind","type":"a720757f704a2512","optional":false}],"indices":[]}, + "904bf499d98be4a3": {"kind":"union","types":["7382c4fc95fe8c5d","b765ee31033cea46","f6cb462f808309e3"]}, + "938f7c7357bc403e": {"kind":"primitive","type":"null"}, + "95a3ffccbf3c8afc": {"kind":"literal","value":"owner"}, + "95c0e88fcf0bafb2": {"kind":"literal","value":"assistant"}, + "9645cf93769a6dfb": {"kind":"literal","value":"user-question-reply"}, + "96bfb916a727cd44": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "96f4a9c81fc0f940": {"kind":"union","types":["3e2ece4967cfebe9","62ecb62dc0fe1b5e"]}, + "99135d8a39379779": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "99e7e7d7fed55ae6": {"kind":"literal","value":"tool-jobs"}, + "99f0e98184709ad1": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "9ac52f0f84fec2eb": {"kind":"literal","value":"model-selection"}, + "9edc162949abf93b": {"kind":"object","properties":[{"name":"deliveryId","type":"4cd48f3e5108bd6e","optional":false},{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"db6c5246911d47a7","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"ruleId","type":"4cd48f3e5108bd6e","optional":false},{"name":"source","type":"4cd48f3e5108bd6e","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "9f65c43e340bba31": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "a13d8f379222ece8": {"kind":"literal","value":"tool"}, + "a15a19ef63cf5d1b": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "a16e567c8cd2c55c": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "a2066bd52f61218b": {"kind":"object","properties":[{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "a3c6f1ea38e9a61d": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "a3e299483348f27a": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"78b9204976ab9faa","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "a720757f704a2512": {"kind":"literal","value":"tool-registry"}, + "a9059a81430b3207": {"kind":"literal","value":"hooks-claude-code"}, + "aa2bf9e82b9fdeb9": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "aa93010d0e521ad7": {"kind":"primitive","type":"boolean"}, + "aae343bc3d663c83": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "aba175fb20ffc711": {"kind":"array","element":"83079c8a3f733ac3"}, + "ada310bf0bdb8fed": {"kind":"object","properties":[{"name":"kind","type":"68f44f59e91c4139","optional":false}],"indices":[]}, + "ade057f4fdc387ae": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "aec150797b655c5f": {"kind":"literal","value":"catalog"}, + "b062078e899958f9": {"kind":"object","properties":[{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "b0b0eb1b16490f6b": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"mediaType","type":"2f1e6a7ebace43d7","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":true},{"name":"originalDimensions","type":"0f46153645c29784","optional":true},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "b2a5c8c008a31e3d": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "b2a948a5340a9341": {"kind":"object","properties":[{"name":"inserted","type":"5d00abf938272f22","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]}, + "b3681c026a0b98e9": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "b36fd842cd004b53": {"kind":"object","properties":[{"name":"data","type":"b2a948a5340a9341","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]}, + "b4ec0af39fd52ed9": {"kind":"object","properties":[{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "b52f215a6cd69105": {"kind":"array","element":"8064c03c87c357fd"}, + "b613f83572fe2fbf": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "b6f94c17dd9bbd7c": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "b70f3e81fe11b7a5": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "b765ee31033cea46": {"kind":"literal","value":"set"}, + "b782058cef83caf3": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"7a5b68bd1a5118c8","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, + "b79641a9d25e7566": {"kind":"object","properties":[{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "b815d7b91cdd9fc6": {"kind":"literal","value":"goal"}, + "ba2a1a8f194b1bb2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "ba4c40b4381e0dcd": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "baccc97ec34be6c4": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "bb94f3217aa56c88": {"kind":"literal","value":"hooks-codex"}, + "bbc5696e3f13c758": {"kind":"opaque","reason":"unknown"}, + "bcf0caf62d964b2f": {"kind":"object","properties":[{"name":"endSeq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"op","type":"f6cb462f808309e3","optional":false},{"name":"startSeq","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "bf16d2e98a7f1389": {"kind":"literal","value":"subagent-settled"}, + "c1f0f17b6493b85d": {"kind":"object","properties":[{"name":"arguments","type":"4cd48f3e5108bd6e","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"1f0133217eeeffd8","optional":false}],"indices":[]}, + "c5228b7a55498d84": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "c55827fba7018198": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"9645cf93769a6dfb","optional":false},{"name":"outcome","type":"85d6d96ff8842974","optional":false}],"indices":[]}, + "c5687b1313206a6f": {"kind":"literal","value":"cordis-host-runner"}, + "c748fb42c80a1200": {"kind":"array","element":"86e1e384495ee09a"}, + "cb1c8959e1726f82": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "cda2345a9be9d139": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "cda79b839d8cc0c7": {"kind":"union","types":["09f3f8dc417f2782","4b259f7f8f0a44ad","7580e7d96dc8475f","b782058cef83caf3","fa87537f4a9c1e25"]}, + "d4e4d575abeb4b72": {"kind":"literal","value":"canceled"}, + "d85590a5675c6e2f": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "d9c462b9f62fdd3e": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"a13d8f379222ece8","optional":false}],"indices":[]}, + "d9edf1862e4471d6": {"kind":"literal","value":"schedule"}, + "dacf3d5f3e4ff619": {"kind":"literal","value":true}, + "db06ef35b27a3842": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "db6c5246911d47a7": {"kind":"literal","value":"webhook"}, + "dba54c09c6e7aafc": {"kind":"object","properties":[{"name":"kind","type":"4edec1b770e8ec4c","optional":false}],"indices":[]}, + "dd00cdccd0ba487b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "dd5ee8762bd8a14b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "dd6cc2bb2a8f2824": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "dde0cdf9d426768b": {"kind":"object","properties":[{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "de25352d2dc41963": {"kind":"literal","value":"user/message"}, + "de30b1fa12c75f81": {"kind":"literal","value":"image/gif"}, + "deaae5cd30987d4f": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "deebfc7c62177ea8": {"kind":"object","properties":[{"name":"kind","type":"95a3ffccbf3c8afc","optional":false}],"indices":[]}, + "df843efd87c78cf8": {"kind":"array","element":"4f91f187baeabef5"}, + "e438a9d4fb883b3e": {"kind":"literal","value":"append"}, + "e7f4cbb3a57f4cbd": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "e8de0f457fed5b9f": {"kind":"literal","value":"notice"}, + "ebb768d85ec87fb6": {"kind":"literal","value":"image/webp"}, + "edbbfff1128099fd": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "ee577a0cc18664c9": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "efa1a091017ce533": {"kind":"object","properties":[{"name":"data","type":"a3e299483348f27a","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]}, + "f19bee84bf3d2090": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "f230c0b583e23b25": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "f29d63336e8266d8": {"kind":"object","properties":[{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "f2da0f27c1e6add5": {"kind":"object","properties":[{"name":"attachment","type":"f19bee84bf3d2090","optional":false},{"name":"type","type":"277b61f9c6f81704","optional":false}],"indices":[]}, + "f4a66f654996c093": {"kind":"object","properties":[{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "f56c5b59ca016131": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"7f13860ee89a2d35","optional":false},{"name":"references","type":"df843efd87c78cf8","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]}, + "f6329fb30c7ea801": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "f6605d6c57933aea": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "f68c335306535a00": {"kind":"literal","value":"user-approval"}, + "f6afe41e3926f422": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "f6cb462f808309e3": {"kind":"literal","value":"replace"}, + "f7283223aacdc39b": {"kind":"object","properties":[{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "f88f1b7b6bb733f6": {"kind":"literal","value":1}, + "f97f47b0eb87905e": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "fa87537f4a9c1e25": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"95c0e88fcf0bafb2","optional":false},{"name":"source","type":"719c51f67074f970","optional":false}],"indices":[]}, + "fb8b6a2e53cb9cf4": {"kind":"literal","value":"image"}, + "fbd2c74b346a1595": {"kind":"literal","value":"agent-instructions"}, + "fc2ae6962f9f8801": {"kind":"object","properties":[{"name":"entries","type":"aba175fb20ffc711","optional":false},{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"6ebcc2dbec9f2dab","optional":false},{"name":"update","type":"dacf3d5f3e4ff619","optional":true}],"indices":[]}, + "fcf427290c6213c2": {"kind":"primitive","type":"never"}, + "fcf81d3c58c13593": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "fe1a7acdeed4d0f5": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "fee1caf8ab6a65c1": {"kind":"literal","value":"instructions"} + } +} diff --git a/docs/persistence-changes/2026-10-22-action-card-status.zh.md b/docs/persistence-changes/2026-10-22-action-card-status.zh.md new file mode 100644 index 0000000000..4257655568 --- /dev/null +++ b/docs/persistence-changes/2026-10-22-action-card-status.zh.md @@ -0,0 +1,60 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-10-22-action-card-status + +[English](2026-10-22-action-card-status.md) | 中文 + +## 概述 + +为写入日志的模型上下文新增操作卡片状态的来源标识。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-22-action-card-status +baseline: false +changes: + - root: "event:agent/inbox/spliced" + previous: "2026-10-13-user-rpc-principal" + after: "b36fd842cd004b532ce96fd5d6f5be28e017eda043131aa3cf5e69edc436108e" + decision: same-version + - root: "event:developer/message" + previous: "2026-10-13-user-rpc-principal" + after: "5ef58f2a1bdc576c1f12fa1f4da1920ece020b13c0f991a578cddcbeb28bc5be" + decision: same-version + - root: "event:session/title-llm-request" + previous: "2026-10-13-user-rpc-principal" + after: "efa1a091017ce533e38b0ef3e14d11a5c102ff1793229b18dad719a4268d9adc" + decision: same-version + - root: "event:user/message" + previous: "2026-10-13-user-rpc-principal" + after: "58b122df513aeaf7ddc5783dbb1fd844e2979bb273964e30ae351a8db6510019" + decision: same-version +``` + + +## 兼容性 + +已有消息和记录不变。没有操作卡片插件的读取方保留新的 action-cards 来源类型及其 JSON 元数据,并把记录的文本当作普通上下文。只有写入方使用 through 序号确认已送达的决定;它不授予任何权限,也不改变回放的解释。 + + +## 验证 + +对 action-cards、creation-recommendations、client-ui-action-cards 和 client-ui-creation-recommendations 的测试运行 pnpm exec vitest run:120 个测试通过,源码覆盖率 100%。Web creation-recommendations 回放在下一次回答之前记录了已忽略卡片的状态通知。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index 5339de2d2b..40d842e063 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/README.i18n.yaml @@ -15,8 +15,8 @@ en: 14c57f30e676f6e3 zh: b1a0ccf26fab3727 /session-persistence-formats/format-references: - en: 17ef09dfc5a89073 - zh: 59b8d60acb66c2dc + en: e3511a4ac3bc4457 + zh: 828f3c39f80d5370 /session-persistence-formats/scope-and-evidence: en: b220062139ddac1e zh: 7c4c0efe05dafa9c diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index 174c007423..74c575b300 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 65 / 614 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 65 / 622 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index 055e07f954..12eb600771 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 65 / 614 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 65 / 622 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index a9625525ff..d70bd19123 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -5,7 +5,7 @@ {"key":"JsonlHeaderLine","kind":"header","digest":"18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f"}, {"key":"SessionEventEnvelope","kind":"envelope","digest":"5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc"}, {"key":"event:agent-preset/selected","kind":"event","event":"agent-preset/selected","surface":false,"digest":"a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b"}, - {"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034"}, + {"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"b36fd842cd004b532ce96fd5d6f5be28e017eda043131aa3cf5e69edc436108e"}, {"key":"event:approval/asked","kind":"event","event":"approval/asked","surface":false,"digest":"3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1"}, {"key":"event:approval/decided","kind":"event","event":"approval/decided","surface":false,"digest":"bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3"}, {"key":"event:approval/policy","kind":"event","event":"approval/policy","surface":false,"digest":"26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78"}, @@ -18,7 +18,7 @@ {"key":"event:compaction/start","kind":"event","event":"compaction/start","surface":false,"digest":"db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965"}, {"key":"event:compaction/summary","kind":"event","event":"compaction/summary","surface":false,"digest":"e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602"}, {"key":"event:deliverables/presented","kind":"event","event":"deliverables/presented","surface":false,"digest":"13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176"}, - {"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c"}, + {"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"5ef58f2a1bdc576c1f12fa1f4da1920ece020b13c0f991a578cddcbeb28bc5be"}, {"key":"event:feedback/message-delete","kind":"event","event":"feedback/message-delete","surface":false,"digest":"3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796"}, {"key":"event:feedback/message-put","kind":"event","event":"feedback/message-put","surface":false,"digest":"b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf"}, {"key":"event:feedback/record","kind":"event","event":"feedback/record","surface":false,"digest":"b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8"}, @@ -40,7 +40,7 @@ {"key":"event:session-log-deepseek/delivery-accepted","kind":"event","event":"session-log-deepseek/delivery-accepted","surface":false,"digest":"d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9"}, {"key":"event:session/end-seed","kind":"event","event":"session/end-seed","surface":false,"digest":"5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e"}, {"key":"event:session/title","kind":"event","event":"session/title","surface":false,"digest":"1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5"}, - {"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056"}, + {"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"efa1a091017ce533e38b0ef3e14d11a5c102ff1793229b18dad719a4268d9adc"}, {"key":"event:step/end","kind":"event","event":"step/end","surface":false,"digest":"e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05"}, {"key":"event:step/start","kind":"event","event":"step/start","surface":false,"digest":"4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986"}, {"key":"event:subagent/catalog","kind":"event","event":"subagent/catalog","surface":false,"digest":"53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac"}, @@ -62,29 +62,27 @@ {"key":"event:tool/result","kind":"event","event":"tool/result","surface":true,"digest":"7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397"}, {"key":"event:turn/end","kind":"event","event":"turn/end","surface":false,"digest":"0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce"}, {"key":"event:turn/start","kind":"event","event":"turn/start","surface":false,"digest":"aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8"}, - {"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8"}, + {"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"58b122df513aeaf7ddc5783dbb1fd844e2979bb273964e30ae351a8db6510019"}, {"key":"event:web/deepseek-search-llm-request","kind":"event","event":"web/deepseek-search-llm-request","surface":false,"digest":"0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52"}, {"key":"event:working-directory/change","kind":"event","event":"working-directory/change","surface":false,"digest":"124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3"}, {"key":"event:workspace/changes","kind":"event","event":"workspace/changes","surface":false,"digest":"e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72"} ], "types": [ - {"digest":"00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f","names":["packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData"],"sources":["packages/session/session-title-llm/src/index.ts:37"]}, {"digest":"0116b1f89c05221d56b0ba35c83d302bf4857bc7277d247e9bbaf74b27d2c66e","names":[],"sources":[]}, {"digest":"0203243b72fc867afc81aff5f4cf2167e564ed342534eb612fc92f295cf07789","names":[],"sources":[]}, {"digest":"022623b9ef98ec47ec3c3bae0c51027c70d2601141c5e92e26a0d83d1b4cef06","names":[],"sources":[]}, - {"digest":"023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4","names":[],"sources":["packages/core/session/src/types.ts:207"]}, + {"digest":"023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4","names":[],"sources":["packages/core/session/src/types.ts:211"]}, {"digest":"02b2c23f2d94b028c9588a8faace3fce1217405db346da3389368b495457bf79","names":[],"sources":["packages/extensions/cordis-host-runner/src/index.ts:14"]}, {"digest":"0341d4c3dfcd53253cdc1e789d9130d3cd848b6f8b0706bf92f313a68b39ff23","names":[],"sources":[]}, - {"digest":"040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b","names":["packages/llm/llm/src/message.ts#Message"],"sources":["packages/llm/llm/src/message.ts:196"]}, + {"digest":"05205074b24d8340cfd7995f6cb64779c7f1241490954700115601e264178877","names":[],"sources":[]}, {"digest":"05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04","names":[],"sources":[]}, {"digest":"05d2ae1e0c7f8770f83f9a09047afb39a335d8ac986df3193659aa1e581472cf","names":["packages/interaction/user-approval/src/index.ts#ApprovalPolicy"],"sources":["packages/interaction/user-approval/src/index.ts:68"]}, {"digest":"0657d3ca5ac4e7bf79b00bcf860b1c7159d01a9da137497221ec2f1776e365f7","names":["packages/core/session/src/types.ts#SessionSeq"],"sources":["packages/core/session/src/types.ts:33"]}, - {"digest":"0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034","names":[],"sources":[]}, {"digest":"07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235","names":["packages/session/session-title/src/types.ts#SessionTitleModelIdentity","packages/subagent/tool-subagent/src/model-selection.ts#AllowedModelRoute"],"sources":["packages/session/session-title/src/types.ts:20","packages/subagent/tool-subagent/src/model-selection.ts:9"]}, {"digest":"07eccc9ea2f9b5439d76cc77d5e062936677d43f07a23da749819b0a22ee36b7","names":[],"sources":[]}, {"digest":"09655b34d7f3cf1e9005974de2357de2d4a60deea165d16dac1eadaa6ec539a8","names":[],"sources":[]}, {"digest":"09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363","names":["packages/llm/llm/src/message.ts#SystemMessage"],"sources":["packages/llm/llm/src/message.ts:151"]}, - {"digest":"0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3","names":[],"sources":["packages/core/session/src/types.ts:205"]}, + {"digest":"0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3","names":[],"sources":["packages/core/session/src/types.ts:209"]}, {"digest":"0a8a062bc9c3c0d97fd97742262f2ea53dd32297f834e81114870d38ac3dc6da","names":[],"sources":[]}, {"digest":"0aa62ab51b36c0e64b134bd03ba8725d758744180a1a447da49beaad194f219f","names":[],"sources":[]}, {"digest":"0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555","names":["packages/llm/llm/src/types.ts#ReplayEnvelope"],"sources":["packages/llm/llm/src/types.ts:448"]}, @@ -101,7 +99,6 @@ {"digest":"0ed8d1108fe7a5e1c3078ef1faea12199a56b6b1216ccaceb0f6103b0c9a7691","names":[],"sources":[]}, {"digest":"0eeb17e1df89b1ebc2902116fe7145e0d321ef0db26e6bdebc802cb8513a01c1","names":[],"sources":[]}, {"digest":"0f46153645c297846a3fad3911636659ca4717a5e7d71c9f50bffacd312115ef","names":[],"sources":["packages/attachment/attachment/src/types.ts:28"]}, - {"digest":"0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7","names":["packages/llm/llm/src/message.ts#DeveloperMessage"],"sources":["packages/llm/llm/src/message.ts:157"]}, {"digest":"0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce","names":[],"sources":[]}, {"digest":"0fe52387f65052702ed9d7cd59db826468936595d1b3eec207e7a823adc719db","names":[],"sources":[]}, {"digest":"1033093edd0db80ff410e00830b523405e00bb0c7684948e531ff65095799625","names":[],"sources":[]}, @@ -123,7 +120,7 @@ {"digest":"15d5dfdd822aa35e115afd74a8982825a493880457774e6850bc1520b50875e4","names":[],"sources":[]}, {"digest":"16d0507cb94b4cfc84a87446b1ba7652d6ec1d65ee1684789e747e30d542800e","names":[],"sources":[]}, {"digest":"17aae616ef020b8b9f011ec398ec935450fb06d3e2a0722c456f63aeaf5ff728","names":[],"sources":[]}, - {"digest":"17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3","names":[],"sources":["packages/core/session/src/types.ts:429"]}, + {"digest":"17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3","names":[],"sources":["packages/core/session/src/types.ts:435"]}, {"digest":"18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0","names":["packages/schedule/schedule/src/types.ts#ScheduleDeleteChange"],"sources":["packages/schedule/schedule/src/types.ts:230"]}, {"digest":"18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f","names":["packages/session/session-persistence-jsonl/src/format.ts#HeaderLine"],"sources":["packages/session/session-persistence-jsonl/src/format.ts:83"]}, {"digest":"18fcdd273a2f2f9d813a3be5e71df5903a963a5c88e46b90b14de6e2db890da0","names":[],"sources":[]}, @@ -138,7 +135,7 @@ {"digest":"1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5","names":[],"sources":[]}, {"digest":"1c2686c0682e7bc51d61117b2652bd4a80707e331aa276d6ef915ae32534a330","names":[],"sources":[]}, {"digest":"1c5a58f4599bd4a9711712551ffe2442a5e5fcd0758bafe630cc4ead850bdaa7","names":[],"sources":[]}, - {"digest":"1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d","names":[],"sources":["packages/core/session/src/types.ts:343"]}, + {"digest":"1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d","names":[],"sources":["packages/core/session/src/types.ts:349"]}, {"digest":"1cde1bedde79c5e8a56aee9c05c8e0a579cf5bc611deb85b93fa331a16ca978f","names":["packages/compaction/compaction-image-offload/src/projection.ts#ImageOffloadTarget"],"sources":["packages/compaction/compaction-image-offload/src/projection.ts:9"]}, {"digest":"1d5f04a03ea75fc8eeece544129eba0f49f0c82046e9c404461920390135c7b2","names":[],"sources":[]}, {"digest":"1d76406cbb2ddadb0dbbb51e9d8bc6cf678fed455cf8eb512f36f7c86d37a62f","names":[],"sources":["packages/llm/llm/src/types.ts:157"]}, @@ -151,7 +148,7 @@ {"digest":"1ebc6f9ff3aa9e3a38f8695f49bacdab1f8b7a3832741853ca46f40af56ccd01","names":[],"sources":[]}, {"digest":"1f0133217eeeffd8d387baf1ec8ed9856e719bcd999dc08e48624cba96d75fd7","names":[],"sources":[]}, {"digest":"1f1385dbc3e875c021e387c7096d671d606f22daec51b25c80348735b6bbfa10","names":[],"sources":[]}, - {"digest":"1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0","names":["packages/core/session/src/types.ts#TurnEndCancelCause"],"sources":["packages/core/session/src/types.ts:197"]}, + {"digest":"1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0","names":["packages/core/session/src/types.ts#TurnEndCancelCause"],"sources":["packages/core/session/src/types.ts:201"]}, {"digest":"1fdad624334d354820dfb3a26e28d0bc9fc1b62a33f87bab63f715b3f4391bfd","names":[],"sources":[]}, {"digest":"20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5","names":[],"sources":["packages/plan/plan-mode/src/index.ts:53"]}, {"digest":"2142e391b9ccb60d487afcf373ce74db98d952f69e8289eddb352a7e16e996c5","names":[],"sources":[]}, @@ -178,11 +175,9 @@ {"digest":"2aad1179f957631477b582e57cd8f538cb82618fc3fb816bde1171154235b065","names":[],"sources":[]}, {"digest":"2ab12a0e345658f9342816d773fe610f049982bcad82631f43b9205abe43e5ee","names":[],"sources":[]}, {"digest":"2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095","names":[],"sources":["packages/llm/llm/src/types.ts:473"]}, - {"digest":"2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8","names":[],"sources":[]}, {"digest":"2aed07493b1c65b6d00de5586da1e743cf501684b9ad43a1765fb4007d4ea456","names":[],"sources":[]}, {"digest":"2c4c21f19f426a58f2dac914e34313aa43f8408972de857a486d1f086d5adb6c","names":[],"sources":[]}, {"digest":"2c64d4bc7264fd3f8bed47e96ab8a593bce05a1e4e1c436dcd244a3b7e0c2a9d","names":[],"sources":[]}, - {"digest":"2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711","names":["packages/llm/llm/src/message.ts#UserMessage"],"sources":["packages/llm/llm/src/message.ts:162"]}, {"digest":"2d11ca7b0d4493e244b74eba093227866b33249841e59a1e9bf56afed38604c3","names":[],"sources":["packages/web/web-search-deepseek/src/provider.ts:71"]}, {"digest":"2da725e554cd89301d829ed9dc45a572c3cd1b93b5e91d0d71f007976d8a0863","names":[],"sources":[]}, {"digest":"2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f","names":["packages/schedule/schedule/src/types.ts#ScheduleChange"],"sources":["packages/schedule/schedule/src/types.ts:256"]}, @@ -194,10 +189,11 @@ {"digest":"30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f","names":["packages/session/session-title/src/types.ts#SessionTitleSource"],"sources":["packages/session/session-title/src/types.ts:28"]}, {"digest":"312aee2f0c42aabbae6cc60d92d596d8453c0aeb061c5868d1755a69ef49e69a","names":[],"sources":[]}, {"digest":"31907de9563751822af6d9be02bafaf26faa098bbd1cb412524134d577cc1648","names":["packages/compaction/compaction/src/checkpoint.ts#CompactionCheckpointSource"],"sources":["packages/compaction/compaction/src/checkpoint.ts:22"]}, + {"digest":"3247e566f4340029325b70e4366b56fa9719977f1e39033b769a2ec221ab22a9","names":[],"sources":[]}, {"digest":"326a9bfda27dda5882fb3cbe6e715df41dae2298fdae3d66437457268a665693","names":["packages/llm/llm/src/call-config.ts#LlmCallConfigAdapterDefaults"],"sources":["packages/llm/llm/src/call-config.ts:54"]}, {"digest":"3305a4bb6b6c29b482ba91d59c60b47e8f98282d3dacc8c29e4a4c2712209bdd","names":[],"sources":[]}, {"digest":"33341aac09312300de5a178e6cc6f6544a4876f7f710cfea55eda473c8d329ef","names":["packages/llm/llm/src/assistant-stream.ts#AssistantStreamRecord"],"sources":["packages/llm/llm/src/assistant-stream.ts:20"]}, - {"digest":"335e242de1fcc17b6ca920fc420d71bec2d76e53e37955c00948b65ab77f05c5","names":["packages/core/session/src/types.ts#SurfaceOp"],"sources":["packages/core/session/src/types.ts:464"]}, + {"digest":"335e242de1fcc17b6ca920fc420d71bec2d76e53e37955c00948b65ab77f05c5","names":["packages/core/session/src/types.ts#SurfaceOp"],"sources":["packages/core/session/src/types.ts:470"]}, {"digest":"347de3380f2da308d0c74e08821f9e85559f55af4c5010795b110d00f7fc9dbd","names":[],"sources":[]}, {"digest":"34d1ab5c5df378186d6054b1a1beea9a41e41965d6f336f0bc0025c441a82e66","names":[],"sources":[]}, {"digest":"35203ba7ad5ef6f97d556b85df20ae98f04f09c65748cecdf8eefdb8b6405ffc","names":[],"sources":[]}, @@ -236,7 +232,7 @@ {"digest":"44818000e56acb97afdb5045ce235eb55e3fb1ccabc9fc7d0750bb38f36a7f8e","names":[],"sources":[]}, {"digest":"44c8c77aa9aa100b0d74d2deee85474b29faa6eee0ac0f662f7abe9931ddd7f2","names":[],"sources":[]}, {"digest":"4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986","names":[],"sources":[]}, - {"digest":"454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020","names":[],"sources":[]}, + {"digest":"45f005e6bbb7645248be792b715a74bc7fdf5a67f5381655d3a75dcc7aa2112e","names":[],"sources":[]}, {"digest":"45f2a07ea94573123b6ae14db787a40062dec3834f36d8be92c523d40ba28448","names":[],"sources":["packages/interaction/user-approval/src/types.ts:44"]}, {"digest":"477614bca595a9e7adb5a1870be446b9a8684f3ba8950259d6289f2d3b74f028","names":[],"sources":["packages/compaction/compaction-image-offload/src/projection.ts:25"]}, {"digest":"47e5d84f1a93450a78c7f3954b43990d0f629af7c034a56313e5deb63e772c1b","names":[],"sources":[]}, @@ -252,8 +248,8 @@ {"digest":"4b1e632e05776982a2d21c25a50c7c04c199d311c5675218fa73ff9fe30d22cd","names":["packages/schedule/schedule/src/types.ts#LegacyScheduleRecord"],"sources":["packages/schedule/schedule/src/types.ts:185"]}, {"digest":"4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731","names":["packages/llm/llm/src/message.ts#ToolResultMessage"],"sources":["packages/llm/llm/src/message.ts:173"]}, {"digest":"4b7ddaf3710b88ff9b8df66304f5f97bf03688fd19730eedc2e9535203c820ea","names":[],"sources":[]}, - {"digest":"4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db","names":[],"sources":["packages/core/session/src/types.ts:194"]}, - {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:380","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]}, + {"digest":"4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db","names":[],"sources":["packages/core/session/src/types.ts:198"]}, + {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:386","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]}, {"digest":"4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159","names":[],"sources":[]}, {"digest":"4e44c9118152f6829634bfd508e47669ab0497caffa380c94b4be91f9390d35d","names":[],"sources":[]}, {"digest":"4e887768586528565381dadbddee6cef555874148089f4579e0b4b1ad096fc9c","names":["packages/llm/llm/src/types.ts#TextBlock"],"sources":["packages/llm/llm/src/types.ts:62","packages/web/web-search-deepseek/src/provider.ts:66"]}, @@ -269,14 +265,14 @@ {"digest":"525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336","names":[],"sources":[]}, {"digest":"529e0ccd0e34079ab4ff21ed87f810cee0388246263bf22e8aba5986b56fae05","names":[],"sources":["packages/util/values/src/index.ts:4"]}, {"digest":"52c6113cb0e53f345324bf84f01928bea5d5df57ee80bc0dd65407b8bc5a3734","names":[],"sources":[]}, - {"digest":"5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777","names":[],"sources":["packages/core/session/src/types.ts:377"]}, - {"digest":"530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6","names":["packages/core/session/src/types.ts#RequestHeaderReason"],"sources":["packages/core/session/src/types.ts:275"]}, + {"digest":"5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777","names":[],"sources":["packages/core/session/src/types.ts:383"]}, + {"digest":"530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6","names":["packages/core/session/src/types.ts#RequestHeaderReason"],"sources":["packages/core/session/src/types.ts:279"]}, {"digest":"53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac","names":[],"sources":[]}, {"digest":"53cf1704ae0709c7678bcf070c83c619fc48ca1b5c173b74cfa8702f22a8e492","names":[],"sources":[]}, - {"digest":"54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0","names":[],"sources":["packages/core/session/src/types.ts:357"]}, + {"digest":"54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0","names":[],"sources":["packages/core/session/src/types.ts:363"]}, {"digest":"557e148d30b79ee48645850832b30c717d7c3b64641deb5d6133b8b4e91ee0b8","names":[],"sources":[]}, {"digest":"5654685d7557ec747e74887237065e626a5bdfa426ca43f7fbb0b6fee9b0916c","names":[],"sources":[]}, - {"digest":"56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23","names":[],"sources":["packages/core/session/src/types.ts:223"]}, + {"digest":"56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23","names":[],"sources":["packages/core/session/src/types.ts:227"]}, {"digest":"570e85022434613394418ad33e77e9f1f7a05699b33d0b62eae0677a638c3833","names":[],"sources":[]}, {"digest":"576a2de8355eb09850963ff5953420b172e274d9283666eb7d889097db120796","names":[],"sources":[]}, {"digest":"5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc","names":[],"sources":[]}, @@ -285,17 +281,18 @@ {"digest":"5808e36c95355f0e745201b5db1b6bcec4ebf5adb75d3e82d7d59cc89635ab80","names":["packages/llm/llm/src/types.ts#FinishReason"],"sources":["packages/llm/llm/src/types.ts:165"]}, {"digest":"587bb11da730e06aa52b62c087d217d6f8f22459b25f31cb9e1e1baf17944e5e","names":[],"sources":[]}, {"digest":"589d0d91239681f89ee485459dc8c2f73ea47183b5534289d966637024a2c35d","names":[],"sources":[]}, + {"digest":"58b122df513aeaf7ddc5783dbb1fd844e2979bb273964e30ae351a8db6510019","names":[],"sources":[]}, {"digest":"58f94390b982f8a5ac1f4f235ba612557f4a1dc4a995d8b1cec63ce7639decb0","names":[],"sources":[]}, {"digest":"5abab3a27fd2736660ecddd94b28d6bbf114986dd894880153bd1be3ccacb563","names":[],"sources":[]}, {"digest":"5b232a9b9bfcc0fc6794dab7865bcf203a65785c5de5aa647a51228bff628413","names":[],"sources":[]}, {"digest":"5b6fb1f226ff56402db08a6aae82af76100b14056271875f69ec526a1ae05d51","names":[],"sources":["packages/interaction/commands/src/types.ts:112"]}, {"digest":"5bb69b88517f6214f2af4d1a6d2b771e271ad3ad695cc9c2c272069ae698c9bc","names":[],"sources":[]}, - {"digest":"5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8","names":["packages/llm/llm/src/message.ts#MessageSource"],"sources":["packages/llm/llm/src/message.ts:136"]}, {"digest":"5bc66269cfaf614c0ecda8ecb469f23101a7609793fc2b693ac23c4aecdb575e","names":[],"sources":[]}, {"digest":"5bd310104cfe448e530beb11422d2f791e2e32e8e95fdc528a15f9ca693dae75","names":[],"sources":[]}, {"digest":"5c45bf4c544a7211dcd8ba6ba7e5f1bc39b49e7a9df9d5cbdc8e87c22771b37b","names":[],"sources":[]}, {"digest":"5cb63db9fe57dc09c3a1c2b84c3edf73ff5b62960155d8436a2377a5c43ce32c","names":[],"sources":[]}, {"digest":"5cdbf342b7748d152bc987b68f58e565e5bba4b227e076e2d2120d87662e45cd","names":[],"sources":[]}, + {"digest":"5d00abf938272f22eb47199e9d40801e8d8ec4c88e2b138b15aa2a5a5bf87520","names":[],"sources":[]}, {"digest":"5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78","names":[],"sources":[]}, {"digest":"5d17f27a1ef9f7026c0232714adf7573961536d75f3303ca97973975f5af170e","names":[],"sources":[]}, {"digest":"5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694","names":[],"sources":[]}, @@ -303,6 +300,7 @@ {"digest":"5dd766ccd1702a1142ad8b4bd44f2242f8e8a6752669ad3de0a5d224329b1438","names":[],"sources":["packages/compaction/compaction/src/types.ts:82"]}, {"digest":"5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e","names":[],"sources":[]}, {"digest":"5ebce1317eacd2c148456627a09668e927ee15c204f0643f5ea76b46b54a2647","names":[],"sources":[]}, + {"digest":"5ef58f2a1bdc576c1f12fa1f4da1920ece020b13c0f991a578cddcbeb28bc5be","names":[],"sources":[]}, {"digest":"5f2117d1856a5ab43c28371faa978ebfaa48f8c7d734ea3fc94842210df83dca","names":["packages/llm/llm/src/types.ts#LlmFailure"],"sources":["packages/llm/llm/src/types.ts:41"]}, {"digest":"5f26a6c20b37632f8f57729d171c671def4683d994ac6257a8dffcd855101627","names":[],"sources":[]}, {"digest":"5f758f3de5f00c18968a7c6d347083aac5c9ae13f6a79c78f6611b23c4973564","names":["packages/subagent/subagent/src/descriptor.ts#OneShotSubagentDescriptorData"],"sources":["packages/subagent/subagent/src/descriptor.ts:61"]}, @@ -328,14 +326,14 @@ {"digest":"6ab45ac76839722df912f3ba50f1c6b41f2d614bde11f1aabfae04117ccdc80d","names":[],"sources":[]}, {"digest":"6b23ed9ce1b397e602788dbe033693bf97642a69e23fcab1cc45bf6f13b6d619","names":[],"sources":[]}, {"digest":"6bbe793f6a93dc8d50a4d4d3fbb6f8ed15650bcb7e151d28669628f7b7c3fc25","names":["packages/goal/goal/src/types.ts#GoalSnapshot"],"sources":["packages/goal/goal/src/types.ts:60"]}, - {"digest":"6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5","names":[],"sources":["packages/core/session/src/types.ts:332"]}, - {"digest":"6c03546143cdf6ae88f1745ac98c5139efa1588d1415249bbc5f3104d32ab800","names":[],"sources":["packages/core/session/src/types.ts:192"]}, + {"digest":"6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5","names":[],"sources":["packages/core/session/src/types.ts:338"]}, + {"digest":"6c03546143cdf6ae88f1745ac98c5139efa1588d1415249bbc5f3104d32ab800","names":[],"sources":["packages/core/session/src/types.ts:196"]}, {"digest":"6c1cccb433ddc153facb380c7dda956237ba5c31f8a532a69142dc277890cc9b","names":[],"sources":[]}, - {"digest":"6c5a37d42ebdb12db4410699259335f1e149e3a0260425684c00b153afc8990d","names":[],"sources":["packages/core/session/src/types.ts:193"]}, + {"digest":"6c5a37d42ebdb12db4410699259335f1e149e3a0260425684c00b153afc8990d","names":[],"sources":["packages/core/session/src/types.ts:197"]}, {"digest":"6ccb6bc423fb3952458abf9c1b1c3284208b7e660344b612cc55da773d61afe4","names":[],"sources":[]}, {"digest":"6cda0d6d3b4336d83998fe580628426df81c2afbab5d768b8813b56d569120e8","names":[],"sources":[]}, {"digest":"6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65","names":[],"sources":[]}, - {"digest":"6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def","names":[],"sources":["packages/api/session-controller/src/types.ts:389"]}, + {"digest":"6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def","names":[],"sources":["packages/api/session-controller/src/types.ts:395"]}, {"digest":"6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406","names":[],"sources":["packages/preset/agent-preset-registry/src/session.ts:31"]}, {"digest":"6ea608bdc8a7a63d3d23b84c49721325094db88d136482e43a820945295eee3e","names":[],"sources":[]}, {"digest":"6ebcc2dbec9f2dabca9f668ae6d39e877899b180740570f453409db339221df3","names":[],"sources":[]}, @@ -351,16 +349,19 @@ {"digest":"71bb3254ec8f1e491ce63b762a7b5f18709de23aa4e8abcc8b886f4b6ce051dd","names":[],"sources":["packages/llm/llm/src/assistant-stream.ts:28"]}, {"digest":"72ec79127a9c0d0241b1106a74cc2b24c81ce467170f1c3c93f7b71a9496d227","names":[],"sources":[]}, {"digest":"7382c4fc95fe8c5db65ca4d9da572b115d0dbb818b7f32c22ab040318ffbb4bc","names":[],"sources":[]}, + {"digest":"7404c6123fdaa591474a37979a5a8ae70d1387af7c9f09bfe568b218935a974d","names":[],"sources":["packages/core/session/src/types.ts:317"]}, {"digest":"750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5","names":["packages/jobs/jobs/src/types.ts#JobSettleCause"],"sources":["packages/jobs/jobs/src/types.ts:247"]}, {"digest":"7554565be416b0342053ee6a46256c9dab07c487349b4a5f10bab1edc151c619","names":[],"sources":[]}, {"digest":"756b5e23a5cb2c7f486f366a828f7461c88902af21125bd6810881f4ccbff6cc","names":[],"sources":[]}, + {"digest":"7580e7d96dc8475fffe813a42af499665de3d4517fcdb3bc2337d0340e98d616","names":["packages/llm/llm/src/message.ts#DeveloperMessage"],"sources":["packages/llm/llm/src/message.ts:157"]}, {"digest":"7624ca28c61ca0eaa4f015140f3cea6e0c16cce2f2960fab5f531203d253903e","names":[],"sources":[]}, {"digest":"763c8a20af487263a0080548274f7437ef4a86e0ba8769127d1ced10a72c664d","names":[],"sources":[]}, {"digest":"76632533f67879dcb346fcae640540dbc89b2472b0bce88c59f7e48fc5c0e668","names":[],"sources":[]}, {"digest":"78a9be340090b735a39980e7222ce730c972fbcf1de7c9e0aef2125884b7caee","names":[],"sources":[]}, - {"digest":"79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985","names":[],"sources":["packages/core/session/src/types.ts:214"]}, - {"digest":"79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056","names":[],"sources":[]}, + {"digest":"78b9204976ab9faaf133c3488b73d1dfb6f1a4bb6c0903e3473cd41a5e1e39e1","names":[],"sources":[]}, + {"digest":"79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985","names":[],"sources":["packages/core/session/src/types.ts:218"]}, {"digest":"79ddc907312f97cf9553d4841e8b3fc937097bd8d97900a790efad3c05255e99","names":[],"sources":["packages/todo/tool-todo/src/types.ts:33"]}, + {"digest":"7a5b68bd1a5118c827df9e2466acc5e4d16b83e3f97a2304d992958647592690","names":["packages/llm/llm/src/message.ts#MessageSource"],"sources":["packages/llm/llm/src/message.ts:136"]}, {"digest":"7a651f3b672c52fb615f22c0ce9cb3cc5b22aad92e6bbeefb0808fd7b9233a50","names":[],"sources":[]}, {"digest":"7af85bf70d4eafce63e739adae38ea85501e68df3a0e22f97f3a0be90fd9e4cb","names":[],"sources":[]}, {"digest":"7b1ce60193fa8b69292e5843858d9cee7dc565dcb40d33c136e5d7e8b8d4296a","names":[],"sources":[]}, @@ -431,7 +432,6 @@ {"digest":"9645cf93769a6dfb227bd400bd6cb9fa7bab9140f4a851e4ae92514ade6e3114","names":[],"sources":[]}, {"digest":"96bfb916a727cd4445a594df264c523fc039749a31bfed924b8cc7bb308a5bb0","names":[],"sources":[]}, {"digest":"96f4a9c81fc0f940ef71528a8cc66731ae4cf1f40d79680c5fe3c39f96d7723c","names":["packages/core/agent/src/types.ts#InboxTarget"],"sources":["packages/core/agent/src/types.ts:40"]}, - {"digest":"97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c","names":[],"sources":[]}, {"digest":"9890113e97dd3541787bfe6704f2b223d3a890b5325ca0acae2b0398909b413e","names":[],"sources":["packages/sandbox/sandbox-policy/src/session-mode.ts:33"]}, {"digest":"99135d8a3937977955eaac5f8f04f930dcd1562f8450a54a51cde9b921dd59a6","names":[],"sources":[]}, {"digest":"9947aa5938fe19a895298da412917acbf9ffb5093a73ab8baa257e6ae9e63a07","names":[],"sources":[]}, @@ -440,7 +440,7 @@ {"digest":"99f0e98184709ad10f27bfdeaafb4457d9d2b413f402331764798cdb7d3cbe4d","names":[],"sources":[]}, {"digest":"9a2a9029f8d7ede05336980d8342737557f487b913bfd28853d0ab5214600ab5","names":[],"sources":["packages/web/web-search-deepseek/src/provider.ts:64"]}, {"digest":"9a410ddf56792b2b7cd4a224a2e54dc4dee30a8e8e6b1c2b9a1d19992b217adc","names":["packages/workflow/tool-workflow/src/types.ts#ToolWorkflowAgentStartData"],"sources":["packages/workflow/tool-workflow/src/types.ts:20"]}, - {"digest":"9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7","names":["packages/core/session/src/types.ts#EpochHeader"],"sources":["packages/core/session/src/types.ts:242"]}, + {"digest":"9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7","names":["packages/core/session/src/types.ts#EpochHeader"],"sources":["packages/core/session/src/types.ts:246"]}, {"digest":"9ac52f0f84fec2eb9f71c2937e497d759db5ca0da4836d93a679d113684141f2","names":[],"sources":[]}, {"digest":"9acbe713f3f641921dd3333b240aa72e176d795327c24e8d9db9b68b16771b49","names":[],"sources":[]}, {"digest":"9b4fd974c124c83743efe030ba4e1250e087505f3f74588716a521fb8d453267","names":[],"sources":[]}, @@ -464,6 +464,7 @@ {"digest":"a2be0f0cb59f0d4a06557d45847d1938aced238baa77a6da416e590b8b958553","names":[],"sources":[]}, {"digest":"a3a259a1aa5401af786ce08c4a8419681d3000d73467991b5fc563389bc4ebd2","names":[],"sources":[]}, {"digest":"a3c6f1ea38e9a61d7a9706057ba65aabe02dfe6d9dd90d4736aa62325d874531","names":[],"sources":[]}, + {"digest":"a3e299483348f27a4787a36bb3f29cd86024c65336abc10bb3d687468302c4cf","names":["packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData"],"sources":["packages/session/session-title-llm/src/index.ts:37"]}, {"digest":"a3edd1efd1fef21b72c55e213c8a8b24196b091abde2b89db52be0b2aaae3cfa","names":[],"sources":[]}, {"digest":"a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c","names":[],"sources":["packages/experimental/agent-team/src/types.ts:232"]}, {"digest":"a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc","names":[],"sources":[]}, @@ -471,12 +472,11 @@ {"digest":"a6da9ad5d42ae83a6833a8288b076688e8e0801633ea36cbdd8a30a6d9f3160c","names":["packages/workflow/tool-workflow/src/types.ts#ToolWorkflowRunStartData"],"sources":["packages/workflow/tool-workflow/src/types.ts:14"]}, {"digest":"a720757f704a2512e9d2f20da4a093e582472176236a75ab8b8eb7aaff17d85a","names":[],"sources":[]}, {"digest":"a7cf43ce7c2a4c038feed1885cd7a00d5c6ee2d90a7e0d56b46f78a3e1ca327f","names":[],"sources":[]}, - {"digest":"a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735","names":[],"sources":["packages/core/session/src/types.ts:385","packages/core/tool-executor-ptc/src/types.ts:27"]}, + {"digest":"a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735","names":[],"sources":["packages/core/session/src/types.ts:391","packages/core/tool-executor-ptc/src/types.ts:27"]}, {"digest":"a853902cfab417b8f08aa51ad6855fea46f34ed4588a74e47403035111d0fb86","names":[],"sources":["packages/interaction/commands/src/types.ts:105"]}, {"digest":"a8a2b9011fc067862df7ce40748868467e5778283d7ac193c13f77684a914c57","names":[],"sources":[]}, {"digest":"a9059a81430b320792bbb7c2ee1f8f6c77ff71e40ee44c18aec72e157c522694","names":[],"sources":[]}, {"digest":"a926dc71814edd01a4f041eb5fc766d153db3407ef4704e98f4c1b6527309ebb","names":["packages/schedule/schedule/src/types.ts#LegacyEveryScheduleRecord"],"sources":["packages/schedule/schedule/src/types.ts:173"]}, - {"digest":"a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f","names":[],"sources":[]}, {"digest":"a99ec0993c62c058e50af9f68922202a3b54f5e259bc2abf1915a73381d2316d","names":[],"sources":[]}, {"digest":"a99f76fd149051c363f960bfeeb5b9509587ecaf7591a42797e4bfe810d8987a","names":["packages/todo/tool-todo/src/types.ts#TodoItem"],"sources":["packages/todo/tool-todo/src/types.ts:23"]}, {"digest":"aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8","names":[],"sources":[]}, @@ -485,14 +485,14 @@ {"digest":"aa93010d0e521ad7c42273f58d5c9b8bf1dfc94edf1b805a3971b364a35c1877","names":[],"sources":[]}, {"digest":"aacda84a69739238aa782eea902d3073017d9d74b9808de2bf5a83c9738cbdc6","names":[],"sources":[]}, {"digest":"aae343bc3d663c83f2e8b5e1c7eea5d509dc7a5361bc8bd8b5ac3bb8d3b1ed56","names":[],"sources":[]}, - {"digest":"ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7","names":[],"sources":["packages/core/session/src/types.ts:216","packages/llm/llm/src/types.ts:159"]}, + {"digest":"ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7","names":[],"sources":["packages/core/session/src/types.ts:220","packages/llm/llm/src/types.ts:159"]}, {"digest":"aba175fb20ffc71112c55efe70458ff5d7dbfb83501892b4ba8d077f0ea5d7ba","names":[],"sources":[]}, {"digest":"abd6ca62a5ab166553848324a23fef90eed7f59c8021bd63e7ea5bc6fd666b0c","names":["packages/web/web-search-deepseek/src/provider.ts#DeepSeekSearchLlmRequest"],"sources":["packages/web/web-search-deepseek/src/provider.ts:55"]}, - {"digest":"ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585","names":[],"sources":["packages/core/session/src/types.ts:392"]}, + {"digest":"ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585","names":[],"sources":["packages/core/session/src/types.ts:398"]}, {"digest":"ad22a5ca5bbaab2e1440779cd3648e85ddfc6f73b5207394eda699555cf31430","names":[],"sources":[]}, {"digest":"ad3b56aa2fc1ad4250e7399295d34a8eae592e1907e85852e2291b2e498a30bc","names":[],"sources":[]}, {"digest":"ad92f473a03ae72599ff26c86e897c25e16269519ad2a87340f0bea946517af8","names":[],"sources":[]}, - {"digest":"ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05","names":["packages/interaction/commands/src/types.ts#CommandSource"],"sources":["packages/core/session/src/types.ts:191","packages/interaction/commands/src/types.ts:75","packages/interaction/commands/src/types.ts:79","packages/llm/llm/src/message.ts:111","packages/session/session-title/src/types.ts:35"]}, + {"digest":"ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05","names":["packages/interaction/commands/src/types.ts#CommandSource"],"sources":["packages/core/session/src/types.ts:195","packages/interaction/commands/src/types.ts:75","packages/interaction/commands/src/types.ts:79","packages/llm/llm/src/message.ts:111","packages/session/session-title/src/types.ts:35"]}, {"digest":"ade057f4fdc387aec6426f48704a1f9359cfc6fee13f27735b07f9b215ec16ca","names":[],"sources":[]}, {"digest":"ae63d9d1080ed8945031594d93f8d96651d7101b848719cc3e1475875cd3d649","names":[],"sources":[]}, {"digest":"aec150797b655c5f15ebb7a501454b0c208fbaccec9ace49d7de2171fc02f47d","names":[],"sources":[]}, @@ -502,15 +502,17 @@ {"digest":"b0127044ab31a702bddfd785d345f5abd7a70876746e895ce443afa3e60ddf2d","names":[],"sources":[]}, {"digest":"b062078e899958f99ae464514578b8c093924dfe34bd9a835932b420ff2150b1","names":[],"sources":[]}, {"digest":"b0b0eb1b16490f6bee135b2377e8f8c39227974c799ad7d49ff418af23ecbc35","names":["packages/attachment/attachment/src/types.ts#ImageAttachmentRef"],"sources":["packages/attachment/attachment/src/types.ts:11"]}, - {"digest":"b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081","names":["packages/core/session/src/types.ts#RequestContext"],"sources":["packages/core/session/src/types.ts:256"]}, + {"digest":"b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081","names":["packages/core/session/src/types.ts#RequestContext"],"sources":["packages/core/session/src/types.ts:260"]}, {"digest":"b1664fa89f8eab49a14e7e3168d8bc4da4aa9fd76332f6c4a053d839e5276ae7","names":[],"sources":[]}, {"digest":"b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1","names":[],"sources":["packages/experimental/agent-team/src/types.ts:230"]}, {"digest":"b1f62380f6746fedd629e71065e3583cef83334875b499ac9665c2eb87acfbf5","names":[],"sources":[]}, {"digest":"b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e","names":[],"sources":[]}, {"digest":"b2245a906e13aa94f2d3cc6cf7b04cfa0267f7b7f4d468de1b00b1a314e54626","names":[],"sources":[]}, {"digest":"b2a5c8c008a31e3dc6c3013502f2356aca13894b31c8e70601fa92f2d40d1dba","names":[],"sources":[]}, + {"digest":"b2a948a5340a9341fa3bab99507c7090c5b0456de72ec28b5b157b8df3f1d640","names":[],"sources":["packages/core/agent/src/types.ts:110"]}, {"digest":"b2d2a01e51341a3bfec809198952c8c830a7899f4cb6ecd3c1dc0b5d9a2af943","names":["packages/experimental/agent-team/src/types.ts#TeamTaskStatus"],"sources":["packages/experimental/agent-team/src/types.ts:72"]}, {"digest":"b3681c026a0b98e98b103f057dfd217fbe904129c090efd668253ec9aea7fece","names":[],"sources":[]}, + {"digest":"b36fd842cd004b532ce96fd5d6f5be28e017eda043131aa3cf5e69edc436108e","names":[],"sources":[]}, {"digest":"b38f206d59bad891fa58c89f97a0036987b3defe7685acbd318fa55eb85f9751","names":[],"sources":[]}, {"digest":"b4ec0af39fd52ed971990e42f04e5be3204882e555c409eb330f8238eec21569","names":[],"sources":[]}, {"digest":"b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf","names":[],"sources":[]}, @@ -519,12 +521,14 @@ {"digest":"b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4","names":["packages/session/session-title/src/types.ts#SessionTitleEventData"],"sources":["packages/session/session-title/src/types.ts:41"]}, {"digest":"b57a2889408de36e4516807194c73bdecd51a92f633ccd05db81bf049200f420","names":[],"sources":[]}, {"digest":"b57ae1b37e52bee0a21cbdace168dc6a15582bcda8c306dad962c5dcc8a8e28b","names":[],"sources":[]}, + {"digest":"b613f83572fe2fbf7259c72308cafd008f4fceea897749128a48bcd158f290aa","names":[],"sources":[]}, {"digest":"b63b089a00b3b9357675b3e0c3d3e9aa4fdbda1fba9c91a6b0c59ffdbb89f505","names":["packages/schedule/schedule/src/types.ts#LegacyAtScheduleRecord"],"sources":["packages/schedule/schedule/src/types.ts:167"]}, {"digest":"b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a","names":[],"sources":[]}, - {"digest":"b6ef9c6210be8350cd465f485cf520031fffa6bf3809c61a1e713e834c5982d0","names":[],"sources":["packages/core/session/src/types.ts:203"]}, + {"digest":"b6ef9c6210be8350cd465f485cf520031fffa6bf3809c61a1e713e834c5982d0","names":[],"sources":["packages/core/session/src/types.ts:207"]}, {"digest":"b6f94c17dd9bbd7cd57015198b7c7c88407f0e3b89faab176ca3c11c3d864d3e","names":[],"sources":[]}, {"digest":"b70f3e81fe11b7a5f2504fb47a4502fb8d9c8f0731fdfe8ddcaf9b41f5a6e196","names":[],"sources":[]}, {"digest":"b765ee31033cea463863faf3fc6a0a1b85c47c6a2f13fe1ce833abe56105114b","names":[],"sources":[]}, + {"digest":"b782058cef83caf3eb44a16adeef5657e4d5cdff2d50655aba418c4490670a69","names":["packages/llm/llm/src/message.ts#UserMessage"],"sources":["packages/llm/llm/src/message.ts:162"]}, {"digest":"b79641a9d25e756636dc0c706fdf3c4583ccb7d4920e009e6e86c0759cf4de4c","names":[],"sources":[]}, {"digest":"b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5","names":[],"sources":[]}, {"digest":"b815d7b91cdd9fc6ccc7dcba05cfc5ce1d55fab5fc0c0801803cd3630cedb602","names":[],"sources":[]}, @@ -534,6 +538,7 @@ {"digest":"b9305f0ea75ccd6a6061c27fde19f2ab85534f9f3c1a717b573b077ebae0f611","names":[],"sources":[]}, {"digest":"b978cff734e62143eb56c9125423ec275405eda969802d42aaf73ecb987d3b26","names":[],"sources":[]}, {"digest":"b993441f8ce7d27b80e619e113e232ae8e62d3d5a8340f6bfe12d8c4c018e62f","names":[],"sources":[]}, + {"digest":"ba2a1a8f194b1bb21f270713398f6643e206e8ff5f7f26f6d36a88e3f0f0ab10","names":[],"sources":[]}, {"digest":"ba4c40b4381e0dcdfab8fc9fc75b5c9b3e8f09ef56deeb1bec8e703b0ef8cd6d","names":[],"sources":[]}, {"digest":"ba77b58995e36f14bb13886b0493d0a1a63d5db10b1a6cd9f6ac65a72cf832ec","names":[],"sources":[]}, {"digest":"bab68657940075483f086fffb711682a45bcc99c43d659fbe1c36af21ac05dd7","names":[],"sources":[]}, @@ -542,7 +547,7 @@ {"digest":"bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3","names":[],"sources":[]}, {"digest":"bb94f3217aa56c8876bbff0b8460d6a73cbead545659abb29e76cd0cf2cb92d6","names":[],"sources":[]}, {"digest":"bbc5696e3f13c758237b316e3cf08cda0b9fd51ffe672539883457769faa185d","names":[],"sources":[]}, - {"digest":"bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825","names":[],"sources":["packages/core/session/src/types.ts:466"]}, + {"digest":"bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825","names":[],"sources":["packages/core/session/src/types.ts:472"]}, {"digest":"bd499eac047efe091a4df7382eadd06d25cf0cb31fee3981e8b24d85762841cd","names":[],"sources":[]}, {"digest":"bf16d2e98a7f13894ded2cffe5ce857bf473954a0e3c0ed9bc7739e2e1c7b78a","names":[],"sources":[]}, {"digest":"c0ea97bc89dfc46fa94eab956f5c3ee6af2f964aa7db6327cb18b451f207bef7","names":["packages/workflow/tool-workflow/src/types.ts#ToolWorkflowAgentEndData"],"sources":["packages/workflow/tool-workflow/src/types.ts:29"]}, @@ -568,6 +573,7 @@ {"digest":"cbca49bde16b656d5b8fbe4a8a507769f7b594c6287782b809c03814941bc5e3","names":[],"sources":[]}, {"digest":"cd186c309e2bef305705e1d324139240f5e681f4f6a957d6a25b0225f65560df","names":[],"sources":[]}, {"digest":"cda2345a9be9d139df49ccbd16dcba716f3440178bf20ff8b801fda1f5eb77dc","names":[],"sources":[]}, + {"digest":"cda79b839d8cc0c7a7bd09dd067de04925f6e9037080d878b65aaa5a383f117c","names":["packages/llm/llm/src/message.ts#Message"],"sources":["packages/llm/llm/src/message.ts:196"]}, {"digest":"cdca80b8dad24d4e7b1d5a288a8475ba8d8d46be385bd2da85f122ee836f1670","names":[],"sources":[]}, {"digest":"cdee10e87dbed8440d8b6935888624884878efc7af95402e94171b29f173dced","names":[],"sources":["packages/llm/llm-retry/src/types.ts:17"]}, {"digest":"cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6","names":["packages/core/tool-executor-ptc/src/types.ts#PtcDispatchEventData"],"sources":["packages/core/tool-executor-ptc/src/types.ts:21"]}, @@ -592,9 +598,11 @@ {"digest":"db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965","names":[],"sources":[]}, {"digest":"dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd","names":[],"sources":["packages/core/tool-executor-ptc/src/types.ts:68"]}, {"digest":"dbdafceb9a900183cccc80596498d386f3bf7bddead2b8cfde68453aa4739edc","names":[],"sources":[]}, + {"digest":"dd00cdccd0ba487b92362ef67db58cd264f48c1bfd368291424a1da9a899489a","names":[],"sources":[]}, {"digest":"dd2780e7e07684332f7a9ab047237798d260e7eaffb1321cb38eca695d184c3d","names":[],"sources":[]}, {"digest":"dd5ee8762bd8a14b6c6bffedd1753e4e78505bb0f9ea1621018d03a54eef59d5","names":[],"sources":[]}, {"digest":"dd6cc2bb2a8f28248347021ad93565360b196a042360b4cd16b569d3a2a91f2d","names":[],"sources":[]}, + {"digest":"dde0cdf9d426768b649b5a9783d2144e7f900c35ef65229cd89036dbf411d1b0","names":[],"sources":[]}, {"digest":"de25352d2dc4196309bd84b5bac6319f6d5c898ea0e82bb5ae66c5738d0d426f","names":[],"sources":[]}, {"digest":"de30b1fa12c75f81df6aa4096e1595296d2d04e2be14a7c29849cf8028b79048","names":[],"sources":[]}, {"digest":"deaae5cd30987d4f49398e8f9d994c125907e7f928300745fb1dbf15f145d0e7","names":[],"sources":[]}, @@ -613,8 +621,8 @@ {"digest":"e41bdebe246abe17154de7e7a1d81035437eee8d20e7f1edff0d91e2598ad828","names":[],"sources":[]}, {"digest":"e42b0f40b5d0b628c6e1c44a8fb9eda0780037a81959e1b93d2dfb47b46b0e87","names":[],"sources":[]}, {"digest":"e438a9d4fb883b3e4a8d9fc8913aa1550ed50eb384bfbf943100a5605b537498","names":[],"sources":[]}, - {"digest":"e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654","names":[],"sources":["packages/core/session/src/types.ts:299"]}, - {"digest":"e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1","names":[],"sources":["packages/core/session/src/types.ts:363"]}, + {"digest":"e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654","names":[],"sources":["packages/core/session/src/types.ts:303"]}, + {"digest":"e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1","names":[],"sources":["packages/core/session/src/types.ts:369"]}, {"digest":"e4cac694f8b6e1d52e48c699464f1c4c26111ef87e558f7931040d00fdc8d486","names":[],"sources":[]}, {"digest":"e5d3828df1ec3e2a66879de0659e0f4866d9cfffc00825ee731a66f8a0c03d98","names":[],"sources":[]}, {"digest":"e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344","names":["packages/schedule/schedule/src/types.ts#EveryScheduleDispatchChange"],"sources":["packages/schedule/schedule/src/types.ts:244"]}, @@ -624,24 +632,26 @@ {"digest":"e7ce49f12953583fad217bf80fb9f1463acff2c3e9471a823782661675fb124c","names":[],"sources":[]}, {"digest":"e7e154d47d5fbc5366330fb4dade1f108c6dfe4335044d21ded14b7bf1dce084","names":[],"sources":[]}, {"digest":"e7e8f1318170b8284f16ab45a557afd4675c96071c0f2a456636481dab20b427","names":[],"sources":[]}, + {"digest":"e7f4cbb3a57f4cbdb61ea41e95bcba626ad1fff6bb9b1e063b1d50a32da6e864","names":[],"sources":[]}, {"digest":"e8de0f457fed5b9ff94dc4950b59ef75fd8ba8af665cc7899485924a62653367","names":[],"sources":[]}, {"digest":"e95f957154b2b1b58692af53d4131180b6dd19e2264869f876dbaa959991494e","names":[],"sources":[]}, {"digest":"ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403","names":[],"sources":["packages/experimental/agent-team/src/types.ts:234"]}, - {"digest":"eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c","names":[],"sources":["packages/core/session/src/types.ts:197"]}, + {"digest":"eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c","names":[],"sources":["packages/core/session/src/types.ts:201"]}, {"digest":"ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8","names":[],"sources":[]}, {"digest":"ec38b5949af8eacaf00df002f4acbe344f934f8a061e9cdc65a52a48e5f6dd93","names":[],"sources":[]}, - {"digest":"ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7","names":[],"sources":["packages/core/session/src/types.ts:230"]}, + {"digest":"ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7","names":[],"sources":["packages/core/session/src/types.ts:234"]}, {"digest":"edbbfff1128099fda7b792c56330d2cb04debc9790988e66d1c3f4026e813e0e","names":[],"sources":[]}, {"digest":"edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf","names":[],"sources":[]}, {"digest":"ee577a0cc18664c9daee50e9455153321155604f8f1817362f8f4a4b6fb9e1ad","names":[],"sources":[]}, {"digest":"eea687d437880bd5f55fb447a561112800ca34334c0724d9cecb98493059d9e4","names":["packages/schedule/schedule/src/types.ts#ScheduleCreateChange"],"sources":["packages/schedule/schedule/src/types.ts:223"]}, {"digest":"eefbea819caeeea91ccb02dfafc16c68dd7f961cf9c1b2be25a2084509cf5c5a","names":[],"sources":[]}, - {"digest":"ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545","names":[],"sources":["packages/core/session/src/types.ts:301","packages/core/session/src/types.ts:303"]}, + {"digest":"ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545","names":[],"sources":["packages/core/session/src/types.ts:305","packages/core/session/src/types.ts:307"]}, {"digest":"ef226a90ddac6cd8e631a3a337a3a3e27f9494be92befb7c718b404681d75e21","names":[],"sources":[]}, {"digest":"ef4f00923b9edbae15c7ef05320280bf61147ca17146cf59690359bfd5e5da45","names":[],"sources":[]}, + {"digest":"efa1a091017ce533e38b0ef3e14d11a5c102ff1793229b18dad719a4268d9adc","names":[],"sources":[]}, {"digest":"f05fb85e715af4ab4de0b6ba111166dd0435bd8bb74a0fa0f32b9d174113fc7c","names":[],"sources":[]}, {"digest":"f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b","names":["packages/core/agent/src/types.ts#ModelSelection"],"sources":["packages/core/agent/src/types.ts:79"]}, - {"digest":"f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402","names":["packages/core/session/src/types.ts#TurnEndReason"],"sources":["packages/core/session/src/types.ts:234"]}, + {"digest":"f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402","names":["packages/core/session/src/types.ts#TurnEndReason"],"sources":["packages/core/session/src/types.ts:238"]}, {"digest":"f1996af2602e6f62b5ab179ca5c4af00d8753938b74cc6ef102de09e99e74a7e","names":[],"sources":[]}, {"digest":"f19bee84bf3d20904553cdcd57465232bc1c2a194f54c4d195292ba0487223a6","names":["packages/attachment/attachment/src/types.ts#FileAttachmentRef"],"sources":["packages/attachment/attachment/src/types.ts:39"]}, {"digest":"f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515","names":[],"sources":["packages/llm/llm/src/types.ts:476"]}, @@ -650,7 +660,6 @@ {"digest":"f2da0f27c1e6add59982c37b15e4a4912c5b33466859cce3b13d503600e24aef","names":["packages/llm/llm/src/types.ts#FileBlock"],"sources":["packages/llm/llm/src/types.ts:98"]}, {"digest":"f311671e07bc7fa645a0b6597de9966b97147956565296ba76a36543801b66c5","names":["packages/llm/llm/src/types.ts#SystemPromptUpdate"],"sources":["packages/llm/llm/src/types.ts:414"]}, {"digest":"f4a66f654996c093aae700ea4c434ff2ee3569fea9c0e5813ccf6afdccabd163","names":[],"sources":[]}, - {"digest":"f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb","names":[],"sources":["packages/core/session/src/types.ts:313"]}, {"digest":"f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1","names":[],"sources":[]}, {"digest":"f56c5b59ca0161310c338d31dd16cf14287ccd602ff4181058df178c1c286d7f","names":["packages/context/session-reference/src/types.ts#SessionReferenceSource"],"sources":["packages/context/session-reference/src/types.ts:13"]}, {"digest":"f6329fb30c7ea8013e8975ca5e78284fb7685cc6bd620e8aa4e9a99bc81d45eb","names":[],"sources":[]}, @@ -663,10 +672,9 @@ {"digest":"f731ffe80810b0289b3e694d8f40af6f01865c02a622ee457be90ca808b66e87","names":["packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent"],"sources":["packages/subagent/subagent/src/catalog.ts:28"]}, {"digest":"f789af5ab844827eb5a4f357c0afae1eeed108c6c22d8ca1a56e586b349a7c85","names":[],"sources":[]}, {"digest":"f88f1b7b6bb733f685f5fb6a8744a5db9199fa53c90463ea1a33bad51153671d","names":[],"sources":[]}, - {"digest":"f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d","names":[],"sources":["packages/core/session/src/types.ts:290","packages/deliverables/workspace-changes/src/types.ts:106"]}, + {"digest":"f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d","names":[],"sources":["packages/core/session/src/types.ts:294","packages/deliverables/workspace-changes/src/types.ts:106"]}, {"digest":"f97f47b0eb87905ed77cc27c5e4ed75ef805cb372c98905253709de1aab9b63c","names":[],"sources":[]}, {"digest":"f9cfc89e8fe085b4fcc9cd32b74b6f103608c54d7d8cd97b47c01de34f252ba3","names":[],"sources":[]}, - {"digest":"fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42","names":[],"sources":["packages/core/agent/src/types.ts:110"]}, {"digest":"fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be","names":["packages/llm/llm/src/message.ts#AssistantMessage"],"sources":["packages/llm/llm/src/message.ts:167"]}, {"digest":"fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd","names":["packages/sandbox/sandbox/src/index.ts#SandboxMode"],"sources":["packages/sandbox/sandbox/src/index.ts:30"]}, {"digest":"fab35c4520883c0d69e42d079c9f543fc5ac7a2d15b537b5572fcd99059c7018","names":[],"sources":[]}, @@ -684,18 +692,16 @@ {"digest":"fee1caf8ab6a65c1bc981f5a1730164490f22a2a89ffa3d0b76f1f432c44c8b5","names":[],"sources":[]} ], "nodes": { - "00c054d3018c198d": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"a927d444c928ac0e","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "0116b1f89c05221d": {"kind":"literal","value":"web_search_20250305"}, "0203243b72fc867a": {"kind":"object","properties":[{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, "022623b9ef98ec47": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, "023a355b2be0d688": {"kind":"object","properties":[{"name":"kind","type":"254dce209e735f9c","optional":false}],"indices":[]}, "02b2c23f2d94b028": {"kind":"object","properties":[{"name":"kind","type":"c5687b1313206a6f","optional":false}],"indices":[]}, "0341d4c3dfcd5325": {"kind":"literal","value":"product-interaction"}, - "040cd65423bd6426": {"kind":"union","types":["09f3f8dc417f2782","0f6265188dae7ba6","2cf075b013d363ad","4b259f7f8f0a44ad","fa87537f4a9c1e25"]}, + "05205074b24d8340": {"kind":"literal","value":"action-cards"}, "05a2796621b1ee6c": {"kind":"literal","value":"unknown"}, "05d2ae1e0c7f8770": {"kind":"union","types":["1571525adc95a24e","9e07693fd7cd9bab"]}, "0657d3ca5ac4e7bf": {"kind":"primitive","type":"number"}, - "0755b7d1128319ba": {"kind":"object","properties":[{"name":"data","type":"fa106c5b33b657e8","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]}, "07e1a58c58b593f5": {"kind":"object","properties":[{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "07eccc9ea2f9b543": {"kind":"literal","value":"security-privacy-permission"}, "09655b34d7f3cf1e": {"kind":"literal","value":"system-prompt"}, @@ -717,7 +723,6 @@ "0ed8d1108fe7a5e1": {"kind":"object","properties":[{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, "0eeb17e1df89b1eb": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, "0f46153645c29784": {"kind":"object","properties":[{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, - "0f6265188dae7ba6": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"5bc2d7f02503fc6b","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, "0f8512903d94f57a": {"kind":"object","properties":[{"name":"data","type":"e481c46af3813e00","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"4312134fc6f9cad5","optional":false}],"indices":[]}, "0fe52387f6505270": {"kind":"literal","value":"plan-mode"}, "1033093edd0db80f": {"kind":"object","properties":[{"name":"data","type":"1ccb56061942f5cc","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"6ab45ac76839722d","optional":false}],"indices":[]}, @@ -794,11 +799,9 @@ "2aad1179f9576314": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, "2ab12a0e345658f9": {"kind":"array","element":"83da72c6857601c7"}, "2abb711460e6238d": {"kind":"object","properties":[{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"6f091fe32ab24407","optional":false}],"indices":[]}, - "2ae8594a998c9862": {"kind":"object","properties":[{"name":"data","type":"2cf075b013d363ad","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]}, "2aed07493b1c65b6": {"kind":"literal","value":"tool/ptc-dispatch-start"}, "2c4c21f19f426a58": {"kind":"literal","value":"unavailable"}, "2c64d4bc7264fd3f": {"kind":"literal","value":"step/start"}, - "2cf075b013d363ad": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"5bc2d7f02503fc6b","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, "2d11ca7b0d4493e2": {"kind":"object","properties":[{"name":"max_uses","type":"0657d3ca5ac4e7bf","optional":false},{"name":"name","type":"935c8d486292b13c","optional":false},{"name":"type","type":"0116b1f89c05221d","optional":false}],"indices":[]}, "2da725e554cd8930": {"kind":"literal","value":"fork"}, "2e3523541e942ef6": {"kind":"union","types":["18dc21f12bbac8ad","8e8e91646a5cae3f","e6063d0451dc0202","eea687d437880bd5"]}, @@ -810,6 +813,7 @@ "30d5a537b2966857": {"kind":"union","types":["1b5649238876e288","ada310bf0bdb8fed","b81e9de81a481b24"]}, "312aee2f0c42aabb": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "31907de956375182": {"kind":"object","properties":[{"name":"compactionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"6b23ed9ce1b397e6","optional":false},{"name":"sourceCommandId","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, + "3247e566f4340029": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "326a9bfda27dda58": {"kind":"object","properties":[{"name":"maxTokens","type":"dacf3d5f3e4ff619","optional":true},{"name":"reasoningEffort","type":"dacf3d5f3e4ff619","optional":true}],"indices":[]}, "3305a4bb6b6c29b4": {"kind":"union","types":["0341d4c3dfcd5325","07eccc9ea2f9b543","5bd310104cfe448e","7180844e1066230c","9d80129e13edfb6f","e4cac694f8b6e1d5","f05fb85e715af4ab"]}, "33341aac09312300": {"kind":"union","types":["3860b4757d512a28","487b65c37bfedb8b","71bb3254ec8f1e49","e72269f931559e90"]}, @@ -852,7 +856,7 @@ "44818000e56acb97": {"kind":"literal","value":"feedback/message-delete"}, "44c8c77aa9aa100b": {"kind":"literal","value":"subagent/catalog"}, "4513e088d43e6c68": {"kind":"object","properties":[{"name":"data","type":"ef11a3b7fa2033ae","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"2c64d4bc7264fd3f","optional":false}],"indices":[]}, - "454a0fa879e38076": {"kind":"array","element":"2cf075b013d363ad"}, + "45f005e6bbb76452": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "45f2a07ea9457312": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":true},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"reason","type":"4cd48f3e5108bd6e","optional":true},{"name":"toolName","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "477614bca595a9e7": {"kind":"object","properties":[{"name":"targets","type":"9e2c06d8653585b3","optional":false}],"indices":[]}, "47e5d84f1a93450a": {"kind":"literal","value":"session-log-deepseek/delivery-accepted"}, @@ -901,17 +905,18 @@ "5808e36c95355f0e": {"kind":"union","types":["1d76406cbb2ddadb","287211846beea1ec","693f4952fb5565da","8e31bb958e9b015e","ab7ec5f9eb2c6dfa"]}, "587bb11da730e06a": {"kind":"literal","value":"session/title-llm-request"}, "589d0d91239681f8": {"kind":"literal","value":"developer"}, + "58b122df513aeaf7": {"kind":"object","properties":[{"name":"data","type":"b782058cef83caf3","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]}, "58f94390b982f8a5": {"kind":"literal","value":"image/png"}, "5abab3a27fd27366": {"kind":"literal","value":"text-delta"}, "5b232a9b9bfcc0fc": {"kind":"literal","value":"after"}, "5b6fb1f226ff5640": {"kind":"object","properties":[{"name":"commandId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"1ab2ef1296f157ba","optional":false},{"name":"sourceEventSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"text","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, "5bb69b88517f6214": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, - "5bc2d7f02503fc6b": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","1938c8c20f1cba7c","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","4e97c3d85c0fc817","52c6113cb0e53f34","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5bb69b88517f6214","5bc66269cfaf614c","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","672c4aa2a9b736e3","6ccb6bc423fb3952","6dacb1584f2bfbe2","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f324cea3916c051","8fcb0b44a575e998","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","cb1c8959e1726f82","cda2345a9be9d139","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","deaae5cd30987d4f","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","fc2ae6962f9f8801","fcf81d3c58c13593","fe1a7acdeed4d0f5"]}, "5bc66269cfaf614c": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, "5bd310104cfe448e": {"kind":"literal","value":"resource-cost"}, "5c45bf4c544a7211": {"kind":"object","properties":[{"name":"data","type":"8a9c990773f798ea","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"ae63d9d1080ed894","optional":false}],"indices":[]}, "5cb63db9fe57dc09": {"kind":"literal","value":"teardown"}, "5cdbf342b7748d15": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "5d00abf938272f22": {"kind":"array","element":"b782058cef83caf3"}, "5d03ba38734809bc": {"kind":"array","element":"0657d3ca5ac4e7bf"}, "5d17f27a1ef9f702": {"kind":"literal","value":"tool-workflow/agent-end"}, "5d542ac7bdf13310": {"kind":"object","properties":[{"name":"childCreatedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"childId","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":true},{"name":"mode","type":"d28765f87d6b6ab4","optional":false},{"name":"version","type":"93d38436347f67c9","optional":false}],"indices":[]}, @@ -919,6 +924,7 @@ "5dd766ccd1702a11": {"kind":"object","properties":[{"name":"shadowedRange","type":"9e41386b3a0c9572","optional":false},{"name":"shadowedSeqs","type":"5d03ba38734809bc","optional":false},{"name":"shadowedTokenCount","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "5e6db6e24948d4a8": {"kind":"object","properties":[{"name":"data","type":"17d1afb770d99419","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1c2686c0682e7bc5","optional":false}],"indices":[]}, "5ebce1317eacd2c1": {"kind":"literal","value":"subagent/descriptor"}, + "5ef58f2a1bdc576c": {"kind":"object","properties":[{"name":"data","type":"7404c6123fdaa591","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]}, "5f2117d1856a5ab4": {"kind":"object","properties":[{"name":"code","type":"4cd48f3e5108bd6e","optional":false},{"name":"message","type":"4cd48f3e5108bd6e","optional":false},{"name":"offloadImages","type":"0657d3ca5ac4e7bf","optional":true},{"name":"providerRetryAfterMs","type":"0657d3ca5ac4e7bf","optional":true},{"name":"requestId","type":"4cd48f3e5108bd6e","optional":true},{"name":"status","type":"0657d3ca5ac4e7bf","optional":true}],"indices":[]}, "5f26a6c20b37632f": {"kind":"object","properties":[{"name":"data","type":"9a410ddf56792b2b","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"ad92f473a03ae725","optional":false}],"indices":[]}, "5f758f3de5f00c18": {"kind":"object","properties":[{"name":"label","type":"4cd48f3e5108bd6e","optional":true},{"name":"mode","type":"d28765f87d6b6ab4","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, @@ -967,16 +973,19 @@ "71bb3254ec8f1e49": {"kind":"object","properties":[{"name":"dt","type":"5d03ba38734809bc","optional":false},{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"texts","type":"93c33d9687613293","optional":false},{"name":"time0","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"688e853dc149f0d3","optional":false}],"indices":[]}, "72ec79127a9c0d02": {"kind":"array","element":"c592ce75aab73fca"}, "7382c4fc95fe8c5d": {"kind":"literal","value":"remove"}, + "7404c6123fdaa591": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"7580e7d96dc8475f","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "750a2b129d67704d": {"kind":"union","types":["5cb63db9fe57dc09","6cf082d0fb6034de","bab6865794007548"]}, "7554565be416b034": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, "756b5e23a5cb2c7f": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "7580e7d96dc8475f": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"7a5b68bd1a5118c8","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, "7624ca28c61ca0ea": {"kind":"literal","value":"create"}, "763c8a20af487263": {"kind":"object","properties":[{"name":"data","type":"7d226b9fd95b4ba2","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"237c004c73b6b288","optional":false}],"indices":[]}, "76632533f67879dc": {"kind":"literal","value":"delegation"}, "78a9be340090b735": {"kind":"literal","value":"tmux-context"}, + "78b9204976ab9faa": {"kind":"array","element":"cda79b839d8cc0c7"}, "79650cfa10aa3c52": {"kind":"object","properties":[{"name":"error","type":"5f2117d1856a5ab4","optional":false},{"name":"kind","type":"a2be0f0cb59f0d4a","optional":false}],"indices":[]}, - "79a4c87f8bfb4d6d": {"kind":"object","properties":[{"name":"data","type":"00c054d3018c198d","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]}, "79ddc907312f97cf": {"kind":"object","properties":[{"name":"todos","type":"4b7ddaf3710b88ff","optional":false}],"indices":[]}, + "7a5b68bd1a5118c8": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","1938c8c20f1cba7c","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","3247e566f4340029","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","45f005e6bbb76452","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","4e97c3d85c0fc817","52c6113cb0e53f34","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5bb69b88517f6214","5bc66269cfaf614c","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","672c4aa2a9b736e3","6ccb6bc423fb3952","6dacb1584f2bfbe2","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f324cea3916c051","8fcb0b44a575e998","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b613f83572fe2fbf","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba2a1a8f194b1bb2","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","cb1c8959e1726f82","cda2345a9be9d139","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd00cdccd0ba487b","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","dde0cdf9d426768b","deaae5cd30987d4f","e7f4cbb3a57f4cbd","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","fc2ae6962f9f8801","fcf81d3c58c13593","fe1a7acdeed4d0f5"]}, "7a651f3b672c52fb": {"kind":"literal","value":"session/title"}, "7af85bf70d4eafce": {"kind":"object","properties":[{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "7b1ce60193fa8b69": {"kind":"literal","value":"working-directory"}, @@ -1047,7 +1056,6 @@ "9645cf93769a6dfb": {"kind":"literal","value":"user-question-reply"}, "96bfb916a727cd44": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, "96f4a9c81fc0f940": {"kind":"union","types":["3e2ece4967cfebe9","62ecb62dc0fe1b5e"]}, - "97e0162f983c2745": {"kind":"object","properties":[{"name":"data","type":"f4ed4717b4c259e9","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]}, "9890113e97dd3541": {"kind":"object","properties":[{"name":"mode","type":"fab2bb09bc5e3b9d","optional":false},{"name":"source","type":"76632533f67879dc","optional":true}],"indices":[]}, "99135d8a39379779": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, "9947aa5938fe19a8": {"kind":"union","types":["576a2de8355eb098","a99ec0993c62c058"]}, @@ -1080,6 +1088,7 @@ "a2be0f0cb59f0d4a": {"kind":"literal","value":"error"}, "a3a259a1aa5401af": {"kind":"literal","value":"hook/result"}, "a3c6f1ea38e9a61d": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "a3e299483348f27a": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"78b9204976ab9faa","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "a3edd1efd1fef21b": {"kind":"literal","value":"workspace-write"}, "a40d12070f6f4a12": {"kind":"object","properties":[{"name":"task","type":"51edec100659da3a","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"a0f906856fc06f1c","optional":false}],"indices":[]}, "a6567ccb2e530606": {"kind":"object","properties":[{"name":"data","type":"0d47a8f3d847243c","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"0a8a062bc9c3c0d9","optional":false}],"indices":[]}, @@ -1092,7 +1101,6 @@ "a8a2b9011fc06786": {"kind":"literal","value":"team/message/delivered"}, "a9059a81430b3207": {"kind":"literal","value":"hooks-claude-code"}, "a926dc71814edd01": {"kind":"object","properties":[{"name":"everySeconds","type":"0657d3ca5ac4e7bf","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"e42b0f40b5d0b628","optional":false},{"name":"prompt","type":"4cd48f3e5108bd6e","optional":false},{"name":"scheduledAt","type":"4cd48f3e5108bd6e","optional":false},{"name":"title","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, - "a927d444c928ac0e": {"kind":"array","element":"040cd65423bd6426"}, "a99ec0993c62c058": {"kind":"object","properties":[{"name":"compactionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"llmStreamCall","type":"dacf3d5f3e4ff619","optional":false},{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":true},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"rawOutput","type":"b52f215a6cd69105","optional":false},{"name":"shadowedRange","type":"9e41386b3a0c9572","optional":false},{"name":"shadowedSeqs","type":"5d03ba38734809bc","optional":false},{"name":"shadowedTokenCount","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceCommandId","type":"4cd48f3e5108bd6e","optional":true},{"name":"summary","type":"b52f215a6cd69105","optional":false},{"name":"usage","type":"fec5442b9ee4122e","optional":true}],"indices":[]}, "a99f76fd149051c3": {"kind":"object","properties":[{"name":"content","type":"4cd48f3e5108bd6e","optional":false},{"name":"status","type":"fd0d91a1f9dd2efb","optional":false}],"indices":[]}, "aa0957eca50aeb28": {"kind":"object","properties":[{"name":"data","type":"f90eb4ab2d3897bc","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"0af3ecfb8c60d416","optional":false}],"indices":[]}, @@ -1125,8 +1133,10 @@ "b222069eea2d7680": {"kind":"object","properties":[{"name":"data","type":"477614bca595a9e7","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"9acbe713f3f64192","optional":false}],"indices":[]}, "b2245a906e13aa94": {"kind":"literal","value":"forked"}, "b2a5c8c008a31e3d": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "b2a948a5340a9341": {"kind":"object","properties":[{"name":"inserted","type":"5d00abf938272f22","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]}, "b2d2a01e51341a3b": {"kind":"union","types":["1ebc6f9ff3aa9e3a","234142ee4f996f38","94c56aeb1564ad73","e7e8f1318170b828"]}, "b3681c026a0b98e9": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "b36fd842cd004b53": {"kind":"object","properties":[{"name":"data","type":"b2a948a5340a9341","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]}, "b38f206d59bad891": {"kind":"literal","value":"disposed"}, "b4ec0af39fd52ed9": {"kind":"object","properties":[{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, "b5086d249e8502e9": {"kind":"object","properties":[{"name":"data","type":"aa7bc2817014ca6c","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"6cda0d6d3b4336d8","optional":false}],"indices":[]}, @@ -1135,12 +1145,14 @@ "b56f6a885da3dc39": {"kind":"object","properties":[{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"source","type":"30d5a537b2966857","optional":false},{"name":"title","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "b57a2889408de36e": {"kind":"literal","value":"failed"}, "b57ae1b37e52bee0": {"kind":"literal","value":"team/task"}, + "b613f83572fe2fbf": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "b63b089a00b3b935": {"kind":"object","properties":[{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"17aae616ef020b8b","optional":false},{"name":"prompt","type":"4cd48f3e5108bd6e","optional":false},{"name":"scheduledAt","type":"4cd48f3e5108bd6e","optional":false},{"name":"title","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, "b6896938660a1dfe": {"kind":"object","properties":[{"name":"childCreatedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"childId","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":true},{"name":"mode","type":"d28765f87d6b6ab4","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]}, "b6ef9c6210be8350": {"kind":"object","properties":[{"name":"kind","type":"1ebc6f9ff3aa9e3a","optional":false}],"indices":[]}, "b6f94c17dd9bbd7c": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, "b70f3e81fe11b7a5": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, "b765ee31033cea46": {"kind":"literal","value":"set"}, + "b782058cef83caf3": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"7a5b68bd1a5118c8","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, "b79641a9d25e7566": {"kind":"object","properties":[{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, "b79ada42962cad01": {"kind":"object","properties":[{"name":"data","type":"86d1d0e9cfebb240","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"5ebce1317eacd2c1","optional":false}],"indices":[]}, "b815d7b91cdd9fc6": {"kind":"literal","value":"goal"}, @@ -1150,6 +1162,7 @@ "b9305f0ea75ccd6a": {"kind":"literal","value":"hook/invoked"}, "b978cff734e62143": {"kind":"object","properties":[{"name":"data","type":"79ddc907312f97cf","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"af0750d32d97d47b","optional":false}],"indices":[]}, "b993441f8ce7d27b": {"kind":"tuple","elements":[{"type":"9a2a9029f8d7ede0","optional":false,"rest":false}]}, + "ba2a1a8f194b1bb2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "ba4c40b4381e0dcd": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, "ba77b58995e36f14": {"kind":"array","element":"07e1a58c58b593f5"}, "bab6865794007548": {"kind":"literal","value":"kill"}, @@ -1184,6 +1197,7 @@ "cbca49bde16b656d": {"kind":"literal","value":"block-start"}, "cd186c309e2bef30": {"kind":"literal","value":"approval/asked"}, "cda2345a9be9d139": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "cda79b839d8cc0c7": {"kind":"union","types":["09f3f8dc417f2782","4b259f7f8f0a44ad","7580e7d96dc8475f","b782058cef83caf3","fa87537f4a9c1e25"]}, "cdca80b8dad24d4e": {"kind":"literal","value":"tool-workflow/run-start"}, "cdee10e87dbed844": {"kind":"object","properties":[{"name":"delayMs","type":"0657d3ca5ac4e7bf","optional":false},{"name":"failure","type":"5f2117d1856a5ab4","optional":false},{"name":"maxRetries","type":"0657d3ca5ac4e7bf","optional":false},{"name":"mode","type":"3bc287e765d58dc3","optional":false},{"name":"policyKey","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"retry","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retryId","type":"4cd48f3e5108bd6e","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "cef171adf021a186": {"kind":"object","properties":[{"name":"arguments","type":"bbc5696e3f13c758","optional":false},{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"error","type":"a81059ca06999da1","optional":true},{"name":"isError","type":"aa93010d0e521ad7","optional":false},{"name":"meta","type":"c592ce75aab73fca","optional":true},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"parentCallId","type":"4cd48f3e5108bd6e","optional":false},{"name":"rootCallId","type":"4cd48f3e5108bd6e","optional":false},{"name":"subCallId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, @@ -1208,9 +1222,11 @@ "db874d463b0fdec7": {"kind":"object","properties":[{"name":"data","type":"3e4e7683b0192c9f","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"e2d0367e5716bc18","optional":false}],"indices":[]}, "dba54c09c6e7aafc": {"kind":"object","properties":[{"name":"kind","type":"4edec1b770e8ec4c","optional":false}],"indices":[]}, "dbdafceb9a900183": {"kind":"literal","value":"hook"}, + "dd00cdccd0ba487b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "dd2780e7e0768433": {"kind":"literal","value":"fresh"}, "dd5ee8762bd8a14b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, "dd6cc2bb2a8f2824": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "dde0cdf9d426768b": {"kind":"object","properties":[{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "de25352d2dc41963": {"kind":"literal","value":"user/message"}, "de30b1fa12c75f81": {"kind":"literal","value":"image/gif"}, "deaae5cd30987d4f": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, @@ -1240,6 +1256,7 @@ "e7ce49f12953583f": {"kind":"literal","value":"initial"}, "e7e154d47d5fbc53": {"kind":"literal","value":"clear"}, "e7e8f1318170b828": {"kind":"literal","value":"deleted"}, + "e7f4cbb3a57f4cbd": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "e8de0f457fed5b9f": {"kind":"literal","value":"notice"}, "e95f957154b2b1b5": {"kind":"literal","value":"assistant/attempt"}, "ea08ec1df0c33108": {"kind":"object","properties":[{"name":"message","type":"2eafefdc6f79a492","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"a0f906856fc06f1c","optional":false}],"indices":[]}, @@ -1255,6 +1272,7 @@ "ef11a3b7fa2033ae": {"kind":"object","properties":[{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "ef226a90ddac6cd8": {"kind":"literal","value":"killed"}, "ef4f00923b9edbae": {"kind":"literal","value":"compaction/summary"}, + "efa1a091017ce533": {"kind":"object","properties":[{"name":"data","type":"a3e299483348f27a","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]}, "f05fb85e715af4ab": {"kind":"literal","value":"task-result"}, "f09e5e7acf5ff5cc": {"kind":"object","properties":[{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, "f1687f982fa3d41e": {"kind":"union","types":["023a355b2be0d688","0a30c3be336cdd4f","56b7c047694875a8","79650cfa10aa3c52","ab7ec5f9eb2c6dfa","b6ef9c6210be8350","ec8b5ff7c77ca689"]}, @@ -1266,7 +1284,6 @@ "f2da0f27c1e6add5": {"kind":"object","properties":[{"name":"attachment","type":"f19bee84bf3d2090","optional":false},{"name":"type","type":"277b61f9c6f81704","optional":false}],"indices":[]}, "f311671e07bc7fa6": {"kind":"literal","value":"in-history"}, "f4a66f654996c093": {"kind":"object","properties":[{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, - "f4ed4717b4c259e9": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"0f6265188dae7ba6","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "f5520c1d261ca86e": {"kind":"object","properties":[{"name":"data","type":"e3056e0dda3a379e","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"24a8188c4d61b70a","optional":false}],"indices":[]}, "f56c5b59ca016131": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"7f13860ee89a2d35","optional":false},{"name":"references","type":"df843efd87c78cf8","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]}, "f6329fb30c7ea801": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, @@ -1282,7 +1299,6 @@ "f90eb4ab2d3897bc": {"kind":"object","properties":[{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "f97f47b0eb87905e": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, "f9cfc89e8fe085b4": {"kind":"literal","value":"text-chunks"}, - "fa106c5b33b657e8": {"kind":"object","properties":[{"name":"inserted","type":"454a0fa879e38076","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]}, "fa87537f4a9c1e25": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"95c0e88fcf0bafb2","optional":false},{"name":"source","type":"719c51f67074f970","optional":false}],"indices":[]}, "fab2bb09bc5e3b9d": {"kind":"union","types":["12a0cfa619ac4a5f","18fce9540bb20897","a3edd1efd1fef21b"]}, "fab35c4520883c0d": {"kind":"literal","value":"rejected"}, diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 621444176d..856a932e75 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -6,8 +6,8 @@ en: 564dfcaaca384e23 zh: 5d215635919b5bc4 /durable-attachments/identity-and-verified-metadata: - en: 96378c208b220c6f - zh: 7af93f6a79520732 + en: 3e14f535f7587b0c + zh: cbbd513a5dff5fed /durable-attachments/commit-and-verified-read-payloads: - en: f79753272bb0ec70 - zh: 61830c41021f9274 + en: db2017cdf91e260a + zh: 6ba39bbc7cef4d8f diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 2e330672f7..bccf3c33a3 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -58,7 +58,7 @@ interface ImageAttachmentLimits { The local backend admits at most 20 images and 200 MiB of encoded source data per message. One source may use up to 50 MiB, 64,000,000 pixels, and 65,535 pixels on either side. These source limits precede the independent normalization stage, which limits the long edge to 2048 pixels and encoded data to 4 MiB by default. -The reference records intrinsic dimensions and encoded length so clients can lay out history without decoding first, while every authoritative read still re-checks digest, media signature, dimensions, and metadata against the object. +The reference records intrinsic dimensions and encoded length so clients can lay out history without decoding first. Reads verify the stored object against the supplied reference's ID, format, byte length, width, and height. Optional display names and pre-normalization dimensions are returned unchanged as caller-supplied display information, without verification against stored bytes. ## Commit and verified-read payloads @@ -173,7 +173,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 upload receipt resolution, replaces base64 image uploads with durable references, passes durable file references and already stored images unchanged, and counts every image toward the per-message image count and aggregate byte limits, a stored image by the bytes of its upload. `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. +`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 upload receipt resolution, replaces base64 image uploads with durable references, passes durable file references and already stored images unchanged, and counts every image toward the per-message image count and aggregate byte limits, a stored image by the bytes of its upload. `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 object against the supplied complete reference without consulting Session history, then returns that reference with the bytes. Access policy belongs to the calling API; Session Controller uses the store-level [image access rule](../../.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.md). `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. @@ -243,8 +243,8 @@ isAttachmentError(error: unknown): error is AttachmentError abstract saveImage(input: SaveImageAttachment): Promise /** - * Read one image and verify that bytes still match the recorded reference. - * @param ref - durable reference from the session log. + * Read one image and verify its ID, format, byte length, and dimensions against the supplied reference. + * @param ref - supplied image reference; name and originalDimensions are preserved without file verification. * @param signal - optional cancellation for backend read and verification work. * @returns the verified bytes and normalized attachment reference. * @throws the signal reason when aborted, or a storage error when verification fails. @@ -282,7 +282,7 @@ saveFileStream(input: SaveFileStreamAttachment): Promise * Read and verify one verbatim stored file as bounded chunks. Providers must * not collect the complete file in memory. Backends without verbatim file * reads keep this default rejection. - * @param ref - durable reference from the session log. + * @param ref - durable file reference from the session log. * @param signal - optional cancellation for backend reads and verification work. * @returns exact file bytes in order; integrity failures reject the iteration. */ diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 057ed080f4..c917947b73 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -58,7 +58,7 @@ interface ImageAttachmentLimits { 本地后端每条消息最多准入 20 张图片,源图编码数据总量不超过 200 MiB。单张源图不得超过 50 MiB、64,000,000 像素和单边 65,535 像素。这些源文件限制先于独立的规范化阶段执行;该阶段默认把长边限制为 2048 像素,把编码数据限制为 4 MiB。 -引用记录固有尺寸和编码长度,使客户端无需先解码即可排布历史记录;每次权威读取仍会根据对象重新校验摘要、媒体签名、尺寸和元数据。 +引用记录固有尺寸和编码长度,使客户端无需先解码即可排布历史记录。读取会把存储对象与传入引用中的 ID、格式、字节数、宽度和高度比对。可选的显示名称和规范化前尺寸作为调用方提交的显示信息原样返回,不根据存储字节校验。 ## 提交与经校验读取的数据 @@ -173,7 +173,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()` 把规范化对象与传入的完整引用比对,无需查询 Session 历史,随后返回该引用与字节。访问规则由调用 API 负责;Session Controller 使用存储范围的[图片访问规则](../../.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.zh.md)。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切的路由目标尺寸和编码字节目标派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -243,8 +243,8 @@ isAttachmentError(error: unknown): error is AttachmentError abstract saveImage(input: SaveImageAttachment): Promise /** - * Read one image and verify that bytes still match the recorded reference. - * @param ref - durable reference from the session log. + * Read one image and verify its ID, format, byte length, and dimensions against the supplied reference. + * @param ref - supplied image reference; name and originalDimensions are preserved without file verification. * @param signal - optional cancellation for backend read and verification work. * @returns the verified bytes and normalized attachment reference. * @throws the signal reason when aborted, or a storage error when verification fails. @@ -282,7 +282,7 @@ saveFileStream(input: SaveFileStreamAttachment): Promise * Read and verify one verbatim stored file as bounded chunks. Providers must * not collect the complete file in memory. Backends without verbatim file * reads keep this default rejection. - * @param ref - durable reference from the session log. + * @param ref - durable file reference from the session log. * @param signal - optional cancellation for backend reads and verification work. * @returns exact file bytes in order; integrity failures reject the iteration. */ diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 0d52b7d7e5..7754efcc03 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -6,8 +6,8 @@ en: 962b4398c9052959 zh: 14471c340a9f6e60 /profile-management/management-records: - en: 2def418f04e9fc10 - zh: af0bd1610bdc2f2b + en: 8813788cd8a86903 + zh: 3c30cb180fc1a33d /profile-management/market-records: en: 3dfc62d837d61b9b zh: ec223be315f3dc62 diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 6296facc93..157ef5da64 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -10,7 +10,7 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p `PluginInfo` carries module identity, effective enablement, fiber phase and optional display `meta`, plus a unique `patchId` or a `readOnlyReason`. -`BundleInfo.official` identifies project-maintained shipped optional bundles and on-demand catalog entries independently of installation. `availability` identifies a readable installation-provided or profile package, or `missing`; on-demand catalog entries exclude undeclared transitive copies. `installed` records the profile dependency declaration. An on-demand `installTarget` carries the host-derived exact spec and version. `BundleInfo` also carries the package name, optional installed version, selected enablement, removal availability, update eligibility (`updatable`), optional resolution error, and, for a bundle the profile's own dependency supplies and the installation does not, `source`: that dependency as a spec `pnpm add` accepts. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time. +`BundleInfo.official` identifies project-maintained shipped optional bundles and on-demand catalog entries independently of installation. `availability` identifies a readable installation-provided or profile package, or `missing`; on-demand catalog entries exclude undeclared transitive copies. `installed` records the profile dependency declaration. An on-demand `installTarget` carries the host-derived exact spec and version. `BundleInfo` also carries the package name, optional installed version, selected enablement, removal availability, update eligibility (`updatable`), optional resolution or unsupported-platform error, and, for a bundle the profile's own dependency supplies and the installation does not, `source`: that dependency as a spec `pnpm add` accepts. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time. `InstallBundleOptions.saveExact` passes `--save-exact` to the shared package installer. `InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer. `approvedBuilds` grants persistent script permission to the supplied pending package names before installation. `registry` names the registry asked first; absent, the configured one. @@ -69,6 +69,53 @@ interface PendingModule { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.actionCards` — `ActionCards` + +Card tool, kind registry, Session projection, user actions and status notices. + +```ts cordis-catalog +/** + * Register one action kind for the caller's lifetime. + * @param kind - the kind's skill, details schema, verbs and handler. + * @returns disposer removing the kind; actions on its cards are then unavailable. + * @throws when another kind already registered the same action. + */ +register
(kind: ActionCardKind
): () => void + +/** + * Record a card decision in the Session log; the next model request reports it. + * @param agent - agent whose Session owns the card. + * @param decision - the card's new state. + * @throws when no kind ever registered the decision's action with this service, or its phase is one the projection derives. + */ +record(agent: Agent, decision: ActionCardDecision): void + +/** + * Read the cards of one Session. + * @param session - a Session whose projections are prepared. + * @returns proposals and decisions by call id. + */ +view(session: Session): ActionCardView + +/** + * Apply a user action to a logged card. Shared checks run first, then the kind reconciles the card; `dismiss` is + * recorded here and every other verb goes to the card's kind. `dismiss` also works on an unstarted card whose kind + * is no longer registered. Concurrent actions on one card share one operation. + * @param agent - live root resolved from the displayed Session. + * @param callId - the card's `propose_action` call id. + * @param verb - `dismiss` or one of the kind's verbs. + * @param payload - kind-defined fields such as a chosen location. + * @returns acceptance of background work, the decision in effect, or a refusal. + */ +@Remote act(agent: Agent, callId: ToolCallId, verb: string, payload?: Record): Promise +``` + +Types: [Agent](core.md) · [Session](session.md) · [ToolCallId](core.md) + +Source: [`packages/experimental/action-cards/src/index.ts`](../../packages/experimental/action-cards/src/index.ts) + ### `ctx.configEditor` — `ConfigEditor` @@ -355,6 +402,26 @@ async entry(url: string, name: string): Promise Source: [`packages/boot/plugin-market/src/index.ts`](../../packages/boot/plugin-market/src/index.ts) + + +### `ctx.pluginRecommendations` — `PluginRecommendations` + +Catalog lookups for card text and the enabling behind the `enable_official_plugin` card. + +```ts cordis-catalog +/** + * Read the current catalog text for a logged card. + * @param agent - Exact live root resolved by the Gateway from the Session id. + * @param callId - The card's `propose_action` call id, never a package supplied by the browser. + * @returns the plugin's title, description and selection, or a refusal when the card names no available official plugin. + */ +@Remote async describe(agent: Agent, callId: ToolCallId): Promise +``` + +Types: [Agent](core.md) · [ToolCallId](core.md) + +Source: [`packages/experimental/plugin-recommendations/src/index.ts`](../../packages/experimental/plugin-recommendations/src/index.ts) + ### `ctx.pluginRegistryProbe` — `PluginRegistryProbe` diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index e7fbb2c805..1e5f78cf0e 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -10,7 +10,7 @@ `PluginInfo` 包含模块标识、实际启停状态、fiber 阶段和可选的展示 `meta`,以及唯一的 `patchId` 或 `readOnlyReason`。 -`BundleInfo.official` 标识项目维护的随附可选组合包和按需目录项,独立于安装状态。`availability` 表示可读取的安装随附包、profile 包或 `missing`;按需目录项不计入未声明的传递依赖副本。`installed` 记录 profile 的依赖声明。按需条目的 `installTarget` 包含 Host 生成的精确 spec 和版本。`BundleInfo` 还包含包名、可选的安装版本、组合层选择状态、删除可用性、更新资格(`updatable`)、可选的解析错误;由 profile 自身依赖提供、且安装不提供的组合包还带有 `source`,即该依赖在 `pnpm add` 中可用的 spec。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。 +`BundleInfo.official` 标识项目维护的随附可选组合包和按需目录项,独立于安装状态。`availability` 表示可读取的安装随附包、profile 包或 `missing`;按需目录项不计入未声明的传递依赖副本。`installed` 记录 profile 的依赖声明。按需条目的 `installTarget` 包含 Host 生成的精确 spec 和版本。`BundleInfo` 还包含包名、可选的安装版本、组合层选择状态、删除可用性、更新资格(`updatable`)、可选的解析错误或平台不支持错误;由 profile 自身依赖提供、且安装不提供的组合包还带有 `source`,即该依赖在 `pnpm add` 中可用的 spec。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。 `InstallBundleOptions.saveExact` 向共享包安装器传入 `--save-exact`。`InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。`registry` 指定首先询问的注册表;缺省为配置的那个。 @@ -69,6 +69,53 @@ interface PendingModule { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.actionCards` — `ActionCards` + +Card tool, kind registry, Session projection, user actions and status notices. + +```ts cordis-catalog +/** + * Register one action kind for the caller's lifetime. + * @param kind - the kind's skill, details schema, verbs and handler. + * @returns disposer removing the kind; actions on its cards are then unavailable. + * @throws when another kind already registered the same action. + */ +register
(kind: ActionCardKind
): () => void + +/** + * Record a card decision in the Session log; the next model request reports it. + * @param agent - agent whose Session owns the card. + * @param decision - the card's new state. + * @throws when no kind ever registered the decision's action with this service, or its phase is one the projection derives. + */ +record(agent: Agent, decision: ActionCardDecision): void + +/** + * Read the cards of one Session. + * @param session - a Session whose projections are prepared. + * @returns proposals and decisions by call id. + */ +view(session: Session): ActionCardView + +/** + * Apply a user action to a logged card. Shared checks run first, then the kind reconciles the card; `dismiss` is + * recorded here and every other verb goes to the card's kind. `dismiss` also works on an unstarted card whose kind + * is no longer registered. Concurrent actions on one card share one operation. + * @param agent - live root resolved from the displayed Session. + * @param callId - the card's `propose_action` call id. + * @param verb - `dismiss` or one of the kind's verbs. + * @param payload - kind-defined fields such as a chosen location. + * @returns acceptance of background work, the decision in effect, or a refusal. + */ +@Remote act(agent: Agent, callId: ToolCallId, verb: string, payload?: Record): Promise +``` + +Types: [Agent](core.zh.md) · [Session](session.zh.md) · [ToolCallId](core.zh.md) + +Source: [`packages/experimental/action-cards/src/index.ts`](../../packages/experimental/action-cards/src/index.ts) + ### `ctx.configEditor` — `ConfigEditor` @@ -355,6 +402,26 @@ async entry(url: string, name: string): Promise Source: [`packages/boot/plugin-market/src/index.ts`](../../packages/boot/plugin-market/src/index.ts) + + +### `ctx.pluginRecommendations` — `PluginRecommendations` + +Catalog lookups for card text and the enabling behind the `enable_official_plugin` card. + +```ts cordis-catalog +/** + * Read the current catalog text for a logged card. + * @param agent - Exact live root resolved by the Gateway from the Session id. + * @param callId - The card's `propose_action` call id, never a package supplied by the browser. + * @returns the plugin's title, description and selection, or a refusal when the card names no available official plugin. + */ +@Remote async describe(agent: Agent, callId: ToolCallId): Promise +``` + +Types: [Agent](core.zh.md) · [ToolCallId](core.zh.md) + +Source: [`packages/experimental/plugin-recommendations/src/index.ts`](../../packages/experimental/plugin-recommendations/src/index.ts) + ### `ctx.pluginRegistryProbe` — `PluginRegistryProbe` diff --git a/docs/subsystems/computer-use.md b/docs/subsystems/computer-use.md index bc8f1b0491..9a9400e124 100644 --- a/docs/subsystems/computer-use.md +++ b/docs/subsystems/computer-use.md @@ -85,10 +85,10 @@ Browser controls for provider-owned native permission and application decisions. @Remote async enablePersistentPtc(): Promise /** - * Read application decisions without waiting for the native helper. - * @returns Saved grants and refused applications with their persistence owner. + * Read fresh stored application decisions without issuing a native request. + * @returns Saved grants, refused applications and durable-storage availability. */ -@Remote applications(): CuaApplicationState +@Remote applications(): Promise /** * Inspect system privileges independently of application decisions and icons. diff --git a/docs/subsystems/computer-use.zh.md b/docs/subsystems/computer-use.zh.md index 1323c6b033..e43718f0ed 100644 --- a/docs/subsystems/computer-use.zh.md +++ b/docs/subsystems/computer-use.zh.md @@ -85,10 +85,10 @@ Browser controls for provider-owned native permission and application decisions. @Remote async enablePersistentPtc(): Promise /** - * Read application decisions without waiting for the native helper. - * @returns Saved grants and refused applications with their persistence owner. + * Read fresh stored application decisions without issuing a native request. + * @returns Saved grants, refused applications and durable-storage availability. */ -@Remote applications(): CuaApplicationState +@Remote applications(): Promise /** * Inspect system privileges independently of application decisions and icons. diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 8f35e17f05..2c2dedab48 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -156,7 +156,11 @@ interface SessionHeader { readonly createdAt: number /** Absolute working directory the session was created in (if any). */ readonly cwd?: string - /** The session this one was forked from (seed lineage), if any. */ + /** + * Direct creator of a subagent Session. Ordinary forks omit this field; + * historical ordinary forks may retain their source id here. Presence alone + * does not classify a Session as a subagent; use `origin` for that distinction. + */ readonly parentSession?: SessionId /** * Whether this Session contains a fork-inherited event prefix. The exact prefix diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index c88b884fdb..aeec8c81c0 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -156,7 +156,11 @@ interface SessionHeader { readonly createdAt: number /** Absolute working directory the session was created in (if any). */ readonly cwd?: string - /** The session this one was forked from (seed lineage), if any. */ + /** + * Direct creator of a subagent Session. Ordinary forks omit this field; + * historical ordinary forks may retain their source id here. Presence alone + * does not classify a Session as a subagent; use `origin` for that distinction. + */ readonly parentSession?: SessionId /** * Whether this Session contains a fork-inherited event prefix. The exact prefix diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 1a778923dc..53d89173b4 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -45,8 +45,8 @@ en: 45d1a9ee6d610ac5 zh: 79c061252fde4ceb /sessions/live-session-fork-api: - en: c72c73ce4b4c697c - zh: 8a34785a92df0ad5 + en: 06189884c62a80a1 + zh: 30121aed7a011c72 /sessions/why-a-turn-ended-turnendreasonmap: en: 966cc60273fb3911 zh: de5123ea9dbb0df2 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index e9369b0702..7cbc91298d 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -65,8 +65,10 @@ interface SessionEventMap { * The rendered system prompt on the model-visible surface. The loop appends * the first one as surface node 0 before the step's first `user/message`. * A prepared in-history route can append nonempty changes in a continuing - * series. An incapable route or new series normalizes text to the first system - * node. Normalization empties nonempty later nodes, then rewrites the head if + * series. An incapable route, or a new series whose effective text differs, + * normalizes text to the first system node; a series start whose latest + * surviving node already holds the rendering leaves it in place. + * Normalization empties nonempty later nodes, then rewrites the head if * needed, through logged per-node replacements. An empty rendering always * clears all active system nodes, leaving no older instructions model-visible. * Empty later nodes are dormant and project to no message; an empty head with @@ -683,7 +685,7 @@ Everything else (`turn/*`, `step/*`, `assistant/attempt`, plugin-owned `llm/retr `ctx.sessions.create(id, { seed, meta })` is the low-level replay/fork primitive. For ordinary live-session forks, `SessionStore` exposes one policy API: -- `fork(source, boundary?, childSessionId?)` accepts a live `Session` object or live `SessionId`, selects source events through the inclusive `SessionSeq` boundary (default: current last event), requires the selected prefix to end outside an open turn, then creates a live child session with deep-cloned seed events, `parentSession`, `isSeeded: true`, the exact `inheritedEventCount`, and inherited `cwd`. +- `fork(source, boundary?, childSessionId?)` accepts a live `Session` object or live `SessionId`, selects source events through the inclusive `SessionSeq` boundary (default: current last event), closes any open tail, then creates an independent live session with deep-cloned seed events, `isSeeded: true`, the exact `inheritedEventCount`, and inherited `cwd`, without `parentSession`. An explicit `boundary` lets callers fork from any stable between-turn position, including a previous `turn/end` or a later standalone log-only event, even if the source has newer events or an open current turn. The API rejects a prefix that ends inside an open turn instead of clipping silently. `dsh-subagent-fork-in-process` keeps its completed-prefix clipping because tool-time delegation usually starts while the parent turn is open; ordinary session branching should make the requested boundary explicit. @@ -837,14 +839,6 @@ Host service backing the generated `ctx.remote.session` namespace. */ resolveAgent(sessionId: SessionId): Promise -/** - * Inspect one attached or persisted Session without activating its Agent. - * @param sessionId - durable Session identity. - * @param signal - optional caller cancellation for persistence reads. - * @returns the current attached state or persisted header and event prefix. - */ -inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise - /** * Read all visible Session rows without resuming an Agent. * @param _request - reserved empty list request. @@ -944,8 +938,8 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | @Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise /** - * Read one image proven reachable from the addressed Session log. - * @param request - Session and attachment identities used for authorization. + * Read one image from the attachment store after gateway authorization. + * @param request - image reference and gateway permission scope; Session existence is not checked. * @returns the durable attachment reference and base64-encoded bytes. */ @Remote('attachment') attachment(request: SessionAttachmentRequest): Promise @@ -966,11 +960,51 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | /** * Read one cold-safe, message-aligned Session history page. - * @param request - durable address, backward cursor, and page budget. + * @param request - durable address, optional fixed cut, backward cursor, and page budget. * @param signal - cancellation for persistence reads. - * @returns one chronological page. + * @returns one chronological page with its inclusive cursor. */ -@Remote({ name: 'page', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise +@Remote events(request: SessionPageRequest, signal: AbortSignal): Promise + +/** + * Subscribe to Session events without reading a snapshot. + * @param request - Session address. + * @param signal - subscription cancellation. + * @returns readiness and subsequent events, without a snapshot. + */ +@Remote({ mode: 'stream' }) observeEvent(request: SessionObserveEventRequest, signal: AbortSignal): AsyncIterable + +/** + * Subscribe to sparse Session projection changes. + * @param request - target Session. + * @param signal - subscription cancellation. + * @returns readiness and sparse projection changes. + */ +@Remote({ mode: 'stream' }) observeProjections(request: SessionProjectionsRequest, signal: AbortSignal): AsyncIterable + +/** + * Read passive Session state. + * @param request - target Session. + * @param signal - read cancellation. + * @returns current passive state. + */ +@Remote state(request: SessionStateRequest, signal: AbortSignal): Promise + +/** + * Subscribe to complete Session state replacements. + * @param request - target Session. + * @param signal - subscription cancellation. + * @returns complete passive state replacements. + */ +@Remote({ mode: 'stream' }) observeState(request: SessionStateRequest, signal: AbortSignal): AsyncIterable + +/** + * Wait for Session quiescence or pending input with bounded assistant output. + * @param request - Session, wait bounds and output window. + * @param signal - caller cancellation. + * @returns stopping state and bounded assistant output. + */ +@Remote wait(request: SessionWaitRequest, signal: AbortSignal): Promise /** * Follow one Session log from its opening or resume cursor. @@ -979,7 +1013,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @returns a complete opening snapshot followed by gap-free durable event * frames and optional cursorless assistant-stream frames. */ -@Remote({ mode: 'stream', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable /** * Read exact projections, returning cached hints only when migration is required. @@ -987,7 +1021,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation for the Session read. * @returns a sequenced baseline, cached hints when migration is deferred, or null when absent. */ -@Remote('projections') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise +@Remote('projections') projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise /** * Stream a complete live-control baseline followed by replacement frames. @@ -997,7 +1031,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | @Remote({ mode: 'stream' }) async *control(signal: AbortSignal): AsyncIterable ``` -Types: [SessionId](core.md) · [SessionInspection](persistence.md) · [SessionSearchRequest](session-query.md) +Types: [SessionId](core.md) · [SessionSearchRequest](session-query.md) Source: [`packages/api/session-controller/src/index.ts`](../../packages/api/session-controller/src/index.ts) @@ -1128,7 +1162,7 @@ get(id: SessionId): Session | undefined list(): Session[] /** - * Create a live child session from an exact prefix of a live source. + * Create an independent live session from an exact prefix of a live source. * `boundary` is an inclusive source event seq; omitted means the source's * current last event. An open tail receives synthetic tool results and * step/turn closers with the forked cause. Closed steps and turns remain diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 7971f5e7fd..1f40042967 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -65,8 +65,10 @@ interface SessionEventMap { * The rendered system prompt on the model-visible surface. The loop appends * the first one as surface node 0 before the step's first `user/message`. * A prepared in-history route can append nonempty changes in a continuing - * series. An incapable route or new series normalizes text to the first system - * node. Normalization empties nonempty later nodes, then rewrites the head if + * series. An incapable route, or a new series whose effective text differs, + * normalizes text to the first system node; a series start whose latest + * surviving node already holds the rendering leaves it in place. + * Normalization empties nonempty later nodes, then rewrites the head if * needed, through logged per-node replacements. An empty rendering always * clears all active system nodes, leaving no older instructions model-visible. * Empty later nodes are dormant and project to no message; an empty head with @@ -685,7 +687,7 @@ declare class Session { `ctx.sessions.create(id, { seed, meta })` 是底层的回放/fork 原语。对于普通的活跃会话 fork,`SessionStore` 暴露一个策略 API: -- `fork(source, boundary?, childSessionId?)` 接受一个活跃的 `Session` 对象或活跃的 `SessionId`,选取到 `SessionSeq` boundary(含)为止的源事件(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,然后创建一个活跃的子会话,包含深克隆的 seed event、`parentSession`、`isSeeded: true`、精确 `inheritedEventCount` 及继承的 `cwd`。 +- `fork(source, boundary?, childSessionId?)` 接受一个活跃的 `Session` 对象或活跃的 `SessionId`,选取到 `SessionSeq` boundary(含)为止的源事件(默认为当前最后一个事件),闭合任何开放尾部,然后创建一个独立的活跃会话,包含深克隆的 seed event、`isSeeded: true`、精确 `inheritedEventCount` 及继承的 `cwd`,不含 `parentSession`。 显式 `boundary` 允许调用者从任意稳定的轮次间位置 fork,包括之前的 `turn/end` 或更晚的独立纯日志事件,即使源会话有更新的事件或正在进行的轮次。API 拒绝结束于开放轮次内的前缀,而不是静默截断。`dsh-subagent-fork-in-process` 保留其已完成前缀截断逻辑,因为工具调用时的委托通常在父轮次仍然打开时启动;普通的会话分支应显式指定请求的 boundary。 @@ -841,14 +843,6 @@ Host service backing the generated `ctx.remote.session` namespace. */ resolveAgent(sessionId: SessionId): Promise -/** - * Inspect one attached or persisted Session without activating its Agent. - * @param sessionId - durable Session identity. - * @param signal - optional caller cancellation for persistence reads. - * @returns the current attached state or persisted header and event prefix. - */ -inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise - /** * Read all visible Session rows without resuming an Agent. * @param _request - reserved empty list request. @@ -948,8 +942,8 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | @Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise /** - * Read one image proven reachable from the addressed Session log. - * @param request - Session and attachment identities used for authorization. + * Read one image from the attachment store after gateway authorization. + * @param request - image reference and gateway permission scope; Session existence is not checked. * @returns the durable attachment reference and base64-encoded bytes. */ @Remote('attachment') attachment(request: SessionAttachmentRequest): Promise @@ -970,11 +964,51 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | /** * Read one cold-safe, message-aligned Session history page. - * @param request - durable address, backward cursor, and page budget. + * @param request - durable address, optional fixed cut, backward cursor, and page budget. * @param signal - cancellation for persistence reads. - * @returns one chronological page. + * @returns one chronological page with its inclusive cursor. */ -@Remote({ name: 'page', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise +@Remote events(request: SessionPageRequest, signal: AbortSignal): Promise + +/** + * Subscribe to Session events without reading a snapshot. + * @param request - Session address. + * @param signal - subscription cancellation. + * @returns readiness and subsequent events, without a snapshot. + */ +@Remote({ mode: 'stream' }) observeEvent(request: SessionObserveEventRequest, signal: AbortSignal): AsyncIterable + +/** + * Subscribe to sparse Session projection changes. + * @param request - target Session. + * @param signal - subscription cancellation. + * @returns readiness and sparse projection changes. + */ +@Remote({ mode: 'stream' }) observeProjections(request: SessionProjectionsRequest, signal: AbortSignal): AsyncIterable + +/** + * Read passive Session state. + * @param request - target Session. + * @param signal - read cancellation. + * @returns current passive state. + */ +@Remote state(request: SessionStateRequest, signal: AbortSignal): Promise + +/** + * Subscribe to complete Session state replacements. + * @param request - target Session. + * @param signal - subscription cancellation. + * @returns complete passive state replacements. + */ +@Remote({ mode: 'stream' }) observeState(request: SessionStateRequest, signal: AbortSignal): AsyncIterable + +/** + * Wait for Session quiescence or pending input with bounded assistant output. + * @param request - Session, wait bounds and output window. + * @param signal - caller cancellation. + * @returns stopping state and bounded assistant output. + */ +@Remote wait(request: SessionWaitRequest, signal: AbortSignal): Promise /** * Follow one Session log from its opening or resume cursor. @@ -983,7 +1017,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @returns a complete opening snapshot followed by gap-free durable event * frames and optional cursorless assistant-stream frames. */ -@Remote({ mode: 'stream', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable /** * Read exact projections, returning cached hints only when migration is required. @@ -991,7 +1025,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation for the Session read. * @returns a sequenced baseline, cached hints when migration is deferred, or null when absent. */ -@Remote('projections') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise +@Remote('projections') projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise /** * Stream a complete live-control baseline followed by replacement frames. @@ -1001,7 +1035,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | @Remote({ mode: 'stream' }) async *control(signal: AbortSignal): AsyncIterable ``` -Types: [SessionId](core.zh.md) · [SessionInspection](persistence.zh.md) · [SessionSearchRequest](session-query.zh.md) +Types: [SessionId](core.zh.md) · [SessionSearchRequest](session-query.zh.md) Source: [`packages/api/session-controller/src/index.ts`](../../packages/api/session-controller/src/index.ts) @@ -1132,7 +1166,7 @@ get(id: SessionId): Session | undefined list(): Session[] /** - * Create a live child session from an exact prefix of a live source. + * Create an independent live session from an exact prefix of a live source. * `boundary` is an inclusive source event seq; omitted means the source's * current last event. An open tail receives synthetic tool results and * step/turn closers with the forked cause. Closed steps and turns remain diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index d6917635cf..5ea5ab4b27 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.i18n.yaml @@ -12,11 +12,11 @@ en: 21e88d68154c2c92 zh: 8c9e05c65ef0f34d /typert-remote-calls/remote-access-resources: - en: fa95f70437c07395 - zh: d4870b678b569a89 + en: 2e27d0745a4e3eac + zh: eb4ad825852c595b /typert-remote-calls/typert-registry: - en: 5827ef50011811c9 - zh: 21a9e7b368ba211a + en: d891e9390cf890da + zh: f45a31cc2ce2021e /typert-remote-calls/host-gateway: en: 58b923de12e48f18 zh: d3c2372d8becf65e diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index d68d4a1a36..1fe31a9918 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -286,7 +286,7 @@ interface RemoteInvocation { ## Remote access resources -Every Remote method is under the access control of [`dsh-api-remote-access`](../../packages/api/remote-access/README.md): a non-owner call is allowed only when the policy for its principal kind binds, on every resource the call touches, a role that lists the endpoint action. The package that owns an identity type merge-declares its resource kind in `TypertResourceMap`; `global`, the built-in identity-less resource, is never a kind there. The Gateway reads a kind's identities from the `resources` readers of `@Remote({ resources })` or `@RemoteScope(key, { resources })`, else from a lookup parameter or scoped Context that declares the kind as its `resource`, else from the default field registered in `typert.resources`; a call that names no resource touches `global`. The [Peer principals Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns the decision. +Every Remote method is under the access control of [`dsh-api-remote-access`](../../packages/api/remote-access/README.md): a non-owner call is allowed only when the policy for its principal kind binds, on every resource the call touches, a role that lists the endpoint action. The package that owns an identity type merge-declares its resource kind in `TypertResourceMap`; `global`, the built-in identity-less resource, is never a kind there. The Gateway reads a kind's identities from the `resources` readers of `@Remote({ resources })` or `@RemoteScope(key, { resources })`, else from a lookup parameter or scoped Context that declares the kind as its `resource`, else from the default field or resolver registered in `typert.resources`; a call that names no resource touches `global`. The [Peer principals Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns the decision. ```ts type-equiv /** @@ -334,11 +334,14 @@ interface RemoteScopeOptions { * throw, an empty list, or an identity other than a non-empty string * refuses the call. For a kind without a reader, the Gateway reads the * identity of a scoped Context whose Host adapter declares that `resource` - * and of lookup parameters declaring it, else the field registered in - * `typert.resources`: a top-level argument or, failing that, the first - * object argument. A call that carries no identity touches `global`. The - * readers and these defaults together must name every resource the method - * accesses: access control never checks a resource they omit. + * and of lookup parameters declaring it, else the registered default of + * `typert.resources`: a `resolve` function receiving the validated named + * wire values and their ordered parameters, or a `field` naming a top-level + * argument or, failing that, a field of the first object argument. A + * throwing resolver, an array result, or a field holding an array refuses + * the call. A call that carries no identity touches `global`. The readers + * and these defaults together must name every resource the method accesses: + * access control never checks a resource they omit. */ readonly resources?: TypertResourceReaders } @@ -358,7 +361,7 @@ interface RemoteMethodOptions exten ## Typert registry -`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, scoped Context providers, and the default fields of resource kinds. A lookup provider owns the stable wire declaration and default resolver; Host composition can configure an effect-scoped synchronous or asynchronous resolver for the same key, and unloading that configuration restores the default policy. Registrations are Cordis-owned effects and return awaitable disposers. +`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, scoped Context providers, and the default fields or resolvers of resource kinds. A lookup provider owns the stable wire declaration and default resolver; Host composition can configure an effect-scoped synchronous or asynchronous resolver for the same key, and unloading that configuration restores the default policy. Registrations are Cordis-owned effects and return awaitable disposers. ```ts type-equiv /** Minimal Typert runtime consumed through dependency inversion. */ @@ -441,6 +444,8 @@ interface TypertGateway { * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean + /** @returns detached metadata for all unsettled forwarded events in arrival order. */ + getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] /** * Register the application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. @@ -661,6 +666,12 @@ Resolve strict generated definitions or conservative SRC markers against current */ hasLiveClient(): boolean +/** + * Inspect unsettled forwarded events without changing their lifetime. + * @returns detached event names and Agent identities. + */ +getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] + /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 270e5509bd..840825979e 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -286,7 +286,7 @@ interface RemoteInvocation { ## Remote 访问资源 -每个 Remote 方法都受 [`dsh-api-remote-access`](../../packages/api/remote-access/README.zh.md) 的访问控制:只有当该 principal 种类的 policy 在调用访问的每个资源上都绑定了列出该端点 action 的 role 时,非 owner 调用才被放行。拥有某个身份类型的包把其资源种类声明合并进 `TypertResourceMap`;内置的无身份资源 `global` 从不是其中的种类。Gateway 从 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` 的 `resources` 读取函数读取某个种类的身份,其次是把该种类声明为 `resource` 的 lookup 参数或作用域 Context,再次是在 `typert.resources` 中登记的默认字段;不指向任何资源的调用访问 `global`。决策由 [Peer 身份属性 Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md) 负责。 +每个 Remote 方法都受 [`dsh-api-remote-access`](../../packages/api/remote-access/README.zh.md) 的访问控制:只有当该 principal 种类的 policy 在调用访问的每个资源上都绑定了列出该端点 action 的 role 时,非 owner 调用才被放行。拥有某个身份类型的包把其资源种类声明合并进 `TypertResourceMap`;内置的无身份资源 `global` 从不是其中的种类。Gateway 从 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` 的 `resources` 读取函数读取某个种类的身份,其次是把该种类声明为 `resource` 的 lookup 参数或作用域 Context,再次是在 `typert.resources` 中登记的默认字段或 resolver;不指向任何资源的调用访问 `global`。决策由 [Peer 身份属性 Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md) 负责。 ```ts type-equiv /** @@ -334,11 +334,14 @@ interface RemoteScopeOptions { * throw, an empty list, or an identity other than a non-empty string * refuses the call. For a kind without a reader, the Gateway reads the * identity of a scoped Context whose Host adapter declares that `resource` - * and of lookup parameters declaring it, else the field registered in - * `typert.resources`: a top-level argument or, failing that, the first - * object argument. A call that carries no identity touches `global`. The - * readers and these defaults together must name every resource the method - * accesses: access control never checks a resource they omit. + * and of lookup parameters declaring it, else the registered default of + * `typert.resources`: a `resolve` function receiving the validated named + * wire values and their ordered parameters, or a `field` naming a top-level + * argument or, failing that, a field of the first object argument. A + * throwing resolver, an array result, or a field holding an array refuses + * the call. A call that carries no identity touches `global`. The readers + * and these defaults together must name every resource the method accesses: + * access control never checks a resource they omit. */ readonly resources?: TypertResourceReaders } @@ -358,7 +361,7 @@ interface RemoteMethodOptions exten ## Typert 注册表 -`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方、作用域上下文提供方与资源种类的默认字段。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 +`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方、作用域上下文提供方与资源种类的默认字段或 resolver。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 ```ts type-equiv /** Minimal Typert runtime consumed through dependency inversion. */ @@ -441,6 +444,8 @@ interface TypertGateway { * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean + /** @returns detached metadata for all unsettled forwarded events in arrival order. */ + getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] /** * Register the application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. @@ -661,6 +666,12 @@ Resolve strict generated definitions or conservative SRC markers against current */ hasLiveClient(): boolean +/** + * Inspect unsettled forwarded events without changing their lifetime. + * @returns detached event names and Agent identities. + */ +getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] + /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 8265143029..6cfa137a71 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 6cc2cd9ced70bb09 zh: 1c0e6cb4ba2ed637 /tool-schema-catalog/tool-package-map: - en: 10fe940a3d17cde7 - zh: ef1e8863c28e3c05 + en: 4d42222acd39d083 + zh: 8153a5065bf2ebd6 /tool-schema-catalog/deepseek-ai-dsh-plugin-manager: en: e0143916bb634762 zh: e0143916bb634762 @@ -54,14 +54,20 @@ en: 4910ab327694ee97 zh: 17e79e4366cb636d /tool-schema-catalog/deepseek-ai-dsh-experimental-browser-use-stagehand-native/stagehand-tabs: - en: 095b18f9376e073d - zh: abd8785f8acaa555 + en: a0d5b9b891358120 + zh: 25793a62709770b5 +/tool-schema-catalog/deepseek-ai-dsh-action-cards: + en: 60c9df6ffda98a1d + zh: 60c9df6ffda98a1d +/tool-schema-catalog/deepseek-ai-dsh-action-cards/propose-action: + en: 633c32b6df889830 + zh: b88ea00b2033c11b /tool-schema-catalog/deepseek-ai-dsh-tool-ask-user: en: e713a4a11a15d13c zh: e713a4a11a15d13c /tool-schema-catalog/deepseek-ai-dsh-tool-ask-user/ask-user-question: - en: 2f86adf8ae823afb - zh: a5dad3d821e75e55 + en: 664dae29d0eae65f + zh: 2efa5d3f2cb31bf9 /tool-schema-catalog/deepseek-ai-dsh-tool-executor-ptc: en: 8c8643ef46fd075f zh: 8c8643ef46fd075f diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 24101e126f..9b466762d3 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -19,6 +19,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-plugin-market` | `plugin_market` | `ctx.tools`, `ctx.pluginMarket` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-mcp-resources` | `list_mcp_resource_templates`, `list_mcp_resources`, `read_mcp_resource` | `ctx.tools`, `ctx.mcpResources` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`, `stagehand_extract`, `stagehand_navigate`, `stagehand_observe`, `stagehand_screenshot`, `stagehand_tabs` | `ctx.browserUse`, `ctx.agents`, `ctx.tools`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | - | +| `@deepseek-ai/dsh-action-cards` | `propose_action` | `ctx.tools`, `ctx.agents`, `ctx.sessionProjections`, `ctx.remoteAccess` | `tool/call`, `tool/result`, `plugin:action-cards/decision`, `user/message` | - | Declared only to an Agent that loaded the skill of a registered card kind; the kind packages register their actions, and the Personalized recommendations bundle supplies the shipped kinds. | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userQuestions` | `tool/call`, `tool/result after an answer or timeout`, `late user/message` | - | ask_user_question keeps the original blocking behavior by default; set `mode: timed` to opt into a foreground timeout and pending result while the question remains answerable. In timed mode, `timeout: -1` keeps that call blocking indefinitely. | | `@deepseek-ai/dsh-tool-executor-ptc` | `run_code` | `ctx.workingDirectory (Agent executions)`, `ctx.tools`, `ctx.ptcRuntime`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | The PTC executor exposes only run_code to the model and declares other visible capabilities in a generated SDK in the runtime’s language. Program bindings enter the shared guarded tool pipeline in submission order; concurrency-safe bodies overlap up to maxParallelSubCalls, and nested results remain linked to the outer program. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agents`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | @@ -497,13 +498,56 @@ List, create, select, or close a Stagehand browser tab. Source: [`packages/experimental/browser-use-stagehand-native/src/index.ts`](../packages/experimental/browser-use-stagehand-native/src/index.ts) + + +## `@deepseek-ai/dsh-action-cards` + +### `propose_action` + +Show the user one optional action card under this answer. Call only as the skill that names the action instructs. + +```json +{ + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "Action name from the skill." + }, + "title": { + "type": "string", + "description": "Card title." + }, + "brief": { + "type": "string", + "description": "What the action does, in the user's language." + }, + "details": { + "type": "object", + "description": "Fields the skill lists for this action.", + "additionalProperties": true + } + }, + "required": [ + "action", + "title", + "brief", + "details" + ] +} +``` + +Source: [`packages/experimental/action-cards/src/index.ts`](../packages/experimental/action-cards/src/index.ts) + +Declared only to an Agent that loaded the skill of a registered card kind; the kind packages register their actions, and the Personalized recommendations bundle supplies the shipped kinds. + ## `@deepseek-ai/dsh-tool-ask-user` ### `ask_user_question` -Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. +Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. An answer item with empty selected and no custom means the user skipped that question: continue without it, do not ask it again, and do not treat the skip as permission. ```json { diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 27670f9964..092c2b2a10 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -23,6 +23,7 @@ | `@deepseek-ai/dsh-plugin-market` | `plugin_market` | `ctx.tools`, `ctx.pluginMarket` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-mcp-resources` | `list_mcp_resource_templates`, `list_mcp_resources`, `read_mcp_resource` | `ctx.tools`, `ctx.mcpResources` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`、`stagehand_extract`、`stagehand_navigate`、`stagehand_observe`、`stagehand_screenshot`、`stagehand_tabs` | `ctx.browserUse`、`ctx.agents`、`ctx.tools`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | - | +| `@deepseek-ai/dsh-action-cards` | `propose_action` | `ctx.tools`、`ctx.agents`、`ctx.sessionProjections`、`ctx.remoteAccess` | `tool/call`、`tool/result`、`plugin:action-cards/decision`、`user/message` | - | 只向加载过已注册卡片种类 Skill 的 Agent 声明;各种类的包注册自己的动作,个性化推荐 Bundle 提供随产品发布的种类。 | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after an answer or timeout`、`late user/message` | - | ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才启用前台超时与 pending 结果,同时问题仍可回答;timed 模式内 `timeout: -1` 让本次调用无限期阻塞。 | | `@deepseek-ai/dsh-tool-executor-ptc` | `run_code` | `ctx.workingDirectory (Agent executions)`、`ctx.tools`、`ctx.ptcRuntime`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | PTC 执行器只向模型公开 run_code,并在运行时语言生成的 SDK 中声明其他可见能力。程序绑定按提交顺序进入共享的受守卫工具流水线;并行安全的函数体最多重叠 maxParallelSubCalls 个,嵌套结果保持与外层程序关联。 | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agents`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 | @@ -501,13 +502,56 @@ 来源:[`packages/experimental/browser-use-stagehand-native/src/index.ts`](../packages/experimental/browser-use-stagehand-native/src/index.ts) + + +## `@deepseek-ai/dsh-action-cards` + +### `propose_action` + +在本次回答下方向用户展示一张可选操作卡片。只按指定该操作的 Skill 的要求调用。 + +```json +{ + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "Action name from the skill." + }, + "title": { + "type": "string", + "description": "Card title." + }, + "brief": { + "type": "string", + "description": "What the action does, in the user's language." + }, + "details": { + "type": "object", + "description": "Fields the skill lists for this action.", + "additionalProperties": true + } + }, + "required": [ + "action", + "title", + "brief", + "details" + ] +} +``` + +来源:[`packages/experimental/action-cards/src/index.ts`](../packages/experimental/action-cards/src/index.ts) + +只向加载过已注册卡片种类 Skill 的 Agent 声明;各种类的包注册自己的动作,个性化推荐 Bundle 提供随产品发布的种类。 + ## `@deepseek-ai/dsh-tool-ask-user` ### `ask_user_question` -继续操作前,如果需要确认、选择或缺失的信息,请向用户提出简明问题。 +继续操作前,如果需要确认、选择或缺失的信息,请向用户提出简明问题。如果回答项的 `selected` 为空且没有 `custom`,表示用户跳过了该问题:不带该回答继续执行,不要再次提问,也不要将跳过视为许可。 ```json { diff --git a/docs/upgrade-guide/v0.2.1-rc.2/cua-consent-storage/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/cua-consent-storage/guide.i18n.yaml new file mode 100644 index 0000000000..385596d866 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/cua-consent-storage/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/cua-consent-storage/guide.md +/: + en: 131777d89cb23df3 + zh: 44e50f8c09b2c4b9 +/computer-use-consent-storage: + en: fb837293708860f5 + zh: 8ee31126d1faf7c7 +/computer-use-consent-storage/change: + en: 88150a92531212bf + zh: a0103c4d035a4af7 +/computer-use-consent-storage/migration: + en: c7a4989a79f4fbca + zh: 630fc23128ee0520 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/cua-consent-storage/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/cua-consent-storage/guide.md new file mode 100644 index 0000000000..05a0bd4011 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/cua-consent-storage/guide.md @@ -0,0 +1,22 @@ +--- +kind: upgrade-guide +description: "Computer Use application consent moves from profile configuration to durable profile-scoped storage." +--- + +# Computer Use consent storage + +English | [中文](guide.zh.md) + +## Change + +Computer Use stores application consent through `storageDomain`. With the default JSON backend, each canonical profile directory has its own `$DSH_HOME/storages/cua_permissions_.json`. “Allow for this session” now follows the conversation ID across DSH restart and Computer Use disablement. It does not authorize another conversation. Unverifiable applications still require a matching native process identity; restarting that application may require new consent. + +`allowedApps` is a legacy import source. The first store access imports it once. Subsequent configuration edits do not grant or revoke access, and a revoked grant is not restored by reloading the old configuration. The permission page remains the authoring interface for grants and refusals. macOS privileges are unchanged. + +## Migration + +1. Keep the profile's existing storage services mounted. Shipped profiles already include `storageDomain` and the JSON backend; custom profiles need the same services. A profile without domain storage refuses authorization operations. +2. Enable Computer Use and open its details to initialize storage. Confirm that the always-allowed applications appear. +3. Remove `allowedApps` from the CUA entry in the profile configuration and other overlays after confirmation. Keep the remaining CUA configuration. +4. Use the permission page to revoke applications or allow asking again. Existing conversation-only grants held by an older running process cannot be recovered; grant those once after upgrading. Newly granted conversation access then survives restart. +5. Keep the storage file with that profile's state. Moving the profile to a different real directory selects a different store. File locks coordinate processes on one machine, not different machines sharing a network filesystem. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/cua-consent-storage/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/cua-consent-storage/guide.zh.md new file mode 100644 index 0000000000..fb9e8f39f3 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/cua-consent-storage/guide.zh.md @@ -0,0 +1,22 @@ +--- +kind: upgrade-guide +description: "Computer Use 应用授权从 profile 配置迁移到按 profile 隔离的持久存储。" +--- + +# Computer Use 授权存储 + +[English](guide.md) | 中文 + +## 变更 + +Computer Use 通过 `storageDomain` 保存应用授权。使用默认 JSON 后端时,每个 profile 的真实目录对应独立的 `$DSH_HOME/storages/cua_permissions_.json`。“本会话允许”现在按对话 ID 跨 DSH 重启和 Computer Use 启停保留,不授权其他对话。无法验证签名的应用仍需匹配本机进程身份,应用重启后可能需要重新授权。 + +`allowedApps` 作为旧数据来源,只在首次访问存储时导入一次。此后修改配置不会授予或撤销访问权限,重新加载旧配置也不会恢复已撤销的许可。权限页继续提供授权管理和清除拒绝的操作。macOS 系统权限保持原样。 + +## 迁移 + +1. 保留 profile 的现有存储服务。随附 profile 已包含 `storageDomain` 和 JSON 后端,自定义 profile 需要挂载相同服务。有 profile 但没有域存储时,授权操作会被拒绝。 +2. 启用 Computer Use 并打开详情页,初始化存储。确认始终允许的应用显示正确。 +3. 确认后,从 profile 配置及其他覆盖层的 CUA 条目中删除 `allowedApps`,保留其他 CUA 配置。 +4. 在权限页撤销应用授权或允许再次询问。旧进程内已有的会话许可无法恢复,升级后需要重新授予一次;新授予的会话许可随后可跨重启保留。 +5. 将存储文件作为该 profile 的状态保留。把 profile 移到另一个真实目录后会使用不同存储。文件锁协调同一电脑上的进程,不支持多台电脑共用网络文件系统。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.i18n.yaml new file mode 100644 index 0000000000..efe907e2eb --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.md +/: + en: 1e002ad3fd9b1e79 + zh: aeba3ee1e74fa2ae +/image-read-requests-carry-the-complete-attachment-reference: + en: df690494e7cec0b8 + zh: e21d937f2f305d8e +/image-read-requests-carry-the-complete-attachment-reference/change: + en: cca17091c60b9757 + zh: 8f300ab9d0b870d8 +/image-read-requests-carry-the-complete-attachment-reference/migration: + en: b593e623619ea508 + zh: 41714f50a249f941 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.md new file mode 100644 index 0000000000..3aac18a52f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.md @@ -0,0 +1,23 @@ +--- +kind: upgrade-guide +description: "Image read requests require a complete attachment reference and no longer check Session membership." +--- + +# Image read requests carry the complete attachment reference + +English | [中文](guide.zh.md) + +## Change + +In 0.2.1-rc.3, `session/attachment` accepted `{ sessionId, attachmentId }` and searched the requested Session's history for the complete image reference. It now requires `{ sessionId, attachment: ref }`, where `ref` is an `ImageAttachmentRef` containing `attachmentId`, `mediaType`, `bytes`, `width`, and `height`. The Client's `session.readAttachment()` also takes the complete reference instead of an ID. + +The Host passes this reference to the existing `AttachmentStore.readImage(ref)`. It verifies the ID, actual format, byte length, width, and height against the stored object. The response remains `{ attachment, data }`, with base64 bytes in `data`. It preserves the supplied reference, including optional `name` and `originalDimensions`; those two fields are caller-supplied display information and are not verified against the file. Missing, corrupt, and mismatched objects fail explicitly. + +Connection authentication and Gateway permission checks still apply. Any identity authorized to call the endpoint for the supplied `sessionId` may read an image in the same store, including one referenced only by another Session or by no Session. The handler does not check image membership or Session existence and no longer returns `session/not-found` for an absent Session after authorization. + +## Migration + +1. Update wire calls from `{ sessionId, attachmentId: ref.attachmentId }` to `{ sessionId, attachment: ref }`, using the complete reference from the message image block. Update Client calls from `session.readAttachment(ref.attachmentId)` to `session.readAttachment(ref)`. +2. Treat returned `name` and `originalDimensions` as the caller's display information. Only the ID, media type, byte length, width, and height are checked against the stored object. +3. If your deployment depends on Session membership to isolate images, run separate Host instances with separate `DSH_HOME` directories before upgrading. The shipped composition does not select separate stores by Session within one Host. Gateway permissions govern endpoint invocation, not the image's Session membership. +4. Confirm that an allowed request with a complete reference can read an image absent from the supplied Session, including when that Session does not exist. Denied Gateway requests, missing images, and references with mismatched intrinsic metadata must fail. Existing Session logs and stored images need no migration. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.zh.md new file mode 100644 index 0000000000..d4d3a2e2b6 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/attachment-read-access/guide.zh.md @@ -0,0 +1,23 @@ +--- +kind: upgrade-guide +description: "图片读取请求必须携带完整附件引用,并且不再检查 Session 关联。" +--- + +# 图片读取请求需携带完整附件引用 + +[English](guide.md) | 中文 + +## 变更 + +在 0.2.1-rc.3 中,`session/attachment` 接收 `{ sessionId, attachmentId }`,并在请求中的 Session 历史里查找完整图片引用。现在,请求必须使用 `{ sessionId, attachment: ref }`,其中 `ref` 是包含 `attachmentId`、`mediaType`、`bytes`、`width` 和 `height` 的 `ImageAttachmentRef`。Client 的 `session.readAttachment()` 也改为接收完整引用,不再只接收 ID。 + +Host 把该引用交给已有的 `AttachmentStore.readImage(ref)`,将 ID、实际格式、字节数、宽度和高度与存储对象校验。响应仍为 `{ attachment, data }`,`data` 包含 base64 字节。响应保留传入引用,包括可选的 `name` 和 `originalDimensions`;这两个字段是调用方提交的显示信息,不根据文件校验。对象缺失、损坏或与引用不符时会明确失败。 + +连接身份认证和 Gateway 权限检查仍然适用。任何有权为请求中的 `sessionId` 调用该接口的身份,都可以读取同一存储中的图片,包括仅被其他 Session 引用或未被任何 Session 引用的图片。处理函数不检查图片关联或 Session 是否存在,授权通过后不再因 Session 不存在而返回 `session/not-found`。 + +## 迁移 + +1. 把 wire 调用从 `{ sessionId, attachmentId: ref.attachmentId }` 改为 `{ sessionId, attachment: ref }`,使用消息图片块中的完整引用。把 Client 调用从 `session.readAttachment(ref.attachmentId)` 改为 `session.readAttachment(ref)`。 +2. 把返回的 `name` 和 `originalDimensions` 视为调用方的显示信息。只有 ID、媒体类型、字节数、宽度和高度会与存储对象校验。 +3. 如果部署依赖 Session 关联来隔离图片,请在升级前改用独立 Host 实例及各自的 `DSH_HOME` 目录。随附组合不会在一个 Host 内按 Session 选择独立存储。Gateway 权限约束接口调用,不约束图片的 Session 关联。 +4. 确认获准调用的请求可以凭完整引用读取其 Session 中没有记录的图片,即使该 Session 不存在也可以读取。Gateway 拒绝的请求、缺失图片和固有元数据不符的引用必须失败。已有 Session 日志和存储图片无需迁移。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/independent-session-forks/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/independent-session-forks/guide.i18n.yaml new file mode 100644 index 0000000000..d75920e7c9 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/independent-session-forks/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/independent-session-forks/guide.md +/: + en: 68f919837577038b + zh: 6ae8223b1542c369 +/ordinary-forks-are-independent-sessions: + en: 4556d51bd8086cfe + zh: 8811b03db9225739 +/ordinary-forks-are-independent-sessions/change: + en: 0a2790279aec9e24 + zh: 5cca673af988a228 +/ordinary-forks-are-independent-sessions/migration: + en: 53b586ad6498fdb8 + zh: dfa70928e17478e2 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/independent-session-forks/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/independent-session-forks/guide.md new file mode 100644 index 0000000000..0bff80da2a --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/independent-session-forks/guide.md @@ -0,0 +1,23 @@ +--- +kind: upgrade-guide +description: "Ordinary Session forks stop recording their source relationship and stop appearing as subagents." +--- + +# Ordinary forks are independent Sessions + +English | [中文](guide.zh.md) + +## Change + +Ordinary forks copy the selected history and continue independently. They omit `SessionHeader.parentSession` and the Client's `parentSessionId`; no replacement source field is written. Subagents, including fork-backend subagents, retain their direct creator. Existing logs retain their recorded metadata. + +SDK `subagent.started` excludes ordinary forks, including historical ones carrying a parent id. ACP list/resume admits ordinary forks; headless adoption also accepts them when cwd and preset checks pass. Client lists no longer indent them or insert newly discovered forks before their sources; saved manual positions remain intact. Source-based trace queries, parent filters, telemetry, and remote logs cannot recover the source of new ordinary forks. Workspace binding, optional title increments, inherited history, and seed boundaries remain available. + +The Session format stays V4. Older V4 readers may reject a parentless fork containing inherited delivery acknowledgements as a corrupt log with `current-generation delivery marker names the wrong Session`. The tested readers refuse before granting a handle or truncating the file. They do not provide an upgrade-specific diagnostic. See the [compatibility decision](../../../../.agents/notes/implemented/architecture/2026-10-23-independent-session-forks.md). + +## Migration + +1. Upgrade every reader and writer sharing the Session directory before resuming newly created forks. Do not edit old headers or delete delivery records to bypass an older reader's rejection. +2. Replace consumer checks that treat any `parentSession` as a subagent with `origin === 'subagent'`. Continuation still requires a valid descriptor from the Session's own event range; inherited descriptors do not establish its identity. +3. Remove any ordinary-fork workflow that requires persistent source tracing. Keep source-dependent actions such as workspace selection within the fork operation. +4. Confirm that an ordinary fork has no parent field, survives restart with inherited delivery records, and produces no `subagent.started`; confirm that real subagents retain their creator and notification. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/independent-session-forks/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/independent-session-forks/guide.zh.md new file mode 100644 index 0000000000..fdd5b53f99 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/independent-session-forks/guide.zh.md @@ -0,0 +1,23 @@ +--- +kind: upgrade-guide +description: "普通 Session fork 停止记录来源关系,也不再显示为子代理。" +--- + +# 普通 fork 是独立 Session + +[English](guide.md) | 中文 + +## 变更 + +普通 fork 复制选定历史并独立继续运行。它们省略 `SessionHeader.parentSession` 及客户端的 `parentSessionId`,不写入替代来源字段。子代理(包括使用 fork backend 的子代理)保留直接创建者。已有日志保留记录中的元数据。 + +SDK 的 `subagent.started` 排除普通 fork,包括携带父 ID 的历史会话。ACP 列表和恢复允许普通 fork;通过 cwd 和 preset 检查的普通 fork 也可由 headless 沿用。客户端列表不再将它们缩进,也不再把新发现的 fork 插到来源之前;已保存的手动位置保持不变。基于来源的追踪查询、父关系筛选、遥测和远端日志无法还原新普通 fork 的来源。工作区绑定、可选标题递增、继承历史和 seed 边界仍然可用。 + +Session 格式保持 V4。旧 V4 reader 可能将包含继承投递确认的无父关系 fork 作为损坏日志拒绝,报错为 `current-generation delivery marker names the wrong Session`。已测试的 reader 在授予句柄或截断文件之前拒绝,不提供专门的升级诊断。参见[兼容性决策](../../../../.agents/notes/implemented/architecture/2026-10-23-independent-session-forks.zh.md)。 + +## 迁移 + +1. 在恢复新建 fork 之前,升级共享 Session 目录的全部 reader 和 writer。不要编辑旧 header 或删除投递记录来绕过旧 reader 的拒绝。 +2. 将消费方中把任意 `parentSession` 视为子代理的检查替换为 `origin === 'subagent'`。继续运行仍要求来自 Session 自身事件区间的有效 descriptor;继承的 descriptor 不建立其身份。 +3. 移除依赖持久化来源追踪的普通 fork 工作流。工作区选择等依赖来源的动作保留在 fork 操作期间。 +4. 确认普通 fork 不含父字段、携带继承投递记录也能在重启后恢复,且不产生 `subagent.started`;确认真正子代理保留创建者及通知。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.i18n.yaml new file mode 100644 index 0000000000..8e84c0dcab --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.md +/: + en: 789ee06aa8932a32 + zh: d465eb26824f2b4b +/request-activation-when-following-a-session: + en: 25bd54a8216b1c71 + zh: edaa12b04713528a +/request-activation-when-following-a-session/change: + en: d4021672fedcbfb7 + zh: 35d5e76a69301e4a +/request-activation-when-following-a-session/migration: + en: aa22d72ca3f91d38 + zh: ebe991eca4bf74c2 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.md new file mode 100644 index 0000000000..cb01f8e90e --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Session follow callers must explicitly request background Agent activation." +--- +# Request activation when following a Session + +English | [中文](guide.zh.md) + +## Change + +`session.follow` no longer automatically resumes a cold ordinary Agent when `activate` is omitted. It reads the opening snapshot and follows subsequent events without activation. This affects integrations that used opening a history stream to make an Agent available. The shipped Web Client sends `activate: true` explicitly and retains its opening behavior. + +With `activate: true`, activation starts once the opening snapshot is computed, before it is delivered; that snapshot does not acknowledge Agent readiness. Subagent Sessions still do not use ordinary Agent activation. + +## Migration + +1. Add `activate: true` to `session.follow` requests that must resume a cold ordinary Agent. Keep the existing `address` and history-window fields. +2. For passive viewers, omit `activate` or set it to `false`. +3. Verify that an activation-enabled stream still returns its opening snapshot, then check Agent availability separately. Do not treat receipt of the snapshot as completion of activation. + +See the [Session Controller reference](../../../../packages/api/session-controller/README.md) for history and state observation behavior. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.zh.md new file mode 100644 index 0000000000..5df6e523a3 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Session follow 调用方必须显式请求后台 Agent 激活。" +--- +# 跟随 Session 时显式请求激活 + +[English](guide.md) | 中文 + +## 变更 + +`session.follow` 在省略 `activate` 时不再自动恢复冷普通 Agent。它只读取开场快照并跟随后续事件,不进行激活。这会影响通过打开历史流使 Agent 可用的集成。随产品提供的 Web Client 显式发送 `activate: true`,保留原有打开行为。 + +设置 `activate: true` 时,激活在开场快照计算完成后、交付之前开始;该快照不确认 Agent 已就绪。子会话仍不走普通 Agent 激活。 + +## 迁移 + +1. 为需要恢复冷普通 Agent 的 `session.follow` 请求添加 `activate: true`,保留原有 `address` 和历史窗口字段。 +2. 被动查看器可以省略 `activate` 或设为 `false`。 +3. 确认启用激活的流仍返回开场快照,再单独检查 Agent 是否可用。不要把收到快照当作激活完成。 + +历史与状态观察行为见 [Session Controller 参考](../../../../packages/api/session-controller/README.zh.md)。 diff --git a/package.json b/package.json index e6b98964d8..368901f21a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "license": "MIT", "private": true, "type": "module", @@ -24,7 +24,7 @@ "build:bench": "npm run build:native-system && npm run build:lib && tsdown --config-loader native --config benchmarks/tsdown.config.ts", "build:official": "tsx scripts/build.ts --profile official", "build:lib": "pnpm run build:lib:host && pnpm run build:lib:client", - "build:lib:host": "tsx scripts/compile-referenced-projects.ts host-libraries && node --max-old-space-size=6144 ./node_modules/typescript/bin/tsc -p tsconfig.host.json && pnpm --filter @deepseek-ai/dsh-desktop run bundle", + "build:lib:host": "tsx scripts/compile-referenced-projects.ts host-libraries && node --max-old-space-size=8192 ./node_modules/typescript/bin/tsc -p tsconfig.host.json && pnpm --filter @deepseek-ai/dsh-desktop run bundle", "build:lib:client": "tsc -b tsconfig.client.json && tsdown --config-loader native --env.DSH_BUILD_FACE client", "build:web": "pnpm --filter @deepseek-ai/dsh-web-frontend run build", "build:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run build", @@ -63,7 +63,7 @@ "test:coverage:partitioned": "tsx scripts/run-coverage-partitions.ts", "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:bench": "npm run build:bench && npm run build:web && npm run test:bench:built", - "test:bench:built": "vitest run --config vitest.bench.config.ts", + "test:bench:built": "tsx scripts/run-bench-suite.ts", "test:bench:memory": "tsx scripts/run-memory-pressure-bench.ts", "test:expected": "vitest run --config vitest.expected.config.ts", "test:expected:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.expected.config.ts", @@ -134,7 +134,7 @@ "docs:build": "tsx website/build.ts && pnpm run verify-doc-site-fragments", "docs:build:mpa": "tsx website/build.ts --mpa && pnpm run verify-doc-site-fragments", "docs:preview": "pnpm --filter @deepseek-ai/website run preview", - "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts website/tests/mermaid-viewer.spec.ts website/tests/image-viewer.spec.ts website/tests/code-groups.spec.ts website/tests/page-markdown-actions.spec.ts website/tests/raw-markdown.spec.ts && pnpm run docs:build", + "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts website/tests/mermaid-viewer.spec.ts website/tests/image-viewer.spec.ts website/tests/code-groups.spec.ts website/tests/page-markdown-actions.spec.ts website/tests/raw-markdown.spec.ts website/tests/table-filter.spec.ts && pnpm run docs:build", "website:dev": "pnpm run docs:dev", "website:build": "pnpm run docs:build", "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts", @@ -300,6 +300,7 @@ "@deepseek-ai/dsh-cua-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-subagent-oar": "workspace:*", "@deepseek-ai/dsh-experimental-tool-subagent-oar": "workspace:*", - "@deepseek-ai/dsh-experimental-cua-macos": "workspace:*" + "@deepseek-ai/dsh-experimental-cua-macos": "workspace:*", + "@deepseek-ai/dsh-action-cards": "workspace:*" } } diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 701a4ae942..92c89f5ff2 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -15,8 +15,8 @@ en: ffc55792d6e922e1 zh: 83bd759638857e2d /packages/package-groups: - en: d67996086e3b4f28 - zh: cb758b1fcee5ab3e + en: c397fc0c1885058a + zh: c720d0e91dd1d15e /packages/release-expectations: en: 236081c1659b25ab zh: 809110adf003802e diff --git a/packages/README.md b/packages/README.md index b6d04e0f04..ed7c55ec5a 100644 --- a/packages/README.md +++ b/packages/README.md @@ -75,11 +75,11 @@ Every package lives in exactly one group; new packages join existing groups, and | [`sdk/`](sdk/README.md) | Out-of-process SDK: JSON-RPC protocol and TypeScript client/server | | [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | | [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | -| [`boot/`](boot/README.md) | Shared app-bin boot glue and installation bundle lists | +| [`boot/`](boot/README.md) | Shared app-bin boot glue | | [`host/`](host/README.md) | Web GUI host services, directory picking, application launch, plugin inventory, and product telemetry | | [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | | [`test-support/`](test-support/README.md) | Test infrastructure (testkits, replay, Loader smokes) | -| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, home/path helpers, timeout, retention) | +| [`util/`](util/README.md) | Low-level utilities and data-only package lists shared across groups (`Branded`, home/path helpers, timeout, retention, official bundle lists) | ----- diff --git a/packages/README.zh.md b/packages/README.zh.md index f1cb7f8266..ae3a18bbc1 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -75,11 +75,11 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会 | [`sdk/`](sdk/README.zh.md) | 进程外 SDK:JSON-RPC 协议与 TypeScript 客户端/服务器 | | [`acp/`](acp/README.zh.md) | 仅面向自动化的 ACP(Agent Client Protocol)服务器 | | [`interaction/`](interaction/README.zh.md) | 人机协作平面:批准/交互 seam、权限预设、命令、询问用户的工具 | -| [`boot/`](boot/README.zh.md) | 共享的 app bin 启动粘合层与安装方组合包列表 | +| [`boot/`](boot/README.zh.md) | 共享的 app bin 启动粘合层 | | [`host/`](host/README.zh.md) | Web GUI 宿主服务、目录选择、应用启动、插件清单和产品遥测 | | [`client/`](client/README.zh.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | | [`test-support/`](test-support/README.zh.md) | 测试基础设施(testkit、回放、Loader 冒烟测试) | -| [`util/`](util/README.zh.md) | 组间共享的低层零依赖工具(`Branded`、home/路径辅助函数、超时、留存) | +| [`util/`](util/README.zh.md) | 组间共享的低层工具与纯数据包列表(`Branded`、home/路径辅助函数、超时、留存、官方组合包列表) | ----- diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml index aca817ae8a..bbd4e8b862 100644 --- a/packages/acp/acp/README.i18n.yaml +++ b/packages/acp/acp/README.i18n.yaml @@ -27,8 +27,8 @@ en: 4ed8e37834267edb zh: a32d5dedb4e8e919 /deepseek-ai-dsh-acp/use-this-package/protocol-contract: - en: 8f358b006f93f6ab - zh: a02dc9640cac4edd + en: 195315c12edf895f + zh: de307288858a8ece /deepseek-ai-dsh-acp/understand-the-implementation: en: ea8afe89dbdd1934 zh: 6567979f92f34afd diff --git a/packages/acp/acp/README.md b/packages/acp/acp/README.md index 44a63a1da2..06ba85def5 100644 --- a/packages/acp/acp/README.md +++ b/packages/acp/acp/README.md @@ -73,6 +73,8 @@ One connection can run several sessions at once, each independent. The calls a c | `session/update` | Committed assistant messages and thoughts, generic tool lifecycle, configuration changes, and context usage, serialized per session. | | `session/request_permission` | A permission prompt with one-shot allow/reject choices; your client can answer automatically. | +Session listing and resume admit ordinary forks, including historical logs with `parentSession`; `origin: subagent` remains excluded. + A prompt remains in flight while managed descendants run, while their results trigger later root turns, and while committed output drains. Root work accepted during that output drain joins the same activity interval before completion. Its completion includes the latest root stop reason and any assistant output delivery failure; only the original prompt turn pins the model selection captured at admission. Cancellation stops the root activity present at that call and waits for that activity and its queued output. Later child messages may start autonomous work and do not delay the cancelled response; the session remains available for later prompts. Idle descendants with parked inbox messages remain resident but do not delay prompt completion. Closing the session also drains its descendants. A failure to append `turn/start` or `turn/end` rejects the prompt even when a preceding turn completed. diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md index 500c90f5d2..a22b08c924 100644 --- a/packages/acp/acp/README.zh.md +++ b/packages/acp/acp/README.zh.md @@ -73,6 +73,8 @@ kind: "package-reference" | `session/update` | 已提交 assistant 消息与 thought、通用工具生命周期、配置变化与上下文用量,按会话串行交付。 | | `session/request_permission` | 带一次性允许/拒绝选项的权限提示;你的客户端可以自动回答。 | +会话列表和恢复允许普通 fork,包括含有 `parentSession` 的历史日志;`origin: subagent` 仍被排除。 + 受管理后代运行期间、它们的结果触发根 Agent 后续轮次期间,以及已提交输出仍在交付期间,提示词保持进行中。输出交付期间接受的根 Agent 工作会加入同一活动区间,并在该区间完成后结算。最终结果包含最新根轮次的停止原因与所有助手输出交付错误;只有原始提示词轮次固定使用接受时捕获的模型选择。取消会停止调用时已有的根 Agent 活动,并等待该活动及其已排队输出完成。之后到达的子 Agent 消息可以启动自主工作,但不会延迟取消响应;会话仍可接受后续提示词。收件箱消息处于停放状态的空闲后代继续驻留,但不延迟提示词完成。关闭会话还会释放其后代。 即使前一轮已经完成,追加 `turn/start` 或 `turn/end` 失败仍会使提示词请求失败。 diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index ca9ad8609d..41962dd596 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index c05ccbf3e5..792ec6a424 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -248,7 +248,7 @@ export function apply(ctx: Context, config: AcpConfig): void { activating.add(sessionId) return (async (): Promise => { const persisted = (await persistence.stat(sessionId, { signal }))?.header - if (persisted === undefined || persisted.origin === 'subagent' || persisted.parentSession !== undefined) { + if (persisted === undefined || persisted.origin === 'subagent') { throw invalidParams(`session is not resumable: ${sessionId}`) } if (!await sameDirectory(persisted.cwd, params.cwd)) { @@ -308,7 +308,6 @@ export function apply(ctx: Context, config: AcpConfig): void { || activating.has(header.id) || ctx.sessions.get(header.id) !== undefined || header.origin === 'subagent' - || header.parentSession !== undefined || header.cwd === undefined || !isAbsolute(header.cwd) ) return undefined diff --git a/packages/acp/acp/tests/bridge.spec.ts b/packages/acp/acp/tests/bridge.spec.ts index dcfcfddef6..c13044799e 100644 --- a/packages/acp/acp/tests/bridge.spec.ts +++ b/packages/acp/acp/tests/bridge.spec.ts @@ -6,7 +6,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import { ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm' -import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { SessionPersistenceRevision, type SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' @@ -388,6 +388,22 @@ describe('automation-only ACP bridge', () => { await expect(harness.client.listSessions({ cursor: 'not-a-cursor' })).rejects.toThrow(/cursor is invalid/) }) + it.each([false, true])('resumes an ordinary fork with legacy parent metadata=%s', async (legacy) => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const id = SessionId('ordinary-fork') + const writer = await harness.ctx.sessionPersistence.create({ + version: SESSION_FORMAT_VERSION, id, createdAt: 1, isSeeded: true, + cwd: process.cwd(), ...(legacy ? { parentSession: SessionId('source') } : {}), + }, { inheritedEventCount: SessionLogOffset(0) }) + try { + await writer.append([{ type: 'session/end-seed', seq: SessionSeq(0), time: 1, data: { inherited: true } }]) + } finally { await writer.close() } + await expect(harness.client.listSessions({})).resolves.toMatchObject({ sessions: [{ sessionId: id }] }) + await harness.client.resumeSession({ sessionId: id, cwd: process.cwd() }) + expect(harness.ctx.agents.get(id)?.session.header.parentSession).toBe(legacy ? 'source' : undefined) + }) + it('filters non-resumable headers and canonical missing workspaces', async () => { harness = await makeBridgeHarness() await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -411,6 +427,7 @@ describe('automation-only ACP bridge', () => { await expect(harness.client.listSessions({ cwd: 'relative' })).rejects.toThrow(/absolute path/) await expect(harness.client.listSessions({ cwd: '/missing/filter' })).resolves.toEqual({ sessions: [ + { sessionId: 'fork', cwd: '/missing/filter' }, { sessionId: 'valid-a', cwd: '/missing/filter' }, { sessionId: 'valid-b', cwd: '/missing/filter' }, ], diff --git a/packages/api/account-controller/package.json b/packages/api/account-controller/package.json index 088357e98f..30aa41c6da 100644 --- a/packages/api/account-controller/package.json +++ b/packages/api/account-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-account-controller", "description": "Expose safe account operations over authenticated Remote", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index da77f603e9..eb3352e60d 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -15,8 +15,8 @@ en: d9859219cdb0f367 zh: 2a3cfa8cb786e327 /deepseek-ai-dsh-api-gateway/host-service-typertgatewayservice-ctx-key-typertgateway: - en: 891bab7fceee4f0a - zh: be55b40151f049a1 + en: 6ee072ffebd2eb38 + zh: 6a8723d13f443d51 /deepseek-ai-dsh-api-gateway/client-service-clientremote-ctx-key-remote: en: 0916113cdbe794fb zh: 14893c2cba95bfd6 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index fac42718bf..fea754c891 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -32,7 +32,9 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api Every call names the Peer it speaks for: `InvokeRemoteRequest.peer` and the `peer` argument of `wireStream.open()` are required, and the Gateway never supplies a default Peer. Dispatch from a disposed Peer fails with `gateway/peer-disposed`, for unary calls, streams, `$events`, and `$events/result` alike, and wire paths check it before they read the payload. The Host service injects `remoteAccess` and does not start without [`dsh-api-remote-access`](../remote-access/README.md). After the endpoint and argument-field checks, every call validates its JSON arguments once against their Typert-compiled types, failing with `gateway/input-invalid`, and dispatch reuses the validated values. For a call whose Peer principal is not the owner, after that validation and before lookup and Context resolution, the Gateway resolves the resources the call touches and asks `ctx.remoteAccess.decide({ principal, action, resources, args })` with the endpoint as the action and the validated arguments as `args`, or refuses with reason `resource-invalid` without asking when the resources are invalid; a refused call throws `remote-access/denied` with details `{ action, reason }`, so a refused call fails before it activates any Session. An owner call is dispatched without resource resolution or a decision. -Resources are read from the arguments validated against their Typert-compiled types, which drop object fields those types do not declare, while lookup and Context identities keep their wire values. For each resource kind, a reader in the method's `@Remote({ resources })` or `@RemoteScope(key, { resources })` options wins; it is called with the method's positional arguments, where lookup positions hold wire identities and the cancellation position holds the call's signal, and is read from the class metadata of the root Service instance. Otherwise every lookup parameter whose provider declares that kind through `resource` contributes its wire identity, an omitted optional one contributing none, and a Context method whose Host Context adapter declares that kind contributes its Context identity, read from the arguments before the Context is resolved. Otherwise the kind's default field registered in `typert.resources` is read from a top-level argument of that name or, failing that, from the first object argument. A reader that throws or returns an empty list, a default field holding an array, or an identity, including a Context identity, that is not a non-empty string makes the resolution `invalid`; a call that names no resource touches the `global` resource alone, and a call that names resources touches only those. A forwarded event carries every resource its entry declares, so there a reader returning `undefined` also makes the resolution `invalid`, while an entry without readers touches `global`. +Resources are read from validated arguments: Typert-compiled codecs drop undeclared object fields, while SRC fallback checks JSON safety without stripping them; lookup and Context identities keep their wire values. For each resource kind, a reader in the method's `@Remote({ resources })` or `@RemoteScope(key, { resources })` options wins; it is called with the method's positional arguments, where lookup positions hold wire identities and the cancellation position holds the call's signal, and is read from the class metadata of the root Service instance. Otherwise every lookup parameter whose provider declares that kind through `resource` contributes its wire identity, an omitted optional one contributing none, and a Context method whose Host Context adapter declares that kind contributes its Context identity, read from the arguments before the Context is resolved. Otherwise the kind's declaration in `typert.resources` supplies either a default field or a synchronous resolver. A field reads a top-level argument of that name or, failing that, the first object argument. A resolver is called once with `{ args, parameters }`: named validated wire values and ordered parameter descriptions containing `name` and `wire`. Both defaults return one identity or `undefined`; arrays are invalid. An explicit reader may return several identities. A resolver or reader that throws, a reader returning an empty list, or an identity, including a Context identity, that is not a non-empty string makes the resolution `invalid`; a call that names no resource touches the `global` resource alone, and a call that names resources touches only those. A forwarded event carries every resource its entry declares, so there a reader returning `undefined` also makes the resolution `invalid`, while an entry without readers touches `global`. + +`getPendingRemoteEvents()` returns detached event-name and Agent-ID metadata for currently unsettled forwarded events in arrival order. It neither exposes request bodies nor changes event lifetimes, and it does not provide a change notification. A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 86ad50da63..1dad623204 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -32,7 +32,9 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 每次调用都指明它所代表的 Peer:`InvokeRemoteRequest.peer` 与 `wireStream.open()` 的 `peer` 参数都是必填项,Gateway 从不提供默认 Peer。来自已释放 Peer 的分发以 `gateway/peer-disposed` 失败,一元调用、流、`$events` 与 `$events/result` 一律如此,wire 路径在读取 payload 之前检查。Host 服务注入 `remoteAccess`,没有 [`dsh-api-remote-access`](../remote-access/README.zh.md) 就不启动。每次调用在 endpoint 与参数字段检查之后按 Typert 编译类型校验一次 JSON 参数,失败为 `gateway/input-invalid`,分派复用校验后的值。对 Peer 的 principal 不是 owner 的调用,Gateway 在该校验之后、lookup 与 Context 解析之前解析调用访问的资源,并以端点为 action、校验后的参数为 `args` 询问 `ctx.remoteAccess.decide({ principal, action, resources, args })`,资源非法时不询问而以原因 `resource-invalid` 拒绝;被拒的调用抛出 details 为 `{ action, reason }` 的 `remote-access/denied`,因此被拒绝的调用在激活任何 Session 之前就失败。owner 的调用不解析资源、不询问判定即分派。 -资源从经 Typert 编译类型校验后的参数中读取,校验会移除类型未声明的对象字段,lookup 与 Context 身份保持 wire 值。对每个资源种类,方法 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` options 中的读取函数优先;它以方法的位置参数调用(lookup 位为 wire 身份,取消参数位为调用的 signal),从根 Service 实例的类元数据读取。否则 provider 经 `resource` 声明了该种类的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Host Context adapter 声明了该种类的 Context 方法贡献其 Context 身份,在解析 Context 之前直接从参数读取。再否则从同名顶层参数读取该种类在 `typert.resources` 中登记的默认字段,没有同名参数时读第一个对象参数的同名字段。读取函数抛错或返回空列表、默认字段读到数组、或某个身份(含 Context 身份)不是非空字符串时,解析结果为 `invalid`;不指明任何资源的调用只访问 `global` 资源,指明了资源的调用只访问这些资源。转发事件携带其条目声明的每个资源,因此在事件路径上读取函数返回 `undefined` 同样使解析结果为 `invalid`,而没有读取函数的条目访问 `global`。 +资源从已校验参数中读取:Typert 编译 codec 会移除未声明的对象字段,SRC fallback 只检查 JSON 安全性而不移除字段;lookup 与 Context 身份保持 wire 值。对每个资源种类,方法 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` options 中的读取函数优先;它以方法的位置参数调用(lookup 位为 wire 身份,取消参数位为调用的 signal),从根 Service 实例的类元数据读取。否则 provider 经 `resource` 声明了该种类的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Host Context adapter 声明了该种类的 Context 方法贡献其 Context 身份,在解析 Context 之前直接从参数读取。再否则使用该种类在 `typert.resources` 中登记的默认字段或同步 resolver。字段规则读取同名顶层参数,没有时读取第一个对象参数的同名字段。resolver 以 `{ args, parameters }` 调用一次:命名的已校验 wire 值,以及包含 `name`、`wire` 的有序参数描述。两种默认规则均返回单个身份或 `undefined`,数组无效。显式读取函数可以返回多个身份。resolver 或读取函数抛错、读取函数返回空列表、或某个身份(含 Context 身份)不是非空字符串时,解析结果为 `invalid`;不指明任何资源的调用只访问 `global` 资源,指明了资源的调用只访问这些资源。转发事件携带其条目声明的每个资源,因此在事件路径上读取函数返回 `undefined` 同样使解析结果为 `invalid`,而没有读取函数的条目访问 `global`。 + +`getPendingRemoteEvents()` 按到达顺序返回当前未结算转发事件的独立元数据,仅含事件名和 Agent ID。它不暴露请求正文,不改变事件生命周期,也不提供变化通知。 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index d525c2dedf..2b4183a776 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/src/access.ts b/packages/api/gateway/src/access.ts index d85ebb3e65..f68079c47e 100644 --- a/packages/api/gateway/src/access.ts +++ b/packages/api/gateway/src/access.ts @@ -22,10 +22,10 @@ type ResourceIds = readonly unknown[] | 'invalid' * `resources` reader of the method wins; otherwise every lookup parameter * whose provider declares the kind contributes its wire resource id, and so * does the Context identity of a Context method whose Host adapter declares - * the kind; otherwise the kind's default field registered in - * `typert.resources` is read from a top-level argument or, failing that, - * from the first object argument. - * @param typert - registry holding default fields, lookup providers, and Host Context adapters. + * the kind; otherwise the kind's registered default resolver receives named + * wire values and ordered parameter descriptions. A default field instead + * reads a top-level argument or, failing that, the first object argument. + * @param typert - registry holding default fields or resolvers, lookup providers, and Host Context adapters. * @param readers - the method's `resources` readers, or `undefined` when it declares none. * @param descriptor - receiver, parameter order, lookups, and cancellation slot of the method. * @param args - named arguments matched to the descriptor's field set: JSON @@ -33,9 +33,9 @@ type ResourceIds = readonly unknown[] | 'invalid' * identities as wire values. * @param signal - value placed in the cancellation slot of the positional arguments. * @returns the touched resources, the global resource alone when none; - * `undefined` when the resources are invalid: a reader throws or returns - * an empty list, a default field holds an array, or a resource id is not a - * non-empty string. + * `undefined` when the resources are invalid: a reader or resolver throws, + * a reader returns an empty list, a default field or resolver holds an + * array, or a resource id is not a non-empty string. */ export function callResources( typert: Pick, @@ -46,25 +46,32 @@ export function callResources( ): readonly RemoteAccessResource[] | undefined { // Each later source replaces an earlier one for its kind and keeps the kind's position. const sources = new Map ResourceIds>() - for (const { kind, field } of typert.resources.entries()) { - sources.set(kind, () => { - // A default field names exactly one resource id; only a reader may return several. - const id = defaultFieldId(descriptor, args, field) + const positionalArgs: readonly unknown[] = [ + ...descriptor.parameters.map(parameter => args[parameter.wire]), + ...descriptor.cancellation === undefined ? [] : [signal], + ] + for (const declaration of typert.resources.entries()) { + sources.set(declaration.kind, () => { + let id: unknown + if (declaration.resolve !== undefined) { + const resolve = declaration.resolve + try { + id = resolve({ args, parameters: descriptor.parameters }) + } catch { + // A resolver failure cannot identify an authorized resource. + return 'invalid' + } + } else { + id = defaultFieldId(descriptor, args, declaration.field) + } return Array.isArray(id) ? 'invalid' : id === undefined ? [] : [id] }) } for (const [kind, ids] of lookupResources(typert, descriptor, args)) { sources.set(kind, () => ids) } - let positional: readonly unknown[] | undefined for (const [kind, reader] of Object.entries(readers ?? {})) { - sources.set(kind, () => { - positional ??= [ - ...descriptor.parameters.map(parameter => args[parameter.wire]), - ...descriptor.cancellation === undefined ? [] : [signal], - ] - return read(reader, positional, []) - }) + sources.set(kind, () => read(() => reader(...positionalArgs), [])) } const resources: RemoteAccessResource[] = [] for (const [kind, ids] of sources) { @@ -91,7 +98,7 @@ export function eventResources( if (declared.length === 0) return GLOBAL const resources: RemoteAccessResource[] = [] for (const [kind, reader] of declared) { - if (!collect(resources, kind, read(reader, args, 'invalid'))) return undefined + if (!collect(resources, kind, read(() => reader(...args), 'invalid'))) return undefined } return resources } @@ -129,11 +136,11 @@ function lookupResources( return ids } -/** Run one reader with spread positional arguments: `undefined` yields `absent`, a list names several and must not be empty. */ -function read(reader: TypertResourceReader, args: readonly unknown[], absent: ResourceIds): ResourceIds { +/** Read resource identities: `undefined` yields `absent`, a list names several and must not be empty. */ +function read(reader: () => unknown, absent: ResourceIds): ResourceIds { let value: unknown try { - value = reader(...args) + value = reader() } catch { // Any reader error means it cannot name its resources, so they are invalid. return 'invalid' diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 878c77cf5e..4fe2652671 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -36,6 +36,7 @@ import { } from '@deepseek-ai/dsh-typert-protocol' import type { InvokeRemoteRequest, + PendingRemoteEventInfo, TypertGateway, TypertGatewayErrorCode, TypertGatewayWireStream, @@ -70,6 +71,7 @@ import { export type { InvokeRemoteRequest, + PendingRemoteEventInfo, TypertGateway, TypertGatewayErrorCode, TypertGatewayWireStream, @@ -333,6 +335,14 @@ export class TypertGatewayService extends Service implements TypertGateway { return false } + /** + * Inspect unsettled forwarded events without changing their lifetime. + * @returns detached event names and Agent identities. + */ + getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] { + return [...this.pendingRemoteEvents.values()].map(({ frame }) => ({ event: frame.event, agentId: frame.agentId })) + } + /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index 83ae7f0730..b802aba061 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -5,7 +5,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { PeerScope, RemoteUplinkWindow, TypertResourceReader } from '@deepseek-ai/dsh-typert-protocol' -import type { RemoteEventHostInfo } from './stream-protocol.ts' +import type { RemoteEventAgentId, RemoteEventHostInfo } from './stream-protocol.ts' /** One Remote method request after a carrier has decoded its envelope. */ export interface InvokeRemoteRequest { @@ -144,6 +144,12 @@ export type TypertGatewayErrorCode = | 'gateway/signature-invalid' | 'gateway/uplink-overflow' +/** Public metadata of one unsettled forwarded Remote event. */ +export interface PendingRemoteEventInfo { + readonly event: string + readonly agentId: RemoteEventAgentId +} + /** Host dispatcher consumed by Connection adapters. */ export interface TypertGateway { /** Carrier adapter shared by WebSocket and in-process transports. */ @@ -155,6 +161,9 @@ export interface TypertGateway { */ hasLiveClient(): boolean + /** @returns detached metadata for all unsettled forwarded events in arrival order. */ + getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] + /** * Register the application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. diff --git a/packages/api/gateway/tests/gateway-access.host.spec.ts b/packages/api/gateway/tests/gateway-access.host.spec.ts index 960405939f..346f50a8be 100644 --- a/packages/api/gateway/tests/gateway-access.host.spec.ts +++ b/packages/api/gateway/tests/gateway-access.host.spec.ts @@ -21,6 +21,7 @@ import { type TypertContext, type TypertLookup, type TypertResource, + type TypertResourceResolveInput, } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { z } from 'zod' @@ -48,6 +49,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { /** Test-only resource kinds; the Gateway itself names no kind. */ 'test-shelf': TypertResource 'test-desk': TypertResource + 'test-frame': TypertResource } } @@ -58,11 +60,12 @@ const GUEST: Guest = { kind: 'test-guest', guest: 'gateway' } const GLOBAL = { kind: 'global' } as const const shelf = (id: string) => ({ kind: 'test-shelf', id }) as const const desk = (id: string) => ({ kind: 'test-desk', id }) as const +const frame = (id: string) => ({ kind: 'test-frame', id }) as const const FIXTURE_ACTIONS: readonly RemoteAccessAction[] = [ 'accessFixture/open', 'accessFixture/read', 'accessFixture/byAgent', 'accessFixture/byLookup', 'accessFixture/byLookupAndOption', 'accessFixture/byList', 'accessFixture/scoped', 'accessFixture/scopedOnDesk', - 'accessFixture/scopedByReader', 'accessFixture/annotate', 'absentAccess/read', 'event:fixture/notice', 'event:fixture/shelf', + 'accessFixture/scopedByReader', 'accessFixture/annotate', 'accessFixture/bySession', 'absentAccess/read', 'event:fixture/notice', 'event:fixture/shelf', ] function role(resource: K): RemoteAccessRole { @@ -78,6 +81,8 @@ function on(resource: RemoteAccessResource): RemoteAccessBinding { return { role: role('test-shelf'), resource } case 'test-desk': return { role: role('test-desk'), resource } + case 'test-frame': + return { role: role('test-frame'), resource } default: throw new Error('no fixture role for this resource kind') } @@ -126,6 +131,12 @@ class AccessFixture extends TypertRemoteService { return this.seen() } + @Remote + bySession(sessionId: string): string { + void sessionId + return this.seen() + } + @Remote annotate(request: { readonly title: string }): string { this.requests.push(request) @@ -365,6 +376,22 @@ describe('Gateway access checks', () => { expect(decisions()).toEqual([{ action: 'absentAccess/read', resources: [GLOBAL] }]) }) + it('passes declared parameter names to a registered resolver through dispatch', async () => { + const { ctx, hold } = await setup() + const resolve = vi.fn(({ args, parameters }: TypertResourceResolveInput) => { + const named = parameters.find(parameter => parameter.name === 'sessionId') + return named === undefined ? undefined : args[named.wire] as string + }) + const dispose = ctx.typert.resources.register('test-frame', { resolve }) + const guest = peerFor(ctx, GUEST) + hold(frame('f1')) + await expect(call(ctx, guest, 'bySession', { sessionId: 'f1' })).resolves.toBe('test-guest') + await expect(call(ctx, guest, 'bySession', { sessionId: 'f2' })) + .rejects.toMatchObject({ code: 'remote-access/denied' }) + expect(resolve).toHaveBeenCalledTimes(2) + await dispose() + }) + it('checks access before resolving lookups, so a refused call activates nothing', async () => { const { ctx, hold } = await setup() const resolve = vi.fn((id: string) => ({ id })) @@ -703,6 +730,7 @@ describe('Gateway forwarded events for non-owner Peers', () => { await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'ready', host: { home: '/fixture' } } }) expect(ctx.typertGateway.hasLiveClient()).toBe(true) + expect(ctx.typertGateway.getPendingRemoteEvents()).toEqual([]) const agentCtx = ctx.extend({}) const subject = {} const settled = vi.fn() @@ -718,6 +746,10 @@ describe('Gateway forwarded events for non-owner Peers', () => { push({ event: 'fixture/shelf', args: ['s2'], resources }) push({ event: 'fixture/shelf', args: ['s1'], resources }) await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'waterfall', event: 'fixture/ask' } }) + const pending = ctx.typertGateway.getPendingRemoteEvents() + expect(pending).toEqual([{ event: 'fixture/ask', agentId: 'agent-1' }]) + expect(pending).not.toBe(ctx.typertGateway.getPendingRemoteEvents()) + expect(pending[0]).not.toBe(ctx.typertGateway.getPendingRemoteEvents()[0]) await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/notice' } }) await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/shelf', args: ['s2'] } }) await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/shelf', args: ['s1'] } }) @@ -868,6 +900,55 @@ describe('resource resolution', () => { expect(callResources(typert, undefined, method(), {}, NEVER)).toEqual([GLOBAL]) }) + it('resolves default identities through the registered function and preserves explicit overrides', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + const resolve = vi.fn(({ args, parameters }: TypertResourceResolveInput) => { + const named = parameters.find(parameter => parameter.wire === 'roomId') + if (named !== undefined) return args[named.wire] as string | undefined + const first = parameters[0] + const request = first === undefined ? undefined : args[first.wire] as { address?: { id: string } } | undefined + return request?.address?.id + }) + const dispose = ctx.typert.resources.register('test-shelf', { resolve }) + expect(callResources(ctx.typert, undefined, method('roomId'), { roomId: 'r1' }, NEVER)).toEqual([ROOM]) + expect(callResources(ctx.typert, undefined, method('request'), { request: { address: { id: 'r1' } } }, NEVER)).toEqual([ROOM]) + expect(callResources(ctx.typert, undefined, method('request'), { request: {} }, NEVER)).toEqual([GLOBAL]) + resolve.mockClear() + const args = { other: {}, later: { address: { id: 'r1' } } } + expect(callResources(ctx.typert, undefined, method('other', 'later'), args, NEVER)).toEqual([GLOBAL]) + expect(resolve).toHaveBeenCalledExactlyOnceWith({ args, parameters: method('other', 'later').parameters }) + resolve.mockClear() + expect(callResources(ctx.typert, undefined, { + ...method('renamed'), cancellation: { parameter: 'signal' }, + }, { renamed: { address: { id: 'r1' } } }, NEVER)).toEqual([ROOM]) + expect(resolve).toHaveBeenCalledExactlyOnceWith({ + args: { renamed: { address: { id: 'r1' } } }, parameters: method('renamed').parameters, + }) + expect(callResources(ctx.typert, { 'test-shelf': () => 'override' }, method('request'), { + request: { address: { id: 'r1' } }, + }, NEVER)).toEqual([shelf('override')]) + await dispose() + expect(ctx.typert.resources.entries()).toEqual([]) + ctx.typert.resources.register('test-shelf', { resolve: () => { throw new Error('cannot resolve') } }) + expect(callResources(ctx.typert, undefined, method('request'), { request: {} }, NEVER)).toBeUndefined() + }) + + it('rejects arrays from a default resolver instead of authorizing several identities', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + for (const ids of [[], ['r1'], ['r1', 'r2']]) { + const dispose = ctx.typert.resources.register('test-shelf', { + // Exercise invalid results from an untyped provider at the authorization boundary. + resolve: () => ids as never, + }) + expect(callResources(ctx.typert, undefined, method(), {}, NEVER)).toBeUndefined() + await dispose() + } + }) + it('lets a reader replace the defaults for its kind, called with the positional wire arguments', async () => { const typert = await registry() const readers = { 'test-shelf': (ids: unknown) => ids } diff --git a/packages/api/job-controller/package.json b/packages/api/job-controller/package.json index da53adf0fe..40649ebff3 100644 --- a/packages/api/job-controller/package.json +++ b/packages/api/job-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-job-controller", "description": "Job Remote observation stream and the reference-counted client job-output service", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/remote-access/README.i18n.yaml b/packages/api/remote-access/README.i18n.yaml index 461af0d1f9..a95b536e3c 100644 --- a/packages/api/remote-access/README.i18n.yaml +++ b/packages/api/remote-access/README.i18n.yaml @@ -15,8 +15,8 @@ en: a18ecc232e3c3933 zh: b8e43580403f6d77 /deepseek-ai-dsh-api-remote-access/host-service-remoteaccessservice-ctx-key-remoteaccess: - en: 8a4be795c7aa2ef0 - zh: 02550b975e2e8d61 + en: 3d009c4a86374cbf + zh: ba3c624639ed18b9 /deepseek-ai-dsh-api-remote-access/client-face: en: d4cf8f646fdf2d39 zh: 247f643e991aa806 diff --git a/packages/api/remote-access/README.md b/packages/api/remote-access/README.md index ab35a6b598..f888679c15 100644 --- a/packages/api/remote-access/README.md +++ b/packages/api/remote-access/README.md @@ -37,7 +37,9 @@ Every composition that mounts [API Gateway](../gateway/README.md) mounts this se A role `{ name, resource, actions }` is a plain value naming an action set over one resource kind, `global` or a kind declared in `TypertResourceMap`. A binding `{ role, resource, condition? }` grants the role on one resource of the role's kind. A policy answers `bindings(principal)` and an optional `explain(request)` synchronously from state it holds in memory. A request is allowed only when, on every resolved resource, some binding names that resource, its role lists the action, and it has no condition or its condition allows. Resources are independent: a grant on `global` implies nothing on any other resource, and no resource implies another. A condition receives `{ action, resource, args? }` and runs only for a binding whose resource and role match; `args` is absent for events, snapshots, and list filtering. `bindings()` runs once per `decide()`, once per `allows()` check, and once per snapshot computation; it is never cached across decisions, so time-dependent state applies to the next call. -The Gateway resolves resources from `@Remote` and `@RemoteScope` `resources` readers, lookup and Host Context adapter `resource` declarations, and the default fields in `typert.resources`, and the [API Gateway](../gateway/README.md) owns that order; this package reads no argument field. A call that names no resource touches the `global` resource alone, so only a `global` role can allow it; putting an action in a `global` role lets the principal call it on any resource the call does not name. The Gateway refuses a call whose resources are invalid with `resource-invalid` without asking `decide()`. +The Gateway resolves resources from `@Remote` and `@RemoteScope` `resources` readers, lookup and Host Context adapter `resource` declarations, and the default field or resolver declarations in `typert.resources`, and the [API Gateway](../gateway/README.md) owns that order; this package reads no argument field. A call that names no resource touches the `global` resource alone, so only a `global` role can allow it; putting an action in a `global` role lets the principal call it on any resource the call does not name. The Gateway refuses a call whose resources are invalid with `resource-invalid` without asking `decide()`. + +Policy authors must treat `session/attachment` as access to the entire attachment store: granting that action on any one Session resource lets the caller supply that `sessionId` and read images referenced by other Sessions or by none. Gateway still checks that Session resource, but the handler checks neither Session existence nor image membership. Use separate Host instances with separate `DSH_HOME` directories when readers require separate image stores; see the [image access rule](../../../.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.md). When `bindings()`, a condition, or `explain()` throws, the decision is refused with `policy-failed`; in a snapshot, a throwing `bindings()` leaves the principal no grant and a throwing condition excludes its action. A faulty rule therefore never breaks the shared `$events` source, the snapshot stream, or page rendering. The service logger reports the first failure of each policy after each `invalidate()` and each policy registration or removal; later failures of that policy stay silent until then. diff --git a/packages/api/remote-access/README.zh.md b/packages/api/remote-access/README.zh.md index 514f885b33..ab073ed7c0 100644 --- a/packages/api/remote-access/README.zh.md +++ b/packages/api/remote-access/README.zh.md @@ -37,7 +37,9 @@ kind: "package-reference" role `{ name, resource, actions }` 是普通值,表示限定一种资源种类(`global` 或在 `TypertResourceMap` 中声明的种类)的 action 集合。binding `{ role, resource, condition? }` 在该 role 种类的一个资源上授予该 role。policy 根据预先载入内存的状态同步回答 `bindings(principal)` 与可选的 `explain(request)`。只有在每个已解析资源上都有某个 binding 指向该资源、其 role 列出该 action、且没有 condition 或 condition 放行时,请求才被放行。资源之间相互独立:`global` 上的授权不蕴含任何其他资源上的授权,任何资源也不蕴含另一个资源。condition 收到 `{ action, resource, args? }`,只对资源与 role 都匹配的 binding 调用;事件、快照与列表过滤时没有 `args`。`bindings()` 在每次 `decide()`、每次 `allows()` 判定与每次快照计算中各调用一次,不跨判定缓存,因此依赖时间的状态对下一次调用即时生效。 -Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lookup 与 Host Context adapter 的 `resource` 声明、`typert.resources` 中的默认字段解析资源,解析顺序由 [API Gateway](../gateway/README.zh.md) 负责;本包不读取任何参数字段。不指明任何资源的调用只访问 `global` 资源,因此只有 `global` 级 role 能放行;把某个 action 放进 `global` 级 role,等于允许该 principal 在调用未指明的任何资源上调用它。资源非法的调用由 Gateway 以 `resource-invalid` 拒绝,不询问 `decide()`。 +Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lookup 与 Host Context adapter 的 `resource` 声明、`typert.resources` 中的默认字段或 resolver 声明解析资源,解析顺序由 [API Gateway](../gateway/README.zh.md) 负责;本包不读取任何参数字段。不指明任何资源的调用只访问 `global` 资源,因此只有 `global` 级 role 能放行;把某个 action 放进 `global` 级 role,等于允许该 principal 在调用未指明的任何资源上调用它。资源非法的调用由 Gateway 以 `resource-invalid` 拒绝,不询问 `decide()`。 + +policy 作者必须把 `session/attachment` 视为整个附件存储的读取权限:在任意一条 Session 资源上授予该 action,就允许调用方使用这个 `sessionId` 读取其他 Session 引用或未被任何 Session 引用的图片。Gateway 仍检查该 Session 资源,但处理函数既不检查 Session 是否存在,也不检查图片关联。需要让不同读者使用不同图片存储时,须使用独立 Host 实例及各自的 `DSH_HOME` 目录;见[图片访问规则](../../../.agents/notes/implemented/architecture/2026-10-23-store-scoped-image-reads.zh.md)。 `bindings()`、condition 或 `explain()` 抛错时,本次判定以 `policy-failed` 拒绝;计算快照时,`bindings()` 抛错即该 principal 没有 grant,condition 抛错即该 action 不放行。有问题的规则因此不会破坏共享的 `$events` 事件源、快照流或页面渲染。每次 `invalidate()` 以及每次 policy 注册或撤回之后,服务日志对每个 policy 只报告第一次失败;此后该 policy 的失败不再记录,直到下一次上述变化。 diff --git a/packages/api/remote-access/package.json b/packages/api/remote-access/package.json index bcc911aee9..f4e3e13975 100644 --- a/packages/api/remote-access/package.json +++ b/packages/api/remote-access/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remote-access", "description": "Method-level Remote access control for non-owner Peers and the per-Peer access snapshot", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index c0ae5bdf82..288aabea9b 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 55fd8071ea..a9ca9616e5 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: aabd5be911e477f5 - zh: 2f7fdb44bc8777bc + en: 6bbd371bb1907ad1 + zh: 03a1b13862404c14 /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 5e4cfb4419..708479b2b3 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -33,9 +33,11 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. -Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. A receipt names a staged file or a staged image, and an image receipt subjects the prompt to the same image-input check as an encoded image. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. +Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. A receipt names a staged file or a staged image, and an image receipt subjects the prompt to the same image-input check as an encoded image. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. -Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers the `session` resource kind with the default field `sessionId` through `typert.resources`. `session/page` and `session/follow` declare a `session` reader in `@Remote({ resources })` that reads `request.address.sessionId` for an ordinary Session address and `request.address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. +Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers a default resolver for the `session` resource kind through `typert.resources`. It reads a named `sessionId` parameter, otherwise the first object argument's `sessionId` or `address`; unrelated string parameters name no Session. An object containing both `sessionId` and `address` is invalid, even when they name the same Session. `session/events`, the deprecated `session/page`, `session/observeEvent`, and `session/follow` use this resolver to read `address.sessionId` for an ordinary Session address and `address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections`, `session/observeProjections`, `session/state`, `session/observeState`, and `session/wait` read the Session resource from `request.sessionId`, so each call and subscription names only the Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. + +`events` reads one chronological page from a durable cut and returns its records, `hasMore`, and an inclusive `cursor`; omitting `throughSeq` selects the observed log end, and the compatible `page` method carries the same read with a deprecated entry point. `events`, `observeEvent`, and `follow` take an Address; projection and state operations take a Session ID. Page reads and Follow openings validate the supplied Address. Pure `observeEvent` and `observeProjections` subscriptions neither acquire an Observation nor establish existence: their `ready` frame carries no snapshot cursor. List rows carry `formatStatus` when available, so Clients can identify migration-required Sessions without requesting their projections. @@ -62,7 +64,7 @@ During uninterrupted following, transcript and steering echoes stay in Chat thro The user-invocable `skills/list` metadata includes the winning provider’s optional instruction-file `path`. The composer can preview that file without loading every skill body or activating a cold Agent. -Fork copies the exact inclusive event prefix selected by `atSeq`, including a cut inside an open turn. The child records its inherited marker before synthetic fork results and closing events. Omitting `atSeq` selects the latest completed turn and its standalone tail, stopping before the next turn or queued input; a nonexistent event is rejected. The chat action selects a completed turn. +Fork copies the exact inclusive event prefix selected by `atSeq`, including a cut inside an open turn, into an independent Session without `parentSession`. Client placeholders likewise omit `parentSessionId`; list indentation applies only to subagent-origin rows, including when historical ordinary forks retain a parent id. Workspace attachment and optional title increments use the source selected for the operation. The child records its inherited marker before synthetic fork results and closing events. Omitting `atSeq` selects the latest completed turn and its standalone tail, stopping before the next turn or queued input; a nonexistent event is rejected. The chat action selects a completed turn. A resume blocked by an existing write handle returns `session/writer-held` with the Session id; other resume failures retain `gateway/internal`. @@ -72,7 +74,7 @@ The Client's initial `follow`, reconnect openings, and `loadOlder()` request at Queue edits replace pending content with non-empty text only. -Attachment authorization reads declared content fields of built-in Session events and completed assistant stream blocks, including flat V4 tool-role messages. Unknown event payloads and unrelated fields cannot authorize attachment reads. +`session.attachment` receives `{ sessionId, attachment: ImageAttachmentRef }` and passes the complete reference to `AttachmentStore.readImage()`, without reading or traversing Session history. The Client supplies the message's reference through `session.readAttachment(ref)`. Connection authentication and Gateway permission checks still apply; `sessionId` selects the Session resource for the endpoint's permission check. After authorization, the handler does not check whether that Session exists and does not return `session/not-found` merely because it is absent. An authorized caller may read any image in the same store, including an image referenced only by another Session or by no Session. The response preserves the supplied reference after verifying its ID, media type, byte length, width, and height against the stored bytes. Optional `name` and `originalDimensions` pass through as caller-supplied display information and are not verified against the file. Missing or invalid objects return a terminal attachment failure. The Client's historical-image cache retains one result and browser URL per Session and attachment ID. The controller composes `ArchivedSessionGate` through `ctx.plugin`: it loads once the Agent registry, Session store, and Workspace registry exist and unwinds with the controller. Its `agent/pre-step` listener rejects a step proposed for an archived Session or for a subagent descendant of one — read from the Session header's lineage fields, never a fork — so a late waking delivery ends its turn as `blocked` without a model request; unarchiving lifts the gate for the whole lineage. The work an archived Session still runs is reported and stopped by its owners through the Workspace registry's archive admission ([seam](../../workspace/workspace/README.md)): the running turn by the [Agent registry](../../core/agent/README.md), owned jobs by the [job registry seam](../../jobs/jobs/README.md), subagent descendants by the [Subagent](../../subagent/subagent/README.md) runtime, reminders by the [Schedule](../../schedule/schedule/README.md) plugin; this controller reports nothing itself. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index ad0de238fb..b64f424d4d 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -33,9 +33,11 @@ kind: "package-reference" Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 -每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。凭证指向一个暂存文件或一张暂存图片;图片凭证与编码图片一样要经过图片输入检查。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 +每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。凭证指向一个暂存文件或一张暂存图片;图片凭证与编码图片一样要经过图片输入检查。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 -每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `sessionId` 登记 `session` 资源种类。`session/page` 与 `session/follow` 在 `@Remote({ resources })` 中声明 `session` 读取函数:对普通 Session 地址读 `request.address.sessionId`,对 subagent 地址读 `request.address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 +每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 为 `session` 资源种类登记默认 resolver。它读取具名 `sessionId` 参数,否则读取第一个对象参数的 `sessionId` 或 `address`;无关字符串参数不指明 Session。对象同时包含 `sessionId` 和 `address` 时无效,即使两者指向同一 Session。`session/events`、已弃用的 `session/page`、`session/observeEvent` 与 `session/follow` 使用该 resolver:对普通 Session 地址读 `address.sessionId`,对 subagent 地址读 `address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections`、`session/observeProjections`、`session/state`、`session/observeState` 与 `session/wait` 从 `request.sessionId` 读取会话资源,因此每次调用与订阅都只点名它可读的会话。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 + +`events` 从一个持久切点读取一页时间正序的记录,返回记录、`hasMore` 和包含末项的 `cursor`;省略 `throughSeq` 时选取观察到的日志末尾,兼容的 `page` 方法提供同样的读取,但入口已弃用。`events`、`observeEvent` 和 `follow` 接收 Address,投影和状态操作接收 Session ID。分页读取与 Follow 开场会校验传入的 Address。纯 `observeEvent` 和 `observeProjections` 订阅既不获取 Observation,也不确认实体存在:其 `ready` 帧不携带快照游标。 列表行在状态可用时携带 `formatStatus`,Client 无需请求其投影即可识别需要迁移的 Session。 @@ -62,7 +64,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 面向用户调用的 `skills/list` 元数据包含胜出提供方可选的指令文件 `path`。输入框可据此预览文件,无需加载每个 skill 的正文或激活冷态 Agent。 -Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在开放轮次内截取。子会话在合成的 fork 结果和结束事件之前记录继承标记。省略 `atSeq` 时选择最近已结束轮次及其独立尾部,在下一轮次或排队输入之前停止;不存在的事件会被拒绝。聊天操作选择已结束轮次。 +Fork 将 `atSeq` 选定的含边界事件前缀(包括开放轮次内的截点)复制到不带 `parentSession` 的独立 Session。客户端占位项同样省略 `parentSessionId`;列表缩进仅适用于 subagent 来源的行,历史普通 fork 即使保留父 ID 也不缩进。工作区绑定及可选标题递增使用本次操作选定的源会话。子会话在合成的 fork 结果和结束事件之前记录继承标记。省略 `atSeq` 时选择最近已结束轮次及其独立尾部,在下一轮次或排队输入之前停止;不存在的事件会被拒绝。聊天操作选择已结束轮次。 恢复会话时若已有写句柄占用,返回 `session/writer-held`,并携带会话 id;其他恢复失败仍返回 `gateway/internal`。 @@ -72,7 +74,7 @@ Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以 队列编辑仅允许用非空文本替换待处理内容。 -附件授权读取内置 Session 事件声明的内容字段与已完成的 assistant 流块,包括扁平的 V4 tool 角色消息。未知事件载荷与无关字段不能授权附件读取。 +`session.attachment` 接收 `{ sessionId, attachment: ImageAttachmentRef }`,并把完整引用交给 `AttachmentStore.readImage()`,不读取或遍历 Session 历史。Client 通过 `session.readAttachment(ref)` 传入消息中的引用。连接身份认证和 Gateway 权限检查仍然适用;`sessionId` 为该接口的权限检查指定 Session 资源。授权通过后,处理函数不检查该 Session 是否存在,也不会仅因其不存在而返回 `session/not-found`。获准调用的身份可以读取同一存储中的任意图片,包括仅被其他 Session 引用或未被任何 Session 引用的图片。响应在把 ID、媒体类型、字节数、宽度和高度与存储字节校验后,保留传入的引用。可选的 `name` 和 `originalDimensions` 作为调用方提交的显示信息原样返回,不根据文件校验。对象缺失或无效时返回明确的附件失败。Client 的历史图片缓存按 Session 和附件 ID 保留一份结果与浏览器 URL。 本控制器通过 `ctx.plugin` 组合 `ArchivedSessionGate`:在 Agent 注册表、Session store 与 Workspace 注册表就绪后加载,随控制器一起释放。它的 `agent/pre-step` 监听器会拒绝为已归档会话或其子代理子孙——从 Session header 的血缘字段读出,从不包括 fork——提出的步骤,因此迟到的唤醒投递会让该回合以 `blocked` 收口而不发出模型请求;取消归档即为整条血缘解除门禁。已归档会话仍在跑的工作由各自的 owner 通过 Workspace 注册表的归档准入([接缝](../../workspace/workspace/README.zh.md))报告与停止:运行中的回合由 [Agent 注册表](../../core/agent/README.zh.md)负责,所属任务由[任务注册表接缝](../../jobs/jobs/README.zh.md)负责,子代理子孙由 [Subagent](../../subagent/subagent/README.zh.md) runtime 负责,提醒由 [Schedule](../../schedule/schedule/README.zh.md) 插件负责;本控制器自己不报告任何内容。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index faf27a4fa3..3eadb51df1 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, @@ -73,6 +73,7 @@ "dependencies": { "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-deque": "workspace:*", + "@deepseek-ai/dsh-timeout": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "mime-types": "^3.0.2", "zod": "4.4.3" @@ -131,6 +132,7 @@ "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", "@deepseek-ai/dsh-api-gateway": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-attachment-local": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-file-upload": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 4467fefc0f..d16e041773 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -9,7 +9,6 @@ import { installModelSelection } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-preset-registry' import { brandString } from '@deepseek-ai/dsh-brand' import type { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-typert-registry' @@ -123,40 +122,6 @@ export function apiSessionSubagentOwnershipError(sessionId: SessionId): ApiSessi ) } -/** - * Inspect one cold Session without repairing, resuming, or publishing it. - * @param ctx - Host context carrying Session persistence. - * @param sessionId - durable Session identity. - * @param signal - optional cancellation for persistence reads. - * @returns the persisted header and complete event prefix. - */ -export async function inspectApiSession( - ctx: Context, - sessionId: SessionId, - signal?: AbortSignal, -): Promise { - try { - using observation = await ctx.sessionQuery.observeSession(sessionId, { - ...(signal === undefined ? {} : { signal }), - projectionMode: 'none', - }) - if (observation.header.cwd === undefined) { - throw new ApiSessionNotFound(`session "${sessionId}" not found`) - } - return { - meta: observation.header, - inheritedEventCount: observation.inheritedEventCount, - events: [...observation.events], - } - } catch (error: unknown) { - if (error instanceof SessionQueryError - && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { - throw new ApiSessionNotFound(`session "${sessionId}" not found`) - } - throw error - } -} - /** Owns every operation that may create, resume, or configure a Web Agent. */ export class ApiSessionAgentController { private readonly resumes = new Map>() diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 2c8f16c4ae..41f4209316 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -7,7 +7,7 @@ * must stub); implementation-internal entry points (history staging, wire-frame * dispatch) stay on the class, invisible out here. */ -import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' @@ -91,12 +91,12 @@ export interface ISession { requestId?: SessionRequestId, ): Promise> /** - * Resolve one durable image referenced by this session. - * @param attachmentId - opaque id found in the folded session log. - * @returns the authenticated reference and decoded bytes. + * Resolve one stored image using this Session's gateway permission scope. + * @param attachment - complete image reference from the folded session log. + * @returns the supplied reference after storage verification, and decoded bytes. */ readAttachment( - attachmentId: AttachmentIdType, + attachment: ImageAttachmentRef, ): Promise> /** * Apply one edit, remove, or Steer action to a still-pending queue occurrence. diff --git a/packages/api/session-controller/src/client/sessions/lineage.ts b/packages/api/session-controller/src/client/sessions/lineage.ts index 298ff2183b..b6d8bbdfc5 100644 --- a/packages/api/session-controller/src/client/sessions/lineage.ts +++ b/packages/api/session-controller/src/client/sessions/lineage.ts @@ -60,7 +60,8 @@ export function flattenLineage( const children = new Map() const roots: TitledSessionSummary[] = [] for (const s of summaries) { - if (s.parentSessionId !== undefined && byId.has(s.parentSessionId)) { + // Historical ordinary forks may retain a parent id without being subagents. + if (s.origin === 'subagent' && s.parentSessionId !== undefined && byId.has(s.parentSessionId)) { const list = children.get(s.parentSessionId) ?? [] list.push(s) children.set(s.parentSessionId, list) diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index f800a8c2be..105930d1ef 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -553,7 +553,7 @@ export class SessionManager { * immediately (same synchronous-addressability guarantee as create). * Blankness starts provisionally true so the authoritative Host summary can * preserve it or lower it after an exact cut before the first `turn/start`; - * lineage rides parentSessionId. A child published before Workspace + * the fork has no durable source relationship. A child published before Workspace * attachment fails is also reconciled into the list. * @param opts - source session, optional exact inclusive boundary seq, and permission to start migration. * @returns the fork result (the child session id). @@ -573,7 +573,6 @@ export class SessionManager { if (childId !== undefined) { this.recordMutation({ kind: 'placeholder', summary: { agentAvailable: true, sessionId: childId, updatedAt: Date.now(), running: false, blank: true, - parentSessionId: opts.sessionId, ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}), } }) } diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 09494e01ab..59c554b92b 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { InboxState, InboxTarget } from '@deepseek-ai/dsh-agent/types' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' -import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { UserMessage } from '@deepseek-ai/dsh-llm/types' @@ -315,16 +315,16 @@ export class Session implements SessionFace { } /** - * Resolve one image referenced by this session into browser-consumable bytes. - * @param attachmentId - opaque id found in the folded session log. - * @returns the authenticated reference and decoded bytes. + * Resolve one stored image into browser-consumable bytes using this Session's gateway permission scope. + * @param attachment - complete image reference from the folded session log. + * @returns the supplied reference after storage verification, and decoded bytes. */ async readAttachment( - attachmentId: AttachmentIdType, + attachment: ImageAttachmentRef, ): Promise> { const result = await this.remote.session.attachment({ sessionId: this.sessionId, - attachmentId, + attachment, }) if (!result.ok) return result const binary = atob(result.value.data) diff --git a/packages/api/session-controller/src/client/transport.ts b/packages/api/session-controller/src/client/transport.ts index fc2a11a0bb..554eec515e 100644 --- a/packages/api/session-controller/src/client/transport.ts +++ b/packages/api/session-controller/src/client/transport.ts @@ -178,6 +178,7 @@ export class SessionEventStream extends RemoteJournalStream< let assistantRevision: number | undefined for await (const frame of this.remote.session.follow({ address: this.address, + activate: true, assistantStream: true, ...this.repairRequest(request), }, signal)) { diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index c91f6e5b02..5309872331 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -7,12 +7,12 @@ import { brandString } from '@deepseek-ai/dsh-brand' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { - AttachmentAdmissionPart, ImageAttachmentRef, + AttachmentAdmissionPart, } from '@deepseek-ai/dsh-attachment' import type { FileUploadReceiptId, StagedAttachment } from '@deepseek-ai/dsh-client-file-upload/types' import type {} from '@deepseek-ai/dsh-client-file-upload' import { - ReasoningEffortId, assistantStreamChunks, createUserMessage, freezeMessage, + ReasoningEffortId, createUserMessage, freezeMessage, } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' import { buildForkSeed } from '@deepseek-ai/dsh-session/fork' @@ -26,12 +26,10 @@ import type { Workspace } from '@deepseek-ai/dsh-workspace' import { ApiSessionAgentController, ApiSessionCwdConflict, - ApiSessionNotFound, ApiSessionPresetConflict, ApiSessionSubagentOwnership, apiSessionSubagentOwnershipError, hasApiSessionSubagentOwner, - inspectApiSession, type ApiSessionCreated, } from './agent.ts' import type { @@ -54,12 +52,6 @@ import type { SessionRequestId, } from './types.ts' -interface SessionReadState { - readonly id: SessionId - readonly header: SessionHeader - readonly events: readonly SessionEvent[] -} - type PromptContentCandidate = | SessionPromptRequest['content'][number] | Extract['content'][number] @@ -273,7 +265,6 @@ export class SessionCommandController { inheritedEventCount: SessionLogOffset(boundary + 1), meta: { ...(source.header.cwd === undefined ? {} : { cwd: source.header.cwd }), - parentSession: source.header.id, isSeeded: true, ...(composition.agentPreset === undefined ? {} @@ -367,34 +358,13 @@ export class SessionCommandController { } /** - * Read one durable image after proving the Session log references it. - * @param request - Session and attachment identities used for authorization. + * Read one durable image using the supplied reference without consulting Session history. + * @param request - image reference and gateway permission scope; Session existence is not checked. * @returns the durable attachment reference and base64-encoded bytes. */ async attachment(request: SessionAttachmentRequest): Promise { - let source: SessionReadState try { - source = await this.readSessionState(request.sessionId) - } catch (error) { - if (error instanceof ApiSessionNotFound) { - throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId }) - } - throw new RemoteError( - 'gateway/internal', - `attachment authorization unavailable for session "${request.sessionId}": ${String(error)}`, - {}, - ) - } - const ref = referencedImage(source.events, String(request.attachmentId)) - if (ref === undefined) { - throw new RemoteError( - 'session/attachment-invalid', - 'Image is not referenced by this session.', - { reason: 'ATTACHMENT_NOT_REFERENCED' }, - ) - } - try { - const stored = await this.ctx.attachments.readImage(ref) + const stored = await this.ctx.attachments.readImage(request.attachment) return { attachment: stored.ref, data: Buffer.from(stored.data).toString('base64'), @@ -534,16 +504,6 @@ export class SessionCommandController { throw new RemoteError('gateway/internal', `failed to create session: ${String(error)}`, {}) } - 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) - return { id: inspected.meta.id, header: inspected.meta, events: inspected.events } - } - private async forkWorkspace(source: SessionHeader): Promise { const workspaces = this.ctx.workspaceRegistry.list() const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id)) @@ -592,80 +552,3 @@ function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean { return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId }) } -function imageBlockIn( - content: unknown, - match: (ref: ImageAttachmentRef) => boolean, -): ImageAttachmentRef | undefined { - if (!Array.isArray(content)) return undefined - for (const value of content) { - if (typeof value !== 'object' || value === null || Array.isArray(value)) continue - const block = value as { readonly type?: unknown; readonly attachment?: unknown } - if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { - const ref = block.attachment as ImageAttachmentRef - if (match(ref)) return ref - } - } - return undefined -} - -/** Read only first-party declared content fields; unknown event payloads stay opaque. */ -function imageInEvent( - event: SessionEvent, - match: (ref: ImageAttachmentRef) => boolean, -): ImageAttachmentRef | undefined { - const data = event.data as { - readonly content?: unknown - readonly message?: { readonly content?: unknown } - readonly inserted?: unknown - readonly summary?: unknown - readonly rawOutput?: unknown - } - // First-party event payloads can be present without their producer plugin mounted. - const type: string = event.type - switch (type) { - case 'user/message': - case 'tool/ptc-dispatch': - return imageBlockIn(data.content, match) - case 'system/message': - case 'developer/message': - case 'tool/result': - case 'team/message/queued': - return imageBlockIn(data.message?.content, match) - case 'agent/inbox/spliced': { - const messages = data.inserted - if (!Array.isArray(messages)) return undefined - for (const message of messages as readonly unknown[]) { - if (typeof message !== 'object' || message === null || Array.isArray(message)) continue - const found = imageBlockIn((message as { readonly content?: unknown }).content, match) - if (found !== undefined) return found - } - return undefined - } - case 'compaction/summary': - return imageBlockIn(data.summary, match) ?? imageBlockIn(data.rawOutput, match) - case 'assistant/message': { - const found = imageBlockIn(data.message?.content, match) - if (found !== undefined) return found - break - } - case 'assistant/attempt': break - default: return undefined - } - const assistant = event as SessionEvent<'assistant/message' | 'assistant/attempt'> - for (const chunk of assistantStreamChunks(assistant.data.stream, 'block-end')) { - const found = imageBlockIn([chunk.block], match) - if (found !== undefined) return found - } - return undefined -} - -function referencedImage( - events: readonly SessionEvent[], - attachmentId: string, -): ImageAttachmentRef | undefined { - for (const event of events) { - const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId) - if (found !== undefined) return found - } - return undefined -} diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index 07fbbfe6be..91cc3508da 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -1,12 +1,8 @@ -/** Cold Session history pagination and live-event source. */ +/** Shared Session history pagination, address validation and wire conversion. */ -import type { Context } from '@deepseek-ai/cordis' -import { Deque } from '@deepseek-ai/dsh-deque' -import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent' import { isAppendSurfaceEvent, SessionLogOffset, - SessionSeq, } from '@deepseek-ai/dsh-session' import type { SessionEvent, @@ -15,309 +11,26 @@ import type { SessionLogOffset as SessionLogOffsetType, SessionSeqCursor, } from '@deepseek-ai/dsh-session' -import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import type {} from '@deepseek-ai/dsh-subagent' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { SessionAddress, - SessionAssistantStreamFrame, SessionEventEntry, - SessionFollowRequest, - SessionFollowFrame, SessionHistoryRecord, - SessionPage, SessionPageRequest, - SessionProjectionBaseline, - SessionProjectionValues, - SessionWireHeader, SessionWireEvent, } from './types.ts' -import { SessionAssistantStreamAccumulator } from './assistant-stream.ts' -const DEFAULT_MAX_MESSAGES = 50 +/** Message budget applied when a history request omits `maxMessages`. */ +export const DEFAULT_MAX_MESSAGES = 50 const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) -/** Implements cold-safe history operations delegated by the Session Controller. */ -export class SessionHistoryController { - private readonly closeFollowers = new Set<() => void>() - private readonly assistantStreams = new Map() - - /** - * @param ctx - Host context carrying Session query and projection services. - * @param promote - starts ordinary Session activation after snapshot delivery. - */ - constructor( - private readonly ctx: Context, - private readonly promote: (observation: SessionObservation) => void, - ) { - ctx.on('agent/assistant-stream', ({ agent, frame }) => { - let stream = this.assistantStreams.get(agent.session.id) - if (stream === undefined) { - stream = new SessionAssistantStreamAccumulator() - this.assistantStreams.set(agent.session.id, stream) - } - stream.accept(frame, cursorBeforeNext(agent.session.seq)) - }, { global: true }) - ctx.on('agent/disposed', ({ agent }) => { - this.assistantStreams.delete(agent.session.id) - }, { global: true }) - ctx.effect(() => () => { - for (const close of this.closeFollowers) close() - this.closeFollowers.clear() - }, 'session-controller.history') - } - - /** - * Read one message-aligned history page without activating an Agent. - * @param request - durable address and backwards-page cursor. - * @param signal - caller cancellation for persistence reads. - * @returns a contiguous event page. - */ - async page(request: SessionPageRequest, signal: AbortSignal): Promise { - validatePageRequest(request) - const throughSeq: SessionSeqCursor = request.throughSeq === -1 - ? -1 - : SessionSeq(request.throughSeq) - const beforeSeq = request.beforeSeq === undefined - ? undefined - : SessionLogOffset(request.beforeSeq) - using source = await this.sourceFor(request.address, signal, false) - signal.throwIfAborted() - const sourceLog = source.events - const sourceCursor: SessionSeqCursor = sourceLog.at(-1)?.seq ?? -1 - if (throughSeq > sourceCursor) { - throw new RemoteError( - 'gateway/bad-request', - `session page through seq ${String(throughSeq)} is past cursor ${String(sourceCursor)}`, - {}, - ) - } - /* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */ - if (throughSeq >= 0 && sourceLog[throughSeq]?.seq !== throughSeq) { - throw new RemoteError('gateway/internal', `session log does not contain through seq ${String(throughSeq)}`, {}) - } - const page = paginate( - sourceLog, - beforeSeq, - request.maxMessages ?? DEFAULT_MAX_MESSAGES, - throughSeq, - request.turnWindow, - ) - const records = pageRecords(page.events) - return { - records, - hasMore: page.hasMore, - } - } - - /** - * Follow events appended after an initial cursor on one durable address. - * @param request - durable address and last committed sequence already held by the caller. - * @param signal - stream cancellation owned by the Remote carrier. - * @returns a complete opening snapshot followed by gap-free durable events and opted-in assistant frames. - */ - async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { - validateHistoryWindow(request) - const { address } = request - const target = addressId(address) - const buffered = new Deque< - | { readonly type: 'event'; readonly event: SessionEvent } - | { - readonly type: 'assistant-stream' - readonly frame: SessionAssistantStreamFrame - readonly ordinal: number - } - >() - let snapshotCursor: SessionSeqCursor | undefined - let assistantStreamOrdinal = 0 - let wake: (() => void) | undefined - const notify = (): void => { - const resume = wake - wake = undefined - resume?.() - } - const follower = { closed: false } - const close = (): void => { - follower.closed = true - notify() - } - this.closeFollowers.add(close) - const disposeEvent = this.ctx.on('session/event', (session, event) => { - if (session.id !== target) return - buffered.pushBack({ type: 'event', event }) - notify() - }, { global: true }) - const disposeCreated = this.ctx.on('session/created', (session) => { - if (session.id !== target) return - // 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)) - for (let index = suffix.length - 1; index >= 0; index -= 1) { - buffered.pushFront({ type: 'event', event: suffix[index] as SessionEvent }) - } - notify() - }, { global: true }) - const disposeAssistantStream = request.assistantStream !== true - ? undefined - : this.ctx.on('agent/assistant-stream', ({ agent, frame }) => { - if (agent.session.id !== target) return - buffered.pushBack({ - type: 'assistant-stream', - frame: wireAssistantStreamFrame(frame, cursorBeforeNext(agent.session.seq)), - ordinal: ++assistantStreamOrdinal, - }) - notify() - }, { global: true }) - const onAbort = (): void => { notify() } - signal.addEventListener('abort', onAbort, { once: true }) - try { - using source = await this.sourceFor(address, signal, true) - const events = source.events - signal.throwIfAborted() - const cursor = source.cursor - snapshotCursor = cursor - const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES, cursor, request.turnWindow) - const assistantStream = request.assistantStream === true - ? this.assistantStreams.get(target)?.snapshot() ?? { revision: 0 } - : undefined - // The accumulator snapshot and this watermark are synchronous. Frames - // through the cut are represented or superseded by that baseline, - // including larger revisions from a retired Agent; later revision - // resets reach Client continuity validation. - const assistantStreamOrdinalCut = assistantStreamOrdinal - yield { - type: 'snapshot', - header: wireHeader(source.header), - cursor, - records: pageRecords(page.events), - hasMore: page.hasMore, - projections: source.projections === undefined - ? { asOfSeq: cursor, values: {} } - : projectionBlock(source.projections), - ...assistantStream === undefined ? {} : { assistantStream }, - } - if (address.kind === 'session' && source.source === 'prepared') { - const promotion = source.retain() - try { - this.promote(promotion) - } catch (error: unknown) { - promotion[Symbol.dispose]() - throw error - } - } - let nextOffset = SessionLogOffset(cursor + 1) - while (!follower.closed && !signal.aborted) { - const item = buffered.popFront() - if (item === undefined) { - await new Promise((resolve) => { wake = resolve }) - continue - } - if (item.type === 'assistant-stream') { - if (item.ordinal > assistantStreamOrdinalCut) { - yield { type: 'assistant-stream', frame: item.frame } - } - continue - } - const expectedSeq = SessionSeq(nextOffset) - if (item.event.seq < expectedSeq) continue - if (item.event.seq !== expectedSeq) { - throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(expectedSeq)}`, {}) - } - nextOffset = SessionLogOffset(nextOffset + 1) - yield entryFor(item.event) - } - } finally { - this.closeFollowers.delete(close) - signal.removeEventListener('abort', onAbort) - disposeCreated() - disposeEvent() - disposeAssistantStream?.() - } - } - - private async sourceFor( - address: SessionAddress, - signal: AbortSignal, - withProjections: boolean, - ): Promise { - const sessionId = addressId(address) - try { - const observation = await this.ctx.sessionQuery.observeSession(sessionId, { - signal, - projectionMode: withProjections || address.kind === 'subagent' ? 'all' : 'none', - }) - if (observation.header.cwd === undefined) { - observation[Symbol.dispose]() - rejectNotFound(address) - } - try { - validateAddress( - address, - observation.header, - observation.inheritedEventCount, - observation.projections, - ) - } catch (error: unknown) { - observation[Symbol.dispose]() - throw error - } - return observation - } catch (error: unknown) { - if (error instanceof SessionQueryError - && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') rejectNotFound(address) - throw error - } - } - -} - -function cursorBeforeNext(nextSeq: SessionLogOffsetType): SessionSeqCursor { - return nextSeq === 0 ? -1 : SessionSeq(nextSeq - 1) -} - -function wireAssistantStreamFrame( - frame: AssistantStreamFrame, - durableCursor: SessionSeqCursor, -): SessionAssistantStreamFrame { - if (frame.type === 'start') return { ...frame, startedAfterSeq: durableCursor } - if (frame.type === 'end') return frame - return { - ...frame, - chunk: frame.chunk as JsonValue, - } -} - -function projectionBlock( - snapshot: NonNullable, -): SessionProjectionBaseline { - return { - asOfSeq: snapshot.asOfSeq, - // Projection definitions validate whole JSON values before snapshot publication. - values: snapshot.values as SessionProjectionValues, - } -} - -function validatePageRequest(request: SessionPageRequest): void { - if (!Number.isSafeInteger(request.throughSeq) - || request.throughSeq < -1 - || Object.is(request.throughSeq, -0)) { - throw new RemoteError('gateway/bad-request', 'throughSeq must be an integer greater than or equal to -1', {}) - } - if (request.beforeSeq !== undefined - && (!Number.isSafeInteger(request.beforeSeq) - || request.beforeSeq < 0 - || Object.is(request.beforeSeq, -0))) { - throw new RemoteError('gateway/bad-request', 'beforeSeq must be a non-negative safe integer', {}) - } - validateHistoryWindow(request) -} - -function validateHistoryWindow(request: Pick): void { +/** + * Reject an invalid message budget or turn window. + * @param request - history window fields. + */ +export function validateHistoryWindow(request: Pick): void { if (request.maxMessages !== undefined && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) { throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {}) @@ -334,11 +47,23 @@ function validateHistoryWindow(request: Pick 0 } } -/** Translate current logical Session metadata to the browser wire. */ -function wireHeader(header: SessionHeader): SessionWireHeader { - return { ...header } -} - -function entryFor(event: SessionEvent): SessionEventEntry { +/** + * Convert a committed event to its wire entry. + * @param event - committed Session event. + * @returns the wire event entry. + */ +export function entryFor(event: SessionEvent): SessionEventEntry { return { type: 'event', // Session.append validates and freezes event data as JSON before publication. @@ -439,7 +179,11 @@ function entryFor(event: SessionEvent): SessionEventEntry { } } -/** Encode one bounded logical page without changing its pagination cut. */ -function pageRecords(events: readonly SessionEvent[]): SessionHistoryRecord[] { +/** + * Encode one bounded logical page without changing its pagination cut. + * @param events - page events. + * @returns the wire records. + */ +export function pageRecords(events: readonly SessionEvent[]): SessionHistoryRecord[] { return events.map(entryFor) } diff --git a/packages/api/session-controller/src/history/assistant-stream.ts b/packages/api/session-controller/src/history/assistant-stream.ts new file mode 100644 index 0000000000..b42a0aba29 --- /dev/null +++ b/packages/api/session-controller/src/history/assistant-stream.ts @@ -0,0 +1,102 @@ +/** Assistant stream baselines and per-connection subscriptions. */ + +import type { Context } from '@deepseek-ai/cordis' +import { SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import { SessionAssistantStreamAccumulator } from '../assistant-stream.ts' +import type { SessionAssistantStreamBaseline, SessionAssistantStreamFrame } from '../types.ts' +import { ListenerBuffer } from './listener.ts' + +/** One accumulator per live Session, shared by every subscription bound to it. */ +export class SessionAssistantStreamsListener { + private readonly streams = new Map() + + /** @param ctx - service context owning the accumulator listeners. */ + constructor(private readonly ctx: Context) { + ctx.on('agent/assistant-stream', ({ agent, frame }) => { + let stream = this.streams.get(agent.session.id) + if (stream === undefined) { + stream = new SessionAssistantStreamAccumulator() + this.streams.set(agent.session.id, stream) + } + stream.accept(frame, agent.session.seq === 0 ? -1 : SessionSeq(agent.session.seq - 1)) + }, { global: true }) + ctx.on('agent/disposed', ({ agent }) => { this.streams.delete(agent.session.id) }, { global: true }) + ctx.effect(() => () => { this.streams.clear() }, 'session-controller.assistant-streams') + } + + /** + * Bind one connection to a Session assistant stream. + * @param sessionId - Session to subscribe. + * @param signal - cancellation owned by the caller. + * @param queue - caller-owned raw notification buffer. + * @returns a listener the caller disposes. + */ + bind(sessionId: SessionId, signal: AbortSignal, + queue: Pick, 'createInput' | 'capture'>): AssistantStreamListener { + return new AssistantStreamListener(this.ctx, sessionId, signal, () => { + return this.streams.get(sessionId)?.snapshot() ?? { revision: 0 } + }, queue) + } +} + +type AssistantEntry = { type: 'assistant-stream'; frame: SessionAssistantStreamFrame } + +/** An assistant notification and its connection-local arrival position. */ +interface AssistantItem { + readonly type: 'assistant-stream' + readonly session: Session + readonly frame: SessionAssistantStreamFrame + readonly ordinal: number +} + +/** Writes assistant notifications into the connection's shared queue. */ +class AssistantStreamListener implements Disposable { + private readonly disposes: (() => void)[] = [] + private ordinal = 0 + private cut = 0 + + constructor( + ctx: Context, + sessionId: SessionId, + signal: AbortSignal, + private _snapshot: () => SessionAssistantStreamBaseline, + output: Pick, 'createInput' | 'capture'>) { + signal.throwIfAborted() + const input = output.createInput(item => this.transform(item)) + try { + this.disposes.push(ctx.on('agent/assistant-stream', ({ agent, frame }) => { + if (agent.session.id !== sessionId) return + output.capture(() => { + const wire: SessionAssistantStreamFrame = frame.type === 'start' + ? { ...frame, startedAfterSeq: agent.session.seq === 0 ? -1 : SessionSeq(agent.session.seq - 1) } + : frame.type === 'chunk' ? { ...frame, chunk: frame.chunk as JsonValue } : frame + input.append({ type: 'assistant-stream', session: agent.session, frame: wire, ordinal: ++this.ordinal }) + }) + }, { global: true })) + } catch (error: unknown) { + this[Symbol.dispose]() + throw error + } + } + + /** @returns the baseline and local arrival cut captured synchronously together. */ + snapshot(): { baseline: SessionAssistantStreamBaseline; ordinal: number } { + return { baseline: this._snapshot(), ordinal: this.ordinal } + } + + /** @param ordinal - local snapshot cut set before queue consumption. */ + setOrdinalCut(ordinal: number): void { + this.cut = ordinal + } + + private *transform(item: AssistantItem): Iterable { + if (this.disposes.length === 0 || item.ordinal <= this.cut) return + yield { type: 'assistant-stream', frame: item.frame } + } + + [Symbol.dispose](): void { + for (const dispose of this.disposes.splice(0).reverse()) dispose() + } +} diff --git a/packages/api/session-controller/src/history/controller.ts b/packages/api/session-controller/src/history/controller.ts new file mode 100644 index 0000000000..d090f0a3f7 --- /dev/null +++ b/packages/api/session-controller/src/history/controller.ts @@ -0,0 +1,256 @@ +/** Independent Session snapshots and passive event subscriptions. */ + +import type { Context } from '@deepseek-ai/cordis' +import { SessionLogOffset, SessionSeq, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session' +import type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' +import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { + DEFAULT_MAX_MESSAGES, paginate, pageRecords, entryFor, addressId, + validateHistoryWindow, validateAddress, rejectNotFound, +} from '../history.ts' +import { EventListener, ProjectionListener } from './listener.ts' +import { SessionAssistantStreamsListener } from './assistant-stream.ts' +import type { + SessionEventFrame, SessionEventsPage, SessionPageRequest, + SessionObserveEventRequest, SessionProjectionsRequest, SessionProjectionFrame, + SessionAddress, SessionProjectionBaseline, SessionProjectionValues, SessionProjectionsValue, SessionWaitOutput, SessionHistoryRecord, +} from '../types.ts' + + +/** Session snapshots do not share lifetime with passive subscriptions. */ +export class HistoryController { + + /** Reconnect state for this controller's assistant subscriptions. */ + readonly streamsListener: SessionAssistantStreamsListener + + /** @param ctx - Host context carrying Session reads and projections. */ + constructor(private readonly ctx: Context) { + this.streamsListener = new SessionAssistantStreamsListener(this.ctx) + } + + /** + * Read a passive page of Session events at an exact cut. + * @param request - Session identity and optional cut and page window. + * @param signal - read cancellation. + * @returns a page at the supplied cut, or the observed log end when omitted. + */ + async events(request: SessionPageRequest, signal: AbortSignal): Promise { + const spec = resolveHistoryRequest(request) + try { + return await this.usingObservation({ sessionId: addressId(request.address), projection: request.address.kind === 'subagent' }, signal, (observation) => { + validateObservationAddress(request.address, observation) + return history(observation, spec) + }) + } catch (error: unknown) { + if (remoteErrorOf(error)?.code === 'session/not-found') rejectNotFound(request.address) + throw error + } + } + + /** + * Subscribe to Session events without reading or activating the Session. + * @param request - Session address to subscribe. + * @param signal - stream cancellation. + * @returns subscription readiness followed by event frames; readiness does not establish existence. + */ + async *observeEvent(request: SessionObserveEventRequest, signal: AbortSignal): AsyncIterable { + using listener = new EventListener(this.ctx, addressId(request.address), signal) + yield { type: 'ready' } + yield* listener.events() + } + + /** + * Read exact projections or migration cache hints without activating the Session. + * @param request - Session identity whose exact projection baseline is required. + * @param signal - read cancellation. + * @returns exact sequenced values, migration cache hints, or null when absent. + */ + async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise { + const { sessionId } = request + if (sessionId.length === 0) { + throw new RemoteError('gateway/bad-request', 'sessionId must not be empty', {}) + } + try { + signal.throwIfAborted() + const live = this.ctx.sessions.get(sessionId) + if (live !== undefined) { + const snapshot = this.ctx.sessionProjections.snapshot(live) + return { kind: 'sequenced', asOfSeq: snapshot.asOfSeq, values: snapshot.values as SessionProjectionValues } + } else { + const stored = await this.ctx.get('sessionPersistence')?.stat(sessionId, { signal }) + signal.throwIfAborted() + if (stored?.formatStatus === 'migration-required') { + const cached = this.ctx.get('sessionProjectionCache')?.cachedSnapshot(stored.header) + return { kind: 'migration-required', values: { ...cached?.values } as SessionProjectionValues } + } + return await this.usingObservation({ sessionId: request.sessionId, projection: true }, signal, observation => ({ + kind: 'sequenced' as const, + ...projections(observation), + })) + } + } catch (error: unknown) { + const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined + if (code === 'SESSION_QUERY_SESSION_NOT_FOUND' || code === 'session/not-found') { + return null + } + if (signal.aborted || code === 'SESSION_QUERY_ABORTED') { + throw new RemoteError('gateway/cancelled', 'Session projection read was cancelled', {}, { cause: error }) + } + if (remoteErrorOf(error) !== undefined) throw error + throw new RemoteError('gateway/internal', 'Session projection read failed', {}, { cause: error }) + } + } + + /** + * Subscribe to sparse projection replacements without reading a baseline. + * @param request - Session identity to subscribe without reading a baseline. + * @param signal - stream cancellation. + * @returns readiness followed by sparse per-key replacement values. + */ + async *observeProjections(request: SessionProjectionsRequest, signal: AbortSignal): AsyncIterable { + using listener = new ProjectionListener(this.ctx, request.sessionId, signal) + yield { type: 'ready' } + yield* listener.changes() + } + + /** + * Read bounded committed assistant messages through a stopping cursor. + * @param sessionId - Session whose committed assistant messages are requested. + * @param afterSeq - exclusive lower bound. + * @param throughSeq - inclusive stopping-state cursor. + * @param maxMessages - positive assistant record limit. + * @param signal - caller cancellation, independent of a completed wait deadline. + * @returns newest-first assistant messages and whether further matching records were omitted. + */ + async assistantOutput( + sessionId: SessionId, afterSeq: number, throughSeq: number, maxMessages: number, signal: AbortSignal, + ): Promise { + return this.usingObservation({ sessionId, projection: false }, signal, (observation) => { + if (throughSeq > observation.cursor) { + throw new RemoteError('gateway/internal', 'Session history no longer covers the wait cursor', {}) + } + const records: SessionHistoryRecord[] = [] + const events = observation.events + for (let index = throughSeq; index > afterSeq; index--) { + const event = events[index] as SessionEvent + if (event.type !== 'assistant/message' || !isAppendSurfaceEvent(event)) continue + if (records.length === maxMessages) return { records, truncated: true } + records.push(entryFor(event)) + } + return { records, truncated: false } + }) + } + + /** + * Acquire a read without activating a Session; projections are opt-in. + * @param request - Session identity and whether to compute projections. + * @param signal - read cancellation. + * @param callback - omitted to transfer lease ownership to the caller. + * @returns a lease the caller must dispose. + */ + usingObservation( + request: { readonly sessionId: SessionId; readonly projection: boolean }, signal: AbortSignal, callback?: undefined, + ): Promise + /** + * Keep the borrowed lease alive until the callback settles, then dispose it. + * @param request - Session identity and whether to compute projections. + * @param signal - read cancellation. + * @param callback - synchronous or asynchronous computation; retain a separate lease if it must escape. + * @returns the callback result; read and callback failures propagate. + */ + usingObservation( + request: { readonly sessionId: SessionId; readonly projection: boolean }, signal: AbortSignal, + callback: (observation: SessionObservation) => T, + ): Promise> + async usingObservation( + { sessionId, projection }: { readonly sessionId: SessionId; readonly projection: boolean }, signal: AbortSignal, + callback?: (observation: SessionObservation) => T, + ): Promise> { + let observation: SessionObservation + try { + observation = await this.ctx.sessionQuery.observeSession(sessionId, { signal, projectionMode: projection ? 'all' : 'none' }) + } catch (error: unknown) { + const code = (error as { readonly code?: unknown } | null | undefined)?.code + if (code === 'SESSION_QUERY_SESSION_NOT_FOUND') { + throw new RemoteError('session/not-found', `session "${sessionId}" not found`, { sessionId }) + } + if (code === 'SESSION_QUERY_ABORTED') { + // Keep the query's own message: cancellation detail was visible before this mapping. + throw new RemoteError('gateway/cancelled', + error instanceof Error ? error.message : 'Session read was cancelled', {}, { cause: error }) + } + throw error + } + try { + signal.throwIfAborted() + } catch (error: unknown) { + observation[Symbol.dispose]() + throw error + } + if (callback === undefined) return observation + using ownedObservation = observation + return await callback(ownedObservation) + } +} + +/** + * Validate an Address against the exact read used by a Controller. + * @param address - caller-supplied Session address. + * @param observation - borrowed read; subagent addresses require projections. + */ +export function validateObservationAddress(address: SessionAddress, observation: SessionObservation): void { + if (observation.header.cwd === undefined) rejectNotFound(address) + validateAddress(address, observation.header, observation.inheritedEventCount, observation.projections) +} + +/** + * Convert a borrowed observation to one page without acquiring or retaining it. + * @param observation - exact read cut owned by the caller. + * @param request - validated page window and optional inclusive cut. + * @returns a chronological page at the requested cut. + */ +export function history(observation: SessionObservation, request: Omit & { readonly maxMessages: number }): SessionEventsPage { + const cursor = request.throughSeq === undefined ? observation.cursor + : request.throughSeq === -1 ? -1 : SessionSeq(request.throughSeq) + if (cursor > observation.cursor) { + throw new RemoteError('gateway/bad-request', `session history cut ${String(cursor)} exceeds ${String(observation.cursor)}`, {}) + } + const page = paginate( + observation.events, + request.beforeSeq === undefined ? undefined : SessionLogOffset(request.beforeSeq), + request.maxMessages, + cursor, + request.turnWindow, + ) + return { cursor, records: pageRecords(page.events), hasMore: page.hasMore } +} + +/** + * Convert the projection baseline without acquiring or retaining its observation. + * @param observation - projected read cut owned by the caller. + * @returns the exact baseline; missing projection support fails explicitly. + */ +export function projections(observation: SessionObservation): SessionProjectionBaseline { + const snapshot = observation.projections + if (snapshot === undefined) throw new RemoteError('session/projections-unavailable', 'Session projections are unavailable', {}) + return { asOfSeq: snapshot.asOfSeq, values: snapshot.values as SessionProjectionValues } +} + +/** + * Validate and resolve the history page window. + * @param request - external history window. + * @returns the validated request with an explicit message budget. + */ +export function resolveHistoryRequest(request: SessionPageRequest): SessionPageRequest & { readonly maxMessages: number } { + if (request.throughSeq !== undefined + && (!Number.isSafeInteger(request.throughSeq) || request.throughSeq < -1 || Object.is(request.throughSeq, -0))) { + throw new RemoteError('gateway/bad-request', 'throughSeq must be an integer greater than or equal to -1', {}) + } + if (request.beforeSeq !== undefined + && (!Number.isSafeInteger(request.beforeSeq) || request.beforeSeq < 0 || Object.is(request.beforeSeq, -0))) { + throw new RemoteError('gateway/bad-request', 'beforeSeq must be a non-negative safe integer', {}) + } + validateHistoryWindow(request) + return { ...request, maxMessages: request.maxMessages ?? DEFAULT_MAX_MESSAGES } +} diff --git a/packages/api/session-controller/src/history/listener.ts b/packages/api/session-controller/src/history/listener.ts new file mode 100644 index 0000000000..a272081620 --- /dev/null +++ b/packages/api/session-controller/src/history/listener.ts @@ -0,0 +1,303 @@ +/** Passive per-Session streams; subscriptions never acquire Session observations. */ + +import type { Context } from '@deepseek-ai/cordis' +import { Deque } from '@deepseek-ai/dsh-deque' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import { entryFor } from '../history.ts' +import type { SessionProjectionChange, SessionEventEntry } from '../types.ts' + +/** Raw history notifications buffered before an opening cut is known. */ +export type EventItem = + | { readonly type: 'event'; readonly session: Session; readonly event: SessionEvent } + | { readonly type: 'created'; readonly session: Session; readonly end: number } + +/** A single subscription buffers before either consumption mode starts. */ +export class EventListener implements Disposable { + private readonly buffer: ListenerBuffer | undefined + private readonly disposes: (() => void)[] = [] + private firstReceivedSeq: number | undefined + private nextExpectedSeq: number | undefined + + /** + * @param ctx - context owning the event registrations. + * @param sessionId - Session whose notifications are emitted. + * @param signal - cancellation that wakes consumers. + * @param queue - optional caller-owned buffer; only an internally created buffer is disposed here. + */ + constructor(ctx: Context, sessionId: SessionId, signal: AbortSignal, + queue?: Pick, 'createInput' | 'capture'>) { + signal.throwIfAborted() + const buffer = queue ?? new ListenerBuffer(ctx, signal) + this.buffer = queue === undefined ? buffer as ListenerBuffer : undefined + const input = buffer.createInput(item => this.transform(item)) + const target = sessionId + try { + this.disposes.push(ctx.on('session/event', (session, event) => { + if (session.id !== target) return + buffer.capture(() => { + this.recordFirst(event.seq) + input.append({ type: 'event', session, event }) + }) + }, { global: true })) + this.disposes.push(ctx.on('session/created', (session) => { + if (session.id !== target) return + buffer.capture(() => { + if (session.firstLiveSeq < session.seq) this.recordFirst(session.firstLiveSeq) + input.prepend({ type: 'created', session, end: session.seq }) + }) + }, { global: true })) + this.buffer?.own(() => { this[Symbol.dispose]() }) + } catch (error: unknown) { + this[Symbol.dispose]() + throw error + } + } + + /** Consume notifications without reading history. + * @returns event frames from the first received event, without loading a baseline. */ + events(): AsyncIterable { + return this.iterate() + } + + /** Set the inclusive opening cut for buffered history notifications. + * @param cursor - snapshot cut set before consuming the queue. */ + setCursor(cursor: number): void { + this.nextExpectedSeq = cursor + 1 + } + + private *transform(item: EventItem): Iterable { + if (this.disposes.length === 0) return + this.nextExpectedSeq ??= this.firstReceivedSeq + const result = eventItem(item, this.nextExpectedSeq) + this.nextExpectedSeq = result.next + for (const event of result.events) { + if (this.disposes.length === 0) return + yield entryFor(event) + } + } + + private async *iterate(): AsyncIterable { + if (this.buffer === undefined) throw new Error('Consume the externally owned history buffer directly') + try { + yield* this.buffer.items() + } finally { + this[Symbol.dispose]() + } + } + + private recordFirst(seq: number): void { + this.firstReceivedSeq = this.firstReceivedSeq === undefined ? seq : Math.min(this.firstReceivedSeq, seq) + } + + [Symbol.dispose](): void { + for (const dispose of this.disposes.splice(0).reverse()) dispose() + this.buffer?.[Symbol.dispose]() + } +} + +/** + * Select the uncovered events represented by one notification. + * @param item - one buffered notification. + * @param next - next required seq, or undefined before the first event. + * @returns uncovered events and the next required seq without acquiring an observation. + */ +export function eventItem(item: EventItem, next?: number): { + events: readonly SessionEvent[] + next: number | undefined +} { + if (item.type === 'event') { + if (next !== undefined && item.event.seq < next) return { events: [], next } + if (next !== undefined && item.event.seq !== next) { + throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(next)}`, {}) + } + return { events: [item.event], next: item.event.seq + 1 } + } + const start = next ?? item.session.firstLiveSeq + if (start >= item.end) return { events: [], next } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session-created history adaptation. + const events = item.session.snapshotEvents(SessionLogOffset(start), SessionLogOffset(item.end)) + for (const event of events) { + if (next !== undefined && event.seq !== next) { + throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(next)}`, {}) + } + next = event.seq + 1 + } + return { events, next } +} + +/** Sparse projection changes retain every key, including keys sharing a seq. */ +export class ProjectionListener implements Disposable { + private readonly buffer: ListenerBuffer<{ session: Session; change: SessionProjectionChange }> + + /** + * @param ctx - context owning projection notifications. + * @param sessionId - Session whose notifications are emitted. + * @param signal - cancellation that removes registrations and wakes consumers. + */ + constructor(ctx: Context, sessionId: SessionId, signal: AbortSignal) { + this.buffer = new ListenerBuffer(ctx, signal) + const input = this.buffer.createInput<{ session: Session; change: SessionProjectionChange }>(item => [item]) + const target = sessionId + try { + this.buffer.own(ctx.sessionProjections.onChanged((session, key, value, seq) => { + if (session.id !== target) return + this.buffer.capture(() => { + input.append({ + session, change: { type: 'projection', key, value: value as JsonValue, seq } satisfies SessionProjectionChange, + }) + }) + })) + } catch (error: unknown) { + this.buffer[Symbol.dispose]() + throw error + } + } + + /** Consume sparse per-key projection replacements. + * @returns validated replacement frames; projection seqs need not be contiguous. */ + async *changes(): AsyncIterable { + try { + for await (const { change } of this.buffer.items()) { + yield change + } + } finally { + this[Symbol.dispose]() + } + } + + [Symbol.dispose](): void { + this.buffer[Symbol.dispose]() + } +} + +/** Owns buffering, callback failure and registration cleanup for one consumer. */ +export class ListenerBuffer implements Disposable { + private readonly values = new Deque() + private readonly transformIds = new Deque() + private readonly transforms: ((value: unknown) => Iterable)[] = [] + private readonly cleanup: (() => void)[] = [] + private wake: (() => void) | undefined + private closed = false + private failure: { readonly error: unknown } | undefined + private consumed = false + + constructor(ctx: Context, signal: AbortSignal) { + signal.throwIfAborted() + const close = (): void => { this[Symbol.dispose]() } + signal.addEventListener('abort', close, { once: true }) + this.own(() => { signal.removeEventListener('abort', close) }) + try { + const dispose = ctx.effect(() => close, 'session-controller.listener-buffer') + this.own(() => { void dispose() }) + } catch (error: unknown) { + this[Symbol.dispose]() + throw error + } + } + + /** + * Register cleanup, or run it immediately when already closed. + * @param dispose - synchronous cleanup. + */ + own(dispose: () => void): void { + if (this.closed) dispose() + else this.cleanup.push(dispose) + } + + /** + * Contain producer failure and wake the consumer with that failure. + * @param action - synchronous producer operation. + */ + capture(action: () => void): void { + if (this.closed) return + try { + action() + } catch (error: unknown) { + this.failure = { error } + this[Symbol.dispose]() + } + } + + /** + * Register a dequeue transform and obtain its write-only input. + * @param transform - conversion bound once and evaluated when each input is dequeued. + * @returns a write-only input sharing this buffer's storage and lifetime. + * @throws when this buffer is already closed. + */ + createInput(transform: (item: Input) => Iterable): { append(item: Input): void; prepend(item: Input): void } { + if (this.closed) throw new Error('Cannot register a transform on a closed listener buffer') + const transformId = this.transforms.length + // Only this typed input can enqueue values with its private transform id. + this.transforms.push(value => transform(value as Input)) + return { + append: (item) => { + if (this.closed) return + this.values.pushBack(item) + this.transformIds.pushBack(transformId) + this.notify() + }, + prepend: (item) => { + if (this.closed) return + this.values.pushFront(item) + this.transformIds.pushFront(transformId) + this.notify() + }, + } + } + + /** + * Consume queued values once, releasing the buffer when iteration ends. + * @returns transformed output values. + */ + async *items(): AsyncIterable { + if (this.consumed) throw new RemoteError('gateway/internal', 'Session listener already has a consumer', {}) + this.consumed = true + try { + for (;;) { + if (this.failure !== undefined) throw this.failure.error + if (this.closed) return + if (this.transformIds.size === 0) { + await new Promise((resolve) => { this.wake = resolve }) + continue + } + const transformId = this.transformIds.popFront() as number + const value = this.values.popFront() + for (const item of (this.transforms[transformId] as (value: unknown) => Iterable)(value)) { + this.throwIfFailed() + if (this.isClosed()) return + yield item + } + } + } finally { + this[Symbol.dispose]() + } + } + + private throwIfFailed(): void { + if (this.failure !== undefined) throw this.failure.error + } + + /** @returns whether disposal ended this buffer while a transform was yielding. */ + private isClosed(): boolean { + return this.closed + } + + private notify(): void { + const wake = this.wake + this.wake = undefined + wake?.() + } + + [Symbol.dispose](): void { + if (this.closed) return + this.closed = true + this.values.clear() + this.transformIds.clear() + this.transforms.length = 0 + this.notify() + for (const dispose of this.cleanup.splice(0).reverse()) dispose() + } +} diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 5eeb7ae03d..94d08c77e3 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -10,21 +10,21 @@ import { errorChain, ReasoningEffortId, type MessageSource } from '@deepseek-ai/ import type {} from '@deepseek-ai/dsh-client-file-upload' import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import type {} from '@deepseek-ai/dsh-session-projection-cache' -import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' -import { Remote, RemoteError, TypertRemoteService, principalOf } from '@deepseek-ai/dsh-typert-protocol' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' +import { Remote, RemoteError, TypertRemoteService, principalOf, type TypertResourceResolveInput } from '@deepseek-ai/dsh-typert-protocol' import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' import { assertNever } from '@deepseek-ai/dsh-util-values' import type {} from '@deepseek-ai/dsh-api-remote-access' import { ApiSessionAgentController, - inspectApiSession, type ApiSessionAgentResult, } from './agent.ts' import { SessionCommandController, hasPromptContent } from './commands.ts' import { SessionControlController } from './control.ts' -import { SessionHistoryController } from './history.ts' +import { HistoryController } from './history/controller.ts' +import { StateController } from './state/controller.ts' +import { WebController } from './web/controller.ts' import { SessionFileReferences } from './file-references.ts' import { ApiSessionList } from './list.ts' import { buildModelCatalog, hasProviderApiKey } from './catalog.ts' @@ -50,8 +50,8 @@ import type { SessionListValue, SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, - SessionPage, - SessionPageRequest, + SessionEventsPage, SessionPageRequest, SessionEventFrame, SessionObserveEventRequest, + SessionProjectionFrame, SessionStateRequest, SessionState, SessionWaitRequest, SessionWaitValue, SessionPromptRequest, SessionPromptValue, SessionRenameRequest, @@ -62,7 +62,6 @@ import type { SessionSelectModelValue, SessionProjectionsRequest, SessionProjectionsValue, - SessionProjectionValues, SessionUpdateQueueRequest, SessionUpdateQueueValue, } from './types.ts' @@ -80,13 +79,31 @@ declare module '@deepseek-ai/cordis' { } /** - * Read the Session resource a history address names: the Session itself, or - * the child Session whose transcript a subagent address reads. - * @param request - history request carrying the durable address. - * @returns the Session whose access the read requires. + * Extract the Session resource from an ID or a history address. + * + * Registers as the `session` kind default, so it applies to every Remote method of + * every namespace: a top-level `sessionId` parameter, otherwise the first object + * parameter carrying `sessionId` or an `address`. A new request type whose first + * object parameter has an unrelated `address` field would be read as an address. + * @param input - validated wire arguments and their ordered parameter descriptions. + * @returns the named Session identity, otherwise the first object's Session identity or history address. + * @throws Error when the request object supplies both sessionId and address. */ -function addressedSession(request: { readonly address: SessionAddress }): SessionId { - return request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId +function resolveSessionResource({ args, parameters }: TypertResourceResolveInput): SessionId | undefined { + const sessionParameter = parameters.find(parameter => parameter.name === 'sessionId' || parameter.wire === 'sessionId') + if (sessionParameter !== undefined) return args[sessionParameter.wire] as SessionId | undefined + for (const parameter of parameters) { + const value = args[parameter.wire] + if (typeof value !== 'object' || value === null || Array.isArray(value)) continue + const request = value as { readonly sessionId?: SessionId; readonly address?: SessionAddress } + const address = request.address + if (request.sessionId !== undefined && address !== undefined) { + throw new Error('Session resource request must not contain both sessionId and address') + } + return request.sessionId ?? (address === undefined ? undefined + : address.kind === 'session' ? address.sessionId : address.childSessionId) + } + return undefined } /** Session Controller deployment policy. */ @@ -128,6 +145,7 @@ export class SessionController extends TypertRemoteService { 'sessionProjections', 'sessionQuery', 'typert', + 'typertGateway', 'workspaceRegistry', ] @@ -136,10 +154,13 @@ export class SessionController extends TypertRemoteService { listWorkSliceMs: z.natural().min(1).default(16), }) + private historyController!: HistoryController + private stateController!: StateController + private webController!: WebController + private agents!: ApiSessionAgentController private commands!: SessionCommandController private controlState!: SessionControlController - private history!: SessionHistoryController private listState!: ApiSessionList private readonly openPath: (path: string, signal: AbortSignal) => Promise private readonly fileApplications: typeof nativeFileApplications @@ -167,7 +188,7 @@ export class SessionController extends TypertRemoteService { protected async [Service.init](): Promise { const ctx = this.ctx - ctx.typert.resources.register('session', { field: 'sessionId' }) + ctx.typert.resources.register('session', { resolve: resolveSessionResource }) this.agents = new ApiSessionAgentController(ctx) this.agents.initialize() this.commands = new SessionCommandController(ctx, this.agents, process.cwd()) @@ -182,7 +203,11 @@ export class SessionController extends TypertRemoteService { ctx.effect(() => async () => { await Promise.allSettled([...this.promotions]) }, 'session-controller.promotions') - this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) + // Assistant-stream observation must be live before the list read yields: an Agent's + // opening frame arriving during that await would otherwise have no listener. + this.historyController = new HistoryController(ctx) + this.stateController = new StateController(ctx, this.historyController) + this.webController = new WebController(ctx, this.historyController, (observation) => { this.promote(observation) }) this.listState = new ApiSessionList(ctx, this.listWorkSliceMs) await this.listState.initialize() ctx.plugin(SessionFileReferences) @@ -218,6 +243,10 @@ export class SessionController extends TypertRemoteService { }) } + /** + * Request background activation through the existing Session Agent controller. + * @param observation - owned lease disposed by the background task; callers retain their own lease separately. + */ private promote(observation: SessionObservation): void { const sessionId = observation.header.id const task = (async () => { @@ -240,28 +269,6 @@ export class SessionController extends TypertRemoteService { return this.agents.resolveAgent(sessionId) } - /** - * Inspect one attached or persisted Session without activating its Agent. - * @param sessionId - durable Session identity. - * @param signal - optional caller cancellation for persistence reads. - * @returns the current attached state or persisted header and event prefix. - */ - inspect( - sessionId: SessionId, - signal?: AbortSignal, - ): Promise { - const attached = this.ctx.sessions.get(sessionId) - if (attached !== undefined) { - 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(), - }) - } - return inspectApiSession(this.ctx, sessionId, signal) - } - /** * Read all visible Session rows without resuming an Agent. * @param _request - reserved empty list request. @@ -482,8 +489,8 @@ export class SessionController extends TypertRemoteService { } /** - * Read one image proven reachable from the addressed Session log. - * @param request - Session and attachment identities used for authorization. + * Read one image from the attachment store after gateway authorization. + * @param request - image reference and gateway permission scope; Session existence is not checked. * @returns the durable attachment reference and base64-encoded bytes. */ @Remote('attachment') @@ -513,13 +520,80 @@ export class SessionController extends TypertRemoteService { /** * Read one cold-safe, message-aligned Session history page. - * @param request - durable address, backward cursor, and page budget. + * @deprecated Use `events`; this entry point remains for existing Web callers. + * @param request - durable address, optional fixed cut, backward cursor, and page budget. * @param signal - cancellation for persistence reads. - * @returns one chronological page. + * @returns one chronological page with its inclusive cursor; an omitted `throughSeq` selects the observed log end. */ - @Remote({ name: 'page', resources: { session: addressedSession } }) - page(request: SessionPageRequest, signal: AbortSignal): Promise { - return this.history.page(request, signal) + @Remote('page') + page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.historyController.events(request, signal) + } + + /** + * Read one cold-safe, message-aligned Session history page. + * @param request - durable address, optional fixed cut, backward cursor, and page budget. + * @param signal - cancellation for persistence reads. + * @returns one chronological page with its inclusive cursor. + */ + @Remote + events(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.historyController.events(request, signal) + } + + /** + * Subscribe to Session events without reading a snapshot. + * @param request - Session address. + * @param signal - subscription cancellation. + * @returns readiness and subsequent events, without a snapshot. + */ + @Remote({ mode: 'stream' }) + observeEvent(request: SessionObserveEventRequest, signal: AbortSignal): AsyncIterable { + return this.historyController.observeEvent(request, signal) + } + + /** + * Subscribe to sparse Session projection changes. + * @param request - target Session. + * @param signal - subscription cancellation. + * @returns readiness and sparse projection changes. + */ + @Remote({ mode: 'stream' }) + observeProjections(request: SessionProjectionsRequest, signal: AbortSignal): AsyncIterable { + return this.historyController.observeProjections(request, signal) + } + + /** + * Read passive Session state. + * @param request - target Session. + * @param signal - read cancellation. + * @returns current passive state. + */ + @Remote + state(request: SessionStateRequest, signal: AbortSignal): Promise { + return this.stateController.state(request, signal) + } + + /** + * Subscribe to complete Session state replacements. + * @param request - target Session. + * @param signal - subscription cancellation. + * @returns complete passive state replacements. + */ + @Remote({ mode: 'stream' }) + observeState(request: SessionStateRequest, signal: AbortSignal): AsyncIterable { + return this.stateController.observeState(request, signal) + } + + /** + * Wait for Session quiescence or pending input with bounded assistant output. + * @param request - Session, wait bounds and output window. + * @param signal - caller cancellation. + * @returns stopping state and bounded assistant output. + */ + @Remote + wait(request: SessionWaitRequest, signal: AbortSignal): Promise { + return this.stateController.wait(request, signal) } /** @@ -529,9 +603,9 @@ export class SessionController extends TypertRemoteService { * @returns a complete opening snapshot followed by gap-free durable event * frames and optional cursorless assistant-stream frames. */ - @Remote({ mode: 'stream', resources: { session: addressedSession } }) + @Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { - return this.history.follow(request, signal) + return this.webController.follow(request, signal) } /** @@ -541,49 +615,8 @@ export class SessionController extends TypertRemoteService { * @returns a sequenced baseline, cached hints when migration is deferred, or null when absent. */ @Remote('projections') - async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise { - const { sessionId } = request - if (sessionId.length === 0) { - throw new RemoteError('gateway/bad-request', 'sessionId must not be empty', {}) - } - try { - signal.throwIfAborted() - const live = this.liveProjections(sessionId) - if (live !== undefined) return live - const stored = await this.ctx.get('sessionPersistence')?.stat(sessionId, { signal }) - signal.throwIfAborted() - const attached = this.liveProjections(sessionId) - if (attached !== undefined) return attached - if (stored === undefined) return null - if (stored.formatStatus === 'migration-required') { - const cached = this.ctx.get('sessionProjectionCache')?.cachedSnapshot(stored.header) - return { kind: 'migration-required', values: { ...cached?.values } as SessionProjectionValues } - } - using observation = await this.ctx.sessionQuery.observeSession(sessionId, { signal }) - const projections = observation.projections - if (projections === undefined) { - throw new RemoteError('session/projections-unavailable', 'Session projections are unavailable', {}) - } - return { kind: 'sequenced', asOfSeq: projections.asOfSeq, values: projections.values as SessionProjectionValues } - } catch (error: unknown) { - if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { - return this.liveProjections(sessionId) ?? null - } - if (signal.aborted || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) { - throw new RemoteError('gateway/cancelled', 'Session projection read was cancelled', {}, { cause: error }) - } - if (error instanceof RemoteError) throw error - throw new RemoteError('gateway/internal', 'Session projection read failed', {}, { cause: error }) - } - } - - private liveProjections( - sessionId: SessionId, - ): Extract | undefined { - const session = this.ctx.sessions.get(sessionId) - if (session === undefined) return undefined - const snapshot = this.ctx.sessionProjections.snapshot(session) - return { kind: 'sequenced', asOfSeq: snapshot.asOfSeq, values: snapshot.values as SessionProjectionValues } + projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise { + return this.historyController.projections(request, signal) } /** @@ -610,9 +643,6 @@ export class SessionController extends TypertRemoteService { } } } - - } -export { buildModelCatalog } export default SessionController diff --git a/packages/api/session-controller/src/state/controller.ts b/packages/api/session-controller/src/state/controller.ts new file mode 100644 index 0000000000..60b97776d9 --- /dev/null +++ b/packages/api/session-controller/src/state/controller.ts @@ -0,0 +1,221 @@ +/** Passive Session state, coalesced observations and quiescence waits. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-api-gateway' +import type { UserMessage } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import type { HistoryController } from '../history/controller.ts' +import { DEFAULT_MAX_MESSAGES, rejectNotFound } from '../history.ts' +import type { SessionPendingInput, SessionQueuedItem, SessionState, SessionStateRequest, SessionWaitRequest, SessionWaitValue } from '../types.ts' + +import { StateListener } from './listener.ts' +import type { VersionedSessionState } from './listener.ts' + +const DEFAULT_WAIT_TIMEOUT_MS = 30_000 + +/** Reads and waits without activating or cancelling Session execution. */ +export class StateController { + + /** + * @param ctx - live Session and pending Remote event services. + * @param historyController - passive read entry used for cold state and its bounded output. + */ + constructor(private readonly ctx: Context, private readonly historyController: HistoryController) {} + + /** + * Read passive execution and pending-input state. + * @param request - Session identity. + * @param signal - read cancellation. + * @returns current passive state. + */ + async state(request: SessionStateRequest, signal: AbortSignal): Promise { + return this.readState(request.sessionId, signal) + } + + /** + * Observe complete passive state replacements. + * @param request - Session identity. + * @param signal - observation cancellation. + * @returns complete state replacements, starting with the current state. + */ + async *observeState(request: SessionStateRequest, signal: AbortSignal): AsyncIterable { + using listener = this.listen(request.sessionId, signal) + for await (const { state } of listener.observeStates()) yield state + } + + /** + * Wait for quiescence, pending input, or the deadline without cancelling execution. + * @param request - Session identity, timeout and assistant output window. + * @param signal - cancellation of this wait only, never of Agent execution. + * @returns the stopping reason and last complete state; no state before timeout is an error. + */ + async wait(request: SessionWaitRequest, signal: AbortSignal): Promise { + const timeoutMs = request.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS + const afterSeq = request.afterSeq ?? -1 + const maxMessages = request.maxMessages ?? DEFAULT_MAX_MESSAGES + if (!Number.isSafeInteger(afterSeq) || afterSeq < -1 || Object.is(afterSeq, -0)) { + throw new RemoteError('gateway/bad-request', 'afterSeq must be an integer greater than or equal to -1', {}) + } + if (!Number.isSafeInteger(maxMessages) || maxMessages <= 0) { + throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {}) + } + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMER_DELAY_MS) { + throw new RemoteError('gateway/bad-request', 'timeoutMs must be a positive supported timer delay', {}) + } + using timer = deadline(signal, timeoutMs, 'session/wait-timeout') + using listener = this.listen(request.sessionId, timer.signal) + const sessionId = request.sessionId + const statesGenerator = listener.observeStates(1_000) + let nextStatePromise = statesGenerator.next() + let currentState: SessionState | undefined + let reason: SessionWaitValue['reason'] = 'timeout' + try { + for (;;) { + const observedState = await nextStatePromise + if (observedState.done) { + timer.signal.throwIfAborted() + throw new RemoteError('session/observation-ended', 'Session observation ended before a stopping state', {}) + } + nextStatePromise = statesGenerator.next() + // Observe rejection on immediate exits without replacing the original demand. + void nextStatePromise.catch(() => undefined) + // Only a state the listener still confirms may drive this wait; an invalidated + // sample never becomes the state a timeout reports. + if (!listener.isCurrent(observedState.value.revision)) continue + currentState = observedState.value.state + const action = computeWaitAction(currentState) + if (action === 'continue') continue + if (action === 'input-required' || action === 'idle') { + reason = action + break + } + + const agent = this.ctx.agents.get(sessionId) + if (agent === undefined || this.ctx.sessions.get(sessionId) !== agent.session) continue + if (await waitForIdleOrStateChange(agent, nextStatePromise) === 'state-changed') continue + if (!listener.isCurrent(observedState.value.revision) || this.ctx.agents.get(sessionId) !== agent + || this.ctx.sessions.get(sessionId) !== agent.session) continue + reason = 'idle' + break + } + } catch (error: unknown) { + // A caller cancellation is never a wait outcome; the deadline absorbs only the read + // it cancelled, and any other failure keeps its own error. + if (signal.aborted) throw signal.reason + if (!expired(timer.signal, error)) throw error + } finally { + // Wake/cancel a pending read before asking the generator to return. + listener[Symbol.dispose]() + await nextStatePromise.catch(() => undefined) + await statesGenerator.return(undefined) + } + signal.throwIfAborted() + if (currentState === undefined) throw new RemoteError('session/wait-timeout', 'No Session state arrived before the wait deadline', {}) + const output = await this.historyController.assistantOutput( + sessionId, afterSeq, currentState.cursor, maxMessages, signal, + ) + return { reason, state: currentState, output } + } + + private listen(sessionId: SessionId, signal: AbortSignal): StateListener { + return new StateListener(this.ctx, sessionId, signal, readSignal => this.readState(sessionId, readSignal)) + } + + /** + * @param sessionId - Session to sample. + * @param signal - read cancellation. + * @param retried - whether a live source already redirected this read once. + * @returns one passive state sample. + */ + private async readState(sessionId: SessionId, signal: AbortSignal, retried = false): Promise { + signal.throwIfAborted() + const agent = this.ctx.agents.get(sessionId) + if (agent !== undefined) { + if (agent.session.header.cwd === undefined) rejectNotFound({ kind: 'session', sessionId }) + return { + cursor: agent.session.seq - 1, + active: true, + running: agent.status === 'running', + queue: queueItems(agent), + pendingInput: this.pendingInput(agent.session.id), + } + } + const state = await this.historyController.usingObservation({ sessionId, projection: false }, signal, (observation) => { + // A live source means the Session attached during this read; report its live state + // rather than a live cursor paired with an inactive sample. + if (!retried && observation.source === 'live') return undefined + if (observation.header.cwd === undefined) rejectNotFound({ kind: 'session', sessionId }) + return { + cursor: observation.cursor, + active: false, + running: false, + queue: null, + pendingInput: this.pendingInput(observation.header.id), + } + }) + signal.throwIfAborted() + return state ?? this.readState(sessionId, signal, true) + } + + private pendingInput(sessionId: SessionId): readonly SessionPendingInput[] { + const kinds = new Set() + for (const { event, agentId } of this.ctx.typertGateway.getPendingRemoteEvents()) { + if (String(agentId) !== sessionId) continue + kinds.add(event === 'approval/request' ? 'approval' : event === 'user-questions/request' ? 'question' : 'other') + } + return [...kinds] + } +} + +/** + * @param signal - wait deadline signal. + * @param error - failure raised by the in-flight read. + * @returns whether the deadline expired and cancelled that read. + */ +function expired(signal: AbortSignal, error: unknown): boolean { + const reason = timeoutOf(signal, 'session/wait-timeout') + if (reason === undefined) return false + // Cancellation carries the deadline reason along its cause chain; any other failure does not. + let current: unknown = error + for (let depth = 0; depth < 4 && current instanceof Error; depth++) { + if (current === reason || current.cause === reason) return true + current = current.cause + } + return error === reason +} + +/** Choose the next wait action; check-idle requires the caller's whenIdle and identity checks. */ +function computeWaitAction(state: SessionState): 'input-required' | 'continue' | 'idle' | 'check-idle' { + if (state.pendingInput.length > 0) return 'input-required' + if (state.running) return 'continue' + if (!state.active) return 'idle' + return 'check-idle' +} + +/** Wait for either driver quiescence or the already-armed next state; preserve rejections from both. */ +function waitForIdleOrStateChange( + agent: Agent, + nextStatePromise: Promise>, +): Promise<'idle' | 'state-changed'> { + return Promise.race([ + agent.whenIdle().then(() => 'idle' as const), + nextStatePromise.then(() => 'state-changed' as const), + ]) +} + +function queueItems(agent: Agent): SessionQueuedItem[] { + const item = (message: UserMessage, placement: SessionQueuedItem['placement']): SessionQueuedItem => ({ + id: message.id, + placement, + ...message.source.kind === 'user' && 'rpcId' in message.source ? { rpcId: message.source.rpcId } : {}, + content: message.content as typeof message.content & readonly JsonValue[], + }) + return [ + ...agent.inbox.nextTurn.map(message => item(message, 'queued')), + ...agent.inbox.nextStep.map(message => item(message, message.source.kind === 'user' ? 'steering' : 'context')), + ] +} diff --git a/packages/api/session-controller/src/state/listener.ts b/packages/api/session-controller/src/state/listener.ts new file mode 100644 index 0000000000..15633a7cd8 --- /dev/null +++ b/packages/api/session-controller/src/state/listener.ts @@ -0,0 +1,116 @@ +/** Coalesced Session state observations with Host-local revisions. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionState } from '../types.ts' + +/** One complete state with its listener-local revision. */ +export interface VersionedSessionState { state: SessionState; revision: number } + +/** Coalesces Session/Agent invalidations with local revisions; pending events are sampled on reads. */ +export class StateListener implements Disposable { + private readonly disposes: (() => void)[] = [] + private readonly cancellation = new AbortController() + private readonly signal: AbortSignal + private revision = 0 + private resolver = createDeferredSignal() + private closed = false + + /** + * @param ctx - lifecycle notification owner. + * @param target - Session to observe. + * @param signal - caller cancellation. + * @param readState - passive complete-state reader. + */ + constructor( + ctx: Context, target: SessionId, signal: AbortSignal, + private readonly readState: (signal: AbortSignal) => Promise, + ) { + signal.throwIfAborted() + this.signal = AbortSignal.any([signal, this.cancellation.signal]) + const notify = (id: string | undefined): void => { + if (id !== target || this.closed) return + this.revision++ + const resolver = this.resolver + this.resolver = createDeferredSignal() + resolver.resolve() + } + const close = (): void => { this[Symbol.dispose]() } + signal.addEventListener('abort', close, { once: true }) + this.disposes.push(() => { signal.removeEventListener('abort', close) }) + try { + this.disposes.push(ctx.on('session/event', (session) => { notify(session.id) }, { global: true })) + this.disposes.push(ctx.on('session/created', (session) => { notify(session.id) }, { global: true })) + this.disposes.push(ctx.on('session/disposed', (session) => { notify(session.id) }, { global: true })) + this.disposes.push(ctx.on('agent/status', ({ agent }) => { notify(agent.id) }, { global: true })) + this.disposes.push(ctx.on('agent/created', ({ agent }) => { notify(agent.id) }, { global: true })) + this.disposes.push(ctx.on('agent/disposed', ({ agent }) => { notify(agent.id) }, { global: true })) + const dispose = ctx.effect(() => close, 'session-controller.state-listener') + this.disposes.push(() => { void dispose() }) + } catch (error: unknown) { + this[Symbol.dispose]() + throw error + } + } + + /** + * Check whether a completed read still represents the latest invalidation. + * @param revision - revision captured with a completed read. + * @returns whether no intervening invalidation occurred. + */ + isCurrent(revision: number): boolean { return !this.closed && revision === this.revision } + + /** + * Read coalesced state replacements with optional periodic refresh. + * @param refreshMs - optional idle recheck interval; omitted for event-only observation. + * @returns complete state replacements with internal consistency revisions. + */ + async *observeStates(refreshMs?: number): AsyncGenerator { + let lastRevision = -1 + try { + while (!this.closed) { + if (lastRevision === this.revision) { + if (refreshMs === undefined) { + await this.resolver.promise + } else { + let timer: ReturnType | undefined + try { + await Promise.race([ + this.resolver.promise, + new Promise((resolve) => { timer = setTimeout(resolve, refreshMs) }), + ]) + } finally { + clearTimeout(timer) + } + if (this.isCurrent(lastRevision)) { + this.revision++ + const resolver = this.resolver + this.resolver = createDeferredSignal() + resolver.resolve() + } + } + } + if (!this.isCurrent(this.revision)) return + lastRevision = this.revision + const state = await this.readState(this.signal) + if (this.isCurrent(lastRevision)) yield { state, revision: lastRevision } + } + } finally { + this[Symbol.dispose]() + } + } + + [Symbol.dispose](): void { + if (this.closed) return + this.closed = true + this.cancellation.abort() + this.resolver.resolve() + for (const dispose of this.disposes.splice(0).reverse()) dispose() + } +} + +function createDeferredSignal(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((settle) => { resolve = settle }) + return { promise, resolve } +} diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 8c6da77a60..e3e2ea375a 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -3,7 +3,7 @@ import type {} from '@deepseek-ai/dsh-agent/types' import type { NativeFileApplication } from '@deepseek-ai/dsh-native-command/types' import type { - AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType, + ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType, } from '@deepseek-ai/dsh-attachment' import type { Branded } from '@deepseek-ai/dsh-brand' import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand' @@ -175,6 +175,10 @@ export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240 declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { + /** A wait observation ended before a stopping state; the caller should reread. */ + 'session/observation-ended': Record + /** No state arrived before the deadline; a timeout after a state returns its value instead. */ + 'session/wait-timeout': Record 'session/provider-credentials-unavailable': Record 'session/provider-models-unavailable': { readonly provider: string } 'session/model-unavailable': { readonly provider: string; readonly model: string } @@ -329,12 +333,14 @@ export interface SessionPromptValue { /** Durable image read request. */ export interface SessionAttachmentRequest { + /** Gateway permission scope only; neither Session existence nor its image references are checked. */ readonly sessionId: SessionId - readonly attachmentId: AttachmentIdType + readonly attachment: ImageAttachmentRef } /** Durable image read response value. */ export interface SessionAttachmentValue { + /** Supplied reference after ID, format, byte length, and dimensions verification; display fields are preserved. */ readonly attachment: ImageAttachmentRef readonly data: string } @@ -459,8 +465,11 @@ export interface SessionWireEvent { /** One message-aligned backwards-history request. */ export interface SessionPageRequest { readonly address: SessionAddress - /** Inclusive log cut obtained from the corresponding follow opening frame. */ - readonly throughSeq: number + /** + * Inclusive log cut; omission selects the observed log end. A follow opening + * reports the cut it covered so a caller can pin the same point. + */ + readonly throughSeq?: number readonly beforeSeq?: number readonly maxMessages?: number /** Stop at a Turn start after both minima, unless maxMessages or history exhaustion wins. */ @@ -475,6 +484,8 @@ export interface SessionPageRequest { /** One live event request for a durable Session address. */ export interface SessionFollowRequest extends Pick { readonly address: SessionAddress + /** Request background activation; omission leaves the read passive. */ + readonly activate?: boolean /** Include process-local assistant presentation frames for the Web client. */ readonly assistantStream?: true } @@ -569,6 +580,84 @@ export type SessionControlFrame = | { readonly type: 'baseline'; readonly value: SessionControlBaseline } | ({ readonly type: 'projection' } & SessionProjectionUpdate) +/** One pending live-Agent inbox item, preserving placement and content order. */ +export interface SessionQueuedItem { + readonly id: MessageId + readonly placement: 'queued' | 'steering' | 'context' + readonly rpcId?: SessionRequestId + readonly content: readonly JsonValue[] +} + +/** State-owned classification of pending forwarded Remote events. */ +export type SessionPendingInput = 'approval' | 'question' | 'other' + +/** Passive Session state; a missing live Agent has no readable queue. */ +export interface SessionState { + readonly cursor: number + readonly active: boolean + readonly running: boolean + readonly queue: readonly SessionQueuedItem[] | null + /** + * Pending Remote requests sampled when this state was read. A pending change alone does not + * trigger `observeState`; `wait` refreshes on its own interval. + */ + readonly pendingInput: readonly SessionPendingInput[] +} + +/** Session identity for a passive state read or subscription. */ +export interface SessionStateRequest { + readonly sessionId: SessionId +} + +/** Wait bounds and assistant output window; timeout omission uses the fixed 30-second default. */ +export interface SessionWaitRequest extends SessionStateRequest { + readonly timeoutMs?: number + /** Exclusive output cursor; omission includes all available assistant records. */ + readonly afterSeq?: number + /** Positive assistant-record limit; omission uses the history message budget. */ + readonly maxMessages?: number +} + +/** Assistant messages after afterSeq through the stopping state's cursor, newest first. */ +export interface SessionWaitOutput { + readonly records: readonly SessionHistoryRecord[] + /** More eligible assistant messages exist in the selected interval. */ + readonly truncated: boolean +} + +/** A stopping condition, the last complete passive state and its bounded assistant output. */ +export interface SessionWaitValue { + readonly reason: 'idle' | 'input-required' | 'timeout' + readonly state: SessionState + readonly output: SessionWaitOutput +} + +/** + * One `session.events` page (or its deprecated `page` alias) with its inclusive cut: + * the supplied `throughSeq`, otherwise the log end it was read at. + */ +export interface SessionEventsPage extends SessionPage { + readonly cursor: number +} + +/** Address for a passive event subscription. */ +export interface SessionObserveEventRequest { + readonly address: SessionAddress +} + +/** Readiness confirms subscription only; event continuity starts with the first delivered event. */ +export type SessionEventFrame = + | { readonly type: 'ready' } + | SessionEventEntry + +/** One sparse per-key replacement; different keys can share a seq. */ +export type SessionProjectionChange = { readonly type: 'projection' } & Omit + +/** Subscription readiness without a baseline, followed by per-key changes. */ +export type SessionProjectionFrame = + | { readonly type: 'ready' } + | SessionProjectionChange + declare module '@deepseek-ai/cordis' { interface Events { /** diff --git a/packages/api/session-controller/src/web/controller.ts b/packages/api/session-controller/src/web/controller.ts new file mode 100644 index 0000000000..7e849e4cca --- /dev/null +++ b/packages/api/session-controller/src/web/controller.ts @@ -0,0 +1,78 @@ +/** Web opening aggregation over one observation and a shared transforming buffer. */ + +import type { Context } from '@deepseek-ai/cordis' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' +import type { HistoryController } from '../history/controller.ts' +import { addressId, rejectNotFound } from '../history.ts' +import { EventListener, ListenerBuffer } from '../history/listener.ts' +import { history, projections, resolveHistoryRequest, validateObservationAddress } from '../history/controller.ts' +import type { SessionFollowFrame, SessionFollowRequest } from '../types.ts' + +/** Combines opening data and consumes transformed listener output from one queue. */ +export class WebController { + + /** + * @param ctx - Host context carrying Session queries and identity projections. + * @param historyController - passive reading and listener construction. + * @param promote - background activation requested before an opening snapshot is delivered. + */ + constructor( + private readonly ctx: Context, + private readonly historyController: HistoryController, + private readonly promote: (observation: SessionObservation) => void, + ) {} + + /** + * Open a history snapshot and follow uncovered history and assistant notifications. + * @param request - Session identity, page budget, assistant output and optional activation (default false). + * @param signal - cancellation for opening and streaming. + * @returns an opening snapshot followed by uncovered queued increments. + */ + async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { + const sessionId = addressId(request.address) + const spec = resolveHistoryRequest(request) + using queue = new ListenerBuffer(this.ctx, signal) + using listener = new EventListener(this.ctx, sessionId, signal, queue) + const streams = this.historyController.streamsListener + using assistantListener = request.assistantStream === true + ? streams.bind(sessionId, signal, queue) + : undefined + using observation = await this.historyController.usingObservation({ sessionId, projection: true }, signal) + .catch((error: unknown) => { + if (remoteErrorOf(error)?.code === 'session/not-found') rejectNotFound(request.address) + throw error + }) + validateObservationAddress(request.address, observation) + let opening: SessionFollowFrame = { + type: 'snapshot', + header: { ...observation.header }, + projections: projections(observation), + ...history(observation, spec), + } + listener.setCursor(opening.cursor) + if (assistantListener !== undefined) { + const { baseline: assistantStream, ordinal } = assistantListener.snapshot() + opening = { ...opening, assistantStream } + assistantListener.setOrdinalCut(ordinal) + } + // Increments come from the queue, so the opening ends this lease in every branch; + // activation continues on its own retained lease. Promotion starts before the opening is + // delivered, so a consumer that stops after the snapshot still gets the activation it asked for. + if (request.activate === true && !signal.aborted + && observation.header.origin !== 'subagent' && observation.source === 'prepared') { + const promotion = observation.retain() + observation[Symbol.dispose]() + try { + this.promote(promotion) + } catch (error: unknown) { + promotion[Symbol.dispose]() + throw error + } + } else { + observation[Symbol.dispose]() + } + yield opening + yield* queue.items() + } +} diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 76380d69fb..aea96d5e04 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -6,7 +6,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' -import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' @@ -15,8 +15,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiSessionAgentController, ApiSessionCwdConflict, - ApiSessionNotFound, - inspectApiSession, } from '../src/agent.ts' import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' @@ -83,61 +81,6 @@ describe('ApiSession identity failures', () => { expect(new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/wanted', '/existing').message) .toContain('belongs to "/existing"') }) - - it('maps absent and cwd-less point observations to not found', async () => { - const ctx = new Context() - roots.push(ctx) - await ctx.plugin(SessionStore) - installSessionReadTestServices(ctx) - await expect(inspectApiSession(ctx, SessionId('missing'))) - .rejects.toBeInstanceOf(ApiSessionNotFound) - - const inspect = vi.fn(() => Promise.resolve(undefined)) - const stat = vi.fn(() => Promise.resolve(undefined)) - const disposeMissing = providePersistence(ctx, { - list: () => Promise.resolve([]), - stat, - inspect, - }) - await expect(inspectApiSession(ctx, SessionId('missing'))).rejects.toBeInstanceOf(ApiSessionNotFound) - // Absence is decided by the stat preflight; the log itself is never opened. - expect(stat).toHaveBeenCalledOnce() - expect(inspect).not.toHaveBeenCalled() - disposeMissing() - - const listed = header('cwd-less-catalog', null) - const disposeListed = providePersistence(ctx, { - list: () => Promise.resolve([listed]), - inspect: () => Promise.resolve({ meta: listed, events: [] }), - }) - await expect(inspectApiSession(ctx, listed.id)).rejects.toBeInstanceOf(ApiSessionNotFound) - disposeListed() - - const catalog = header('cwd-less-inspect') - const inspected = header('cwd-less-inspect', null) - providePersistence(ctx, { - list: () => Promise.resolve([catalog]), - inspect: () => Promise.resolve({ meta: inspected, events: [] }), - }) - await expect(inspectApiSession(ctx, catalog.id)).rejects.toBeInstanceOf(ApiSessionNotFound) - }) - - it('forwards an explicit inspection signal', async () => { - const ctx = new Context() - roots.push(ctx) - await ctx.plugin(SessionStore) - installSessionReadTestServices(ctx) - const meta = header('signalled-inspection') - const inspect = vi.fn(() => Promise.resolve({ meta, inheritedEventCount: SessionLogOffset(0), events: [] })) - providePersistence(ctx, { - list: () => Promise.resolve([meta]), - inspect, - }) - const signal = new AbortController().signal - - await expect(inspectApiSession(ctx, meta.id, signal)).resolves.toEqual({ meta, inheritedEventCount: SessionLogOffset(0), events: [] }) - expect(inspect).toHaveBeenCalledWith(meta.id, signal) - }) }) describe('ApiSession Agent lookup and recovery', () => { diff --git a/packages/api/session-controller/tests/client-contract.client.spec.ts b/packages/api/session-controller/tests/client-contract.client.spec.ts index b2abdc2dc2..20a0f08065 100644 --- a/packages/api/session-controller/tests/client-contract.client.spec.ts +++ b/packages/api/session-controller/tests/client-contract.client.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import type { PromptContentPart as AttachmentPromptContentPart } from '@deepseek-ai/dsh-attachment/types' -import { SessionSeq, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types' +import { SessionSeq, type SessionId, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types' import { MutableSessionEventSource, type SessionAssistantSettlementEntry, type SessionLiveEventEntry, type SessionTransientEventEntry, @@ -11,6 +11,7 @@ import type { ProjectionsBaseline } from '../src/client/sessions/projection-stor import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts' import type { PromptContentPart as SessionPromptContentPart, SessionPageRequest } from '../src/types.ts' import { ev, plainTurn } from './event-script.client.ts' +import { history, pageRule } from './remote/session.client.ts' type RenameSuccess = Extract>, { readonly ok: true }> @@ -70,7 +71,21 @@ describe('Client Session contracts', () => { expectTypeOf().toEqualTypeOf() expectTypeOf().parameter(2).toEqualTypeOf() expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) + + it('returns the current page cursor when throughSeq is omitted and preserves explicit cuts', async () => { + const address = { kind: 'session', sessionId: 'page-fixture' as SessionId } as const + const rule = pageRule(history(plainTurn(SessionSeq(0), 1, 'question', 'answer'))) + const current = await rule({ address }) + expect(current).toMatchObject({ ok: true, value: { cursor: 5, hasMore: false } }) + const cut = await rule({ address, throughSeq: 2 }) + expect(cut).toMatchObject({ ok: true, value: { cursor: 2, hasMore: false } }) + if (!cut.ok) throw new Error('expected a successful history page') + expect(cut.value.records.map(record => record.event.seq)).toEqual([0, 1, 2]) + await expect(rule({ address, throughSeq: -1 })).resolves.toEqual({ + ok: true, value: { cursor: -1, records: [], hasMore: false }, + }) }) it('keeps its text and image prompt parts identical to attachment intake', () => { diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index a23917b312..f575583063 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -6,19 +6,19 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, Inbox } from '@deepseek-ai/dsh-agent' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' +import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import SessionStore, { - SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, SessionSeq, + Session, SessionId, SessionLogOffset, } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session' +import type { UserMessage } from '@deepseek-ai/dsh-session' import { snapshotSubagentDescriptor, SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import { describe, expect, it, vi } from 'vitest' import { ApiSessionAgentController } from '../src/agent.ts' import { SessionCommandController } from '../src/commands.ts' import { unsupportedModel, createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' -import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' +import { installSessionReadTestServices } from './test-remote.ts' declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { @@ -324,259 +324,68 @@ describe('Session queue commands', () => { }) }) -function imageRef(id: string): ImageAttachmentRef { - return { - attachmentId: AttachmentId(id), - mediaType: 'image/png', - bytes: 1, - width: 1, - height: 1, - } -} - -function event(type: string, seq: SessionSeq, data: unknown): SessionEvent { - const surface = ['user/message', 'system/message', 'developer/message', 'tool/result'].includes(type) - return { type, seq, time: seq + 1, data, ...surface ? { surfaceOp: 'append' } : {} } as SessionEvent -} - -async function persistedController( - events: SessionEvent[], - readImage: (ref: ImageAttachmentRef) => Promise<{ ref: ImageAttachmentRef; data: Uint8Array }>, -): Promise<{ ctx: Context; controller: SessionCommandController; sessionId: SessionId }> { - const ctx = new Context() - await ctx.plugin(SessionStore) - const sessionId = SessionId('cold-attachment') - const meta: SessionHeader = { - version: SESSION_FORMAT_VERSION, - id: sessionId, - createdAt: 1, - cwd: '/workspace', - isSeeded: false, - } - ctx.provide('sessionPersistence', testSessionPersistence(ctx, { - list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ - meta, - inheritedEventCount: SessionLogOffset(0), - events, - }), - }) as never) - installSessionReadTestServices(ctx) - ctx.provide('attachments', { readImage } as never) - const agents = { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController - return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), sessionId } -} - -describe('Session attachment authorization', () => { - it.each([ - ['system/message', 'message'], ['developer/message', 'message'], ['tool/result', 'message'], - ['team/message/queued', 'message'], ['tool/ptc-dispatch', 'content'], - ['compaction/summary', 'summary'], ['compaction/summary', 'rawOutput'], - ] as const)('reads the declared %s %s content without rewriting it', async (type, field) => { - const ref = imageRef(`${type}-${field}`) - const content = [{ type: 'image', attachment: ref }] - const message = type === 'team/message/queued' ? { content } - : type === 'tool/result' ? { - id: 'declared-message', role: 'tool', source: { kind: 'tool', callId: 'declared-call' }, - toolCallId: 'declared-call', isError: false, content, - } : { - id: 'declared-message', role: type === 'developer/message' ? 'developer' : 'system', - source: { kind: 'system-prompt' }, content, - } - const data = field === 'message' ? { message } : { [field]: content } - const events = [event(type, SessionSeq(0), data)] - const saved = JSON.stringify(events) - const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) - const fixture = await persistedController(events, readImage) +describe('Session attachment reads', () => { + it.each(['commands-session', 'other-session', 'missing-session'])('reads a supplied reference without looking up history for %s', async (id) => { + const { ctx, controller, agent, resolveAgent } = await commandHarness() + const ref: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + mediaType: 'image/png', bytes: 2, width: 1, height: 1, + } + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'image', attachment: ref }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) })) + ctx.provide('attachments', { readImage } as never) + const failHistory = (): never => { throw new Error('image reads must not access Session history') } + const get = vi.spyOn(ctx.sessions, 'get').mockImplementation(failHistory) + const snapshot = vi.spyOn(agent.session, 'snapshotEvents').mockImplementation(failHistory) + const observe = vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation(failHistory) try { - await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) - .resolves.toEqual({ attachment: ref, data: 'AQ==' }) + await expect(controller.attachment({ sessionId: SessionId(id), attachment: ref })) + .resolves.toEqual({ attachment: ref, data: 'AQI=' }) expect(readImage).toHaveBeenCalledExactlyOnceWith(ref) - expect(JSON.stringify(events)).toBe(saved) + expect(get).not.toHaveBeenCalled() + expect(snapshot).not.toHaveBeenCalled() + expect(observe).not.toHaveBeenCalled() + expect(resolveAgent).not.toHaveBeenCalled() } finally { - await fixture.ctx.fiber.dispose() + get.mockRestore() + snapshot.mockRestore() + observe.mockRestore() + await ctx.fiber.dispose() } }) - it('does not authorize an attachment through malformed inbox entries', async () => { - const ref = imageRef('not-referenced') - for (const value of [undefined, null, {}, [null, 1, []]]) { - const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) - const fixture = await persistedController([event('agent/inbox/spliced', SessionSeq(0), { inserted: value })], readImage) - try { - await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) - .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }) - expect(readImage).not.toHaveBeenCalled() - } finally { - await fixture.ctx.fiber.dispose() - } - } - }) - - it('ignores unrelated fields of a known message event and its content blocks', async () => { - const ref = imageRef('not-a-content-occurrence') - const content = [{ type: 'image', attachment: ref }] - const stored = event('user/message', SessionSeq(0), { - id: 'metadata', role: 'user', source: { kind: 'user' }, - content: [{ type: 'text', text: 'no image', content }, { type: 'plugin:vendor', data: { content }, content }], - message: { content }, inserted: [{ content }], - }) - const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) - const fixture = await persistedController([stored], readImage) - try { - await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) - .rejects.toMatchObject({ code: 'session/attachment-invalid' }) - expect(readImage).not.toHaveBeenCalled() - } finally { - await fixture.ctx.fiber.dispose() - } - }) - - it.each(['external/image-record', 'session/title-llm-request'])('denies attachment access from %s without an attachment carrier', async (type) => { - const ref = imageRef('opaque-event-image') - const content = [{ type: 'image', attachment: ref }] - const opaque = { ...event(type, SessionSeq(0), { - content, message: { content }, inserted: [{ content }], messages: [{ content }], - stream: [{ type: 'chunk', time: 1, chunk: { type: 'block-end', index: 0, block: content[0] } }], - }), ignorable: true as const } - const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) - const fixture = await persistedController([opaque], readImage) - try { - await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) - .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }) - expect(readImage).not.toHaveBeenCalled() - } finally { - await fixture.ctx.fiber.dispose() - } - }) - - it('finds references in direct, message, inserted, and streamed content', async () => { - const direct = imageRef('direct') - const message = imageRef('message') - const inserted = imageRef('inserted') - const streamed = imageRef('streamed') - const events: SessionEvent[] = [ - { ...event('user/message', SessionSeq(0), { - id: 'direct', role: 'user', source: { kind: 'user' }, - content: [null, [], { type: 'text', text: 'none' }, { type: 'image', attachment: direct }], - }), ignorable: true as const }, - { - type: 'assistant/message', seq: SessionSeq(1), time: 2, surfaceOp: 'append', - data: { - turn: 1, - step: 1, - stream: [], - message: createAssistantMessage({ - content: [{ type: 'image', attachment: message }], - source: { provider: 'fixture', model: 'fixture' }, - }), - }, - }, - event('agent/inbox/spliced', SessionSeq(2), { - target: 'next-turn', - start: 0, - inserted: [null, 1, [], { content: [] }, createUserMessage({ - content: [{ type: 'image', attachment: inserted }], - source: { kind: 'user' }, - })], - }), - event('assistant/attempt', SessionSeq(3), { - turn: 1, - step: 1, - stream: [ - { - type: 'chunk', - time: 3, - chunk: { type: 'block-start', index: 0, blockType: 'text' }, - }, - { - type: 'chunk', - time: 3, - chunk: { type: 'block-end', index: 0, block: { type: 'text', text: '' } }, - }, - ], - }), - event('assistant/attempt', SessionSeq(4), { - turn: 1, - step: 1, - stream: [{ - type: 'chunk', - time: 4, - chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: streamed } }, - }], - }), - ] - const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, data: Uint8Array.of(1) })) - const { ctx, controller, sessionId } = await persistedController(events, readImage) - - for (const ref of [direct, message, inserted, streamed]) { - await expect(controller.attachment({ sessionId, attachmentId: ref.attachmentId })) - .resolves.toEqual({ attachment: ref, data: 'AQ==' }) - } - expect(readImage).toHaveBeenCalledTimes(4) - await ctx.fiber.dispose() - }) - - it('maps missing persistence identities and attachment backend failures', async () => { - const noPersistence = new Context() - await noPersistence.plugin(SessionStore) - installSessionReadTestServices(noPersistence) - const noPersistenceController = new SessionCommandController( - noPersistence, - { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController, - '/workspace', - ) - await expectFailure(noPersistenceController.attachment({ - sessionId: SessionId('missing'), attachmentId: AttachmentId('att'), - }), 'session/not-found') - - const missing = new Context() - await missing.plugin(SessionStore) - missing.provide('sessionPersistence', testSessionPersistence(missing, { - list: () => Promise.resolve([]), - inspect: vi.fn(), - }) as never) - installSessionReadTestServices(missing) - const missingController = new SessionCommandController( - missing, - { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController, - '/workspace', - ) - await expectFailure(missingController.attachment({ - sessionId: SessionId('missing'), attachmentId: 'att' as never, - }), 'session/not-found') - - for (const thrown of [ - new AttachmentError('stored image is unavailable', 'ATTACHMENT_NOT_FOUND'), - new Error('backend offline'), - ]) { - const ref = imageRef(`failure-${thrown.name}`) - const fixture = await persistedController( - [event('user/message', SessionSeq(0), { id: 'failure', role: 'user', source: { kind: 'user' }, content: [{ type: 'image', attachment: ref }] })], - () => Promise.reject(thrown), - ) - await expectFailure(fixture.controller.attachment({ - sessionId: fixture.sessionId, - attachmentId: ref.attachmentId, - }), thrown instanceof AttachmentError ? 'session/attachment-invalid' : 'gateway/internal') - await fixture.ctx.fiber.dispose() - } - }) - - it('maps a cold observation failure to an internal authorization error', async () => { + it.each([ + 'INVALID_ATTACHMENT_REF', 'ATTACHMENT_NOT_FOUND', 'ATTACHMENT_CORRUPT', 'ATTACHMENT_READ_FAILED', + ] as const)('returns %s as an explicit attachment failure', async (reason) => { const ctx = new Context() - await ctx.plugin(SessionStore) - installSessionReadTestServices(ctx) - vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline')) - const controller = new SessionCommandController( - ctx, - { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController, - '/workspace', - ) + const readImage = vi.fn(() => Promise.reject(new AttachmentError('Stored image is unavailable.', reason))) + ctx.provide('attachments', { readImage } as never) + const controller = new SessionCommandController(ctx, new ApiSessionAgentController(ctx), '/workspace') + try { + await expect(controller.attachment({ + sessionId: SessionId('unopened'), + attachment: { attachmentId: AttachmentId('requested'), mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, + })) + .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason } }) + } finally { + await ctx.fiber.dispose() + } + }) - await expectFailure(controller.attachment({ - sessionId: SessionId('unreadable'), attachmentId: AttachmentId('att'), - }), 'gateway/internal') - await ctx.fiber.dispose() + it('contains unexpected backend failures without exposing internal details', async () => { + const ctx = new Context() + ctx.provide('attachments', { readImage: () => Promise.reject(new Error('/private/storage failed')) } as never) + const controller = new SessionCommandController(ctx, new ApiSessionAgentController(ctx), '/workspace') + try { + await expect(controller.attachment({ + sessionId: SessionId('unopened'), + attachment: { attachmentId: AttachmentId('requested'), mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, + })) + .rejects.toMatchObject({ code: 'gateway/internal', message: 'Unable to read image attachment.', details: {} }) + } finally { + await ctx.fiber.dispose() + } }) }) diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index 789caf78d5..664c4f9244 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -3,7 +3,8 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { LlmAttemptId, createUserMessage } from '@deepseek-ai/dsh-llm' +import type TypertGatewayService from '@deepseek-ai/dsh-api-gateway' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' @@ -22,6 +23,75 @@ describe('SessionController facade', () => { expect(SessionController.inject).not.toContain('tools') }) + it('keeps assistant stream observation live while list initialization awaits', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await mountAgentLoopTestDependencies(ctx) + await mountAgentLoopTestHarness(ctx) + const gateway: Partial = { getPendingRemoteEvents: () => [] } + ctx.provide('typertGateway', gateway as TypertGatewayService) + const session = ctx.sessions.create(SessionId('assistant-interleave'), { meta: { cwd: '/workspace' } }) + const partial: Partial = { id: session.id, session, status: 'running', ctx } + const agent = partial as Agent + const entered = Promise.withResolvers() + const gate = Promise.withResolvers() + const original = ctx.sessionProjections.register.bind(ctx.sessionProjections) + let first = true + vi.spyOn(ctx.sessionProjections, 'register').mockImplementation(async (...args: Parameters) => { + if (first) { + first = false + entered.resolve(undefined) + await gate.promise + } + return original(...args) + }) + const mounting = createSessionTestController(ctx, defaults) + await entered.promise + // An Agent start racing the list read must not be lost before the controller publishes. + ctx.emit('agent/assistant-stream', { + agent, + frame: { type: 'start', attemptId: LlmAttemptId('boot-attempt'), revision: 1, turn: 1, step: 1 }, + }) + gate.resolve(undefined) + const controller = await mounting + const frames = controller.follow({ + address: { kind: 'session', sessionId: session.id }, assistantStream: true, + }, new AbortController().signal)[Symbol.asyncIterator]() + await expect(frames.next()).resolves.toMatchObject({ + value: { type: 'snapshot', assistantStream: { revision: 1 } }, + }) + await frames.return?.(undefined) + }) + + it('serves passive reads, subscriptions and waits through its plain controllers', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await mountAgentLoopTestDependencies(ctx) + await mountAgentLoopTestHarness(ctx) + const gateway: Partial = { getPendingRemoteEvents: () => [] } + ctx.provide('typertGateway', gateway as TypertGatewayService) + const controller = await createSessionTestController(ctx, defaults) + const sessionId = SessionId('passive-facade') + ctx.sessions.create(sessionId, { meta: { cwd: '/workspace' } }) + const address = { kind: 'session' as const, sessionId } + const signal = new AbortController().signal + + await expect(controller.events({ address }, signal)).resolves.toMatchObject({ records: [], hasMore: false }) + const historyFrames = controller.observeEvent({ address }, signal)[Symbol.asyncIterator]() + await expect(historyFrames.next()).resolves.toMatchObject({ value: { type: 'ready' } }) + await historyFrames.return?.(undefined) + const projectionFrames = controller.observeProjections({ sessionId }, signal)[Symbol.asyncIterator]() + await expect(projectionFrames.next()).resolves.toMatchObject({ value: { type: 'ready' } }) + await projectionFrames.return?.(undefined) + await expect(controller.state({ sessionId }, signal)) + .resolves.toMatchObject({ active: false, running: false, queue: null }) + const stateFrames = controller.observeState({ sessionId }, signal)[Symbol.asyncIterator]() + await expect(stateFrames.next()).resolves.toMatchObject({ value: { active: false } }) + await stateFrames.return?.(undefined) + await expect(controller.wait({ sessionId, afterSeq: -1, maxMessages: 10, timeoutMs: 50 }, signal)) + .resolves.toMatchObject({ reason: 'idle' }) + }) + it('owns Host service methods and publishes Agent lifecycle projections', async () => { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) @@ -63,13 +133,6 @@ describe('SessionController facade', () => { ctx.on('api-session/error', failure) ctx.on('api-session/activity', activity) - await expect(controller.inspect(sessionId)).resolves.toEqual({ - meta: header, - inheritedEventCount: SessionLogOffset(0), - events, - }) - expect(inspect).toHaveBeenCalledOnce() - const session = ctx.sessions.create(sessionId, { meta: header }) const agent = { id: sessionId, @@ -91,12 +154,6 @@ describe('SessionController facade', () => { await expect(resolveUploadAgent(sessionId)).rejects.toBe(activationError) await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent }) - await expect(controller.inspect(sessionId)).resolves.toEqual({ - meta: header, - inheritedEventCount: SessionLogOffset(0), - events, - }) - expect(inspect).toHaveBeenCalledOnce() ctx.emit('agent/status', { agent, status: 'running' }) ctx.emit('agent/error', { agent, turn: 1, step: 0, error: new Error('fixture failure') }) session.append('user/message', createUserMessage({ @@ -177,6 +234,7 @@ describe('SessionController facade', () => { const abort = new AbortController() const iterator = controller.follow({ address: { kind: 'session', sessionId }, + activate: true, }, abort.signal)[Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) @@ -229,6 +287,7 @@ describe('SessionController facade', () => { }) const iterator = controller.follow({ address: { kind: 'session', sessionId }, + activate: true, }, new AbortController().signal)[Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) diff --git a/packages/api/session-controller/tests/event-script.client.ts b/packages/api/session-controller/tests/event-script.client.ts index 0e7eaed7d1..0d9eea291b 100644 --- a/packages/api/session-controller/tests/event-script.client.ts +++ b/packages/api/session-controller/tests/event-script.client.ts @@ -8,7 +8,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionEventEntry, - SessionPage, + SessionEventsPage, SessionWireEvent, } from '../src/types.ts' @@ -185,8 +185,9 @@ export function entries(events: readonly SessionEvent[]): SessionEventEntry[] { } /** Build one view-less history response value. */ -export function historyValue(events: readonly SessionEvent[], hasMore = false): SessionPage { +export function historyValue(events: readonly SessionEvent[], hasMore = false): SessionEventsPage { return { + cursor: events.at(-1)?.seq ?? -1, records: entries(events), hasMore, } diff --git a/packages/api/session-controller/tests/history-controller.host.spec.ts b/packages/api/session-controller/tests/history-controller.host.spec.ts new file mode 100644 index 0000000000..e3592ddbad --- /dev/null +++ b/packages/api/session-controller/tests/history-controller.host.spec.ts @@ -0,0 +1,171 @@ +import { Context } from '@deepseek-ai/cordis' +import { createMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HistoryController } from '../src/history/controller.ts' +import { WebController } from '../src/web/controller.ts' +import { installSessionReadTestServices } from './test-remote.ts' + +const roots: Context[] = [] +const aborters: AbortController[] = [] +afterEach(async () => { + for (const abort of aborters.splice(0)) abort.abort() + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) + vi.restoreAllMocks() +}) +async function fixture() { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) + const history = new HistoryController(ctx) + const web = new WebController(ctx, history, (observation) => { observation[Symbol.dispose]() }) + const session = ctx.sessions.create(SessionId('history-target'), { meta: { cwd: '/history-test' } }) + const abort = new AbortController() + aborters.push(abort) + const address = { kind: 'session' as const, sessionId: session.id } + return { ctx, history, web, session, abort, address } +} + +describe('HistoryController passive reads', () => { + it('subscribes without reading an Observation and emits later durable events', async () => { + const f = await fixture() + const observe = vi.spyOn(f.ctx.sessionQuery, 'observeSession') + const stream = f.history.observeEvent({ address: f.address }, f.abort.signal)[Symbol.asyncIterator]() + expect(await stream.next()).toEqual({ done: false, value: { type: 'ready' } }) + expect(observe).not.toHaveBeenCalled() + f.session.append('turn/start', { turn: 1 }) + expect(await stream.next()).toMatchObject({ value: { type: 'event', event: { seq: 0 } } }) + f.abort.abort() + expect(await stream.next()).toMatchObject({ done: true }) + }) + + it('subscribes to sparse projection changes without an Observation', async () => { + const f = await fixture() + const observe = vi.spyOn(f.ctx.sessionQuery, 'observeSession') + let receive: Parameters[0] | undefined + const dispose = vi.fn() + vi.spyOn(f.ctx.sessionProjections, 'onChanged').mockImplementation((callback) => { + receive = callback + return dispose + }) + const stream = f.history.observeProjections({ sessionId: f.session.id }, f.abort.signal)[Symbol.asyncIterator]() + expect(await stream.next()).toMatchObject({ value: { type: 'ready' } }) + receive?.(f.session, 'title', 'new title', SessionSeq(0)) + expect(await stream.next()).toMatchObject({ value: { type: 'projection', key: 'title', value: 'new title', seq: 0 } }) + expect(observe).not.toHaveBeenCalled() + f.abort.abort() + expect(await stream.next()).toMatchObject({ done: true }) + expect(dispose).toHaveBeenCalledOnce() + }) + + it('reads newest assistant messages within the exclusive/inclusive interval and detects actual truncation', async () => { + const f = await fixture() + f.session.append('turn/start', { turn: 1 }) + const first = f.session.append('assistant/message', { + turn: 1, step: 1, + message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'first' }], source: { kind: 'model', provider: 'p', model: 'm' } }), + stream: [], + }, { surfaceOp: 'append' }) + const second = f.session.append('assistant/message', { + turn: 1, step: 2, + message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'second' }], source: { kind: 'model', provider: 'p', model: 'm' } }), + stream: [], + }, { surfaceOp: 'append' }) + f.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const observe = vi.spyOn(f.ctx.sessionQuery, 'observeSession') + const all = await f.history.assistantOutput(f.session.id, -1, 3, 2, f.abort.signal) + expect(all.records.map(record => record.event.seq)).toEqual([second.seq, first.seq]) + expect(all.truncated).toBe(false) + const limited = await f.history.assistantOutput(f.session.id, -1, 3, 1, f.abort.signal) + expect(limited.records.map(record => record.event.seq)).toEqual([second.seq]) + expect(limited.truncated).toBe(true) + const interval = await f.history.assistantOutput(f.session.id, first.seq, second.seq, 2, f.abort.signal) + expect(interval.records.map(record => record.event.seq)).toEqual([second.seq]) + expect(observe).toHaveBeenCalledWith(f.session.id, { signal: f.abort.signal, projectionMode: 'none' }) + await expect(f.history.assistantOutput(f.session.id, -1, 9, 2, f.abort.signal)).rejects.toMatchObject({ code: 'gateway/internal' }) + }) + + it('reads at the observed log end when throughSeq is omitted', async () => { + const f = await fixture() + f.session.append('turn/start', { turn: 1 }) + f.session.append('assistant/message', { + turn: 1, step: 1, + message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'latest' }], source: { kind: 'model', provider: 'p', model: 'm' } }), + stream: [], + }, { surfaceOp: 'append' }) + const page = await f.history.events({ address: f.address }, f.abort.signal) + expect(page.cursor).toBe(f.session.seq - 1) + expect(page.records.at(-1)?.event.seq).toBe(f.session.seq - 1) + }) + + it('keeps the append copy a model-facing replacement shadowed', async () => { + const f = await fixture() + f.session.append('turn/start', { turn: 1 }) + const replaced = f.session.append('assistant/message', { + turn: 1, step: 1, + message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'visible' }], source: { kind: 'model', provider: 'p', model: 'm' } }), + stream: [], + }, { surfaceOp: 'append' }) + // Compaction rewrites the model-facing surface with a replacement that shadows the original; + // the human-readable transcript keeps the shadowed append copy. + const replacement = f.session.append('user/message', createMessage({ + role: 'user', content: [{ type: 'text', text: 'compacted' }], source: { kind: 'user' }, + }), { surfaceOp: { op: 'replace', startSeq: replaced.seq, endSeq: replaced.seq }, sourceEventSeqs: [replaced.seq] }) + const output = await f.history.assistantOutput(f.session.id, -1, replacement.seq, 5, f.abort.signal) + expect(output.records.map(record => record.event.seq)).toEqual([replaced.seq]) + }) + + it('releases the opening Observation when activation has nothing to promote', async () => { + const f = await fixture() + const source = await f.ctx.sessionQuery.observeSession(f.session.id) + const dispose = vi.fn(() => { source[Symbol.dispose]() }) + vi.spyOn(f.ctx.sessionQuery, 'observeSession').mockResolvedValue({ ...source, [Symbol.dispose]: dispose }) + const stream = f.web.follow({ address: f.address, activate: true }, f.abort.signal)[Symbol.asyncIterator]() + await stream.next() + f.abort.abort() + await stream.next() + expect(dispose).toHaveBeenCalled() + }) + + it('releases an acquired Observation when caller cancellation wins its return', async () => { + const f = await fixture() + const source = await f.ctx.sessionQuery.observeSession(f.session.id) + const dispose = vi.fn(() => { source[Symbol.dispose]() }) + vi.spyOn(f.ctx.sessionQuery, 'observeSession').mockImplementation(async () => { + f.abort.abort() + return { ...source, [Symbol.dispose]: dispose } + }) + await expect(f.history.usingObservation({ sessionId: f.session.id, projection: false }, f.abort.signal)).rejects.toBeDefined() + expect(dispose).toHaveBeenCalledOnce() + }) + + it('maps a non-Error query cancellation to a cancelled Remote error', async () => { + const f = await fixture() + // A cross-realm or opaque source may reject with the code alone; the message falls back. + vi.spyOn(f.ctx.sessionQuery, 'observeSession').mockRejectedValue({ code: 'SESSION_QUERY_ABORTED' }) + await expect(f.history.events({ address: f.address }, f.abort.signal)).rejects.toMatchObject({ + code: 'gateway/cancelled', message: 'Session read was cancelled', + }) + }) + + it('maps missing subagent Follow reads through the Address error', async () => { + const f = await fixture() + const stream = f.web.follow({ address: { + kind: 'subagent', parentSessionId: f.session.id, childSessionId: SessionId('missing-child'), mode: 'continuable', + } }, f.abort.signal)[Symbol.asyncIterator]() + await expect(stream.next()).rejects.toMatchObject({ code: 'subagent/not-found' }) + }) + + it('does not promote a live Session when activation was requested', async () => { + const f = await fixture() + const promote = vi.fn() + const web = new WebController(f.ctx, f.history, promote) + const stream = web.follow({ address: f.address, activate: true }, f.abort.signal)[Symbol.asyncIterator]() + expect(await stream.next()).toMatchObject({ value: { type: 'snapshot' } }) + const pending = stream.next() + f.abort.abort() + expect(await pending).toMatchObject({ done: true }) + expect(promote).not.toHaveBeenCalled() + }) +}) diff --git a/packages/api/session-controller/tests/image-read.perf.config.ts b/packages/api/session-controller/tests/image-read.perf.config.ts new file mode 100644 index 0000000000..4b9de690db --- /dev/null +++ b/packages/api/session-controller/tests/image-read.perf.config.ts @@ -0,0 +1,16 @@ +/** Compile the diagnostic's private controller adapter; workspace services remain built package imports. */ + +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: { 'image-read.perf': 'image-read.perf.ts' }, + outDir: '../.dsh-build/image-read', + format: 'esm', + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: true, + tsconfig: '../../../../tsconfig.base.json', + deps: { neverBundle: [/^@deepseek-ai\//], onlyBundle: false }, +}) diff --git a/packages/api/session-controller/tests/image-read.perf.ts b/packages/api/session-controller/tests/image-read.perf.ts new file mode 100644 index 0000000000..18a267131a --- /dev/null +++ b/packages/api/session-controller/tests/image-read.perf.ts @@ -0,0 +1,336 @@ +/** Threshold-free, built-runtime measurements of uncached historical image requests. */ + +import assert from 'node:assert/strict' +import { execFileSync, spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { once } from 'node:events' +import { mkdtemp, rm } from 'node:fs/promises' +import { createServer } from 'node:http' +import { arch, cpus, platform, release, tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' +import { crc32, deflateSync } from 'node:zlib' +import { Context } from '@deepseek-ai/cordis' +import { LocalAttachmentStore } from '@deepseek-ai/dsh-attachment-local' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { MessageId, ToolCallId } from '@deepseek-ai/dsh-llm' +import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import SessionQueryEngine from '@deepseek-ai/dsh-session-query' +import { ApiSessionAgentController } from '../src/agent.ts' +import { SessionCommandController } from '../src/commands.ts' +import type { SessionAttachmentValue } from '../src/types.ts' + +const WORKLOADS = { + typical: { turns: 120, images: 8 }, + tail: { turns: 2_000, images: 32 }, +} as const +const SAMPLES = 5 +const WIDTH = 1024 +const HEIGHT = 768 + +interface Timings { + historyObservations: number + historyQueries: number + historySnapshots: number + observationMs: number + snapshotMs: number + beforeStoreMs: number + storeMs: number + storeFinished: number + started: number +} + +/** Production point reads; full-text search is outside this diagnostic. */ +class ImageReadSessionQuery extends SessionQueryEngine { + override searchSessions(): Promise { + return Promise.reject(new Error('image read diagnostic does not search sessions')) + } + + override searchEvents(): Promise { + return Promise.reject(new Error('image read diagnostic does not search events')) + } +} + +function image(ordinal: number): Uint8Array { + const pixels = Buffer.alloc((WIDTH * 3 + 1) * HEIGHT) + let seed = ordinal + 1 + for (let y = 0; y < HEIGHT; y++) { + for (let x = 0; x < WIDTH; x++) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + const offset = y * (WIDTH * 3 + 1) + 1 + x * 3 + const value = x < WIDTH / 4 ? seed >>> 24 : (Math.floor(x / 16) + Math.floor(y / 16) + ordinal) % 256 + pixels[offset] = value + pixels[offset + 1] = (value + ordinal * 17) % 256 + pixels[offset + 2] = (value + y) % 256 + } + } + const chunk = (name: string, data: Buffer): Buffer => { + const output = Buffer.alloc(data.length + 12) + output.writeUInt32BE(data.length) + output.write(name, 4) + data.copy(output, 8) + output.writeUInt32BE(crc32(output.subarray(4, -4)), output.length - 4) + return output + } + const header = Buffer.alloc(13) + header.writeUInt32BE(WIDTH) + header.writeUInt32BE(HEIGHT, 4) + header[8] = 8 + header[9] = 2 + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk('IHDR', header), chunk('IDAT', deflateSync(pixels)), chunk('IEND', Buffer.alloc(0)), + ]) +} + +function populate(session: Session, turns: number, refs: readonly ImageAttachmentRef[]): void { + for (let turn = 1; turn <= turns; turn++) { + const imageIndex = refs.findIndex((_, ordinal) => Math.floor(ordinal * turns / refs.length) + 1 === turn) + const ref = refs[imageIndex] + session.append('turn/start', { turn }) + session.append('step/start', { turn, step: 1 }) + session.append('user/message', { + id: MessageId(`input-${turn}`), role: 'user', source: { kind: 'user' }, + content: [ + { type: 'text', text: `Inspect synthetic module ${turn}. ` + 'Describe the visible result. '.repeat(8) }, + ...(ref === undefined ? [] : [{ type: 'image' as const, attachment: ref }]), + ], + }, { surfaceOp: 'append' }) + const callId = ToolCallId(`call-${turn}`) + const call = session.append('tool/call', { turn, step: 1, callId, name: 'synthetic_read', arguments: '{}' }) + session.append('tool/result', { + turn, step: 1, + message: { id: MessageId(`result-${turn}`), role: 'tool', toolCallId: callId, isError: false, + source: { kind: 'tool', callId }, content: [{ type: 'text', text: `module ${turn}\n` + 'export const value = 42;\n'.repeat(80) }] }, + }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) + const block = { type: 'text' as const, text: `Result ${turn}. ` + 'Synthetic response. '.repeat(40) } + const stream = new AssistantStreamAccumulator() + stream.push({ time: 1_700_000_000_000 + turn, chunk: { type: 'block-start', index: 0, blockType: 'text' } }) + for (let offset = 0; offset < block.text.length; offset += 32) { + stream.push({ time: 1_700_000_000_001 + turn + offset, chunk: { type: 'text-delta', index: 0, text: block.text.slice(offset, offset + 32) } }) + } + stream.push({ time: 1_700_000_001_000 + turn, chunk: { type: 'block-end', index: 0, block } }) + stream.push({ time: 1_700_000_001_001 + turn, chunk: { type: 'finish', reason: { kind: 'stop' } } }) + session.append('assistant/message', { + turn, step: 1, stream: [...stream.snapshot()], + message: { id: MessageId(`reply-${turn}`), role: 'assistant', content: [block], source: { kind: 'model', provider: 'synthetic', model: 'synthetic' } }, + }, { surfaceOp: 'append' }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } +} + +function memory(): { heapUsedBytes: number; rssBytes: number; peakRssBytes: number } { + assert.ok(global.gc, 'run the diagnostic with --expose-gc') + global.gc() + const usage = process.memoryUsage() + return { heapUsedBytes: usage.heapUsed, rssBytes: usage.rss, peakRssBytes: process.resourceUsage().maxRSS * 1024 } +} + +async function transfer(payloads: readonly string[]): Promise { + let index = 0 + const server = createServer((_request, response) => { + response.setHeader('content-type', 'application/json') + response.end(payloads[index++]) + }) + try { + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + assert.ok(address !== null && typeof address !== 'string') + const samples: number[] = [] + for (const payload of payloads) { + const start = performance.now() + const response: Response = await fetch(`http://127.0.0.1:${address.port}`, { signal: AbortSignal.timeout(15_000) }) + const received: string = await response.text() + samples.push(performance.now() - start) + assert.equal(received, payload) + } + return samples + } finally { + server.closeAllConnections() + if (server.listening) await new Promise((resolve, reject) => { + server.close((error) => { if (error === undefined) resolve(); else reject(error) }) + }) + } +} + +async function measure(workload: keyof typeof WORKLOADS) { + const root = await mkdtemp(join(tmpdir(), 'dsh-image-read-perf-')) + const ctx = new Context() + try { + await ctx.plugin(SessionStore) + await ctx.plugin(ImageReadSessionQuery) + await ctx.plugin(LocalAttachmentStore, { dshHome: root }) + const refs: ImageAttachmentRef[] = [] + for (let ordinal = 0; ordinal < WORKLOADS[workload].images; ordinal++) { + refs.push(await ctx.attachments.saveImage({ data: image(ordinal), mediaType: 'image/png' })) + } + const session = ctx.sessions.create(SessionId('synthetic-image-history'), { meta: { cwd: '/synthetic-image-history' } }) + populate(session, WORKLOADS[workload].turns, refs) + let timing: Timings = { + historyObservations: 0, historyQueries: 0, historySnapshots: 0, + observationMs: 0, snapshotMs: 0, beforeStoreMs: 0, + storeMs: 0, storeFinished: 0, started: 0, + } + let traversedEvents = 0 + let countEvents = false + const observeSession = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery) + ctx.sessionQuery.observeSession = async (...args) => { + timing.historyObservations++ + const start = performance.now() + try { + return await observeSession(...args) + } finally { + timing.observationMs += performance.now() - start + } + } + const getSession = ctx.sessions.get.bind(ctx.sessions) + ctx.sessions.get = (id) => { + timing.historyQueries++ + const found = getSession(id) + return found === undefined ? undefined : new Proxy(found, { + get(target, key) { + const member: unknown = Reflect.get(target, key, target) + if (key !== 'snapshotEvents' || typeof member !== 'function') return member + return (...args: unknown[]) => { + timing.historySnapshots++ + const start = performance.now() + const events = Reflect.apply(member as (...args: unknown[]) => readonly unknown[], target, args) + timing.snapshotMs += performance.now() - start + if (!countEvents) return events + return new Proxy(events, { get(array, property) { + if (property !== Symbol.iterator) { + const value: unknown = Reflect.get(array, property, array) + return value + } + return function* () { for (const event of array) { traversedEvents++; yield event } } + } }) + } + }, + }) + } + const attachments = ctx.attachments + const observedAttachments = new Proxy(attachments, { + get(target, key) { + const member: unknown = Reflect.get(target, key, target) + if (key !== 'readImage' || typeof member !== 'function') return member + return async (...args: unknown[]) => { + const start = performance.now() + timing.beforeStoreMs = start - timing.started + const result: unknown = await Reflect.apply(member, target, args) + timing.storeFinished = performance.now() + timing.storeMs = timing.storeFinished - start + return result + } + }, + }) + const observedContext = new Proxy(ctx, { + get(target, key) { + if (key === 'attachments') return observedAttachments + const value: unknown = Reflect.get(target, key, target) + return value + }, + }) + const controller = new SessionCommandController(observedContext, new ApiSessionAgentController(ctx), '/synthetic-image-history') + const baselineMemory = memory() + const outputs: SessionAttachmentValue[] = [] + const samples = [] + for (const ref of refs) { + timing = { + historyObservations: 0, historyQueries: 0, historySnapshots: 0, + observationMs: 0, snapshotMs: 0, beforeStoreMs: 0, + storeMs: 0, storeFinished: 0, started: performance.now(), + } + const cpu = process.cpuUsage() + const value = await controller.attachment({ sessionId: session.id, attachment: ref }) + const completed = performance.now() + const usedCpu = process.cpuUsage(cpu) + assert.deepEqual(value.attachment, ref) + assert.equal(`sha256:${createHash('sha256').update(Buffer.from(value.data, 'base64')).digest('hex')}`, ref.attachmentId) + samples.push({ + controllerMs: completed - timing.started, + controllerCpuMs: (usedCpu.user + usedCpu.system) / 1000, + historyObservations: timing.historyObservations, + historyQueries: timing.historyQueries, historySnapshots: timing.historySnapshots, + observationMs: timing.observationMs, snapshotMs: timing.snapshotMs, + scanAndDispatchMs: timing.beforeStoreMs - timing.observationMs - timing.snapshotMs, + storeMs: timing.storeMs, base64AndReturnMs: completed - timing.storeFinished, + }) + outputs.push(value) + } + const retainedMemory = memory() + const jsonMs: number[] = [] + const payloads = outputs.map((value) => { + const start = performance.now() + const text = JSON.stringify(value) + jsonMs.push(performance.now() - start) + return text + }) + const loopbackTransferMs = await transfer(payloads) + countEvents = true + timing.historyObservations = 0 + timing.historyQueries = 0 + timing.historySnapshots = 0 + for (const ref of refs) await controller.attachment({ sessionId: session.id, attachment: ref }) + const workCounts = { + historyObservations: timing.historyObservations, historyQueries: timing.historyQueries, + historySnapshots: timing.historySnapshots, traversedEvents, + } + if (process.argv.includes('--expect-no-history')) { + assert.deepEqual(workCounts, { historyObservations: 0, historyQueries: 0, historySnapshots: 0, traversedEvents: 0 }) + } + return { + workload, fixture: { ...WORKLOADS[workload], events: session.seq, width: WIDTH, height: HEIGHT, + imageBytes: refs.map(ref => ref.bytes), attachmentIds: refs.map(ref => ref.attachmentId), + fullReferenceRequestJsonBytes: refs.map(attachment => Buffer.byteLength(JSON.stringify({ sessionId: session.id, attachment }))), + idOnlyRequestJsonBytes: refs.map(ref => Buffer.byteLength(JSON.stringify({ + sessionId: session.id, attachmentId: ref.attachmentId, + }))), + responseJsonBytes: payloads.map(text => Buffer.byteLength(text)) }, + samples, jsonMs, loopbackTransferMs, workCounts, baselineMemory, retainedMemory, + } + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + } +} + +for (const specifier of [ + '@deepseek-ai/cordis', '@deepseek-ai/dsh-attachment-local', '@deepseek-ai/dsh-session', '@deepseek-ai/dsh-session-query', +]) { + assert.match(import.meta.resolve(specifier), /\/lib\/.*\.js$/, `${specifier} must resolve to built JavaScript`) +} +assert.ok(import.meta.url.endsWith('.js'), 'compile this diagnostic before running it') +assert.ok(process.execArgv.every(value => !value.includes('tsx')), 'do not use a TypeScript loader') + +const workload = process.argv[3] +if (process.argv[2] === '--sample') { + assert.ok(workload === 'typical' || workload === 'tail') + process.stdout.write(`${JSON.stringify(await measure(workload))}\n`) +} else { + const samples: unknown[] = [] + for (const name of Object.keys(WORKLOADS)) { + for (let sample = 0; sample < SAMPLES; sample++) { + const child = spawnSync(process.execPath, ['--expose-gc', fileURLToPath(import.meta.url), '--sample', name, + ...process.argv.includes('--expect-no-history') ? ['--expect-no-history'] : []], { + encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024, + env: { ...process.env, NODE_OPTIONS: '', TSX_TSCONFIG_PATH: '' }, + }) + if (child.error !== undefined) throw child.error + assert.equal(child.signal, null, child.stderr) + assert.equal(child.status, 0, child.stderr) + const report: unknown = JSON.parse(child.stdout) + samples.push(report) + } + } + process.stdout.write(JSON.stringify({ + revision: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), + node: process.version, platform: platform(), release: release(), arch: arch(), cpu: cpus()[0]?.model, + clock: 'Fresh child per sample; loaded Session; first uncached request per image; OS file cache may be warm from fixture writes. Store includes file I/O, digest and header validation. observationMs measures the production SessionQuery point observation; snapshotMs measures its lazy event-array acquisition. scanAndDispatchMs is the residual before store entry after subtracting observation and snapshot acquisition. JSON and loopback HTTP transfer are separate measurements over retained responses. Gateway, browser, remote network, upload and model inference are excluded.', + memory: 'GC after setup and after all image responses; Session and all base64 responses remain reachable. Process peak RSS includes fixture creation. No retained-memory budget.', + samples, + }, null, 2) + '\n') +} diff --git a/packages/api/session-controller/tests/lineage.client.spec.ts b/packages/api/session-controller/tests/lineage.client.spec.ts index 16f10c14f4..ae9c4389fe 100644 --- a/packages/api/session-controller/tests/lineage.client.spec.ts +++ b/packages/api/session-controller/tests/lineage.client.spec.ts @@ -9,7 +9,7 @@ import { flattenLineage } from '../src/client/sessions/lineage.ts' const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({ agentAvailable: true, sessionId: id as SessionId, updatedAt, running: false, blank: false, - ...(parent !== undefined ? { parentSessionId: parent as SessionId } : {}), + ...(parent !== undefined ? { parentSessionId: parent as SessionId, origin: 'subagent' as const } : {}), }) describe('Session lineage flattening', () => { @@ -26,6 +26,16 @@ describe('Session lineage flattening', () => { ]) }) + it('keeps historical ordinary forks at root level in input order', () => { + const out = flattenLineage([ + s('source', 10), s('unrelated', 20), + { ...s('legacy-fork', 30), parentSessionId: 'source' as SessionId }, + ]) + expect(out.map(row => [row.sessionId, row.depth])).toEqual([ + ['source', 0], ['unrelated', 0], ['legacy-fork', 0], + ]) + }) + it('degrades an orphan (absent parent) to root level without dropping it', () => { const out = flattenLineage([s('orphan', 20, 'ghost-parent'), s('root', 10)]) expect(out.map(e => [e.sessionId, e.depth])).toEqual([['orphan', 0], ['root', 0]]) diff --git a/packages/api/session-controller/tests/listeners.host.spec.ts b/packages/api/session-controller/tests/listeners.host.spec.ts new file mode 100644 index 0000000000..34207efb3c --- /dev/null +++ b/packages/api/session-controller/tests/listeners.host.spec.ts @@ -0,0 +1,263 @@ +import { Context } from '@deepseek-ai/cordis' +import type { Agent, AssistantStreamFrame } from '@deepseek-ai/dsh-agent' +import { LlmAttemptId } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { EventListener, ListenerBuffer, ProjectionListener, eventItem } from '../src/history/listener.ts' +import { SessionAssistantStreamsListener } from '../src/history/assistant-stream.ts' +import type { SessionFollowFrame, SessionEventEntry } from '../src/types.ts' +import { installSessionReadTestServices } from './test-remote.ts' + +const roots: Context[] = [] +const aborts: AbortController[] = [] +function cancellation() { + const abort = new AbortController() + aborts.push(abort) + return abort +} +afterEach(async () => { + for (const abort of aborts.splice(0)) abort.abort() + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) + vi.restoreAllMocks() +}) +async function fixture() { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) + const session = ctx.sessions.create(SessionId('listener-target'), { meta: { cwd: '/listeners' } }) + const abort = cancellation() + const partial: Partial = { id: session.id, session } + const agent = partial as Agent + const emit = (frame: AssistantStreamFrame) => { ctx.emit('agent/assistant-stream', { agent, frame }) } + return { ctx, session, abort, emit, agent } +} +const start = (revision = 1): AssistantStreamFrame => ({ type: 'start', attemptId: LlmAttemptId('attempt'), revision, turn: 1, step: 1 }) + +describe('ListenerBuffer', () => { + it('orders heterogeneous inputs lazily, supports prepend, and admits only one consumer', async () => { + const { ctx, abort } = await fixture() + using buffer = new ListenerBuffer(ctx, abort.signal) + const convert = vi.fn((value: number) => [`n:${value}`]) + const numbers = buffer.createInput(convert) + const words = buffer.createInput(value => [value]) + numbers.append(2) + words.prepend('first') + expect(convert).not.toHaveBeenCalled() + const values = buffer.items()[Symbol.asyncIterator]() + expect(await values.next()).toEqual({ done: false, value: 'first' }) + expect(await values.next()).toEqual({ done: false, value: 'n:2' }) + await expect(buffer.items()[Symbol.asyncIterator]().next()).rejects.toMatchObject({ code: 'gateway/internal' }) + const waiting = values.next() + numbers.append(3) + expect(await waiting).toMatchObject({ value: 'n:3' }) + await values.return?.() + const action = vi.fn() + buffer.capture(action) + numbers.append(4) + words.prepend('closed') + expect(action).not.toHaveBeenCalled() + buffer.own(action) + expect(action).toHaveBeenCalledOnce() + expect(() => buffer.createInput(value => [value])).toThrow('closed') + }) + + it('propagates captured failure both before and during transformation', async () => { + const { ctx, abort } = await fixture() + using first = new ListenerBuffer(ctx, abort.signal) + first.capture(() => { throw new Error('captured') }) + await expect(first.items()[Symbol.asyncIterator]().next()).rejects.toThrow('captured') + using second = new ListenerBuffer(ctx, abort.signal) + const input = second.createInput(function* (value) { + second.capture(() => { throw new Error('transform failure') }) + yield value + }) + input.append(1) + await expect(second.items()[Symbol.asyncIterator]().next()).rejects.toThrow('transform failure') + }) + + it('does not emit after transformation closes its own buffer', async () => { + const { ctx, abort } = await fixture() + using buffer = new ListenerBuffer(ctx, abort.signal) + buffer.createInput(function* (value) { buffer[Symbol.dispose](); yield value }).append(1) + expect(await buffer.items()[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined }) + }) + + it('releases abort registration when effect installation fails', async () => { + const { ctx, abort } = await fixture() + const remove = vi.spyOn(abort.signal, 'removeEventListener') + vi.spyOn(ctx, 'effect').mockImplementation(() => { throw new Error('effect failure') }) + expect(() => new ListenerBuffer(ctx, abort.signal)).toThrow('effect failure') + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)) + }) +}) + +describe('EventListener', () => { + it('starts at the first notification without acquiring a snapshot', async () => { + const { ctx, session, abort } = await fixture() + using listener = new EventListener(ctx, session.id, abort.signal) + const event = session.append('turn/start', { turn: 1 }) + const values = listener.events()[Symbol.asyncIterator]() + expect(await values.next()).toMatchObject({ value: { type: 'event', event } }) + abort.abort() + expect(await values.next()).toMatchObject({ done: true }) + }) + + it('subscribes before an empty Session is created and follows its first later event', async () => { + const { ctx, abort } = await fixture() + const id = SessionId('created-after-bind') + using listener = new EventListener(ctx, id, abort.signal) + const session = ctx.sessions.create(id) + const values = listener.events()[Symbol.asyncIterator]() + const waiting = values.next() + const event = session.append('turn/start', { turn: 1 }) + expect(await waiting).toMatchObject({ value: { event } }) + await values.return?.() + }) + + it('filters buffered events through a supplied cursor', async () => { + const { ctx, session, abort } = await fixture() + using listener = new EventListener(ctx, session.id, abort.signal) + session.append('turn/start', { turn: 1 }) + const event = session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + listener.setCursor(0) + const values = listener.events()[Symbol.asyncIterator]() + expect(await values.next()).toMatchObject({ value: { event } }) + await values.return?.() + }) + + it('leaves an external buffer open and drops notifications after listener disposal', async () => { + const { ctx, session, abort } = await fixture() + using queue = new ListenerBuffer(ctx, abort.signal) + const listener = new EventListener(ctx, session.id, abort.signal, queue) + session.append('turn/start', { turn: 1 }) + await expect(listener.events()[Symbol.asyncIterator]().next()).rejects.toThrow('externally owned') + listener[Symbol.dispose]() + const values = queue.items()[Symbol.asyncIterator]() + const next = values.next() + abort.abort() + expect(await next).toMatchObject({ done: true }) + }) + + it('stops within a creation batch when its binding is disposed', async () => { + const { ctx, session, abort } = await fixture() + session.append('turn/start', { turn: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + using queue = new ListenerBuffer(ctx, abort.signal) + const listener = new EventListener(ctx, session.id, abort.signal, queue) + listener.setCursor(-1) + ctx.emit('session/created', session) + const values = queue.items()[Symbol.asyncIterator]() + expect(await values.next()).toMatchObject({ value: { event: { seq: 0 } } }) + listener[Symbol.dispose]() + const waiting = values.next() + abort.abort() + expect(await waiting).toMatchObject({ done: true }) + }) + + it('cleans a partially installed registration when a later one fails', async () => { + const { ctx, session, abort } = await fixture() + const on = ctx.on.bind(ctx) + let count = 0 + vi.spyOn(ctx, 'on').mockImplementation((...args) => { + if (++count === 2) throw new Error('registration failure') + return on(...args) + }) + expect(() => new EventListener(ctx, session.id, abort.signal)).toThrow('registration failure') + }) + + it('handles raw events and rejects a noncontiguous supplied creation prefix', async () => { + const { session } = await fixture() + const event = session.append('turn/start', { turn: 1 }) + expect(eventItem({ type: 'event', session, event })).toEqual({ events: [event], next: 1 }) + expect(eventItem({ type: 'created', session, end: 0 })).toEqual({ events: [], next: undefined }) + vi.spyOn(session, 'snapshotEvents').mockReturnValue([{ ...event, seq: SessionSeq(1) }]) + expect(() => eventItem({ type: 'created', session, end: 2 }, 0)).toThrow('skipped seq') + expect(eventItem({ type: 'created', session, end: 2 })).toMatchObject({ next: 2 }) + }) +}) + +describe('ProjectionListener', () => { + it('emits sparse changes only for the selected Session', async () => { + const { ctx, session, abort } = await fixture() + let receive!: Parameters[0] + const dispose = vi.fn() + vi.spyOn(ctx.sessionProjections, 'onChanged').mockImplementation((listener) => { receive = listener; return dispose }) + using listener = new ProjectionListener(ctx, session.id, abort.signal) + const other = ctx.sessions.create(SessionId('other')) + receive(other, 'subagent', null, SessionSeq(0)) + receive(session, 'subagent', null, SessionSeq(1)) + const changes = listener.changes()[Symbol.asyncIterator]() + expect(await changes.next()).toMatchObject({ value: { type: 'projection', key: 'subagent', value: null, seq: 1 } }) + abort.abort() + expect(await changes.next()).toMatchObject({ done: true }) + expect(dispose).toHaveBeenCalledOnce() + }) + + it('releases its buffer when projection subscription fails', async () => { + const { ctx, session, abort } = await fixture() + const remove = vi.spyOn(abort.signal, 'removeEventListener') + vi.spyOn(ctx.sessionProjections, 'onChanged').mockImplementation(() => { throw new Error('projection subscribe failed') }) + expect(() => new ProjectionListener(ctx, session.id, abort.signal)).toThrow('projection subscribe failed') + expect(remove).toHaveBeenCalled() + }) +}) + +describe('AssistantStreamListener', () => { + it('cuts baseline-covered arrivals and clears reconnect state on Agent disposal', async () => { + const { ctx, session, abort, emit, agent } = await fixture() + const streams = new SessionAssistantStreamsListener(ctx) + using queue = new ListenerBuffer(ctx, abort.signal) + using listener = streams.bind(session.id, abort.signal, queue) + expect(listener.snapshot()).toEqual({ baseline: { revision: 0 }, ordinal: 0 }) + emit(start()) + expect(listener.snapshot()).toMatchObject({ baseline: { revision: 1 }, ordinal: 1 }) + listener.setOrdinalCut(1) + const frames = queue.items()[Symbol.asyncIterator]() + const end: AssistantStreamFrame = { type: 'end', attemptId: LlmAttemptId('attempt'), revision: 2, index: 0, outcome: { kind: 'abandoned' } } + emit(end) + expect(await frames.next()).toMatchObject({ value: { type: 'assistant-stream', frame: end } }) + ctx.emit('agent/disposed', { agent }) + expect(listener.snapshot()).toEqual({ baseline: { revision: 0 }, ordinal: 2 }) + abort.abort() + expect(await frames.next()).toMatchObject({ done: true }) + }) + + it('records the durable cursor for an attempt started after a committed event', async () => { + const { ctx, session, abort, emit } = await fixture() + session.append('turn/start', { turn: 1 }) + const streams = new SessionAssistantStreamsListener(ctx) + using queue = new ListenerBuffer(ctx, abort.signal) + using listener = streams.bind(session.id, abort.signal, queue) + emit(start()) + listener.setOrdinalCut(0) + const frames = queue.items()[Symbol.asyncIterator]() + expect(await frames.next()).toMatchObject({ value: { frame: { type: 'start', startedAfterSeq: 0 } } }) + await frames.return?.() + }) + + it('leaves queue ownership intact and drops notifications after unbinding', async () => { + const { ctx, session, abort, emit } = await fixture() + const streams = new SessionAssistantStreamsListener(ctx) + using queue = new ListenerBuffer(ctx, abort.signal) + const listener = streams.bind(session.id, abort.signal, queue) + emit(start()) + listener[Symbol.dispose]() + const next = queue.items()[Symbol.asyncIterator]().next() + abort.abort() + expect(await next).toMatchObject({ done: true }) + }) + + it('propagates registration failure without closing the shared queue', async () => { + const { ctx, session, abort } = await fixture() + const streams = new SessionAssistantStreamsListener(ctx) + using queue = new ListenerBuffer(ctx, abort.signal) + vi.spyOn(ctx, 'on').mockImplementation(() => { throw new Error('assistant subscribe failed') }) + expect(() => streams.bind(session.id, abort.signal, queue)).toThrow('assistant subscribe failed') + // The failed binding leaves the shared queue usable for the rest of the connection. + const input = queue.createInput(value => [value]) + const event = session.append('turn/start', { turn: 1 }) + input.append({ type: 'event', event }) + await expect(queue.items()[Symbol.asyncIterator]().next()).resolves.toMatchObject({ value: { type: 'event', event } }) + }) +}) diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index a4f9367735..9661f66a01 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -862,7 +862,7 @@ describe('subagent catalogs', () => { childSessionId: S2, mode: 'continuable', }, - assistantStream: true, + activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 }, }, @@ -958,7 +958,7 @@ describe('subagent catalogs', () => { if (source === 'control') { manager.handleControlFrame({ type: 'baseline', value: { projections: { [S1]: baseline } } }) } else { - mock.stream(FOLLOW, followScript(ok({ records: [], hasMore: false, projections: baseline }))) + mock.stream(FOLLOW, followScript(ok({ cursor: baseline.asOfSeq, records: [], hasMore: false, projections: baseline }))) await session.open() } @@ -983,7 +983,7 @@ describe('subagent catalogs', () => { if (source === 'control') { manager.handleControlFrame({ type: 'baseline', value: { projections: { [S1]: baseline } } }) } else { - mock.stream(FOLLOW, followScript(ok({ records: [], hasMore: false, projections: baseline }))) + mock.stream(FOLLOW, followScript(ok({ cursor: baseline.asOfSeq, records: [], hasMore: false, projections: baseline }))) await manager.get(S1).open() } response.resolve(ok({ kind: 'migration-required', values: { @@ -1261,7 +1261,8 @@ describe('remaining branches', () => { expect(remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: S1, atSeq: 7, ...permission }) expect(remote.session.follow).not.toHaveBeenCalled() - expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S2, parentSessionId: S1 }) + expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S2 }) + expect(manager.getListSnapshot().items[0]).not.toHaveProperty('parentSessionId') }) it('reconciles a fork child published before workspace attachment fails', async ({ mock, remote }) => { @@ -1273,13 +1274,12 @@ describe('remaining branches', () => { expect(result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S2, - parentSessionId: S1, blank: true, })]) - manager.handleSessionAdded(summary(S2, { blank: true, parentSessionId: S1 })) + manager.handleSessionAdded(summary(S2, { blank: true })) expect(manager.getListSnapshot().items[0]?.blank).toBe(true) - manager.handleSessionAdded(summary(S2, { blank: false, parentSessionId: S1 })) + manager.handleSessionAdded(summary(S2, { blank: false })) expect(manager.getListSnapshot().items[0]?.blank).toBe(false) }) @@ -1545,6 +1545,7 @@ describe('connected generation', () => { it('refreshes query baselines without rebuilding independently resumed Session sources', async ({ mock, remote, start }) => { mock.stream(FOLLOW, followScript(ok({ + cursor: 5, records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false, modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' }, diff --git a/packages/api/session-controller/tests/projection-discovery.host.spec.ts b/packages/api/session-controller/tests/projection-discovery.host.spec.ts index 150b82d415..539962a622 100644 --- a/packages/api/session-controller/tests/projection-discovery.host.spec.ts +++ b/packages/api/session-controller/tests/projection-discovery.host.spec.ts @@ -239,7 +239,7 @@ describe('Session projection discovery', () => { kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: 'current-child', mode: 'continuable', label: 'worker' }] }, }) - expect(observe).toHaveBeenCalledWith(parent.header.id, { signal }) + expect(observe).toHaveBeenCalledWith(parent.header.id, { signal, projectionMode: 'all' }) expect(open).toHaveBeenCalledWith(parent.header.id, 'read', { signal }) expect(fixture.ctx.sessions.get(parent.header.id)).toBeUndefined() expect(fixture.ctx.agents.get(parent.header.id)).toBeUndefined() @@ -310,7 +310,7 @@ describe('Session projection discovery', () => { .rejects.toMatchObject({ code: 'session/projections-unavailable' }) }) - it.each([false, true])('rechecks live history when the stored source disappears after preflight (attached: %s)', async (attached) => { + it.each([false, true])('returns the observation not-found result without a second live lookup (attached: %s)', async (attached) => { const fixture = await harness() const parent = await stored(fixture, SessionId('missing-after-preflight')) const snapshot = await fixture.ctx.sessionPersistence.stat(parent.header.id) @@ -322,9 +322,8 @@ describe('Session projection discovery', () => { }) const result = await fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal) - expect(result).toEqual(attached - ? { kind: 'sequenced', ...fixture.ctx.sessionProjections.snapshot(fixture.ctx.sessions.get(parent.header.id)!) } - : null) + expect(result).toBeNull() + expect(fixture.ctx.sessions.get(parent.header.id) !== undefined).toBe(attached) }) it('serves the live registry cut instead of a conflicting cached hint', async () => { @@ -342,7 +341,7 @@ describe('Session projection discovery', () => { expect(cached).not.toHaveBeenCalled() }) - it.each([true, false])('prefers a Session attached during stat (stored header present: %s)', async (present) => { + it.each([true, false])('uses migration preflight before observing a Session attached during stat (stored header present: %s)', async (present) => { const fixture = await harness() const parent = await stored(fixture, SessionId('stat-race'), {}, 3) const snapshot = await fixture.ctx.sessionPersistence.stat(parent.header.id) @@ -358,7 +357,9 @@ describe('Session projection discovery', () => { const live = fixture.ctx.sessions.create(parent.header.id, { meta: parent.header }) live.append('turn/start', { turn: 1 }) finish.resolve(present ? snapshot : undefined) - await expect(operation).resolves.toEqual({ kind: 'sequenced', ...fixture.ctx.sessionProjections.snapshot(live) }) + await expect(operation).resolves.toEqual(present + ? { kind: 'migration-required', values: {} } + : { kind: 'sequenced', ...fixture.ctx.sessionProjections.snapshot(live) }) } finally { finish.resolve(undefined) await Promise.allSettled([operation]) diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index 40e1f3c95f..ab1e185744 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -214,7 +214,7 @@ describe('Session tail-page seeding', () => { it('treats a blockless response as no reset: pushed values survive', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) - mock.stream(FOLLOW, followScript(ok({ records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false }))) + mock.stream(FOLLOW, followScript(ok({ cursor: 5, records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false }))) await session.open() session.projections.apply('test/marks', { marks: ['pushed'] }, SessionSeq(9)) await session.resync() diff --git a/packages/api/session-controller/tests/reference-ownership.client.spec.ts b/packages/api/session-controller/tests/reference-ownership.client.spec.ts index 16de92ef02..b0563eb879 100644 --- a/packages/api/session-controller/tests/reference-ownership.client.spec.ts +++ b/packages/api/session-controller/tests/reference-ownership.client.spec.ts @@ -21,7 +21,7 @@ declare module '@deepseek-ai/dsh-api-session-controller/client' { const viewSource: SessionReferenceSource = 'referenceTestView' const workSource: SessionReferenceSource = 'referenceTestWork' const ID = SessionId('reference-session') -const EMPTY_HISTORY = ok({ records: [], hasMore: false }) +const EMPTY_HISTORY = ok({ cursor: -1, records: [], hasMore: false }) const it = createClientTest({ roster: webApp.closure(['@deepseek-ai/dsh-api-gateway']) }) async function bench(mock: RemoteMock, start: () => Promise, listed = true) { diff --git a/packages/api/session-controller/tests/remote-access.host.spec.ts b/packages/api/session-controller/tests/remote-access.host.spec.ts index 42b78e5190..8be0dac78e 100644 --- a/packages/api/session-controller/tests/remote-access.host.spec.ts +++ b/packages/api/session-controller/tests/remote-access.host.spec.ts @@ -1,13 +1,14 @@ /** * Session Controller under non-owner callers: the Session resource kind it - * registers, the Session readers of `session/page` and `session/follow`, - * `session/list`, `session/search`, and `session/control` called through a - * `global` role and filtered per Session through `remoteAccess.allows()`, the caller recorded - * on prompt sources, and `session/projections` admitted per Session through - * the Gateway. + * registers and its default resolver, which supplies the identities of + * `session/page` and `session/follow`, `session/list`, `session/search`, and + * `session/control` called through a `global` role and filtered per Session + * through `remoteAccess.allows()`, the caller recorded on prompt sources, and + * `session/projections` admitted per Session through the Gateway. */ import { Context } from '@deepseek-ai/cordis' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { Agent } from '@deepseek-ai/dsh-agent' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' import TypertGatewayService from '@deepseek-ai/dsh-api-gateway' @@ -86,8 +87,8 @@ async function harness(): Promise<{ await ctx.plugin(TypertRegistry) await mountAgentLoopTestDependencies(ctx) const loop = await mountAgentLoopTestHarness(ctx) - const shared = await loop.create(SHARED) - const hidden = await loop.create(PRIVATE) + const shared = await loop.create(SHARED, {}, { cwd: '/tmp' }) + const hidden = await loop.create(PRIVATE, {}, { cwd: '/tmp' }) const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const as = (principal: Principal, method: string): Context => { const invocation: RemoteInvocation = { @@ -107,18 +108,77 @@ function message(text: string) { } describe('Session Controller access', () => { - it('owns the Session resource kind and its default sessionId field', async () => { + it('owns the Session resource kind and its default resolver', async () => { const { ctx } = await harness() expect(() => ctx.typert.resources.register('session', { field: 'sessionId' })).toThrow(/already registered/) }) it('reads the Session resource of page and follow requests from the addressed Session', async () => { - const { controller } = await harness() + const { ctx, controller } = await harness() + const resource = ctx.typert.resources.get('session') + if (resource?.resolve === undefined) throw new Error('Session resource resolver is missing') for (const method of ['page', 'follow']) { - const reader = remoteMethods(controller).find(marker => marker.method === method)?.resources?.['session'] - expect(reader?.({ address: { kind: 'session', sessionId: SHARED } })).toBe(SHARED) - // A subagent address reads the child transcript; whether the parent's access covers it is the policy's rule. - expect(reader?.({ address: { kind: 'subagent', parentSessionId: SHARED, childSessionId: PRIVATE } })).toBe(PRIVATE) + const marker = remoteMethods(controller).find(marker => marker.method === method) + expect(marker).toBeDefined() + expect(marker?.resources?.['session']).toBeUndefined() + } + const resolve = (request: unknown) => resource.resolve({ args: { request }, parameters: [{ name: 'request', wire: 'request' }] }) + expect(resolve({ address: { kind: 'session', sessionId: SHARED } })).toBe(SHARED) + expect(resolve({ address: { kind: 'subagent', parentSessionId: SHARED, childSessionId: PRIVATE } })).toBe(PRIVATE) + }) + + it('resolves named Session IDs and first-object requests without treating unrelated strings as identities', async () => { + const { ctx } = await harness() + const resource = ctx.typert.resources.get('session') + if (resource?.resolve === undefined) throw new Error('Session resource resolver is missing') + const resolve = (args: Readonly>, ...names: string[]) => resource.resolve({ + args, parameters: names.map(name => ({ name, wire: name })), + }) + expect(resolve({ sessionId: SHARED, zzz: 'other' }, 'sessionId', 'zzz')).toBe(SHARED) + expect(resolve({ options: { sessionId: SHARED }, yyy: 'other' }, 'options', 'yyy')).toBe(SHARED) + expect(resolve({ options: { address: { kind: 'session', sessionId: SHARED } }, xxx: 'other' }, 'options', 'xxx')).toBe(SHARED) + expect(resolve({ options: { address: { kind: 'subagent', parentSessionId: SHARED, childSessionId: PRIVATE } } }, 'options')).toBe(PRIVATE) + expect(resolve({ ns: SHARED, value: 'other' }, 'ns', 'value')).toBeUndefined() + expect(resolve({ ref: SHARED, value: 'other' }, 'ref', 'value')).toBeUndefined() + expect(resolve({ zzz: 'other', sessionId: SHARED }, 'zzz', 'sessionId')).toBe(SHARED) + expect(resolve({ first: {}, later: { sessionId: SHARED } }, 'first', 'later')).toBeUndefined() + expect(resolve({ count: 1, tags: [], empty: null, options: { sessionId: SHARED } }, 'count', 'tags', 'empty', 'options')).toBe(SHARED) + expect(resolve({}, 'sessionId')).toBeUndefined() + expect(resolve({})).toBeUndefined() + expect(resource.resolve({ args: { target: SHARED }, parameters: [{ name: 'sessionId', wire: 'target' }] })).toBe(SHARED) + }) + + it.each(['page', 'follow'])('authorizes %s by Address and rejects coexisting Session IDs through SRC dispatch', async (method) => { + const { ctx, shared, hidden } = await harness() + shared.inbox.append('next-turn', message('shared history')) + hidden.inbox.append('next-turn', message('private history')) + await ctx.plugin(TypertGatewayService) + guestPolicy(ctx, { + role: { name: 'test:history-reader', resource: 'session', actions: ['session/page', 'session/follow'] }, + resource: { kind: 'session', id: SHARED }, + }) + const abort = new AbortController() + const peer: PeerScope = { id: 'history-access-peer' as PeerId, ctx, principal: GUEST, interactions: 'answer', signal: abort.signal, dispose: async () => {} } + const invoke = async (request: Record): Promise => { + const call = { peer, namespace: 'session', method, args: { request }, signal: abort.signal } + if (method === 'page') return ctx.typertGateway.invoke(call) + const stream = (await ctx.typertGateway.stream(call))[Symbol.asyncIterator]() + try { + return (await stream.next()).value + } finally { + await stream.return?.() + } + } + const request = (sessionId: SessionId) => ({ address: { kind: 'session', sessionId }, throughSeq: 0 }) + try { + await expect(invoke(request(SHARED))).resolves.toBeDefined() + await expect(invoke(request(PRIVATE))).rejects.toMatchObject({ code: 'remote-access/denied', details: { reason: 'no-allow' } }) + for (const target of [SHARED, PRIVATE]) { + await expect(invoke({ ...request(target), sessionId: SHARED })) + .rejects.toMatchObject({ code: 'remote-access/denied', details: { reason: 'resource-invalid' } }) + } + } finally { + abort.abort() } }) @@ -209,6 +269,34 @@ describe('Session Controller access', () => { ]) }) + it('keeps gateway permission checks while allowing an image referenced only by another Session', async () => { + const { ctx, hidden } = await harness() + await ctx.plugin(TypertGatewayService) + const bindings = guestPolicy(ctx) + const ref = { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + mediaType: 'image/png' as const, bytes: 2, width: 1, height: 1, + } + hidden.session.append('user/message', createUserMessage({ + content: [{ type: 'image', attachment: ref }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) })) + ctx.attachments.readImage = readImage + const read = (sessionId: SessionId): Promise => guestCall(ctx, 'attachment', { + request: { sessionId, attachment: ref }, + }) + await expect(read(SHARED)).rejects.toMatchObject({ code: 'remote-access/denied' }) + expect(readImage).not.toHaveBeenCalled() + bindings.push({ + role: { name: 'test:attachment-reader', resource: 'session', actions: ['session/attachment'] }, + resource: { kind: 'session', id: SHARED }, + }) + await expect(read(SHARED)).resolves.toEqual({ attachment: ref, data: 'AQI=' }) + expect(readImage).toHaveBeenCalledExactlyOnceWith(ref) + await expect(read(PRIVATE)).rejects.toMatchObject({ code: 'remote-access/denied' }) + expect(readImage).toHaveBeenCalledTimes(1) + }) + it('lets a non-owner read projections only in a Session its Session role names', async () => { const { ctx } = await harness() await ctx.plugin(TypertGatewayService) diff --git a/packages/api/session-controller/tests/remote/history.client.ts b/packages/api/session-controller/tests/remote/history.client.ts index e954f89a2b..080571b09d 100644 --- a/packages/api/session-controller/tests/remote/history.client.ts +++ b/packages/api/session-controller/tests/remote/history.client.ts @@ -1,7 +1,7 @@ /** Pure history response builders for assembled Session tests. */ import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types' import type { - SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, SessionPage, SessionProjectionBaseline, + SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, SessionPage, SessionEventsPage, SessionProjectionBaseline, } from '../../src/types.ts' import { historyRecordLastSeq } from '../../src/client/sessions/history-records.ts' @@ -11,8 +11,8 @@ import { historyRecordLastSeq } from '../../src/client/sessions/history-records. * @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) } +export function pageThrough(page: SessionPage, throughSeq: number): SessionEventsPage { + return { ...page, cursor: throughSeq, records: page.records.filter(record => historyRecordLastSeq(record) <= throughSeq) } } /** diff --git a/packages/api/session-controller/tests/remote/session.client.ts b/packages/api/session-controller/tests/remote/session.client.ts index 591dd60985..f920d1592e 100644 --- a/packages/api/session-controller/tests/remote/session.client.ts +++ b/packages/api/session-controller/tests/remote/session.client.ts @@ -12,7 +12,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, - SessionPage, SessionPageRequest, + SessionEventsPage, SessionPageRequest, } from '../../src/types.ts' import { entries, historyValue } from '../event-script.client.ts' import { followSnapshot, pageThrough } from './history.client.ts' @@ -24,7 +24,7 @@ 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> +export type HistoryAnswer = RemoteResult | Promise> /** A history answer, or a function of the request that produces one. */ export type HistorySource = HistoryAnswer | ((request: Request) => HistoryAnswer) @@ -43,7 +43,7 @@ export function err(error: RemoteFailure): RemoteResult { * @param hasMore - whether older history exists. * @returns the success result. */ -export function history(events: readonly SessionEvent[], hasMore = false): RemoteResult { +export function history(events: readonly SessionEvent[], hasMore = false): RemoteResult { return ok(historyValue(events, hasMore)) } @@ -102,11 +102,11 @@ export function followScript( */ export function pageRule( history: HistorySource, -): UnaryRuleFn>> { +): UnaryRuleFn>> { return async (page) => { const result = await answer(history, page) if (!result.ok) return result - return ok(pageThrough(result.value, page.throughSeq)) + return ok(pageThrough(result.value, page.throughSeq ?? result.value.cursor)) } } @@ -144,12 +144,12 @@ export const sessionWorld: RemoteTable = { attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==', }), - 'session/page': pageRule(ok({ records: [], hasMore: false })), + 'session/page': pageRule(ok({ cursor: -1, records: [], hasMore: false })), 'session/projections': ok({ kind: 'sequenced', asOfSeq: -1, values: { subagentCatalog: [] } }), 'subagents/prompt': ok({ messageId: 'fake-message' }), 'subagents/interruptByParent': ok({ accepted: true }), }, stream: { - 'session/follow': followScript(ok({ records: [], hasMore: false })), + 'session/follow': followScript(ok({ cursor: -1, records: [], hasMore: false })), }, } diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 1bc9297103..560c05514f 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -11,7 +11,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' -import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' @@ -574,10 +573,7 @@ describe('subagent ownership fence', () => { const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) await ctx.sessionProjections.register(subagentIdentityProjectionDefinition) - const history = await new SessionHistoryController( - ctx, - (observation) => { observation[Symbol.dispose]() }, - ).page({ + const history = await ctx.sessionController.events({ address: { kind: 'subagent', parentSessionId: meta.parentSession as SessionId, diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index 5b00f060f1..72b21aa31e 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -328,7 +328,7 @@ describe('sessions.fork', () => { }, ) - it('cuts exactly at a completed turn and records lineage and cwd', async () => { + it('cuts exactly at a completed turn and retains cwd without source lineage', async () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-source', 2) const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: 2 })) @@ -338,7 +338,7 @@ describe('sessions.fork', () => { expect(child?.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'user/message', 'turn/end', 'session/end-seed', ]) - expect(child?.header.parentSession).toBe(source.id) + expect(child?.header.parentSession).toBeUndefined() expect(child?.header.cwd).toBe('/proj') await ctx.fiber.dispose() }) @@ -379,10 +379,10 @@ describe('sessions.fork', () => { if (!response.ok) return expect(attachSession).toHaveBeenCalledWith(response.value.sessionId) expect(ctx.sessions.get(response.value.sessionId)?.header).toMatchObject({ - parentSession: grandchild.id, cwd: '/proj', }) expect(ctx.sessions.get(response.value.sessionId)?.header.origin).toBeUndefined() + expect(ctx.sessions.get(response.value.sessionId)?.header.parentSession).toBeUndefined() await ctx.fiber.dispose() }) @@ -435,10 +435,10 @@ describe('sessions.fork', () => { expect(resume).not.toHaveBeenCalled() expect(ctx.agents.get(sourceId)).toBeUndefined() expect(ctx.sessions.get(response.value.sessionId)?.header).toMatchObject({ - parentSession: sourceId, cwd: '/proj', }) expect(ctx.sessions.get(response.value.sessionId)?.header.origin).toBeUndefined() + expect(ctx.sessions.get(response.value.sessionId)?.header.parentSession).toBeUndefined() await ctx.fiber.dispose() }) diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts index f1954ed935..328e797677 100644 --- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts +++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts @@ -8,7 +8,8 @@ import SessionStore from '@deepseek-ai/dsh-session' import { LlmAttemptId, ToolCallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' +import { HistoryController } from '../src/history/controller.ts' +import { WebController } from '../src/web/controller.ts' import type { SessionFollowFrame, SessionPage, SessionWireEvent } from '@deepseek-ai/dsh-api-session-controller/types' import { createSessionTestRemote, installSessionReadTestServices } from './test-remote.ts' @@ -57,6 +58,12 @@ async function harness(): Promise<{ ctx: Context }> { return { ctx } } +/** Initialize the independent snapshot owner and Web aggregation for direct journal tests. */ +async function controllers(ctx: Context): Promise<{ history: HistoryController; web: WebController }> { + const history = new HistoryController(ctx) + return { history, web: new WebController(ctx, history, (observation) => { observation[Symbol.dispose]() }) } +} + /** Drain one Session follow until `count` event frames arrive. */ async function collect( iterable: AsyncIterable, @@ -73,11 +80,11 @@ async function collect( /** Open follow and wait until its cursor is fixed before appending fixtures. */ async function openFollow( - history: SessionHistoryController, + web: WebController, sessionId: SessionId, signal: AbortSignal, ): Promise> { - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId }, }, signal)[Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ @@ -107,9 +114,9 @@ describe('Session history raw journal', () => { it('opens an empty opted-in Assistant baseline before any live attempt exists', async () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -127,7 +134,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const originalObserve = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery) const entered = Promise.withResolvers() const release = Promise.withResolvers() @@ -137,7 +144,7 @@ describe('Session history raw journal', () => { return originalObserve(...args) }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -182,7 +189,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const attemptId = LlmAttemptId('live-follow-attempt') const emit = (frame: AssistantStreamFrame): void => { ctx.emit('agent/assistant-stream', { agent, frame }) @@ -197,7 +204,7 @@ describe('Session history raw journal', () => { time: 1, chunk: firstChunk, }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -249,7 +256,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const attemptId = LlmAttemptId(`${session.id}:1`) ctx.emit('agent/assistant-stream', { agent, @@ -267,7 +274,7 @@ describe('Session history raw journal', () => { }, }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -311,7 +318,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const attemptId = LlmAttemptId('revision-gap-attempt') ctx.emit('agent/assistant-stream', { agent, @@ -329,7 +336,7 @@ describe('Session history raw journal', () => { }, }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -351,7 +358,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const attemptId = LlmAttemptId('dense-index-attempt') ctx.emit('agent/assistant-stream', { agent, @@ -369,7 +376,7 @@ describe('Session history raw journal', () => { }, }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -391,7 +398,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const attemptId = LlmAttemptId('cached-baseline-attempt') ctx.emit('agent/assistant-stream', { agent, @@ -402,12 +409,12 @@ describe('Session history raw journal', () => { }) const firstAbort = new AbortController() - const firstIterator = history.follow({ + const firstIterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, firstAbort.signal)[Symbol.asyncIterator]() const secondAbort = new AbortController() - const secondIterator = history.follow({ + const secondIterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, secondAbort.signal)[Symbol.asyncIterator]() @@ -431,9 +438,9 @@ describe('Session history raw journal', () => { it('opens an empty Assistant baseline before the target Agent emits frames', async () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -456,9 +463,9 @@ describe('Session history raw journal', () => { const target = ctx.sessions.create(undefined, { meta: { cwd: '/target' } }) const other = ctx.sessions.create(undefined, { meta: { cwd: '/other' } }) const otherAgent = { id: other.id, session: other, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: target.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -489,7 +496,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const observationStarted = Promise.withResolvers() const releaseObservation = Promise.withResolvers() const originalObserve = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery) @@ -499,7 +506,7 @@ describe('Session history raw journal', () => { return await originalObserve(sessionId, options) }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -539,7 +546,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const attemptId = LlmAttemptId(`${session.id}:1`) ctx.emit('agent/assistant-stream', { agent, @@ -557,7 +564,7 @@ describe('Session history raw journal', () => { return await originalObserve(sessionId, options) }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -613,9 +620,9 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, }, abort.signal)[Symbol.asyncIterator]() const opening = await iterator.next() @@ -655,9 +662,9 @@ describe('Session history raw journal', () => { it('follows raw tool events and preserves result metadata without a Tools service', async () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const stream = await openFollow(history, session.id, abort.signal) + const stream = await openFollow(web, session.id, abort.signal) const collected = collect(stream, 2, abort) const call = session.append('tool/call', { turn: 1, step: 1, callId: ToolCallId('raw-call'), name: 'custom', arguments: '{malformed', @@ -684,9 +691,9 @@ describe('Session history raw journal', () => { it('follows live results without rescanning Session history', async () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const stream = await openFollow(history, session.id, abort.signal) + const stream = await openFollow(web, session.id, abort.signal) const iterator = stream[Symbol.asyncIterator]() session.append('tool/call', { @@ -957,9 +964,9 @@ describe('Session history raw journal', () => { it('follows a result after turn/end without reading the addressed Session log', async () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const stream = await openFollow(history, session.id, abort.signal) + const stream = await openFollow(web, session.id, abort.signal) const iterator = stream[Symbol.asyncIterator]() session.append('turn/start', { turn: 1 }) diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index cda4c14e6a..7c7db7e856 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -329,40 +329,6 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('authorizes attachment bytes only when the session event stream references the id', async () => { - const { ctx, agent, sessionId } = await harness() - const ref = { - attachmentId: 'att-authorized', mediaType: 'image/png' as const, bytes: 2, width: 1, height: 1, - } - const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) })) - ctx.provide('attachments', { readImage } as never) - const remote = await createSessionTestRemote(ctx, { - defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), - cwd: '/tmp', - }) - agent.session.append('agent/inbox/spliced', { - target: 'next-turn', - start: 0, - inserted: [{ - id: 'queued-image', role: 'user', source: { kind: 'user' }, - content: [{ type: 'image', attachment: ref }], - }], - } as never) - - const allowed = await remote.attachment(request({ - sessionId, attachmentId: 'att-authorized' as never, - })) - expect(allowed).toMatchObject({ ok: true, value: { attachment: ref, data: 'AQI=' } }) - const denied = await remote.attachment(request({ - sessionId, attachmentId: 'att-other' as never, - })) - expect(denied).toMatchObject({ - ok: false, - error: { code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }, - }) - expect(readImage).toHaveBeenCalledOnce() - await ctx.fiber.dispose() - }) it('checks configured key references even for empty catalogs and skips profiles without keys', async () => { const ctx = new Context() await ctx.plugin(LlmRuntime) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 8d1838e40f..75f81b9d01 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -15,7 +15,7 @@ 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 { Session } from '../src/client/sessions/session.ts' import { SessionEventStream } from '../src/client/transport.ts' -import type { SessionFollowRequest, SessionPage, SessionPageRequest } from '../src/types.ts' +import type { SessionFollowRequest, SessionEventsPage, SessionPageRequest } from '../src/types.ts' import { entries, ev, historyValue, plainTurn } from './event-script.client.ts' import { sessionBench } from './remote/bench.client.ts' import { @@ -122,7 +122,7 @@ describe('Session open', () => { 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 gate = Promise.withResolvers() const page = plainTurn(SessionSeq(10), 0, '早', '安') mock.stream(FOLLOW, async ([request], stream) => { stream.push(followSnapshot(await gate.promise, request as SessionFollowRequest)) @@ -243,7 +243,7 @@ describe('paging', () => { mock.stream(FOLLOW, followScript(history(newest, true))) await session.open() - const gate = Promise.withResolvers>() + 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)) @@ -279,7 +279,7 @@ describe('paging', () => { mock.stream(FOLLOW, followScript(history(newest, true))) await session.open() const secondRequested = Promise.withResolvers() - const lastPage = Promise.withResolvers>() + const lastPage = Promise.withResolvers>() mock.remote.session.page.mockImplementation(pageRule((request) => { if (request.beforeSeq === 12) return history(middle, true) secondRequested.resolve(undefined) @@ -334,7 +334,7 @@ describe('paging', () => { mock.stream(FOLLOW, followScript(history(newest, true))) await session.open() const secondRequested = Promise.withResolvers() - const lastPage = Promise.withResolvers>() + const lastPage = Promise.withResolvers>() mock.remote.session.page.mockImplementation(pageRule((request) => { if (request.beforeSeq === 12) return history(plainTurn(SessionSeq(6), 1, 'middle', 'answer'), true) secondRequested.resolve(undefined) @@ -360,7 +360,7 @@ describe('paging', () => { mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(12), 2, 'e', 'f'), true))) await session.open() - const gate = Promise.withResolvers>() + 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)) @@ -381,7 +381,7 @@ describe('paging', () => { await session.open() // A plain single-page pull holds the busy flag while the jump is refused. - const gate = Promise.withResolvers>() + 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 @@ -409,7 +409,7 @@ describe('paging', () => { mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(12), 2, 'x', 'y'), true))) await session.open() - const gate = Promise.withResolvers>() + 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; the same follow script serves the new generation. @@ -459,7 +459,7 @@ describe('paging', () => { const session = await sessionBench(mock, start, SID) mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, 'x', 'y'), true))) await session.open() - const gate = Promise.withResolvers>() + const gate = Promise.withResolvers>() mock.remote.session.page.mockImplementation(pageRule(gate.promise)) const first = session.loadOlder() const second = session.loadOlder() @@ -484,7 +484,7 @@ describe('prompt and cancel errors', () => { expect(steered).toEqual({ ok: true, value: { accepted: true } }) expect(cancelled).toEqual({ ok: true, value: { accepted: true } }) expect(mock.log.requests(FOLLOW)).toEqual([ - { address: { kind: 'subagent', ...CHILD }, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, + { address: { kind: 'subagent', ...CHILD }, activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, ]) expect(mock.log.requests(PAGE)).toEqual([]) // The prompt mode crosses the wire as the request's delivery. @@ -593,7 +593,7 @@ describe('prompt and cancel errors', () => { 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: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, + { address: { kind: 'subagent', ...CHILD, mode: 'one-shot' }, activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, ]) expect(mock.log.requests(PAGE)).toEqual([]) expect(mock.log.requests('session/cancel')).toEqual([]) @@ -684,9 +684,11 @@ describe('prompt and cancel errors', () => { 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 ({ mock, start }) => { + it('sends the complete image reference and decodes the gateway response', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) - const result = await session.readAttachment('attachment-1' as never) + const attachment = { attachmentId: AttachmentId('attachment-1'), mediaType: 'image/png' as const, + bytes: 1, width: 1, height: 1, name: 'picture.png', originalDimensions: { width: 2, height: 2 } } + const result = await session.readAttachment(attachment) expect(result).toEqual({ ok: true, value: { @@ -695,7 +697,7 @@ describe('prompt and cancel errors', () => { }, }) expect(mock.log.requests('session/attachment')).toEqual([{ - sessionId: SID, attachmentId: 'attachment-1', + sessionId: SID, attachment, }]) }) @@ -703,7 +705,9 @@ describe('prompt and cancel errors', () => { const session = await sessionBench(mock, start, SID) const failure = new RemoteError('gateway/internal', 'attachment unavailable', {}) mock.remote.session.attachment.mockResolvedValueOnce(err(failure)) - await expect(session.readAttachment(AttachmentId('missing'))).resolves.toMatchObject({ ok: false, error: failure }) + await expect(session.readAttachment({ + attachmentId: AttachmentId('missing'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, + })).resolves.toMatchObject({ ok: false, error: failure }) }) it('reports an unmatched command and forwards execution failures without opening history', async ({ mock, start }) => { @@ -874,7 +878,7 @@ describe('remaining branches', () => { const session = await sessionBench(mock, start, SID) mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) await session.open() - const gate = Promise.withResolvers>() + 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), '洞二')) @@ -887,7 +891,7 @@ describe('remaining branches', () => { it('drops a stale opening without replacing the new generation error state', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) - const stale = Promise.withResolvers>() + const stale = Promise.withResolvers>() mock.stream(FOLLOW, followScript(() => stale.promise)) const opening = session.open() mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) @@ -899,7 +903,7 @@ describe('remaining branches', () => { 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>() + const stale = Promise.withResolvers>() mock.stream(FOLLOW, followScript(() => stale.promise)) const opening = session.open() mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, '新', '代')))) @@ -913,7 +917,7 @@ describe('remaining branches', () => { const session = await sessionBench(mock, start, SID) mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) await session.open() - const repairPull = Promise.withResolvers>() + 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) }) @@ -955,7 +959,7 @@ describe('resync', () => { mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, '旧', '窗')))) await session.open() const oldWindow = session.eventSource.getSnapshot() - const replacement = Promise.withResolvers() + 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)) @@ -1014,7 +1018,7 @@ describe('resync', () => { 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>() + const stale = Promise.withResolvers>() mock.stream(FOLLOW, followScript(() => stale.promise)) const firstOpen = session.open() mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, '新', '代')))) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index d92a83198e..212320fdf6 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -12,7 +12,7 @@ import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/s import { ClientSessions, SessionCreateError, SessionForkError } from '../src/client/sessions/service.ts' import { scopeOf } from '../src/client/scope.ts' import type { - SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, + SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, SessionHistoryRecord, } from '../src/types.ts' import { FOLLOW, err, followScript, sessionWorld } from './remote/session.client.ts' @@ -174,7 +174,7 @@ describe('scope tree', () => { it('opens a conversation from follow projections without a second projection request', async ({ bench }) => { const b = bench() b.mock.stream(FOLLOW, followScript(ok({ - records: [], hasMore: false, + cursor: 0, records: [], hasMore: false, projections: { asOfSeq: 0, values: { subagentCatalog: [{ id: sid('child'), createdAt: 1, mode: 'one-shot', }] } }, @@ -277,7 +277,7 @@ describe('scope tree', () => { it('replaces an active assistant baseline on reconnect without duplicate chunks', async ({ bench }) => { const b = bench() const attemptId = LlmAttemptId('reconnect-attempt') - let records: never[] = [] + let records: SessionHistoryRecord[] = [] let assistantStreamBaseline: SessionAssistantStreamBaseline = { revision: 2, activeAttempt: { @@ -287,7 +287,7 @@ describe('scope tree', () => { }, } b.mock.stream(FOLLOW, followScript( - () => ok({ records, hasMore: false }), + () => ok({ cursor: records.at(-1)?.event.seq ?? -1, records, hasMore: false }), { assistantStream: () => assistantStreamBaseline }, )) await feedList(b, [{ id: 's1' }]) @@ -361,6 +361,7 @@ describe('scope tree', () => { }, } const history = ok({ + cursor: priorMessage.event.seq, records: [priorMessage] as never[], hasMore: false, }) @@ -437,7 +438,7 @@ describe('scope tree', () => { surfaceOp: 'append' as const, }, } - let records = [prior] as never[] + let records: SessionHistoryRecord[] = [prior] let assistantStreamBaseline: SessionAssistantStreamBaseline = { revision: 2, activeAttempt: { @@ -450,7 +451,7 @@ describe('scope tree', () => { }, } b.mock.stream(FOLLOW, followScript( - () => ok({ records, hasMore: false }), + () => ok({ cursor: records.at(-1)?.event.seq ?? -1, records, hasMore: false }), { assistantStream: () => assistantStreamBaseline }, )) await feedList(b, [{ id: 's1' }]) @@ -1002,10 +1003,10 @@ describe('fork', () => { expect(b.svc.binding(sid('child'))).toBeUndefined() expect(b.svc.retainInfo(sid('child')).getSnapshot().referenceCount).toBe(0) await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('child')]).not.toHaveProperty('parentId') expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({ title: childTitle, displayTitle: childTitle, - parentId: 'source', }) }) diff --git a/packages/api/session-controller/tests/state-controller.host.spec.ts b/packages/api/session-controller/tests/state-controller.host.spec.ts new file mode 100644 index 0000000000..5fc74006ff --- /dev/null +++ b/packages/api/session-controller/tests/state-controller.host.spec.ts @@ -0,0 +1,480 @@ +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' +import { brandString } from '@deepseek-ai/dsh-brand' +import type AgentRegistry from '@deepseek-ai/dsh-agent' +import type TypertGatewayService from '@deepseek-ai/dsh-api-gateway' +import type { PendingRemoteEventInfo } from '@deepseek-ai/dsh-api-gateway' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HistoryController } from '../src/history/controller.ts' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { StateController } from '../src/state/controller.ts' +import { StateListener } from '../src/state/listener.ts' +import type { SessionState, SessionRequestId } from '../src/types.ts' +import { installSessionReadTestServices } from './test-remote.ts' + +const roots: Context[] = [] +const aborters: AbortController[] = [] +const operations: Promise[] = [] +function cancellation(): AbortController { + const abort = new AbortController() + aborters.push(abort) + return abort +} +function tracked(promise: Promise): Promise { + operations.push(promise) + void promise.catch(() => undefined) + return promise +} +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { resolve = settle }) + return { promise, resolve } +} +afterEach(async () => { + for (const abort of aborters.splice(0)) abort.abort() + await Promise.allSettled(operations.splice(0)) + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) + vi.restoreAllMocks() + vi.useRealTimers() +}) + +async function fixture() { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) + const session = ctx.sessions.create(SessionId('state-target'), { meta: { cwd: '/state-test' } }) + let agent: Agent | undefined + const get = vi.fn(() => agent) + const agents: Partial = { get } + ctx.provide('agents', agents as AgentRegistry) + const pending: PendingRemoteEventInfo[] = [] + const pendingRead = vi.fn(() => pending) + const gateway: Partial = { getPendingRemoteEvents: pendingRead } + ctx.provide('typertGateway', gateway as TypertGatewayService) + const history = new HistoryController(ctx) + const output = vi.spyOn(history, 'assistantOutput').mockResolvedValue({ records: [], truncated: false }) + const controller = new StateController(ctx, history) + const abort = cancellation() + const request = { sessionId: session.id } + const live = (status: Agent['status'] = 'running', idle = Promise.resolve(undefined)) => { + const value = { + id: session.id, session, status, + inbox: createInboxStub(), + whenIdle: vi.fn(() => idle), + } + const partial: Partial = value + agent = partial as Agent + return value + } + const addPending = (event: string, id = session.id) => { + pending.push({ event, agentId: brandString(id) }) + } + return { + ctx, session, controller, history, output, abort, request, live, get, pending, pendingRead, addPending, + setAgent: (value: Agent | undefined) => { agent = value }, + } +} +const emptyState: SessionState = { cursor: -1, active: false, running: false, queue: null, pendingInput: [] } + +describe('StateController', () => { + it('re-reads a state whose cold read turned live', async () => { + const f = await fixture() + const agent = f.live('running') + // The first lookup misses; the Session is a live source by the time the cold read runs. + let lookups = 0 + f.get.mockImplementation(() => (lookups++ === 0 ? undefined : agent as never)) + const state = await f.controller.state(f.request, new AbortController().signal) + expect(state).toMatchObject({ active: true, running: true, queue: [] }) + expect(state.cursor).toBe(f.session.seq - 1) + expect(lookups).toBeGreaterThan(1) + }) + + it('rejects a live state read whose Session records no cwd', async () => { + const f = await fixture() + const session = f.ctx.sessions.create(SessionId('no-cwd-live')) + const partial: Partial = { + id: session.id, session, status: 'idle', inbox: createInboxStub(), whenIdle: vi.fn(() => Promise.resolve(undefined)), + } + f.setAgent(partial as Agent) + await expect(f.controller.state({ sessionId: session.id }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'session/not-found' }) + }) + + it('rejects a cold state read whose stored header records no cwd', async () => { + const f = await fixture() + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, id: f.session.id, createdAt: 1, isSeeded: false, + } + const observation: SessionObservation = { + source: 'prepared', + header, + inheritedEventCount: SessionLogOffset(0), + events: [], + cursor: -1, + projections: { asOfSeq: -1, values: {} }, + retain: () => observation, + [Symbol.dispose]: () => {}, + } + vi.spyOn(f.history, 'usingObservation').mockImplementation(async (_request, _signal, callback) => + callback?.(observation)) + await expect(f.controller.state(f.request, new AbortController().signal)) + .rejects.toMatchObject({ code: 'session/not-found' }) + }) + + it('reads cold state without projections and reports classified, deduplicated pending input', async () => { + const f = await fixture() + const read = vi.spyOn(f.history, 'usingObservation') + f.addPending('approval/request') + f.addPending('user-questions/request') + f.addPending('fixture/request') + f.addPending('approval/request') + f.addPending('ignored', SessionId('other')) + expect(await f.controller.state(f.request, f.abort.signal)).toEqual({ ...emptyState, pendingInput: ['approval', 'question', 'other'] }) + expect(read).toHaveBeenCalledWith({ sessionId: f.session.id, projection: false }, f.abort.signal, expect.any(Function)) + }) + + it('preserves live queues, RPC identities and steering versus context placement', async () => { + const f = await fixture() + const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } }) + const steering = createUserMessage({ content: [{ type: 'text', text: 'steer' }], source: { kind: 'user', rpcId: 'rpc' as SessionRequestId } }) + const context = createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'user-approval' } }) + const agent = f.live() + agent.inbox.append('next-turn', queued) + agent.inbox.append('next-step', steering) + agent.inbox.append('next-step', context) + expect(await f.controller.state(f.request, f.abort.signal)).toEqual({ + cursor: -1, active: true, running: true, pendingInput: [], + queue: [ + { id: queued.id, placement: 'queued', content: queued.content }, + { id: steering.id, placement: 'steering', content: steering.content, rpcId: 'rpc' }, + { id: context.id, placement: 'context', content: context.content }, + ], + }) + }) + + it('rejects already cancelled state reads', async () => { + const f = await fixture() + f.abort.abort(new Error('cancelled')) + await expect(f.controller.state(f.request, f.abort.signal)).rejects.toThrow('cancelled') + }) + + it('observes complete replacements and closes a pending read on cancellation', async () => { + const f = await fixture() + const states = f.controller.observeState(f.request, f.abort.signal)[Symbol.asyncIterator]() + expect(await states.next()).toMatchObject({ value: emptyState }) + f.live() + const next = tracked(states.next()) + f.session.append('turn/start', { turn: 1 }) + expect(await next).toMatchObject({ value: { active: true, running: true, cursor: 0 } }) + const end = tracked(states.next()) + f.abort.abort() + expect(await end).toEqual({ done: true, value: undefined }) + await states.return?.() + }) + + it('returns inactive idle state and delegates the exact output window with caller signal', async () => { + const f = await fixture() + const result = await f.controller.wait({ ...f.request, afterSeq: 3, maxMessages: 2 }, f.abort.signal) + expect(result).toEqual({ reason: 'idle', state: emptyState, output: { records: [], truncated: false } }) + expect(f.output).toHaveBeenCalledWith(f.session.id, 3, -1, 2, f.abort.signal) + }) + + it('stops immediately for pending input even while the Agent runs', async () => { + const f = await fixture() + f.live() + f.addPending('approval/request') + expect(await f.controller.wait(f.request, f.abort.signal)).toMatchObject({ reason: 'input-required' }) + }) + + it('confirms an active idle Agent with whenIdle', async () => { + const f = await fixture() + const agent = f.live('idle') + expect(await f.controller.wait(f.request, f.abort.signal)).toMatchObject({ reason: 'idle', state: { active: true } }) + expect(agent.whenIdle).toHaveBeenCalledOnce() + }) + + it('polls pending input after one second without Gateway notifications', async () => { + const f = await fixture() + vi.useFakeTimers() + f.live() + const result = tracked(f.controller.wait(f.request, f.abort.signal)) + await vi.advanceTimersByTimeAsync(0) + expect(f.pendingRead).toHaveBeenCalledTimes(1) + f.addPending('user-questions/request') + await vi.advanceTimersByTimeAsync(999) + expect(f.pendingRead).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(await result).toMatchObject({ reason: 'input-required', state: { pendingInput: ['question'] } }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('uses the latest accepted state on timeout and still reads output with the caller signal', async () => { + const f = await fixture() + vi.useFakeTimers() + f.live() + const result = tracked(f.controller.wait({ ...f.request, timeoutMs: 1500 }, f.abort.signal)) + await vi.advanceTimersByTimeAsync(1500) + expect(await result).toMatchObject({ reason: 'timeout', state: { running: true } }) + expect(f.output).toHaveBeenCalledWith(f.session.id, -1, -1, 50, f.abort.signal) + expect(f.abort.signal.aborted).toBe(false) + expect(vi.getTimerCount()).toBe(0) + }) + + it('responds to a standard event before the polling tick and races idle against new state', async () => { + const f = await fixture() + vi.useFakeTimers() + const idle = deferred() + f.live('idle', idle.promise) + const result = tracked(f.controller.wait(f.request, f.abort.signal)) + await vi.advanceTimersByTimeAsync(0) + f.addPending('approval/request') + f.session.append('turn/start', { turn: 1 }) + await vi.advanceTimersByTimeAsync(0) + expect(await result).toMatchObject({ reason: 'input-required', state: { cursor: 0 } }) + idle.resolve(undefined) + expect(vi.getTimerCount()).toBe(0) + }) + + it('propagates cancellation while waiting and cleans periodic reads', async () => { + const f = await fixture() + vi.useFakeTimers() + f.live() + const result = tracked(f.controller.wait(f.request, f.abort.signal)) + await vi.advanceTimersByTimeAsync(0) + f.abort.abort(new Error('caller cancelled')) + await expect(result).rejects.toThrow('caller cancelled') + expect(f.output).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('propagates an idle-driver failure without treating it as a deadline', async () => { + const f = await fixture() + const idle = deferred() + const agent = f.live('idle', idle.promise) + agent.whenIdle.mockImplementation(() => Promise.reject(new Error('driver failed'))) + await expect(f.controller.wait(f.request, f.abort.signal)).rejects.toThrow('driver failed') + idle.resolve(undefined) + }) + + it('propagates a structured read failure raised before the deadline', async () => { + const f = await fixture() + const failure = deferred() + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + await failure.promise + throw new RemoteError('gateway/internal', 'persistent read failed', {}) + }) + const result = tracked(f.controller.wait({ ...f.request, timeoutMs: 5_000 }, f.abort.signal)) + failure.resolve(undefined) + await expect(result).rejects.toMatchObject({ code: 'gateway/internal' }) + }) + + it('keeps a structured read failure raised after the deadline', async () => { + const f = await fixture() + vi.useFakeTimers() + let rejectRead!: (error: Error) => void + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + await new Promise((_resolve, reject) => { rejectRead = reject }) + yield* [] + }) + const result = tracked(f.controller.wait({ ...f.request, timeoutMs: 20 }, f.abort.signal)) + await vi.advanceTimersByTimeAsync(20) + rejectRead(new RemoteError('gateway/internal', 'persistent read failed', {})) + await expect(result).rejects.toMatchObject({ code: 'gateway/internal' }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('reports timeout when its first cold read never yields a state', async () => { + const f = await fixture() + vi.useFakeTimers() + vi.spyOn(f.ctx.sessionQuery, 'observeSession').mockImplementation((_id, options) => new Promise((_resolve, reject) => { + const abort = options?.signal + abort?.addEventListener('abort', () => { + const reason: unknown = abort.reason + reject(Object.assign(new Error('session observation was aborted'), { + code: 'SESSION_QUERY_ABORTED', cause: reason, + })) + }, { once: true }) + })) + const result = tracked(f.controller.wait({ ...f.request, timeoutMs: 20 }, f.abort.signal)) + await vi.advanceTimersByTimeAsync(20) + await expect(result).rejects.toMatchObject({ code: 'session/wait-timeout' }) + expect(f.output).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('rejects a state stream that ends before a stopping observation', async () => { + const f = await fixture() + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () {}) + await expect(f.controller.wait(f.request, f.abort.signal)).rejects.toMatchObject({ code: 'session/observation-ended' }) + }) + + it('settles an already-armed failed read when the preceding observation stops the wait', async () => { + const f = await fixture() + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + yield { state: emptyState, revision: 0 } + throw new Error('next read failed') + }) + expect(await f.controller.wait(f.request, f.abort.signal)).toMatchObject({ reason: 'idle' }) + }) + + it('ignores a yielded state invalidated before wait accepts it', async () => { + const f = await fixture() + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + yield { state: { ...emptyState, running: true }, revision: 0 } + yield { state: emptyState, revision: 1 } + }) + vi.spyOn(StateListener.prototype, 'isCurrent').mockReturnValueOnce(false).mockReturnValue(true) + expect(await f.controller.wait(f.request, f.abort.signal)).toMatchObject({ reason: 'idle', state: emptyState }) + }) + + it('times out with the last accepted state, not a later invalidated sample', async () => { + const f = await fixture() + vi.useFakeTimers() + const accepted = { ...emptyState, running: true } + const stale = { ...emptyState, running: true, cursor: 7 } + const never = deferred() + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + yield { state: accepted, revision: 0 } + yield { state: stale, revision: 0 } + await never.promise + yield* [] + }) + vi.spyOn(StateListener.prototype, 'isCurrent').mockReturnValueOnce(true).mockReturnValue(false) + const result = tracked(f.controller.wait({ ...f.request, timeoutMs: 20 }, f.abort.signal)) + await vi.advanceTimersByTimeAsync(20) + never.resolve(undefined) + await expect(result).resolves.toMatchObject({ reason: 'timeout', state: { cursor: -1 } }) + }) + + it.each(['missing-agent', 'different-session'] as const)('retries active state when its owner is %s', async (condition) => { + const f = await fixture() + const agent = f.live('idle') + if (condition === 'missing-agent') f.setAgent(undefined) + else Object.assign(agent, { session: f.ctx.sessions.create(SessionId('replacement')) }) + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + yield { state: { ...emptyState, active: true, queue: [] }, revision: 0 } + yield { state: emptyState, revision: 0 } + }) + expect(await f.controller.wait(f.request, f.abort.signal)).toMatchObject({ reason: 'idle', state: emptyState }) + expect(agent.whenIdle).not.toHaveBeenCalled() + }) + + it.each(['revision', 'agent', 'session'] as const)('rechecks %s after the idle promise settles', async (changed) => { + const f = await fixture() + const idle = deferred() + const entered = deferred() + const next = deferred() + const agent = f.live('idle', idle.promise) + agent.whenIdle.mockImplementation(() => { entered.resolve(undefined); return idle.promise }) + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + yield { state: { ...emptyState, active: true, queue: [] }, revision: 0 } + await next.promise + yield { state: emptyState, revision: 0 } + }) + const rechecked = deferred() + let checks = 0 + vi.spyOn(StateListener.prototype, 'isCurrent').mockImplementation(() => { + checks++ + if (checks === 2) rechecked.resolve(undefined) + return !(changed === 'revision' && checks === 2) + }) + const result = tracked(f.controller.wait(f.request, f.abort.signal)) + try { + await entered.promise + if (changed === 'agent') f.setAgent(undefined) + if (changed === 'session') Object.assign(agent, { session: f.ctx.sessions.create(SessionId('new-session')) }) + idle.resolve(undefined) + await rechecked.promise + next.resolve(undefined) + expect(await result).toMatchObject({ reason: 'idle', state: emptyState }) + } finally { + idle.resolve(undefined) + next.resolve(undefined) + f.abort.abort() + await Promise.allSettled([result]) + } + }) + + it.each([ + { afterSeq: -2 }, { afterSeq: -0 }, { afterSeq: 1.5 }, + { maxMessages: 0 }, { maxMessages: 1.5 }, + { timeoutMs: 0 }, { timeoutMs: 2147483648 }, { timeoutMs: 1.5 }, + ])('rejects unsupported wait bounds %j', async (bounds) => { + const f = await fixture() + await expect(f.controller.wait({ ...f.request, ...bounds }, f.abort.signal)).rejects.toMatchObject({ code: 'gateway/bad-request' }) + }) +}) + +describe('StateListener', () => { + it('discards an async stale read and coalesces repeated invalidations', async () => { + const f = await fixture() + const first = deferred() + const reading = deferred() + const read = vi.fn() + .mockImplementationOnce(() => { reading.resolve(undefined); return first.promise }) + .mockResolvedValue({ ...emptyState, cursor: 1 }) + using listener = new StateListener(f.ctx, f.session.id, f.abort.signal, read) + const states = listener.observeStates() + const pending = tracked(states.next()) + await reading.promise + f.session.append('turn/start', { turn: 1 }) + f.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + first.resolve(emptyState) + expect(await pending).toEqual({ done: false, value: { state: { ...emptyState, cursor: 1 }, revision: 2 } }) + expect(read).toHaveBeenCalledTimes(2) + expect(listener.isCurrent(0)).toBe(false) + expect(listener.isCurrent(2)).toBe(true) + await states.return(undefined) + expect(listener.isCurrent(2)).toBe(false) + }) + + it('ignores other Sessions and ends when its Context is disposed', async () => { + const f = await fixture() + const read = vi.fn(async () => emptyState) + const listener = new StateListener(f.ctx, f.session.id, f.abort.signal, read) + const states = listener.observeStates() + await states.next() + const pending = tracked(states.next()) + f.ctx.sessions.create(SessionId('unrelated')) + expect(read).toHaveBeenCalledOnce() + await f.ctx.fiber.dispose() + expect(await pending).toEqual({ done: true, value: undefined }) + listener[Symbol.dispose]() + }) + + it('invalidates for each Agent lifecycle notification', async () => { + const f = await fixture() + f.live('idle') + const agent = f.get()! + using listener = new StateListener(f.ctx, f.session.id, f.abort.signal, async () => emptyState) + const states = listener.observeStates() + await states.next() + for (const emit of [ + () => { f.ctx.emit('agent/status', { agent, status: 'running' }) }, + () => { f.ctx.emit('agent/created', { agent, source: 'startup' }) }, + () => { f.ctx.emit('agent/disposed', { agent }) }, + ]) { + const pending = tracked(states.next()) + emit() + expect(await pending).toMatchObject({ done: false, value: { state: emptyState } }) + } + await states.return(undefined) + }) + + it('rejects cancelled construction and contains setup failure by removing abort listeners', async () => { + const f = await fixture() + f.abort.abort() + expect(() => new StateListener(f.ctx, f.session.id, f.abort.signal, async () => emptyState)).toThrow() + const abort = cancellation() + const remove = vi.spyOn(abort.signal, 'removeEventListener') + vi.spyOn(f.ctx, 'on').mockImplementation(() => { throw new Error('registration failed') }) + expect(() => new StateListener(f.ctx, f.session.id, abort.signal, async () => emptyState)).toThrow('registration failed') + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)) + }) +}) diff --git a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts index 9534b8b66d..36216a1f1c 100644 --- a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts +++ b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts @@ -105,7 +105,7 @@ describe('SessionController subagent catalog', () => { kind: 'sequenced', values: { subagentCatalog: [{ id: CHILD, mode: 'continuable', label: 'worker' }] }, }) expect(stat).toHaveBeenCalledWith(PARENT, { signal }) - expect(observe).toHaveBeenCalledWith(PARENT, { signal }) + expect(observe).toHaveBeenCalledWith(PARENT, { signal, projectionMode: 'all' }) expect(open).toHaveBeenCalledWith(PARENT, 'read', { signal }) expect(resume).not.toHaveBeenCalled() expect(ctx.sessions.get(PARENT)).toBeUndefined() diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 114e15734d..145b48f5c3 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -51,7 +51,7 @@ import type { SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, SessionWorkspacePathApplication, - SessionPage, + SessionEventsPage, SessionPageRequest, SessionPromptRequest, SessionPromptValue, @@ -66,7 +66,7 @@ import type { SessionUpdateQueueValue, } from '../src/types.ts' -/** Direct test face matching the generated `ctx.remote.session` unary methods. */ +/** Direct test face for the generated `ctx.remote.session` unary methods these tests exercise. */ export interface TestSessionRemote { workspacePathApplications( request: { readonly path: string }, signal?: AbortSignal, @@ -87,7 +87,7 @@ export interface TestSessionRemote { request: SessionOpenWorkspacePathRequest, signal?: AbortSignal, ): Promise> - page(request: SessionPageRequest, signal?: AbortSignal): Promise> + page(request: SessionPageRequest, signal?: AbortSignal): Promise> projections(request: SessionProjectionsRequest, signal?: AbortSignal): Promise> follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable control(signal?: AbortSignal): AsyncIterable @@ -388,6 +388,7 @@ export async function createSessionTestRemote( signal, ), page: (request, signal = new AbortController().signal) => remoteResult( + // oxlint-disable-next-line typescript/no-deprecated -- Test face keeps the legacy page entry covered. () => direct.page(request, signal), signal, ), diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 87d8fd7479..2c60701f80 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -25,7 +25,7 @@ import type { SessionFollowFrame, SessionFollowRequest, SessionHistoryRecord, - SessionPage, + SessionEventsPage, SessionPageRequest, SessionWireEvent, } from '../src/types.ts' @@ -44,8 +44,8 @@ function entry(seq: number): SessionEventEntry { return { type: 'event', event: { type: 'turn/start', seq, time: seq, data: { turn: seq } } } } -function page(records: readonly SessionHistoryRecord[], hasMore = false): SessionPage { - return { records, hasMore } +function page(records: readonly SessionHistoryRecord[], hasMore = false): SessionEventsPage { + return { cursor: records.at(-1)?.event?.seq ?? -1, records, hasMore } } function snapshot( @@ -102,7 +102,7 @@ class ScriptedSessionRemote implements SessionTransportRemote { constructor( private readonly generations: FollowGeneration[], - private readonly pages: RemoteResult[], + private readonly pages: RemoteResult[], private readonly controlFrames: readonly SessionControlFrame[] = [], private readonly holdControl = true, ) {} @@ -126,7 +126,7 @@ class ScriptedSessionRemote implements SessionTransportRemote { } } - page(request: SessionPageRequest): Promise> { + page(request: SessionPageRequest): Promise> { this.pageRequests.push(request) const result = this.pages.shift() if (result === undefined) throw new Error('no scripted Session page') @@ -326,7 +326,7 @@ describe('Session Client stream adapters', () => { await stream.open({}) await vi.waitFor(() => { expect(changes).toHaveLength(2) }) - expect(remote.followRequests).toEqual([{ address: ADDRESS, assistantStream: true }]) + expect(remote.followRequests).toEqual([{ address: ADDRESS, activate: true, assistantStream: true }]) expect(changes).toMatchObject([ { type: 'replace', page: { assistantStream: baseline } }, { type: 'assistant-stream', frame }, @@ -382,7 +382,7 @@ describe('Session Client stream adapters', () => { code: 'gateway/internal', message: 'session event stream emitted an entry before its opening cursor', }) - expect(remote.followRequests).toEqual([{ address: ADDRESS, assistantStream: true }]) + expect(remote.followRequests).toEqual([{ address: ADDRESS, activate: true, assistantStream: true }]) } finally { await stream.dispose() } @@ -547,7 +547,7 @@ describe('Session Client stream adapters', () => { await stream.prepend({ beforeSeq: 2, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }) expect(remote.followRequests).toEqual([{ - address: ADDRESS, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 }, + address: ADDRESS, activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 }, }]) expect(remote.pageRequests).toEqual([ { address: ADDRESS, throughSeq: 4, beforeSeq: 2, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, @@ -585,8 +585,8 @@ describe('Session Client stream adapters', () => { await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) }) expect(remote.followRequests).toEqual([ - { address: ADDRESS, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, - { address: ADDRESS, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, + { address: ADDRESS, activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, + { address: ADDRESS, activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, ]) expect(remote.pageRequests).toEqual([]) expect(changes.map(change => change.type)).toEqual(['replace', 'append', 'replace']) @@ -616,8 +616,8 @@ describe('Session Client stream adapters', () => { finish.resolve(undefined) await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) }) expect(remote.followRequests).toEqual([ - { address: ADDRESS, assistantStream: true }, - { address: ADDRESS, assistantStream: true }, + { address: ADDRESS, activate: true, assistantStream: true }, + { address: ADDRESS, activate: true, assistantStream: true }, ]) expect(remote.pageRequests).toEqual([]) await stream.dispose() diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index e48b114c91..8a3df2ae43 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -5,8 +5,9 @@ import type { Session, SessionEvent, SessionHeader, SurfaceIntent } from '@deeps import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' -import { describe, expect, it, vi } from 'vitest' -import { SessionHistoryController } from '../src/history.ts' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HistoryController } from '../src/history/controller.ts' +import { WebController } from '../src/web/controller.ts' import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' const signal = (): AbortSignal => new AbortController().signal @@ -72,22 +73,39 @@ function deferred(): Deferred { return { promise, resolve } } -async function setup(): Promise<{ ctx: Context; transport: SessionHistoryController }> { +const roots = new Set() +afterEach(async () => { + await Promise.all([...roots].map(ctx => ctx.fiber.dispose())) + roots.clear() +}) + +function controllers( + ctx: Context, promote: (observation: SessionObservation) => void = (observation) => { observation[Symbol.dispose]() }, +): { + history: HistoryController + web: WebController +} { + roots.add(ctx.root) + installSessionReadTestServices(ctx) + const history = new HistoryController(ctx) + return { history, web: new WebController(ctx, history, promote) } +} + +async function setup(): Promise<{ ctx: Context; history: HistoryController; web: WebController }> { const ctx = new Context() await ctx.plugin(SessionStore) installSessionReadTestServices(ctx) await ctx.sessionProjections.register(subagentIdentityProjectionDefinition) - const transport = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) - return { ctx, transport } + return { ctx, ...controllers(ctx) } } -describe('SessionHistoryController', () => { +describe('Session history and Web follow', () => { it('opens at the current cursor and follows later events from an ordinary Session', async () => { - const { ctx, transport } = await setup() + const { ctx, history, web } = await setup() const session = ctx.sessions.create(SessionId('ordinary'), { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) const abort = new AbortController() - const iterator = transport.follow( + const iterator = web.follow( { address: { kind: 'session', sessionId: session.id } }, abort.signal, )[Symbol.asyncIterator]() @@ -99,7 +117,7 @@ describe('SessionHistoryController', () => { value: { type: 'event', event: { type: 'turn/end', seq: 1 } }, }) - const page = await transport.page( + const page = await history.events( { address: { kind: 'session', sessionId: session.id }, throughSeq: 1 }, new AbortController().signal, ) @@ -113,16 +131,16 @@ describe('SessionHistoryController', () => { const ctx = new Context() await ctx.plugin(SessionStore) installSessionReadTestServices(ctx) - let transport!: SessionHistoryController + let web!: WebController const owner = ctx.plugin(Object.assign( (inner: Context) => { - transport = new SessionHistoryController(inner, (observation) => { observation[Symbol.dispose]() }) + web = controllers(inner).web }, { inject: ['sessions', 'sessionQuery'] }, )) await owner.await() const session = ctx.sessions.create(SessionId('controller-unload'), { meta: { cwd: '/workspace' } }) - const iterator = transport.follow( + const iterator = web.follow( { address: { kind: 'session', sessionId: session.id } }, new AbortController().signal, )[Symbol.asyncIterator]() @@ -138,13 +156,13 @@ describe('SessionHistoryController', () => { }) it('reconnects with a complete replacement snapshot before later live events', async () => { - const { ctx, transport } = await setup() + const { ctx, web } = await setup() const session = ctx.sessions.create(SessionId('resume'), { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) session.append('turn/start', { turn: 2 }) const abort = new AbortController() - const iterator = transport.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, }, abort.signal)[Symbol.asyncIterator]() @@ -168,7 +186,7 @@ describe('SessionHistoryController', () => { }) it('subscribes before a cold read and ignores unrelated and replayed buffered events', async () => { - const { ctx, transport } = await setup() + const { ctx, web } = await setup() const sessionId = SessionId('cold-race') const header: SessionHeader = { version: SESSION_FORMAT_VERSION, @@ -187,7 +205,7 @@ describe('SessionHistoryController', () => { inspect: () => inspected.promise, }) as never) const abort = new AbortController() - const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal) + const iterator = web.follow({ address: { kind: 'session', sessionId } }, abort.signal) [Symbol.asyncIterator]() const opening = iterator.next() @@ -220,9 +238,9 @@ describe('SessionHistoryController', () => { } const observed = deferred() ctx.provide('sessionQuery', { observeSession: () => observed.promise } as never) - const transport = new SessionHistoryController(ctx, vi.fn()) + const { web } = controllers(ctx, vi.fn()) const abort = new AbortController() - const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal) + const iterator = web.follow({ address: { kind: 'session', sessionId } }, abort.signal) [Symbol.asyncIterator]() const opening = iterator.next() @@ -256,11 +274,11 @@ describe('SessionHistoryController', () => { const ctx = new Context() await ctx.plugin(SessionStore) installSessionReadTestServices(ctx) - let transport!: SessionHistoryController + let web!: WebController let agentCtx!: Context await ctx.plugin(Object.assign( (inner: Context) => { - transport = new SessionHistoryController(inner, (observation) => { observation[Symbol.dispose]() }) + web = controllers(inner).web }, { inject: ['sessions', 'sessionQuery'] }, )) @@ -279,7 +297,7 @@ describe('SessionHistoryController', () => { const seed = [event('fixture/start', SessionSeq(0))] cold(ctx, header, seed) const abort = new AbortController() - const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal) + const iterator = web.follow({ address: { kind: 'session', sessionId } }, abort.signal) [Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) @@ -322,7 +340,7 @@ describe('SessionHistoryController', () => { isSeeded: false, } cold(replay.ctx, replayHeader, [event('fixture/start', SessionSeq(0)), event('fixture/gap', SessionSeq(2))]) - const replayed = replay.transport.follow({ + const replayed = replay.web.follow({ address: { kind: 'session', sessionId: replayId }, }, signal())[Symbol.asyncIterator]() await expect(replayed.next()).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' }) @@ -331,7 +349,7 @@ describe('SessionHistoryController', () => { const session = live.ctx.sessions.create(SessionId('live-gap'), { meta: { cwd: '/workspace' } }) append(session, 'fixture/start', {}) live.ctx.provide('agents', { get: () => ({ id: session.id }) } as never) - const followed = live.transport.follow({ + const followed = live.web.follow({ address: { kind: 'session', sessionId: session.id }, }, signal())[Symbol.asyncIterator]() await expect(followed.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) @@ -345,21 +363,21 @@ describe('SessionHistoryController', () => { }) it('opens an empty source at cursor -1', async () => { - const { ctx, transport } = await setup() + const { ctx, history, web } = await setup() const session = ctx.sessions.create(SessionId('empty-follow'), { meta: { cwd: '/workspace' } }) const abort = new AbortController() - const iterator = transport.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, }, abort.signal)[Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: -1 } }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'session', sessionId: session.id }, throughSeq: -1, }, signal())).resolves.toMatchObject({ records: [], hasMore: false }) abort.abort() await expect(iterator.next()).resolves.toMatchObject({ done: true }) }) - it('publishes an empty projection baseline when the query has no registry', async () => { + it('fails explicitly when a projected observation omits its baseline', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const sessionId = SessionId('projectionless-follow') @@ -380,19 +398,83 @@ describe('SessionHistoryController', () => { retain: vi.fn(), [Symbol.dispose]: vi.fn(), } satisfies SessionObservation), } as never) - const history = new SessionHistoryController(ctx, vi.fn()) + const { web } = controllers(ctx, vi.fn()) const abort = new AbortController() - const iterator = history.follow({ address: { kind: 'session', sessionId } }, abort.signal) + const iterator = web.follow({ address: { kind: 'session', sessionId } }, abort.signal) [Symbol.asyncIterator]() - await expect(iterator.next()).resolves.toMatchObject({ - value: { type: 'snapshot', projections: { asOfSeq: -1, values: {} } }, - }) + await expect(iterator.next()).rejects.toMatchObject({ code: 'session/projections-unavailable' }) abort.abort() await expect(iterator.next()).resolves.toMatchObject({ done: true }) await ctx.fiber.dispose() }) + it('does not activate a prepared Session when the request omits activate', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const sessionId = SessionId('passive-activation-default') + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, cwd: '/workspace', isSeeded: false, + } + const disposeSource = vi.fn() + const source: SessionObservation = { + source: 'prepared', + header, + inheritedEventCount: SessionLogOffset(0), + events: [], + cursor: -1, + projections: { asOfSeq: -1, values: {} }, + retain: () => source, + [Symbol.dispose]: disposeSource, + } + ctx.provide('sessionQuery', { observeSession: () => Promise.resolve(source) } as never) + const promote = vi.fn() + const { web } = controllers(ctx, promote) + const abort = new AbortController() + const iterator = web.follow({ address: { kind: 'session', sessionId } }, abort.signal)[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) + expect(promote).not.toHaveBeenCalled() + abort.abort() + await expect(iterator.next()).resolves.toMatchObject({ done: true }) + expect(disposeSource).toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('starts activation for a consumer that stops after the opening snapshot', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const sessionId = SessionId('activation-after-opening') + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, cwd: '/workspace', isSeeded: false, + } + const disposed: string[] = [] + const promotion: SessionObservation = { + source: 'prepared', + header, + inheritedEventCount: SessionLogOffset(0), + events: [], + cursor: -1, + projections: { asOfSeq: -1, values: {} }, + retain: vi.fn(), + [Symbol.dispose]: () => { disposed.push('promotion') }, + } + const source: SessionObservation = { + ...promotion, + retain: () => promotion, + [Symbol.dispose]: () => { disposed.push('source') }, + } + ctx.provide('sessionQuery', { observeSession: () => Promise.resolve(source) } as never) + const promote = vi.fn((observation: SessionObservation) => { observation[Symbol.dispose]() }) + const { web } = controllers(ctx, promote) + const iterator = web.follow({ address: { kind: 'session', sessionId }, activate: true }, signal()) + [Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) + await iterator.return?.(undefined) + expect(promote).toHaveBeenCalledOnce() + expect(disposed).toContain('promotion') + await ctx.fiber.dispose() + }) + it('disposes a retained promotion when background activation rejects synchronously', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -412,18 +494,17 @@ describe('SessionHistoryController', () => { ctx.provide('sessionQuery', { observeSession: () => Promise.resolve(source), } as never) - const history = new SessionHistoryController(ctx, () => { throw new Error('activation failed') }) - const iterator = history.follow({ address: { kind: 'session', sessionId } }, signal()) + const { web } = controllers(ctx, () => { throw new Error('activation failed') }) + const iterator = web.follow({ address: { kind: 'session', sessionId }, activate: true }, signal()) [Symbol.asyncIterator]() - await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) await expect(iterator.next()).rejects.toThrow('activation failed') expect(disposePromotion).toHaveBeenCalledOnce() await ctx.fiber.dispose() }) it('requires the durable parent and mode for a direct subagent address', async () => { - const { ctx, transport } = await setup() + const { ctx, history } = await setup() const parentSessionId = SessionId('parent') const childSessionId = SessionId('child') ctx.sessions.create(parentSessionId, { meta: { cwd: '/workspace' } }) @@ -437,7 +518,7 @@ describe('SessionHistoryController', () => { })) const signal = new AbortController().signal - await expect(transport.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId, @@ -448,17 +529,17 @@ describe('SessionHistoryController', () => { }, signal)).resolves.toMatchObject({ records: [{ type: 'event', event: { type: 'subagent/descriptor' } }], }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'unknown' }, throughSeq: 0, }, signal)).resolves.toMatchObject({ records: [{ type: 'event', event: { type: 'subagent/descriptor' } }], }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId: SessionId('other-parent'), childSessionId, mode: 'unknown' }, throughSeq: 0, }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId: SessionId('other-parent'), @@ -467,7 +548,7 @@ describe('SessionHistoryController', () => { }, throughSeq: 0, }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId, @@ -476,14 +557,14 @@ describe('SessionHistoryController', () => { }, throughSeq: 0, }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'session', sessionId: childSessionId }, throughSeq: 0, }, signal)).rejects.toMatchObject({ code: 'session/agent-busy' }) }) it('preserves a cold inspection failure for the Gateway error branch', async () => { - const { ctx, transport } = await setup() + const { ctx, history } = await setup() const sessionId = SessionId('corrupt-cold') const failure = new Error('cold log is corrupt') const header: SessionHeader = { version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, isSeeded: false, cwd: '/workspace' } @@ -492,7 +573,7 @@ describe('SessionHistoryController', () => { inspect: () => Promise.reject(failure), }) as never) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'session', sessionId }, throughSeq: -1, }, new AbortController().signal)).rejects.toMatchObject({ @@ -502,7 +583,7 @@ describe('SessionHistoryController', () => { }) it('rejects malformed page and follow cursors at the service boundary', async () => { - const { ctx, transport } = await setup() + const { ctx, history, web } = await setup() const session = ctx.sessions.create(SessionId('validation'), { meta: { cwd: '/workspace' } }) const address = { kind: 'session' as const, sessionId: session.id } for (const request of [ @@ -521,9 +602,9 @@ describe('SessionHistoryController', () => { { address, throughSeq: -1, turnWindow: { minMessages: 50, minTurns: 0 } }, { address, throughSeq: -1, turnWindow: { minMessages: 50, minTurns: 1.5 } }, ]) { - await expect(transport.page(request, signal())).rejects.toMatchObject({ code: 'gateway/bad-request' }) + await expect(history.events(request, signal())).rejects.toMatchObject({ code: 'gateway/bad-request' }) } - await expect(transport.page({ address, throughSeq: 0 }, signal())) + await expect(history.events({ address, throughSeq: 0 }, signal())) .rejects.toMatchObject({ code: 'gateway/bad-request' }) const corrupt = await setup() @@ -533,11 +614,11 @@ describe('SessionHistoryController', () => { { version: SESSION_FORMAT_VERSION, id: corruptId, createdAt: 1, cwd: '/workspace', isSeeded: false }, [event('fixture/start', SessionSeq(0)), event('fixture/gap', SessionSeq(2))], ) - await expect(corrupt.transport.page({ + await expect(corrupt.history.events({ address: { kind: 'session', sessionId: corruptId }, throughSeq: 1, }, signal())).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' }) for (const maxMessages of [0, 0.5]) { - const iterator = transport.follow({ address, maxMessages }, signal())[Symbol.asyncIterator]() + const iterator = web.follow({ address, maxMessages }, signal())[Symbol.asyncIterator]() await expect(iterator.next()).rejects.toMatchObject({ code: 'gateway/bad-request' }) } for (const turnWindow of [ @@ -547,15 +628,15 @@ describe('SessionHistoryController', () => { { minMessages: 50, minTurns: 0 }, { minMessages: 50, minTurns: 1.5 }, ]) { - const iterator = transport.follow({ address, turnWindow }, signal())[Symbol.asyncIterator]() + const iterator = web.follow({ address, turnWindow }, signal())[Symbol.asyncIterator]() await expect(iterator.next()).rejects.toMatchObject({ code: 'gateway/bad-request' }) } }) it('reports missing ordinary and subagent sources without fabricating inspection failures', async () => { - const { ctx, transport } = await setup() + const { ctx, history } = await setup() const ordinary = { kind: 'session' as const, sessionId: SessionId('missing') } - await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal())) + await expect(history.events({ address: ordinary, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'session/not-found' }) const inspect = vi.fn(() => Promise.resolve(undefined)) @@ -565,9 +646,9 @@ describe('SessionHistoryController', () => { stat, inspect, }) as never) - await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal())) + await expect(history.events({ address: ordinary, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'session/not-found' }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId: SessionId('parent'), @@ -594,7 +675,7 @@ describe('SessionHistoryController', () => { events: [], }), }) as never) - await expect(first.transport.page({ address, throughSeq: -1 }, signal())) + await expect(first.history.events({ address, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'session/not-found' }) const second = await setup() @@ -608,7 +689,7 @@ describe('SessionHistoryController', () => { events: [], }), }) as never) - await expect(second.transport.page({ address, throughSeq: -1 }, signal())) + await expect(second.history.events({ address, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'session/not-found' }) }) @@ -623,7 +704,7 @@ describe('SessionHistoryController', () => { isSeeded: false, } cold(ordinaryBench.ctx, ordinaryHeader, [event('turn/start', SessionSeq(0), { turn: 1 })]) - await expect(ordinaryBench.transport.page({ + await expect(ordinaryBench.history.events({ address: { kind: 'session', sessionId: ordinaryId }, throughSeq: 0, }, signal())).resolves.toMatchObject({ @@ -649,18 +730,18 @@ describe('SessionHistoryController', () => { } const missing = await setup() cold(missing.ctx, childHeader, []) - await expect(missing.transport.page({ address: childAddress, throughSeq: -1 }, signal())) + await expect(missing.history.events({ address: childAddress, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'subagent/catalog-diagnostic', details: { reason: 'corrupt' } }) const corrupt = await setup() cold(corrupt.ctx, childHeader, [event('subagent/descriptor', SessionSeq(0), { version: 'bad' })]) - await expect(corrupt.transport.page({ address: childAddress, throughSeq: 0 }, signal())) + await expect(corrupt.history.events({ address: childAddress, throughSeq: 0 }, signal())) .rejects.toMatchObject({ code: 'subagent/catalog-diagnostic', details: { reason: 'corrupt' } }) const ordinaryChild = await setup() const { origin: _origin, ...ordinaryChildHeader } = childHeader cold(ordinaryChild.ctx, ordinaryChildHeader, []) - await expect(ordinaryChild.transport.page({ address: childAddress, throughSeq: -1 }, signal())) + await expect(ordinaryChild.history.events({ address: childAddress, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'subagent/unauthorized' }) }) @@ -689,9 +770,9 @@ describe('SessionHistoryController', () => { retain: vi.fn(), [Symbol.dispose]: vi.fn(), } as unknown as SessionObservation), } as never) - const history = new SessionHistoryController(ctx, vi.fn()) + const { history } = controllers(ctx, vi.fn()) - await expect(history.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId, @@ -710,7 +791,7 @@ describe('SessionHistoryController', () => { const session = ordinary.ctx.sessions.create(SessionId('projected'), { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) const ordinarySnapshot = vi.spyOn(ordinary.ctx.sessionProjections, 'snapshot') - const ordinaryPage = await ordinary.transport.page({ + const ordinaryPage = await ordinary.history.events({ address: { kind: 'session', sessionId: session.id }, throughSeq: 0, }, signal()) @@ -727,7 +808,7 @@ describe('SessionHistoryController', () => { mode: 'continuable', provider: 'test', label: 'child', })) const childSnapshot = vi.spyOn(child.ctx.sessionProjections, 'snapshot') - const page = await child.transport.page({ + const page = await child.history.events({ address: { kind: 'subagent', parentSessionId, @@ -741,7 +822,7 @@ describe('SessionHistoryController', () => { }) it('keeps message-aligned pagination contiguous across replacement source-event references', async () => { - const { ctx, transport } = await setup() + const { ctx, history } = await setup() const session = ctx.sessions.create(SessionId('pagination'), { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) append(session, 'user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' }) @@ -754,27 +835,27 @@ describe('SessionHistoryController', () => { sourceEventSeqs: [SessionSeq(1), firstReply.seq, SessionSeq(3), SessionSeq(4), summary.seq], }) - const page = await transport.page({ + const page = await history.events({ address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, maxMessages: 2, }, signal()) expect(page.records.map(entry => entry.event.seq)) .toEqual([3, 4, 5, replacement.seq]) expect(page.hasMore).toBe(true) - const before = await transport.page({ + const before = await history.events({ address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, beforeSeq: 3, maxMessages: 1, }, signal()) expect(before.records.map(entry => entry.event.seq)).toEqual([2]) }) it('keeps cited source events in the page that owns their appended message', async () => { - const { ctx, transport } = await setup() + const { ctx, history } = await setup() const session = ctx.sessions.create(SessionId('pagination-sources'), { meta: { cwd: '/workspace' } }) const source = append(session, 'fixture/source', {}) append(session, 'user/message', { content: [], source: { kind: 'plugin' } }, { surfaceOp: 'append', sourceEventSeqs: [source.seq], }) - const page = await transport.page({ + const page = await history.events({ address: { kind: 'session', sessionId: session.id }, throughSeq: 1, maxMessages: 1, }, signal()) expect(page.records.map(entry => entry.event.seq)).toEqual([0, 1]) diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index 8a5fa75166..acadf82099 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -16,12 +16,24 @@ "src/control.ts", "src/file-references.ts", "src/history.ts", + "src/history/controller.ts", + "src/history/listener.ts", + "src/history/assistant-stream.ts", + "src/state/controller.ts", + "src/state/listener.ts", + "src/web/controller.ts", "src/list.ts", "src/media-references.ts", "src/archived-session-gate.ts", "src/skill-catalog.ts" ], "references": [ + { + "path": "../gateway/tsconfig.host.json" + }, + { + "path": "../../util/timeout" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index 260193276a..1a01d513c4 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/terminal-controller/package.json b/packages/api/terminal-controller/package.json index 1ac43492f5..a7e7ca3a0e 100644 --- a/packages/api/terminal-controller/package.json +++ b/packages/api/terminal-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-terminal-controller", "description": "Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 142b9dfbf3..802e26fb0e 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index ca8ac9b61e..4d1e0ca86a 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index be08b2fdbd..2caf5e3785 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.i18n.yaml @@ -36,8 +36,8 @@ en: dcd9857653645420 zh: a27259a09f17465d /deepseek-ai-dsh-attachment-local/understand-the-implementation/write-and-read-paths: - en: 62ea104a00a7fdb8 - zh: b08575274ca244b4 + en: 4cbbe8a1466ed46f + zh: 49d2d118501867d3 /deepseek-ai-dsh-attachment-local/understand-the-implementation/source-map: en: 05af85aa85934d01 zh: 1c89e80d496dc0a3 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 4844be3670..b27ac0c75a 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -83,6 +83,8 @@ This section explains the durability and verification design behind the storage, Objects land at `/attachments/v1/objects//`; equal bytes deduplicate to one object and one `sha256:` id. Before the first write, the process syncs every ancestor directory of the home down to the filesystem root once, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then stage bytes in `v1/tmp`, sync the temporary file, publish with an atomic exclusive hard link, and sync the publication directories — on Windows, filesystem metadata journaling owns entry durability. Once the save resolves, the reported reference is durable. +Image reads receive a complete reference with a canonical `sha256:` ID, derive the object path within this storage tree, verify the complete digest, and probe the image header. The actual format, byte length, width, and height must match the supplied reference. The returned reference preserves optional `name` and `originalDimensions` as caller-supplied display information; these fields are not verified against the object. Reads neither consult Session history nor reapply admission limits; a missing object or failed verification rejects the read. + Admission accepts up to 20 images and 200 MiB of source bytes per message; one source may use up to 50 MiB, 64 million pixels, and 65,535 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 `/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. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index a079785984..a0663aaebe 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -83,6 +83,8 @@ kind: "package-reference" 对象存放在 `/attachments/v1/objects//`;相同字节会去重为同一个对象和同一个 `sha256:` 标识符。首次写入前,进程会把 home 的每个祖先目录逐级同步到文件系统根目录,因此绝不会把另一个进程已创建但尚未同步的目录误认为安全边界。随后,写入过程把字节暂存到 `v1/tmp`、同步临时文件、以原子且排他的硬链接发布,并同步发布目录——在 Windows 上,文件系统元数据日志负责目录项持久性。保存操作完成后,返回的引用已具备持久性。 +图片读取接收包含规范 `sha256:` ID 的完整引用,在该存储目录内推导对象路径,校验完整摘要,并探测图片文件头。实际格式、字节数、宽度和高度必须与传入引用一致。返回的引用把可选的 `name` 和 `originalDimensions` 作为调用方提交的显示信息原样保留,不根据对象校验这两个字段。读取不查询 Session 历史,也不重新应用准入限制;对象缺失或校验失败都会拒绝读取。 + 准入允许每条消息最多 20 张图片与 200 MiB 源字节;单个源图最多 50 MiB、6400 万像素与单边 65,535 像素。系统应用方向、移除元数据与色彩配置,并把规范化结果限制在 2048×2048 总像素预算、8192 像素长边和 4 MiB 编码字节目标内,因此,即使宽高比极端,图片也会保留短边分辨率。已经满足限制的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 会逐字节直通;GIF、动画、元数据、方向、16-bit PNG 与不兼容色彩空间会触发转换。 请求版本位于由 `dshCachePath` 解析的 `/cache/attachments/request-images/`;显式 `dshHome` 设置同时适用于缓存与持久存储。在两次请求之间清空此缓存会保留持久附件,后续读取会重新生成请求版本。`readImageRequest` 在不放大的前提下缩放到路由选定的目标尺寸,缩放只按长边给定,短边由编码器按路由预测的方式推出,随后通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、目标尺寸、字节目标与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。 diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index b8163bbe34..6ad5b54083 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 7fab679e34..88ab2c9d27 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -423,7 +423,7 @@ export async function saveImageFile( /** * Read and verify one content-addressed image. * @param root - absolute `DSH_HOME/attachments/v1` root. - * @param ref - reference recorded in the session log. + * @param ref - supplied image reference; display fields are returned unchanged. * @param signal - optional cancellation for filesystem and verification work. * @returns verified bytes and reference. * @throws the signal reason when aborted, or an AttachmentError when verification fails. diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 82c2b9f3ba..6e7ff4b011 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -148,6 +148,21 @@ describe('local attachment store', () => { await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG }) }) + it.each([ + { mediaType: 'image/jpeg' }, { bytes: PNG.byteLength + 1 }, { width: 2 }, { height: 2 }, + ] as const)('rejects supplied metadata that differs from stored bytes: %j', async (mismatch) => { + const storageRoot = await root() + const saved = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + await expect(readImageFile(storageRoot, { ...saved, ...mismatch })).rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + }) + + it('preserves supplied display fields while verifying stored image facts', async () => { + const storageRoot = await root() + const saved = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + const supplied = { ...saved, name: 'display.png', originalDimensions: { width: 8, height: 8 } } + await expect(readImageFile(storageRoot, supplied)).resolves.toEqual({ ref: supplied, data: PNG }) + }) + it('rejects publication when the supplied digest does not match the staged bytes', async () => { const storageRoot = await root() const target = join(storageRoot, 'objects', '00', 'mismatch') diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index b2b7ba57ad..125d96125d 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -27,8 +27,8 @@ en: 8a0fa64e84aa4450 zh: ed2ac44ef20250d4 /deepseek-ai-dsh-attachment/use-this-package/reuse-images-across-the-session: - en: c99e4e088be92c4f - zh: 34997ecb9a420d08 + en: ede9266988e20f19 + zh: 6264c601ec62975d /deepseek-ai-dsh-attachment/use-this-package/what-can-go-wrong: en: b35e2f7f17269ae1 zh: 57cf0ef9fee6c7e1 @@ -36,8 +36,8 @@ en: 25d9c7d6c0899d23 zh: ad8380d14810ff1c /deepseek-ai-dsh-attachment/understand-the-implementation/design-decisions: - en: af7deef72fd2fa93 - zh: f217d52ce3971135 + en: 6315b2d1fb12f0ca + zh: c3f99f4c0850fb44 /deepseek-ai-dsh-attachment/understand-the-implementation/service-operations: en: b94d13fe133d8ce6 zh: dd4064d20d510afc diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 2acdc13c42..0e0c2bc05f 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -45,7 +45,7 @@ Commands declaring attachment input receive images and generic files in selectio ### Reuse images across the session -Saved normalized images stay in conversation history and are projected into deterministic, route-sized request versions in later turns; after a restart, a resumed session shows and reuses the same images. When the current execution filesystem maps the stored host object, the request descriptor also carries a read-only process path that the model can inspect. When history or a request version is read back, the stored bytes are checked against what was recorded, so a missing, corrupted, or swapped image surfaces as an error rather than wrong bytes. +Saved normalized images stay in conversation history and are projected into deterministic, route-sized request versions in later turns; after a restart, a resumed session shows and reuses the same images. When the current execution filesystem maps the stored host object, the request descriptor also carries a read-only process path that the model can inspect. History image reads and request projections verify the stored object against the full image reference, including its digest, format, byte length, and dimensions. Missing, corrupted, or swapped images fail explicitly. ### What can go wrong @@ -65,7 +65,7 @@ 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. +- **Verify on read.** Reads verify the immutable object against the supplied reference without consulting a Session. Optional display names and pre-normalization dimensions pass through as caller-supplied display information; the stored bytes do not verify these fields. - **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. diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 06cbe5631a..9c9d2c7fb5 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference" ### 在整个会话中复用图片 -已保存的规范化图片会保留在对话历史中,并在后续轮次投影为确定性的路由尺寸请求版本;重启后,恢复的会话会显示并复用相同的图片。当前执行文件系统可以映射已存宿主对象时,请求描述符还会携带模型可检查的只读进程路径。回读历史或请求版本时,已存储的字节会与记录的内容比对,因此缺失、损坏或被替换的图片会以错误形式呈现,而不是错误的字节。 +已保存的规范化图片会保留在对话历史中,并在后续轮次投影为确定性的路由尺寸请求版本;重启后,恢复的会话会显示并复用相同的图片。当前执行文件系统可以映射已存宿主对象时,请求描述符还会携带模型可检查的只读进程路径。历史图片读取和请求投影会把存储对象与完整图片引用中的摘要、格式、字节数和尺寸比对。图片缺失、损坏或被替换时会明确失败。 ### 可能出什么问题 @@ -65,7 +65,7 @@ kind: "package-reference" - **事件前完成规范化与持久化。** 每个源图都会在批次按序发布前完成准备与校验,因此会话日志绝不会引用部分完成或规范化失败的对象。 - **不可变且保留策略中立。** 对象一经发布即不可变;恢复和 fork 后的会话可能共享它们,因此引用感知的垃圾回收被推迟,而不是与任何单个会话的删除绑定。 -- **读取时校验。** 读取在返回前把字节和元数据与记录的引用比对,请求投影还会完整解码缓存字节,因此缺失、损坏或被替换的对象不会通过校验。 +- **读取时校验。** 读取会把不可变对象与传入引用比对,无需查询 Session。可选的显示名称和规范化前尺寸作为调用方提交的显示信息原样返回;存储字节不校验这两个字段。 - **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器按路由显式选定的目标尺寸与字节目标把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。 - **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方用 `isAttachmentError` 识别错误并按 `code` 路由,绝不依赖原型链。 - **文件原样,图片规范化。**`saveFile` 提交已有字节数组,`saveFileStream` 以背压和取消语义提交有界分块,`readFileStream` 校验并返回有界分块,`fileHostPath` 定位存储对象供按需读取投影;两种文件写入路径都不设准入限制。图片路径保留其独立的规范化、限额与请求版本流水线。`dsh-llm` 中的 `FileBlock` 内容块承载 `FileAttachmentRef`,请求组装会为每条路由将其投影为确定性的句柄文本。 diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 65958a9643..7ef5c29d66 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index ba7f567884..802df0cb6b 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -186,8 +186,8 @@ export abstract class AttachmentStore extends Service { abstract saveImage(input: SaveImageAttachment): Promise /** - * Read one image and verify that bytes still match the recorded reference. - * @param ref - durable reference from the session log. + * Read one image and verify its ID, format, byte length, and dimensions against the supplied reference. + * @param ref - supplied image reference; name and originalDimensions are preserved without file verification. * @param signal - optional cancellation for backend read and verification work. * @returns the verified bytes and normalized attachment reference. * @throws the signal reason when aborted, or a storage error when verification fails. @@ -240,7 +240,7 @@ export abstract class AttachmentStore extends Service { * Read and verify one verbatim stored file as bounded chunks. Providers must * not collect the complete file in memory. Backends without verbatim file * reads keep this default rejection. - * @param ref - durable reference from the session log. + * @param ref - durable file reference from the session log. * @param signal - optional cancellation for backend reads and verification work. * @returns exact file bytes in order; integrity failures reject the iteration. */ diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 006df92bd8..8387f43478 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -24,8 +24,8 @@ en: 65bf2834b81f87da zh: c864ac60a3005b99 /deepseek-ai-dsh-app-boot/use-this-package/profiles: - en: a44af3e33f800634 - zh: 09664fce7f3ac92e + en: a9c82cd220ae8d59 + zh: 7b3b5de574a8840e /deepseek-ai-dsh-app-boot/use-this-package/previewing-the-effective-configuration: en: 646a0ccdd7881ad1 zh: e3696acb1573dd7b diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 26faa52aeb..4d30457f81 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -55,6 +55,8 @@ Admission happens only at composition boundaries DSH owns, and a denied row is d Exact-version exemptions live in the profile's own `compatibility.json`, not in its `package.json`, so an exemption write never touches the dependency manifest, the bundle list, or Cordis patch files. It maps exact `package-name@version` keys to lists of exact DSH runtime versions; neither a plugin upgrade nor a DSH upgrade inherits permission. A missing file means no exemptions. A malformed one never stops the profile from starting: records the reader accepts still apply, each rejected record is reported on stderr beside the plugin denials, and the file is then treated as read-only, so a grant or revocation refuses and asks the user to repair it by hand instead of overwriting their content. [Plugin Manager](../plugin-manager/README.md#version-compatibility-and-exemptions) owns the grant, revocation, and risk confirmation each change requires. `validatePluginVersionExemption(packageVersion, runtimeVersion)` validates canonical exact identities without granting permission; invalid identities throw. +A bundle may declare `dsh.bundle.platforms` as a nonempty list of Node Host platform names, such as `["darwin"]`. The launcher validates this declaration before reading the bundle patch. An unsupported Host skips the entire layer, retains its saved selection, and reports the reason through `skippedBundles`; other layers remain effective. Omission permits every platform. Values must be Node platform names; aliases such as `macos` are invalid. Browser clients do not determine Host support. + 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. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 57d3f16b00..ba551706b8 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -55,6 +55,8 @@ profile 导入插件前,DSH 会将其声明的 DSH 版本要求与 `getDshRunt 精确版本豁免保存在 profile 自己的 `compatibility.json` 中,而不是 `package.json`,因此写豁免不会触及依赖清单、组合包列表或 Cordis patch 文件。它把精确的 `package-name@version` 键映射到精确 DSH 运行时版本列表;插件升级和 DSH 升级都不继承授权。文件缺失表示没有豁免。文件损坏绝不会阻止 profile 启动:读取器接受的记录仍然生效,每条被拒绝的记录会与插件拒绝信息一起输出到 stderr,此后该文件被视为只读,因此授予或撤销会拒绝执行并要求用户手工修复,而不是覆盖用户的内容。[插件管理器](../plugin-manager/README.zh.md#version-compatibility-and-exemptions)负责每次变更所需的授权、撤销与风险确认。 `validatePluginVersionExemption(packageVersion, runtimeVersion)` 校验规范的精确身份而不授予权限;身份非法时抛出错误。 +组合包可以用 `dsh.bundle.platforms` 声明非空的 Node Host 平台名称列表,例如 `["darwin"]`。启动器在读取组合包 patch 前验证此声明。Host 不受支持时会跳过整个配置层,保留已保存的选择,并通过 `skippedBundles` 报告原因;其他配置层继续生效。省略此字段时允许所有平台。值必须是 Node 平台名,`macos` 等别名无效。浏览器所在机器的平台不决定 Host 是否受支持。 + 你的机器本地偏好同样位于 harness home 中: - **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。在文件中设置的进程启动变量(如 `PATH`、`DSH_*`、`XDG_*`)会被拒绝:请改为导出这些变量。四个代理名(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`)只从 harness home 的文件接受,绝不从调用目录的文件接受——后者随 clone 一起到来。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。 diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 1db32358de..07e9757746 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/src/bundle-platform.ts b/packages/boot/app-boot/src/bundle-platform.ts new file mode 100644 index 0000000000..ba9ab28968 --- /dev/null +++ b/packages/boot/app-boot/src/bundle-platform.ts @@ -0,0 +1,32 @@ +/** Host operating-system admission for optional bundle layers. */ + +const NODE_PLATFORMS: readonly string[] = Object.freeze([ + 'aix', 'android', 'darwin', 'freebsd', 'haiku', 'linux', 'openbsd', 'sunos', 'win32', 'cygwin', 'netbsd', +] satisfies NodeJS.Platform[]) + +/** A valid bundle declaration excludes the operating system running DSH. */ +export class UnsupportedBundlePlatformError extends Error { + /** + * @param platforms Node platform names supported by the bundle. + * @param platform Operating system of the DSH Host. + */ + constructor(platforms: readonly string[], platform: string) { + super(`Bundle supports Host platforms ${platforms.join(', ')}; the DSH Host is running ${platform}`) + this.name = 'UnsupportedBundlePlatformError' + } +} + +/** + * Validate package metadata and reject an unsupported Host before selecting or composing a bundle. + * @param platforms The optional `dsh.bundle.platforms` field read from package.json. + * @param platform Operating system of the DSH Host, from Node's `process.platform`. + * @throws When the declaration is malformed or excludes the Host; omission allows every platform. + */ +export function assertBundlePlatform(platforms: unknown, platform: string): void { + if (platforms === undefined) return + if (!Array.isArray(platforms) || platforms.length === 0 + || !platforms.every((value): value is string => typeof value === 'string' && NODE_PLATFORMS.includes(value))) { + throw new Error('dsh.bundle.platforms must be a nonempty list of Node platform names') + } + if (!platforms.includes(platform)) throw new UnsupportedBundlePlatformError(platforms, platform) +} diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index b215c2007a..acab24ebe4 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -56,6 +56,7 @@ declare module '@deepseek-ai/cordis' { } } +export { assertBundlePlatform, UnsupportedBundlePlatformError } from './bundle-platform.ts' export { composeEntries, createRuntimeResolution, diff --git a/packages/boot/app-boot/src/profile-plugins.ts b/packages/boot/app-boot/src/profile-plugins.ts index 1cd55e9135..268d000f5a 100644 --- a/packages/boot/app-boot/src/profile-plugins.ts +++ b/packages/boot/app-boot/src/profile-plugins.ts @@ -1,6 +1,7 @@ /** Installed profile dependencies and their bundle activation after package-manager operations. */ import { join } from 'node:path' +import { assertBundlePlatform } from './bundle-platform.ts' import { readProfileManifest, resolveBundleDir, writeProfileManifest, type ProfileManifest } from './profile.ts' /** Profile directory and installation used by the shared bundle resolver. */ @@ -97,6 +98,8 @@ export function writeProfileBundles( * template entries retain their order and duplicates. New bundle dependencies activate automatically. * @param options - location, inventory captured before pnpm, and whether explicitly disabled bundles remain disabled. * @returns updated inventory and newly added ordinary dependencies for caller-owned warnings. + * @throws If a new selection has invalid platform metadata or excludes the Host. Installed dependencies + * remain; no bundle selection changes are written. */ export function reconcileProfilePlugins(options: ProfilePluginLocation & { readonly before: ProfilePluginInventory @@ -112,7 +115,10 @@ export function reconcileProfilePlugins(options: ProfilePluginLocation & { const previous = after.manifest.dsh?.profile?.bundles ?? [] const bundles = previous.filter(name => !(beforeNames.has(name) || afterNames.has(name)) || bundleNames.has(name)) for (const dependency of after.dependencies) { - if (dependency.bundle && !disabled.has(dependency.name) && !bundles.includes(dependency.name)) bundles.push(dependency.name) + if (dependency.bundle && !disabled.has(dependency.name) && !bundles.includes(dependency.name)) { + assertBundlePlatform(bundleManifest(options, dependency.name)?.dsh?.bundle?.platforms, process.platform) + bundles.push(dependency.name) + } } const changed = bundles.length !== previous.length || bundles.some((name, index) => name !== previous[index]) const manifest = changed ? writeProfileBundles(options.profileDir, after.manifest, bundles) : after.manifest diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 1472e06c64..32719f2484 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -29,6 +29,7 @@ import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { DshBundleManifest, DshPackageManifest } from '@deepseek-ai/dsh-package-manifest' import { INSTALLATION_OWNED_PROFILE_TUPLES, PROFILE_TEMPLATES, RETIRED_BUNDLES } from '@deepseek-ai/dsh-official-bundles' +import { assertBundlePlatform } from './bundle-platform.ts' import { evaluatePluginCompatibility, pluginCompatibilityWarning } from './plugin-compatibility.ts' import { readProfileVersionExemptions } from './profile-compatibility.ts' import { loadOverlayPatches } from './index.ts' @@ -665,7 +666,8 @@ export function resolveBundleDir( * the shared Harness home. This is used by application-owned profiles whose * package project and lifecycle belong to that application. * Retired bundles are removed from the stored bundle list first, rewriting the - * manifest when it listed one. Unreadable bundles, and bundles whose own dsh requirements the profile does not exempt, are skipped + * manifest when it listed one. Unreadable bundles, unsupported Host platforms, and bundles whose own dsh requirements + * the profile does not exempt are skipped * without changing the manifest and listed in `skippedBundles`; nothing is printed. * @param binName - the diagnostic prefix on thrown errors. * @param dir - absolute profile package directory. @@ -692,6 +694,7 @@ export function loadProfileDirectory( if (bundle === undefined) { throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`) } + assertBundlePlatform(bundle.platforms, process.platform) // A bundle is not a plugin row, so row admission never reads its own dsh requirements. const issue = evaluatePluginCompatibility(bundleManifest, exemptions) if (issue !== undefined && !issue.exempted) throw new Error(pluginCompatibilityWarning(issue)) @@ -711,8 +714,8 @@ export function loadProfileDirectory( /** * Load a profile: resolve every `dsh.profile.bundles` entry to its patch - * layer and parse the profile's own patch file. Unreadable or incompatible bundles - * are skipped and listed in `skippedBundles`; profile manifest and user patch errors still throw. + * layer and parse the profile's own patch file. Unreadable bundles, unsupported Host platforms, + * and incompatible bundles are skipped and listed in `skippedBundles`; profile manifest and user patch errors still throw. * @param binName - the diagnostic prefix on thrown errors. * @param name - the profile name. * @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor). diff --git a/packages/boot/app-boot/tests/bundle-platform.spec.ts b/packages/boot/app-boot/tests/bundle-platform.spec.ts new file mode 100644 index 0000000000..eaaa76c690 --- /dev/null +++ b/packages/boot/app-boot/tests/bundle-platform.spec.ts @@ -0,0 +1,18 @@ +/** Bundle declarations admit the Host operating system independently of browser clients. */ +import { expect, it } from 'vitest' +import { assertBundlePlatform, UnsupportedBundlePlatformError } from '../src/bundle-platform.ts' + +it.each(['darwin', 'win32', 'linux'])('allows undeclared platforms and an explicit %s Host', (platform) => { + expect(() => { assertBundlePlatform(undefined, platform) }).not.toThrow() + expect(() => { assertBundlePlatform([platform], platform) }).not.toThrow() + expect(() => { assertBundlePlatform(['darwin', 'win32', 'linux'], platform) }).not.toThrow() +}) + +it.each(['win32', 'linux'])('rejects macOS-only bundles on %s Hosts', (platform) => { + expect(() => { assertBundlePlatform(['darwin'], platform) }).toThrow(UnsupportedBundlePlatformError) + expect(() => { assertBundlePlatform(['darwin'], platform) }).toThrow(`the DSH Host is running ${platform}`) +}) + +it.each([null, true, 'darwin', [], [1], [''], ['Darwin'], [' darwin'], ['macos'], ['darwin', 'linxu'], ['darwin', false]].map(platforms => ({ platforms })))('rejects malformed platform metadata $platforms', ({ platforms }) => { + expect(() => { assertBundlePlatform(platforms, 'darwin') }).toThrow('dsh.bundle.platforms must be a nonempty list') +}) diff --git a/packages/boot/app-boot/tests/profile-plugins.spec.ts b/packages/boot/app-boot/tests/profile-plugins.spec.ts index 04950b4812..223754fc71 100644 --- a/packages/boot/app-boot/tests/profile-plugins.spec.ts +++ b/packages/boot/app-boot/tests/profile-plugins.spec.ts @@ -31,6 +31,18 @@ function installed(location: ProfilePluginLocation, name: string, bundle = true) }) } +it('refuses automatic activation on an unsupported Host without changing the bundle selection', () => { + const location = fixture() + const before = readProfilePlugins(location) + writeManifest(location.profileDir, { dependencies: { restricted: '1.0.0' }, dsh: { profile: { bundles: [] } } }) + writeManifest(join(location.profileDir, 'node_modules/restricted'), { name: 'restricted', dsh: { bundle: { + patch: 'unused.yml', platforms: [process.platform === 'darwin' ? 'win32' : 'darwin'], + } } }) + const saved = readFileSync(join(location.profileDir, 'package.json'), 'utf8') + expect(() => reconcileProfilePlugins({ ...location, before, preserveDisabled: true })).toThrow('Host is running') + expect(readFileSync(join(location.profileDir, 'package.json'), 'utf8')).toBe(saved) +}) + describe('profile plugin inventory', () => { it('uses dependency alias keys, installed versions, and the loader installation precedence', () => { const location = fixture() diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 390e888050..0a67d476e1 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -47,7 +47,7 @@ const tmp = (): string => { /** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */ function stageInstallation( - bundles: Record }>, + bundles: Record; platforms?: string[] }>, appName = 'dsh-app', ): string { const root = tmp() @@ -64,7 +64,7 @@ function stageInstallation( type: 'module', main: './index.js', dependencies: spec.deps ?? {}, - ...spec.patch === undefined ? {} : { dsh: { bundle: { patch: './cordis.patch.yml' } } }, + ...spec.patch === undefined ? {} : { dsh: { bundle: { patch: './cordis.patch.yml', ...spec.platforms === undefined ? {} : { platforms: spec.platforms } } } }, })) writeFileSync(join(dir, 'index.js'), `export const packageName = ${JSON.stringify(name)}\n`) if (spec.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), spec.patch) @@ -76,6 +76,24 @@ function stageInstallation( return join(appDir, 'package.json') } +it('skips an unsupported saved bundle before composing any runtime overrides and retains the selection', () => { + const unsupported = process.platform === 'darwin' ? 'win32' : 'darwin' + const anchor = stageInstallation({ + base: { patch: '- insert:\n - id: ptc-runtime\n name: runtime\n config: { executionMode: isolated }\n' }, + restricted: { platforms: [unsupported], patch: '- id: ptc-runtime\n config: { executionMode: persistent-in-memory }\n' }, + }) + const dir = join(tmp(), 'profile') + initProfile(dir, ['base', 'restricted']) + const before = readFileSync(join(dir, 'package.json'), 'utf8') + const profile = loadProfileDirectory('test', dir, anchor) + expect(profile.layers.map(layer => layer.packageName)).toEqual(['base']) + expect(profile.skippedBundles).toHaveLength(1) + expect(profile.skippedBundles[0]?.packageName).toBe('restricted') + expect(profile.skippedBundles[0]?.reason).toContain(`Host is running ${process.platform}`) + expect(composeEntries(profile.layers.map(layer => layer.patches))).toMatchObject([{ id: 'ptc-runtime', config: { executionMode: 'isolated' } }]) + expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before) +}) + /** Represent one resolved external bundle as a loaded profile layer. */ function stageProfile(home: string, name: string, bundleAnchor: string): Profile { const dir = resolveProfileDir(name, home) diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 95ca31e410..804bb2bc11 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/boot/config-editor/package.json b/packages/boot/config-editor/package.json index 4b41336ae3..d8bfc04659 100644 --- a/packages/boot/config-editor/package.json +++ b/packages/boot/config-editor/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-config-editor", "description": "Persist plugin configuration through profile patches and Loader reconciliation", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/boot/hmr/package.json b/packages/boot/hmr/package.json index a4efcfe4c6..93fa7fb127 100644 --- a/packages/boot/hmr/package.json +++ b/packages/boot/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hmr", "description": "Coordinated module and profile configuration hot reload", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 67a48b4ec1..d0dffb1b6d 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: 87679c51aa3693f5 zh: 11684a48a5806afb /deepseek-ai-dsh-plugin-manager/use-this-package: - en: 7ec407151aa14244 - zh: 7f09ed0329449c4d + en: 4bda68030a6999e0 + zh: 5ae0b9825341c05e /deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions: en: 483285658e267dd5 zh: 846bec2daac59a96 @@ -27,8 +27,8 @@ en: 99b084ff497011d2 zh: 6c047796c1991784 /deepseek-ai-dsh-plugin-manager/understand-the-implementation: - en: 03e1309c5ea3ce41 - zh: a771b3c7e2f7b8e6 + en: adeb9a0a21eedf1b + zh: 5410aeaf1f338e86 /deepseek-ai-dsh-plugin-manager/further-exploration: en: c6538243d8702a0d zh: 04a82d923353c75f @@ -51,8 +51,8 @@ en: d26075545fe1e990 zh: 068c07d642843c94 /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/failure-behavior: - en: c670eb2330b441e8 - zh: 2c8223e03e1e3ef0 + en: 3fde425e82bbd0f4 + zh: 531e2365e7d67480 /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index ed3cdcc0a5..e442534a01 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -39,7 +39,9 @@ For a deployment without agent presets, enable the tool in the profile patch. Pr A plugin toggle updates only `disabled` in the last matching override in the profile's `cordis.patch.yml`, or appends an override when none matches. Matching uses the entry id and any module-name assertion. An override holding only `id` and `disabled` is removed, and none is appended, when the bundle layers and the remaining profile patches already give the entry the requested state, so the profile patch records only departures from the bundles. Home and invocation patches are left out of that comparison; they apply after the profile patch and do not decide what it records. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority. -`listBundles` retains default-hidden bundles and projects each package’s `dsh.bundle.listedByDefault` declaration for GUI consumers; it does not change selection or activation. A non-boolean declaration returns a metadata diagnostic. A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable. +Bundle platform declarations are checked before saving an enabled selection. Unsupported Hosts return `unsupported-platform`. The bundle remains visible with its display metadata and can still be deselected. Automatic activation after a package operation uses the same check. The agent-facing next step asks the user to run the bundle on a supported Host. + +`listBundles` retains default-hidden bundles and derives `listedByDefault` from each package’s visibility declaration and Host platform support. Unsupported bundles are hidden from default GUI lists, including More; explicit profile dependencies and saved selections remain manageable with the platform reason and deselection available. The offline Official catalog carries the same package-owned platform declaration, so uninstalled entries are checked without resolving provider code or querying a registry. These checks do not change selection or activation. A non-boolean declaration returns a metadata diagnostic. A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable. `listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. `source` gives a profile dependency as a spec `pnpm add` accepts: a git address or URL as recorded without the user information of an http(s) URL (a query string stays as recorded), a `file:` or `link:` path made absolute against the profile or the home directory for `~`, and a registry range, tag, or alias after the package name, as is any spec of a package installed under another name; a bundle the installation supplies has none, even when the profile also depends on it, because the installation's copy is the one that loads. The `plugin_manager` tool omits UI display metadata and update eligibility from list results. @@ -127,7 +129,7 @@ A service run whose captured output stays silent for `idleTimeoutMs` is terminat Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. A failed or cancelled package run, or failed bundle validation, restores the manifest and lockfile snapshotted before pnpm ran ([rationale](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md)); later failures retain saved changes as described in [Failure behavior](#failure-behavior). Installations are tracked by request id until their call settles, so a cancellation names one run and joins its settlement without taking the profile lock. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output. -The `plugin-manager/operation` event correlates each service invocation with its start and observed result, or an unknown outcome when an exception escapes result construction. Installation targets retain only parsed registry package names; bundle toggles and removals require the exact package name without a version range or surrounding whitespace. Plugin targets retain only known entry IDs. Exact names need not be installed. Version-exemption records retain the validated package name and exact exemption version, not the currently installed version. Versions describe request-time observations before locking or the resolved installation; invalid or oversized metadata is omitted without losing the operation. Raw specs and error text are excluded. Subscribers run independently without delaying management; synchronous throws and asynchronous rejections do not starve later subscribers or change the result. Direct CLI package operations do not emit this service event. +The `plugin-manager/operation` event correlates each service invocation with its start and observed result, or an unknown outcome when an exception escapes result construction. Installation targets retain only parsed registry package names; bundle toggles and removals require the exact package name without a version range or surrounding whitespace. Plugin targets retain only known entry IDs. Exact names need not be installed. Version-exemption records retain the validated package name and exact exemption version, not the currently installed version. Versions describe request-time observations before locking or the resolved installation; invalid or oversized metadata is omitted without losing the operation. Platform refusals retain the `unsupported-platform` error code. Raw specs and error text are excluded. Subscribers run independently without delaying management; synchronous throws and asynchronous rejections do not starve later subscribers or change the result. Direct CLI package operations do not emit this service event. The `@deepseek-ai/dsh-plugin-manager/diagnostic` entry supplies Desktop Host and shell readers with run IDs, operation records and a parser that retains only whitelisted fields. Malformed or oversized records are rejected; readers report omissions and truncation. The shell accepts records only for the run ID announced by its Host. Diagnostic consumers own persistence and delivery limits; missing records never imply successful operations. @@ -181,7 +183,7 @@ Tool results append to the transcript. Enabling or disabling other tools can cha ### Failure behavior -Failures preserve completed steps and report the actual remaining state. Profile dependencies without valid bundle metadata remain visible and removable, with enablement unavailable. +Failures preserve completed steps and report the actual remaining state. Profile dependencies without valid bundle metadata remain visible and removable, with enablement unavailable. If `dsh plugin` installs a bundle whose platform declaration excludes the Host, automatic activation returns exit code 1 and a diagnostic naming the package. Installed dependencies remain, and bundle selection stays unchanged. Use a supported Host or remove the dependency with `dsh plugin --profile remove `. | Failed operation | Handling | |---|---| diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 9af35405c7..1a606026ce 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -39,7 +39,9 @@ kind: "package-reference" 插件开关只更新 profile 的 `cordis.patch.yml` 中最后一条匹配覆盖项的 `disabled`;没有匹配项时追加。匹配依据是条目 id,以及覆盖项声明的模块名称。如果组合包层和其余 profile patch 已使条目处于所请求的状态,仅含 `id` 与 `disabled` 的覆盖项会被移除,也不会追加覆盖项,因此 profile patch 只记录与组合包不同的设置。该比较不计入 home 和单次启动 patch;它们在 profile patch 之后应用,不决定 profile patch 记录什么。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。 -`listBundles` 保留默认隐藏的组合包,并投影各包的 `dsh.bundle.listedByDefault` 声明供 GUI 使用;它不改变选入或激活行为。声明不是布尔值时返回元数据诊断。已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。 +组合包的平台声明会在保存启用选择前检查。Host 不受支持时返回 `unsupported-platform`。组合包仍保留显示信息,并允许取消选择。包操作后的自动启用执行同一检查。给模型的下一步说明会提示用户在受支持的 Host 上运行该组合包。 + +`listBundles` 保留默认隐藏的组合包,根据各包的展示声明和 Host 平台支持范围生成 `listedByDefault`。不支持的组合包默认不出现在主列表或“更多”中;profile 显式安装的依赖和已保存的选择仍可管理,显示平台原因并允许关闭。官方离线目录携带同一份包级平台声明,因此无需解析提供方代码或查询注册表,也能检查未安装的条目。这些检查不改变选择或激活状态。声明不是布尔值时返回元数据诊断。已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。 `listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`source` 以 `pnpm add` 接受的 spec 给出 profile 依赖:git 地址或 URL 按记录给出,但去掉 http(s) URL 的 user information(查询字符串保持原样);`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,`~` 展开为主目录;注册表版本范围、标签或别名接在包名的 `@` 之后,以别名安装的包的 spec 也是如此。安装提供的组合包没有该字段,即使 profile 也依赖它,因为实际加载的是安装中的副本。`plugin_manager` 工具的列表结果不包含 UI 展示元信息和更新资格。 @@ -127,7 +129,7 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止, 结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。包管理运行失败或被取消,或组合包校验失败时,会恢复 pnpm 运行前快照的 manifest 与 lockfile([理由](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md));后续失败按[失败行为](#failure-behavior)所述保留已保存的改动。安装按 request id 跟踪到调用结束,因此取消只针对一次运行,并且不取 profile 锁就能等待它结束。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。 -`plugin-manager/operation` 事件关联每次服务调用的开始和已观察到的结果;若异常使结果无法构造,则记录结果未知。安装目标仅保留解析出的 registry 包名;启停或移除包的目标必须与包名完全一致,不能含版本范围或首尾空格;插件目标仅保留已知条目 ID。精确包名不要求已安装。版本豁免记录保留已校验的包名和精确豁免版本,而不是当前安装版本。版本表示加锁前请求时观察到的值或安装解析出的值;非法或过长的元数据被省略,不丢弃操作记录。事件不包含原始 spec 和错误文本。订阅者独立执行,不延迟管理操作;同步抛错和异步拒绝都不会阻断后续订阅者或改变结果。直接执行的 CLI 包操作不发送此服务事件。 +`plugin-manager/operation` 事件关联每次服务调用的开始和已观察到的结果;若异常使结果无法构造,则记录结果未知。安装目标仅保留解析出的 registry 包名;启停或移除包的目标必须与包名完全一致,不能含版本范围或首尾空格;插件目标仅保留已知条目 ID。精确包名不要求已安装。版本豁免记录保留已校验的包名和精确豁免版本,而不是当前安装版本。版本表示加锁前请求时观察到的值或安装解析出的值;非法或过长的元数据被省略,不丢弃操作记录。平台拒绝保留 `unsupported-platform` 错误码。事件不包含原始 spec 和错误文本。订阅者独立执行,不延迟管理操作;同步抛错和异步拒绝都不会阻断后续订阅者或改变结果。直接执行的 CLI 包操作不发送此服务事件。 `@deepseek-ai/dsh-plugin-manager/diagnostic` 入口为 Desktop Host 和壳读取器提供运行 ID、操作记录及仅保留白名单字段的解析器。格式错误或超限记录会被拒绝;读取器报告遗漏和截断。壳仅接受其 Host 已声明运行 ID 对应的记录。诊断消费者负责持久化和传输限额;缺失记录不能说明操作成功。 @@ -181,7 +183,7 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止, ### 失败行为 -失败保留已完成步骤,并报告实际残留状态。没有有效组合包声明的 profile 依赖仍可见、可删除,但不能启用。 +失败保留已完成步骤,并报告实际残留状态。没有有效组合包声明的 profile 依赖仍可见、可删除,但不能启用。`dsh plugin` 安装的组合包不支持当前 Host 时,自动启用返回退出码 1,并在诊断中写明包名。已安装的依赖仍保留,组合包选择不变。请使用受支持的 Host,或通过 `dsh plugin --profile remove ` 移除依赖。 | 失败操作 | 处理方式 | |---|---| diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json index 48675d4dd3..196eeb5a20 100644 --- a/packages/boot/plugin-manager/package.json +++ b/packages/boot/plugin-manager/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-manager", "description": "Current-profile plugin and bundle management shared by dsh CLI, Web and agent tools", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/boot/plugin-manager/src/diagnostic.ts b/packages/boot/plugin-manager/src/diagnostic.ts index fa5ed2a5bd..aa79fe5527 100644 --- a/packages/boot/plugin-manager/src/diagnostic.ts +++ b/packages/boot/plugin-manager/src/diagnostic.ts @@ -56,6 +56,7 @@ const operation: z.ZodType = z.discriminatedUnion('phase', [ 'bundle-in-use': 'bundle-in-use', 'stale-approval': 'stale-approval', 'incompatible-version': 'incompatible-version', + 'unsupported-platform': 'unsupported-platform', 'operation-error': 'operation-error', } satisfies { [K in ManagementError['code']]: K }).optional(), restartReason: z.enum({ diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 97e717f6a3..271e6af0d3 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -23,6 +23,7 @@ import { evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions, setProfileVersionExemption, validatePluginVersionExemption, PROFILE_COMPATIBILITY_FILENAME, getDshRuntimeVersion, ResolutionRestartRequired, + assertBundlePlatform, UnsupportedBundlePlatformError, } from '@deepseek-ai/dsh-app-boot' import { OPTIONAL_BUNDLES, OFFICIAL_ON_DEMAND_CATALOG, PROTECTED_PLUGINS } from '@deepseek-ai/dsh-official-bundles' import type {} from '@deepseek-ai/dsh-hmr' @@ -107,6 +108,7 @@ function messageOf(error: unknown): string { return error instanceof Error ? err /** An expected refusal keeps its code; anything else becomes an operation error carrying its exact diagnostic. */ function managementError(error: unknown): ManagementError { + if (error instanceof UnsupportedBundlePlatformError) return { code: 'unsupported-platform' } if (!(error instanceof ManagementFailure)) return { code: 'operation-error', diagnostic: messageOf(error) } return { code: error.code, ...error.incompatible === undefined ? {} : { incompatible: error.incompatible } } } @@ -515,9 +517,14 @@ export class PluginManager extends TypertRemoteService { } const catalogMeta = offered === undefined ? {} : { meta: offered.meta } if (offered !== undefined && !installed && !shipped) { + let platformError: ManagementError | undefined + try { assertBundlePlatform(offered.platforms, process.platform) } + catch (error) { platformError = managementError(error) } + const error = platformError ?? targetError ?? (enabled ? { code: 'not-bundle' as const } : undefined) bundles.push({ name, official, availability: 'missing', ...catalogMeta, ...target, enabled, installed, optional, + ...platformError?.code === 'unsupported-platform' ? { listedByDefault: false } : {}, removable: enabled, rows: [], overrides: [], - ...targetError !== undefined ? { error: targetError } : enabled ? { error: { code: 'not-bundle' } } : {} }) + ...error === undefined ? {} : { error } }) continue } // Installation-owned copies remain protected even if a profile also declares their names. @@ -548,13 +555,18 @@ export class PluginManager extends TypertRemoteService { if (compatibility !== undefined && !compatibility.exempted) throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)]) const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir) const meta = offered?.meta ?? readPluginMeta(info.name ?? name, pathToFileURL(join(dir, 'package.json')).href) + let platformError: ManagementError | undefined + try { assertBundlePlatform(info.dsh?.bundle?.platforms, process.platform) } + catch (error) { platformError = managementError(error) } + if (platformError?.code === 'unsupported-platform') listing = { listedByDefault: false } + const error = platformError ?? targetError bundles.push({ name, official, availability, ...listing, ...target, ...(version === undefined ? {} : { version }), ...(info.description === undefined || info.description === '' ? {} : { description: info.description }), ...meta === undefined ? {} : { meta }, ...sourceOf(info.name), enabled, installed, optional, removable: removable && readOnlyReason === undefined, ...(readOnlyReason === undefined ? {} : { readOnlyReason }), ...this.declaredRows(name, info), - ...targetError === undefined ? {} : { error: targetError } }) + ...error === undefined ? {} : { error } }) } catch (error) { if (enabled || installed || optional || offered !== undefined) { bundles.push({ name, official, availability, ...listing, ...catalogMeta, ...target, ...(version === undefined ? {} : { version }), @@ -1149,6 +1161,7 @@ export class PluginManager extends TypertRemoteService { const metadata = bundleManifest(name, this.profile.dir, this.profile.installAnchor) if (metadata === undefined) throw new ManagementFailure('not-bundle') if (enabled) { + assertBundlePlatform(metadata.dsh?.bundle?.platforms, process.platform) const compatibility = evaluatePluginCompatibility(metadata, readProfileVersionExemptions(this.profile.dir)) if (compatibility !== undefined && !compatibility.exempted) throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)]) this.bundleRows(name) diff --git a/packages/boot/plugin-manager/src/operations.ts b/packages/boot/plugin-manager/src/operations.ts index 8950ad83a3..e7b761ad3c 100644 --- a/packages/boot/plugin-manager/src/operations.ts +++ b/packages/boot/plugin-manager/src/operations.ts @@ -7,7 +7,7 @@ import { execa } from 'execa' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { - bundlePatchPaths, initProfile, readProfileManifest, + assertBundlePlatform, UnsupportedBundlePlatformError, bundlePatchPaths, initProfile, readProfileManifest, resolveBundleDir, resolveProfileDir, loadOverlayPatches, composeEntries, readProfileVersionExemptions, evaluatePluginCompatibility, pluginCompatibilityWarning, type ProfileManifest, } from '@deepseek-ai/dsh-app-boot' @@ -86,12 +86,13 @@ export async function saveManifest(dir: string, manifest: ProfileManifest): Prom await writeFileAtomic(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n', { mode: 0o600 }) } -/** Reconcile package removals and newly installed bundles without re-enabling retained dependencies. */ -async function reconcile(before: ProfileManifest, dir: string, anchor: string, options: PackageOperationOptions): Promise { +/** Reconcile bundle selection, returning platform denials without saving any selection changes. */ +async function reconcile(before: ProfileManifest, dir: string, anchor: string, options: PackageOperationOptions): Promise { const after = readProfileManifest('dsh', dir) const dependencies = Object.keys(after.dependencies ?? {}) const beforeDeps = new Set(Object.keys(before.dependencies ?? {})) const previous = after.dsh?.profile?.bundles ?? [] + const unsupported: string[] = [] const bundles = previous.filter((name) => { if (!beforeDeps.has(name) && !dependencies.includes(name)) return true return dependencies.includes(name) && bundleManifest(name, dir, anchor) !== undefined @@ -103,14 +104,21 @@ async function reconcile(before: ProfileManifest, dir: string, anchor: string, o options.onOutput?.(`dsh: warning: ${name} declares no dsh.bundle — installed as a plain dependency, not a profile layer\n`, 'stderr') continue } + try { assertBundlePlatform(metadata.dsh.bundle.platforms, process.platform) } catch (error) { + if (!(error instanceof UnsupportedBundlePlatformError)) throw error + unsupported.push(`${name}: ${error.message}`) + continue + } for (const file of bundlePatchPaths(resolveBundleDir('dsh', name, anchor, dir), metadata.dsh.bundle)) loadOverlayPatches('dsh', file) if (!bundles.includes(name)) { bundles.push(name) } } - if (JSON.stringify(previous) === JSON.stringify(bundles)) return + if (unsupported.length > 0) return unsupported + if (JSON.stringify(previous) === JSON.stringify(bundles)) return [] after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles } } await saveManifest(dir, after) + return [] } /** @@ -281,6 +289,8 @@ function bundleComponentManifests(manifest: ProfileManifest, dir: string, anchor * @param options Output, activation and cancellation policy. * @returns Exit status, whether the silence bound stopped the run, and the diagnostic path. * A compatibility denial returns exit code 1. Service output is bounded; CLI output uses inherited descriptors. + * Unsupported Host activation returns exit code 1 with named diagnostics; installed dependencies remain, + * and no bundle selection changes are saved. Other reconciliation or filesystem errors reject. * The diagnostic file is closed on return or rejection, including preparation and spawn failures. */ export async function runProfilePnpm( @@ -521,7 +531,14 @@ export async function runProfilePnpm( options.onOutput?.(diagnostic, 'stderr') append(Buffer.from(diagnostic)) } else if (options.activateNewBundles !== false) { - await reconcile(before, dir, context.installAnchor, options) + const unsupported = await reconcile(before, dir, context.installAnchor, options) + if (unsupported.length > 0) { + exitCode = 1 + const diagnostic = `\ndsh: bundle activation rejected: ${unsupported.join('\n')}\ndsh: installed dependencies remain; bundle selection is unchanged. Use a supported Host or remove the unwanted dependencies with 'dsh plugin remove '.\n` + await log.write(diagnostic) + options.onOutput?.(diagnostic, 'stderr') + append(Buffer.from(diagnostic)) + } } } } finally { diff --git a/packages/boot/plugin-manager/src/tools.ts b/packages/boot/plugin-manager/src/tools.ts index 70b46cefc0..57f7fda5ac 100644 --- a/packages/boot/plugin-manager/src/tools.ts +++ b/packages/boot/plugin-manager/src/tools.ts @@ -23,6 +23,7 @@ const ERROR_STEPS: Record = { 'bundle-in-use': 'Call list_plugins, disable the entries from other layers that still use this bundle, then retry.', 'stale-approval': 'Call install_bundle again without approvedBuilds to read the current pendingBuilds.', 'incompatible-version': 'Install a version compatible with the running DSH. A version exemption needs the user\'s explicit acceptance of crash and data-loss risk.', + 'unsupported-platform': 'This bundle cannot run on the current Host platform; ask the user to use a supported Host.', 'operation-error': 'Report the diagnostic to the user; retry only after its cause is addressed.', } diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index 3507d42c6f..efef87b4dc 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -36,7 +36,7 @@ export interface IncompatiblePlugin { /** Localizable management failure and optional external diagnostic. */ export interface ManagementError { - code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'already-installed' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'incompatible-version' | 'operation-error' + code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'already-installed' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'incompatible-version' | 'unsupported-platform' | 'operation-error' diagnostic?: string /** Present with `incompatible-version`: the packages the running DSH version rejects. */ incompatible?: IncompatiblePlugin[] @@ -83,7 +83,7 @@ export interface BundleInfo { meta?: PluginLocalizedMeta /** Untranslated `description` of this bundle's package manifest. */ description?: string - /** GUI listing before explicit selection or profile installation; omission means true. */ + /** Default GUI visibility after package and Host checks; selected or profile-installed bundles remain manageable. */ listedByDefault?: boolean /** Selected in the profile manifest; a load error means its layer was skipped. */ enabled: boolean diff --git a/packages/boot/plugin-manager/tests/diagnostic.spec.ts b/packages/boot/plugin-manager/tests/diagnostic.spec.ts index e8ef0f3832..351e4d9556 100644 --- a/packages/boot/plugin-manager/tests/diagnostic.spec.ts +++ b/packages/boot/plugin-manager/tests/diagnostic.spec.ts @@ -27,3 +27,8 @@ it('retains an observed version before an operation settles', () => { expect(parsePluginOperationRecord(started)?.operation).toMatchObject({ phase: 'started', version: '1.2.3' }) expect(parsePluginOperationRecord({ ...started, operation: { ...started.operation, version: 'x'.repeat(129) } })).toBeUndefined() }) + +it('retains platform refusals in settled operation records', () => { + const record = { ...input, operation: { ...input.operation, application: 'failed', changed: false, errorCode: 'unsupported-platform' } } + expect(parsePluginOperationRecord(record)).toEqual(record) +}) diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 66ab2b323e..3baafed136 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -92,6 +92,40 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr, overlays, connection } } +it.each(['live', 'startup'] as const)('refuses an unsupported bundle before saving or applying it with %s reload', async (reload) => { + const { manager, dir, ctx } = await fixture(reload, false, undefined, {}, undefined, (dir) => { + const path = join(dir, 'node_modules/extra/package.json') + writeFileSync(path, JSON.stringify({ name: 'extra', version: '1.0.0', description: 'Platform-limited bundle', + dsh: { bundle: { patch: './cordis.patch.yml', platforms: [process.platform === 'darwin' ? 'win32' : 'darwin'] } } })) + const manifest = readProfileManifest('test', dir) + manifest.dsh = { profile: { bundles: ['core'] } } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + }) + const before = readFileSync(join(dir, 'package.json'), 'utf8') + const patchBefore = readFileSync(join(dir, 'cordis.patch.yml'), 'utf8') + expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({ + enabled: false, installed: true, listedByDefault: false, description: 'Platform-limited bundle', error: { code: 'unsupported-platform' }, + }) + expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: false, application: 'failed', error: { code: 'unsupported-platform' } }) + expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before) + expect(ctx.get('managedProbe')).toBeUndefined() + expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).toBe(patchBefore) + expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: false }) +}) + +it('allows supported bundle activation and permits deselecting an unsupported saved bundle', async () => { + const { manager, dir } = await fixture() + const path = join(dir, 'node_modules/extra/package.json') + const metadata = readProfileManifest('test', join(dir, 'node_modules/extra')) + metadata.dsh = { bundle: { patch: './cordis.patch.yml', platforms: [process.platform] } } + writeFileSync(path, JSON.stringify(metadata)) + expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'applied' }) + expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: true, application: 'applied' }) + metadata.dsh.bundle!.platforms = [process.platform === 'darwin' ? 'win32' : 'darwin'] + writeFileSync(path, JSON.stringify(metadata)) + expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'applied' }) +}) + it.each(['network', 'timeout'] as const)('stops a GitHub %s before pnpm and attributes it to the repository', async (kind) => { const { manager, dir, connection } = await fixture() const before = readFileSync(join(dir, 'package.json'), 'utf8') @@ -1732,7 +1766,6 @@ it.each([false, true])('rechecks installed bundle peers before accepting a disab expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra']) }) - it('lists absent Official entries offline without resolving their packages or consulting registries', async () => { const { manager } = await fixture() const resolve = vi.spyOn(operations, 'bundleManifest') @@ -1750,6 +1783,30 @@ it('lists absent Official entries offline without resolving their packages or co expect(registry).not.toHaveBeenCalled() }) +it.each([false, true])('applies offline platform admission to an uninstalled Official bundle (selected=%s)', async (selected) => { + const entry = OFFICIAL_ON_DEMAND_CATALOG[0]! + const previous = Object.getOwnPropertyDescriptor(entry, 'platforms') + onTestFinished(() => { + if (previous === undefined) Reflect.deleteProperty(entry, 'platforms') + else Object.defineProperty(entry, 'platforms', previous) + }) + Object.defineProperty(entry, 'platforms', { configurable: true, value: [process.platform === 'darwin' ? 'linux' : 'darwin'] }) + const { manager, dir } = await fixture('startup') + if (selected) { + const manifest = readProfileManifest('test', dir) + manifest.dsh!.profile!.bundles!.push(entry.packageName) + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + } + expect((await manager.listBundles()).find(bundle => bundle.name === entry.packageName)).toMatchObject({ + installed: false, availability: 'missing', enabled: selected, listedByDefault: false, + error: { code: 'unsupported-platform' }, + }) + if (selected) { + expect(await manager.setBundleEnabled(entry.packageName, false)).toMatchObject({ changed: true, enabled: false }) + expect(readProfileManifest('test', dir).dsh?.profile?.bundles).not.toContain(entry.packageName) + } +}) + it('distinguishes an unreadable profile dependency from an absent catalog selection', async () => { const { manager, dir, profile } = await fixture() const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName diff --git a/packages/boot/plugin-manager/tests/operations.spec.ts b/packages/boot/plugin-manager/tests/operations.spec.ts index 87b6977df3..c54260d214 100644 --- a/packages/boot/plugin-manager/tests/operations.spec.ts +++ b/packages/boot/plugin-manager/tests/operations.spec.ts @@ -172,6 +172,40 @@ function install(dir: string, name: string) { writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) } +it.each(['service', 'cli'] as const)('reports unsupported activation through %s diagnostics without saving any selection changes', async (execution) => { + const { dir, context, pnpm } = fixture() + pnpm.mutate = (target) => { + install(target, 'supported') + install(target, 'restricted') + writeFileSync(join(target, 'node_modules/restricted/package.json'), JSON.stringify({ name: 'restricted', version: '1', + dsh: { bundle: { patch: './cordis.patch.yml', platforms: [process.platform === 'darwin' ? 'win32' : 'darwin'] } } })) + } + const messages: string[] = [] + const result = await runPluginCommand(context, ['add', 'restricted'], { + execution, outputBytes: 8192, onOutput: (text) => { messages.push(text) }, + }) + expect(result.exitCode).toBe(1) + expect(result.output).toContain('restricted: Bundle supports Host platforms') + expect(result.output).toContain('installed dependencies remain; bundle selection is unchanged') + expect(result.output).toContain('dsh plugin remove ') + expect(readFileSync(result.logPath, 'utf8')).toContain(result.output) + expect(messages.join('')).toContain(result.output) + expect(readProfileManifest('test', dir).dependencies).toMatchObject({ supported: '1', restricted: '1' }) + expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual([]) +}) + +it('preserves malformed platform metadata errors without selecting the installed bundle', async () => { + const { dir, context, pnpm } = fixture() + pnpm.mutate = (target) => { + install(target, 'malformed') + writeFileSync(join(target, 'node_modules/malformed/package.json'), JSON.stringify({ name: 'malformed', version: '1', + dsh: { bundle: { patch: './cordis.patch.yml', platforms: ['macos'] } } })) + } + await expect(runProfilePnpm(context, ['add', 'malformed'], { execution: 'service', outputBytes: 8192 })) + .rejects.toThrow('dsh.bundle.platforms must be a nonempty list of Node platform names') + expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual([]) +}) + function installGuarded(dir: string, dependency: string, name = dependency, version = '1.0.0', peer = '>=999.0.0') { install(dir, dependency) writeFileSync(join(dir, 'node_modules', dependency, 'package.json'), JSON.stringify({ diff --git a/packages/boot/plugin-manager/tests/tools.spec.ts b/packages/boot/plugin-manager/tests/tools.spec.ts index c5056f884d..a9a5978899 100644 --- a/packages/boot/plugin-manager/tests/tools.spec.ts +++ b/packages/boot/plugin-manager/tests/tools.spec.ts @@ -306,4 +306,6 @@ it('tells the agent the next step for each change outcome it can act on', async expect(await outcome({ changed: false, application: 'failed', packageResult: { kind: 'unknown' }, error: { code: 'operation-error' } })) .toContain('Report the diagnostic') expect(await outcome({ changed: false, application: 'failed' })).toContain('Report the diagnostic') + expect(await outcome({ changed: false, application: 'failed', error: { code: 'unsupported-platform' } })) + .toBe('This bundle cannot run on the current Host platform; ask the user to use a supported Host.') }) diff --git a/packages/boot/plugin-market/package.json b/packages/boot/plugin-market/package.json index 8dd7d7e0ec..863b4300b3 100644 --- a/packages/boot/plugin-market/package.json +++ b/packages/boot/plugin-market/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-market", "description": "Configured plugin market indexes for the Web Discover page and the plugin_market agent tool", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/browser-use/browser-use/package.json b/packages/browser-use/browser-use/package.json index ec5aa9392c..a32c90047e 100644 --- a/packages/browser-use/browser-use/package.json +++ b/packages/browser-use/browser-use/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-browser-use", "description": "Exclusive named browser-use provider registration", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index 86dc1bc2c5..0d3500d904 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 0a439190ee..e6daed00a9 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 64186c0f36..d636b5d4b3 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -24,8 +24,8 @@ en: ca8217837ee9c698 zh: fa20daf27d4e09ef /deepseek-ai-dsh-headless/use-this-package/choosing-the-session-identity: - en: 0ef0c533ef9aebea - zh: fcb267b61bd23437 + en: 85120c2d6db2cac4 + zh: bac532df6c84e755 /deepseek-ai-dsh-headless/use-this-package/supervising-implementation-tasks: en: b10dee3026e8d999 zh: 63eda004e228a48a @@ -66,8 +66,8 @@ en: 0b0f74b412c7c6d8 zh: 6e8f918d0c4bf342 /deepseek-ai-dsh-headless/known-limitations-and-deferred-work: - en: ff000db625d2d0db - zh: 7d08a158aade75f9 + en: 94d8c7731c1435c3 + zh: d0ddc9dd4e030b3f /deepseek-ai-dsh-headless/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 528a2e7e36..f9c53e7d2e 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -72,7 +72,7 @@ The restored store uses [saved credentials before environment fallbacks](../../c ### Choosing the session identity -Every invocation defaults to a fresh `session-` identity, which `--json` reports in its opening `session` event. Pass `--session-id ` to continue that conversation: the runner adopts the persisted Session with that id, and an id with no stored Session fails before the task runs rather than quietly opening an empty history. Adoption requires the composed `sessionPersistence` and `sessionQuery` services, so a profile that omits either fails loudly instead of returning an id whose history dies with the process. An Agent already live under the requested id in this process is refused: another owner may still drive it, so the runner cannot claim an exclusive run interval over it. The identity is opaque, so the exact string is used, whitespace included. The working directory is resolved through the mounted filesystem provider (`fs.resolve('.')` and `fs.processPath()`), or the process cwd when no filesystem service is mounted; new Sessions record that directory. Adoption compares the recorded cwd with the same provider-resolved directory and refuses a Session that is a subagent or forked session, that recorded no working directory, that runs under an agent preset this profile does not compose, or whose preset record is malformed — the check reads the preset the Session log currently records, so a Session that switched preset while blank is rejected too. A supervisor therefore cannot silently drive someone else's conversation under a different composition; any mismatch fails before the task runs. +Every invocation defaults to a fresh `session-` identity, which `--json` reports in its opening `session` event. Pass `--session-id ` to continue that conversation: the runner adopts the persisted Session with that id, and an id with no stored Session fails before the task runs rather than quietly opening an empty history. Adoption requires the composed `sessionPersistence` and `sessionQuery` services, so a profile that omits either fails loudly instead of returning an id whose history dies with the process. An Agent already live under the requested id in this process is refused: another owner may still drive it, so the runner cannot claim an exclusive run interval over it. The identity is opaque, so the exact string is used, whitespace included. The working directory is resolved through the mounted filesystem provider (`fs.resolve('.')` and `fs.processPath()`), or the process cwd when no filesystem service is mounted; new Sessions record that directory. Adoption compares the recorded cwd with the same provider-resolved directory and refuses a Session that is a subagent session, that recorded no working directory, that runs under an agent preset this profile does not compose, or whose preset record is malformed — the check reads the preset the Session log currently records, so a Session that switched preset while blank is rejected too. A supervisor therefore cannot silently drive someone else's conversation under a different composition; any mismatch fails before the task runs. On adoption, the runner records the current deployment route before submitting the task. An omitted effort preserves an explicit saved effort only for the same route. A step already prepared in this process keeps its captured route; subsequent steps read the shared Session selection. @@ -186,7 +186,7 @@ These limits tell you when headless does not fit and what it needs from the `dsh - **No pre-token heartbeat** — in default mode stderr stays silent until the provider emits a non-empty reasoning delta; a delayed first token exposes no earlier progress signal. - **Reasoning enters stderr logs** — in default mode, redirection and supervisors may retain substantially more and potentially sensitive model output; route stderr to a controlled sink when needed. - **Default stdout carries only the final answer** — a run without an assistant message prints an empty stdout line and exits 1; intermediate tool output is not printed unless you opt into `--json`. -- **Adoption is cwd-, ownership-, and preset-scoped** — `--session-id` refuses a Session recorded in another working directory, one that recorded no working directory, one that is a subagent or forked session, or one that runs under an agent preset this profile does not compose or whose preset record is malformed, and requires the composed Session query and persistence services; an identity already live in the process is refused too, because the runner cannot own an exclusive run interval over it. +- **Adoption is cwd-, ownership-, and preset-scoped** — `--session-id` refuses a Session recorded in another working directory, one that recorded no working directory, one that is a subagent session, or one that runs under an agent preset this profile does not compose or whose preset record is malformed, and requires the composed Session query and persistence services; an identity already live in the process is refused too, because the runner cannot own an exclusive run interval over it. - **The event stream is a projection, not the log** — `--json` caps every string except the terminal `final` at 8 KiB and omits events the projection does not model, so it is not a lossless copy of the Session log. diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 97845ae19c..8600a6f2f3 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -72,7 +72,7 @@ agent 会完成该任务,把提供方的每个非空推理(reasoning)增 ### 选择 Session 标识 -每次调用默认使用全新的 `session-` 标识,`--json` 会在开头的 `session` 事件里报告它。传入 `--session-id ` 延续这段对话:runner 沿用该 id 对应的持久化 Session,而该 id 没有持久化 Session 时会在任务运行前失败,而不是悄悄开出一段空历史。沿用要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id。本进程中已存在持有该 id 的存活 Agent 时会被拒绝:它的原 owner 可能仍在驱动它,runner 无法取得独占的运行区间。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。工作目录通过已挂载的文件系统提供方解析(`fs.resolve('.')` 与 `fs.processPath()`);未挂载文件系统服务时使用进程 cwd,新 Session 会记录该目录。沿用会将已记录 cwd 与同一提供方解析出的目录比较,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 +每次调用默认使用全新的 `session-` 标识,`--json` 会在开头的 `session` 事件里报告它。传入 `--session-id ` 延续这段对话:runner 沿用该 id 对应的持久化 Session,而该 id 没有持久化 Session 时会在任务运行前失败,而不是悄悄开出一段空历史。沿用要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id。本进程中已存在持有该 id 的存活 Agent 时会被拒绝:它的原 owner 可能仍在驱动它,runner 无法取得独占的运行区间。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。工作目录通过已挂载的文件系统提供方解析(`fs.resolve('.')` 与 `fs.processPath()`);未挂载文件系统服务时使用进程 cwd,新 Session 会记录该目录。沿用会将已记录 cwd 与同一提供方解析出的目录比较,并拒绝子 agent 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 接管会话时,运行器会在提交任务前记录当前部署路由。未声明推理强度时,仅在路由相同的情况下保留已保存的显式强度。本进程中已准备的步骤保留捕获的路由,后续步骤读取共享的 Session 选择。 @@ -186,7 +186,7 @@ runner 不向请求前缀添加任何内容;它只是驱动组合出的配置 - **首个 token 前没有心跳**——默认模式下,提供方发出第一个非空推理增量前,stderr 保持静默;延迟首个 token 的提供方不会更早给出进度信号。 - **推理进入 stderr 日志**——默认模式下,重定向与监督进程可能保留显著更多且可能敏感的模型输出;需要时应把 stderr 路由到受控位置。 - **默认 stdout 只承载最终答案**——没有 assistant 消息的运行向 stdout 打印空行并以 1 退出;中间工具输出不会打印,除非显式启用 `--json`。 -- **沿用受 cwd、归属与 preset 限制**——`--session-id` 会拒绝记录在其他工作目录、未记录工作目录、属于子 agent 或 fork 会话,或运行在本 profile 不组合的 agent preset 下的 Session、preset 记录畸形的 Session,并要求已组合的 Session 查询与持久化服务;本进程中已存活的身份同样会被拒绝,因为 runner 无法对它取得独占的运行区间。 +- **沿用受 cwd、归属与 preset 限制**——`--session-id` 会拒绝记录在其他工作目录、未记录工作目录、属于子 agent 会话,或运行在本 profile 不组合的 agent preset 下的 Session、preset 记录畸形的 Session,并要求已组合的 Session 查询与持久化服务;本进程中已存活的身份同样会被拒绝,因为 runner 无法对它取得独占的运行区间。 - **事件流是投影而非日志**——`--json` 除终止 `final` 外把每个字符串与对象键限制在 8 KiB,并省略投影未建模的事件,因此它不是 Session 日志的无损副本。 diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index cf07060097..d54cb3bb06 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 6ad84ed4f6..d02729c097 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -180,7 +180,6 @@ function streamReasoning( interface AdoptableHeader { cwd?: string | undefined origin?: 'subagent' | undefined - parentSession?: SessionId | undefined agentPreset?: string | undefined } @@ -231,8 +230,8 @@ function assertAdoptable(header: AdoptableHeader, events: Iterable `session "${sessionId}" runs under agent preset "${preset}", which the one-shot runner does not compose`, ) } - if (header.origin === 'subagent' || header.parentSession !== undefined) { - throw new Error(`session "${sessionId}" is a subagent or forked session and cannot be driven directly`) + if (header.origin === 'subagent') { + throw new Error(`session "${sessionId}" is a subagent session and cannot be driven directly`) } if (header.cwd === undefined) { throw new Error(`session "${sessionId}" recorded no working directory, so it cannot be adopted`) diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 54067fe50c..0d00029409 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -14,7 +14,7 @@ import type { } from '@deepseek-ai/dsh-agent' import AgentDefaultModelConfig from '@deepseek-ai/dsh-agent-default-model' import { LlmAttemptId, ReasoningEffortId, ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage, type MessageId, type StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionLogOffset } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import ApprovalService, { setApprovalPolicy, type ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' @@ -987,7 +987,7 @@ describe('headless runner', () => { }) const result = await test.run() expect(result.code).toBe(1) - expect(result.err).toContain('is a subagent or forked session') + expect(result.err).toContain('is a subagent session') await test.ctx.fiber.dispose() }) @@ -1055,7 +1055,7 @@ describe('headless runner', () => { }) const result = await test.run() expect(result.code).toBe(1) - expect(result.err).toContain('is a subagent or forked session') + expect(result.err).toContain('is a subagent session') await test.ctx.fiber.dispose() }) @@ -1161,19 +1161,23 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) - it('rejects a persisted Session linked to a parent', async () => { - const test = await bench({ afterPrompt: () => {} }, { + it.each([false, true])('resumes an ordinary fork with historical parent metadata: %s', async (legacy) => { + const header = { cwd: process.cwd(), ...(legacy ? { parentSession: brandString('parent-1') } : {}) } + const test = await bench({ + afterPrompt(session, message) { appendTurn(session, 1, message, 'fork resumed', true) }, + }, { sessionId: 'session-exact', - observe: () => Promise.resolve({ - header: { cwd: process.cwd(), origin: 'user', parentSession: 'parent-1' }, - events: [], - [Symbol.dispose]() {}, - }), + observe: () => Promise.resolve({ header, events: [], [Symbol.dispose]() {} }), }) - const result = await test.run() - expect(result.code).toBe(1) - expect(result.err).toContain('is a subagent or forked session') - await test.ctx.fiber.dispose() + try { + const session = test.ctx.sessions.create(brandString('session-exact'), { + seed: [], inheritedEventCount: SessionLogOffset(0), meta: { ...header, isSeeded: true }, + }) + expect(await test.run()).toMatchObject({ code: 0, out: 'fork resumed\n', err: '' }) + expect(session.header.parentSession).toBe(header.parentSession) + } finally { + await test.ctx.fiber.dispose() + } }) it('propagates a Session query failure that is not a missing log', async () => { diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index ee90ed6fb4..9734be57f2 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index 512f282bc7..c7b1195003 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 8281a74594..aee3da55b1 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, bash runtime variables, URL line)", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 1a78b312f4..3392544e2d 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 and generation lifecycle", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json index 1dcf9a9cef..ff5eb04d4e 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 15d1b7e7c6..2e64b3e705 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Web client graph synchronization and rebuilt-bundle reload transport", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index dd011167ef..023f018f5e 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 7d216fb5d8..0ad5561a70 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/product-analytics/package.json b/packages/client/product-analytics/package.json index 9e1b06c156..e73a62160b 100644 --- a/packages/client/product-analytics/package.json +++ b/packages/client/product-analytics/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-product-analytics", "description": "Desktop product event collection and authenticated Host reporting", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/resources/package.json b/packages/client/resources/package.json index 419a20b714..944f8f8092 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/shortcuts/package.json b/packages/client/shortcuts/package.json index e6d70af8d7..77316c991f 100644 --- a/packages/client/shortcuts/package.json +++ b/packages/client/shortcuts/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-shortcuts", "description": "Application keyboard command registry and physical-key routing", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/store/package.json b/packages/client/store/package.json index 9da09e9bc4..70f255823e 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index f4a8bfb805..d370dff454 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index 13d49344cc..9ab90c9d66 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 13b2018090..b63d7f9ce2 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index 4cce54baa5..5adcfb69a3 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -6,7 +6,7 @@ import css from './MessageImage.module.css' export type { MessageImageLabels } from '@deepseek-ai/dsh-client-ui-conversation/client' -/** Loads a session-authorized durable image URL and may expose a cached URL synchronously. */ +/** Loads a Gateway-authorized durable image URL and may expose a cached URL synchronously. */ export type ImageLoader = ((attachment: ImageAttachmentRef) => Promise) & { peek?: (attachment: ImageAttachmentRef) => string | undefined } @@ -62,7 +62,7 @@ function dimensionsOf(image: MessageImageSpec): { readonly width: number; readon * List thumbnails use status icons with localized tooltips and accessible names. * * @param props.image - the durable reference to load, or the local preview to display. - * @param props.load - session-authorized URL loader for the durable arm. + * @param props.load - Gateway-authorized URL loader for the durable arm. * @param props.variant - lone image, cropped gallery tile, or uncropped list thumbnail. * @param props.labels - resolved strings (tooltip, loading, retry, lightbox). * @returns the bounded thumbnail button, or the retry control on failure. diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 7d0ee96bea..dd6588f8bf 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -45,7 +45,7 @@ describe('MessageImage', () => { expect(load).toHaveBeenCalledWith(attachment) }) - it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => { + it('loads a Gateway-authorized URL, bounds the thumbnail, and clicks into the original', async () => { const load = vi.fn().mockResolvedValue('blob:history') const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index e4aef838c4..e70b38f4d2 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 7cc337dbb2..2ff8f221ce 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -33,8 +33,8 @@ en: b0d806604bc17006 zh: 18dabe5dc2d0d419 /deepseek-ai-dsh-client-ui-chat/grouped-rendering: - en: a14c74ed7de0ef9c - zh: d5e618b2ef809150 + en: 3afad0b4c6bf9ce0 + zh: d8179ba4ee9fcd61 /deepseek-ai-dsh-client-ui-chat/scroll-ownership: en: 009ecb4fecd4ee9e zh: ec2714337a1102c5 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 85b3e65c8d..ceeae1d086 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -113,7 +113,7 @@ Live tool deltas share reasoning's frame-batched publication; durable calls and The process group uses a stable `div` layout box, a scroll body, and an uncapped content box that reports growth inside the body. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception. The `conversation.chat.flow` slot adds a `display: contents` DOM anchor; row selectors match that anchor directly, without repeating its column ancestry. The running status shares the rows' parent, so its divider uses the same visible-sibling rules. The anchor contributes no spacing of its own. The earlier-history control directly precedes the flow anchor; their sibling relationship supplies the leading gap. CSS variables do not belong in the Group Definition. -Running group titles, reasoning previews, tool and command rows, the active retry line, and the bottom running status use the shared [TextShimmer](../ui-primitives/README.md#component-catalog). Each row’s text shares one left-to-right highlight, including collapsed group titles; icons and hover chevrons retain their base appearance. Tool and process rows sit 6px apart. An expanded group title has 8px before its content, Assistant responses have 12px from adjacent process rows, and completed-Turn duration/status controls have 16px from their input and from a directly following visible row. A reply that follows the control after folded rows keeps the 12px reply gap, so folding never changes its spacing. Hidden and empty rows, including retained empty slot anchors, add no gap. The running “Deep diving” text retains its blue base while its theme-specific highlight sweeps across it. +Running group titles, reasoning previews, tool and command rows, the active retry line, and the bottom running status use the shared [TextShimmer](../ui-primitives/README.md#component-catalog). Each row’s text shares one left-to-right highlight, including collapsed group titles; icons and hover chevrons retain their base appearance. Ordinary flow rows sit 4px apart. An expanded group title has 8px before its content, Assistant responses have 12px from adjacent process rows, and completed-Turn duration/status controls have 16px from their input and from a directly following visible row. A reply that follows the control after folded rows keeps the 12px reply gap, so folding never changes its spacing. Hidden and empty rows, including retained empty slot anchors, add no gap. The running “Deep diving” text retains its blue base while its theme-specific highlight sweeps across it. The running status shows a whale whose APNG mask inherits the text color. CSS selects the static SVG when reduced motion or forced colors are active, or alpha masks are unsupported. The APNG is a [package asset](../AGENTS.md#dependency-declaration) referenced by the plugin stylesheet; React does not pass image data through DOM styles. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 486da291e6..9113ed2f81 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -113,7 +113,7 @@ Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的 过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。`conversation.chat.flow` 插槽添加一个 `display: contents` DOM 锚点;行选择器直接匹配该锚点,不重复匹配其正文容器祖先。运行状态与各行共享父级,使分隔线使用同一套可见兄弟节点规则。锚点自身不产生间距。更早历史控件紧邻流锚点之前,两者的兄弟关系决定首行间距。CSS 变量不属于 Group Definition。 -运行中的分组标题、思考预览、工具与命令行、当前重试行以及底部运行状态使用共享的 [TextShimmer](../ui-primitives/README.zh.md#component-catalog)。每行的文字共用一道从左到右的高光,包括折叠的分组标题;图标和悬停箭头保留底色。工具和过程行之间相隔 6px;展开的分组标题与内容之间相隔 8px;Assistant 回复与相邻过程行之间相隔 12px;每轮完成/耗时控件与其输入、以及紧随其后的可见行之间保留 16px。控件与回复之间隔着已收起的行时,回复保持 12px 回复间距,因此折叠不会改变它的间距。隐藏行与空行(包括保留的空 slot 锚点)不产生间距。“深度求索”运行文字保留蓝色底色,并使用主题专用高光扫过文字。 +运行中的分组标题、思考预览、工具与命令行、当前重试行以及底部运行状态使用共享的 [TextShimmer](../ui-primitives/README.zh.md#component-catalog)。每行的文字共用一道从左到右的高光,包括折叠的分组标题;图标和悬停箭头保留底色。普通流程行之间相隔 4px;展开的分组标题与内容之间相隔 8px;Assistant 回复与相邻过程行之间相隔 12px;每轮完成/耗时控件与其输入、以及紧随其后的可见行之间保留 16px。控件与回复之间隔着已收起的行时,回复保持 12px 回复间距,因此折叠不会改变它的间距。隐藏行与空行(包括保留的空 slot 锚点)不产生间距。“深度求索”运行文字保留蓝色底色,并使用主题专用高光扫过文字。 运行状态显示一只鲸鱼,其 APNG 遮罩继承文字颜色。启用减少动态效果或强制颜色模式、或浏览器不支持 alpha 遮罩时,CSS 选择静态 SVG。APNG 是插件样式表引用的[包资源](../AGENTS.md#dependency-declaration);React 不通过 DOM 样式传递图片数据。 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 398661a4ff..ed088904bf 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css index 979cef25bb..ba58b53eff 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css @@ -100,7 +100,7 @@ } .body { - --dsh-chat-flow-gap: 6px; + --dsh-chat-flow-gap: 4px; max-height: min(400px, 50vh); overflow-y: auto; overscroll-behavior-y: auto; @@ -146,7 +146,7 @@ } .content > * { flex-shrink: 0; } .content > :not([hidden]):not(:empty) ~ :not([hidden]):not(:empty) { - margin-top: var(--dsh-chat-flow-gap, 6px); + margin-top: var(--dsh-chat-flow-gap, 4px); } .expandedBody { max-height: none; diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index 341a33f612..f05bad098f 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -78,10 +78,10 @@ ~ :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))), .column > .older + :global([data-slot="conversation.chat.flow"]) > :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))) { - margin-top: var(--dsh-chat-flow-gap, 6px); + margin-top: var(--dsh-chat-flow-gap, 4px); } -/* Process rows sit 6px apart; an Assistant response is separated from its +/* Process rows sit 4px apart; an Assistant response is separated from its neighbours by 12px. */ .flowItem[data-chat-group-part="response"] { --dsh-chat-flow-gap: 12px; @@ -136,7 +136,7 @@ steering row of each claim that Chat orders after the Turn's process control; that row keeps the slot above it, the way a completed Turn's actions row precedes the next user message, while rows claimed together - stay 6px apart and opening inputs keep ordinary spacing. The first waiting + stay 4px apart and opening inputs keep ordinary spacing. The first waiting echo sits directly below the running status, so admission swaps the two without moving any bubble. Neighbour gap rules exclude the batch start so this slot always applies. */ @@ -178,7 +178,7 @@ /* While the running Step has no output, the status sits directly below the input. When the first output row appears, the status enters its slot below that row from where it stood: it travels the row's gap plus one 24px row, - which follows the content font size. The gap is 6px for a process row or + which follows the content font size. The gap is 4px for a process row or group header (the default declared on `.running`), 12px for a response, and 16px after the running Turn's process control, which renders empty until the Turn closes, so it precedes a new Turn's first output. The default sits on @@ -260,7 +260,7 @@ flex-direction: column; align-items: flex-start; --dsh-chat-flow-gap: 12px; - --dsh-running-follow: calc(6px + 24px + var(--dsh-content-font-delta, 0px)); + --dsh-running-follow: calc(4px + 24px + var(--dsh-content-font-delta, 0px)); --dsw-alias-label-shimmer: var(--dsw-alias-label-deep-diving-shimmer); color: var(--dsw-alias-label-deep-diving); font-size: calc(var(--dsh-content-font-size, 14px) - 2px); diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index b937357a91..1dee9a0b92 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -39,8 +39,8 @@ en: 8e775c43878ae31a zh: 4c1063c0071ebdbd /chat-conversation-node-rules/display-modes/group-title-rules: - en: 8c5b57a66fe0247c - zh: 5a9e37f255761ac6 + en: f78fb52d4733e6ce + zh: 119e5319b23762ba /chat-conversation-node-rules/process-grouping: en: 3d28b3bb8757bf55 zh: 3b0f451b48db6048 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index 50f598d35c..c0ea49ba5e 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -187,7 +187,7 @@ The labels below describe recorded activity, not successful outcomes. For exampl English lowercases the initial letter of joined labels after the first. Closing a group immediately selects the completed summary; the 150ms minimum applies to running-title changes, not to delaying completion. Detailed hides group headers in running Turns, including groups ended by a reply or steering before their Turn ends. Verbose also hides historical group headers. -Group headers show a category icon, replace it with a down arrow on hover or keyboard focus, and show an up arrow while open. Manually expanded group bodies use 6px row spacing, a `min(400px, 50vh)` height cap, and 24px directional fades. Wheel scrolling can continue into the outer transcript at an edge. Detailed removes the group-level cap in running Turns; Verbose applies this layout to historical Turns as well. Process rows retain 6px spacing in both modes; Assistant responses have 12px clearance. Group headers have the same row height as process rows. Expanded group titles sit 8px above their content; Turn process controls retain 16px clearance from their input and from a directly following visible row; a reply separated from the control by folded rows keeps the 12px reply gap, so folding never changes its spacing. +Group headers show a category icon, replace it with a down arrow on hover or keyboard focus, and show an up arrow while open. Manually expanded group bodies use 4px row spacing, a `min(400px, 50vh)` height cap, and 24px directional fades. Wheel scrolling can continue into the outer transcript at an edge. Detailed removes the group-level cap in running Turns; Verbose applies this layout to historical Turns as well. Process rows retain 4px spacing in both modes; Assistant responses have 12px clearance. Group headers have the same row height as process rows. Expanded group titles sit 8px above their content; Turn process controls retain 16px clearance from their input and from a directly following visible row; a reply separated from the control by folded rows keeps the 12px reply gap, so folding never changes its spacing. An open capped group follows content growth only while its own scroll position is at the bottom. Scrolling away pauses that group's following; returning to the bottom resumes it, independently of outer transcript following. Manually opening an unclosed group starts at the bottom and follows growth; manually opening a closed group starts at the top with following disabled, even when its initial content fits without scrolling. Closing the group in the data or restoring its height cap through a mode change does not reset an already-open reader's position. Browser find retains its own reveal position. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index b7d89f7ec6..c4e94caec3 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -187,7 +187,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 英文组合标题将第一项之后各项的首字母转为小写。组结束时立即切换完成摘要;150ms 最短保留时间针对运行中标题变化,不延迟完成状态。详细模式隐藏运行中轮次的组头,包括被回复或 steering 截断、但所属轮次尚未结束的小组;完全展开模式也隐藏历史组头。 -组头显示类别图标,悬停或键盘聚焦时替换为向下箭头,展开时显示向上箭头。手动展开的组正文使用 6px 行间距、`min(400px, 50vh)` 限高及 24px 方向渐隐;到达边缘时滚轮可以继续滚动外层文本记录。详细模式对运行中轮次取消组级限高;完全展开模式对历史轮次也采用该布局。两种模式下过程行均保留 6px 间距,Assistant 回复周围保留 12px 间距。组头与过程行同高。展开组头与内容相隔 8px,整轮过程控件与其输入、以及紧随其后的可见行之间保留 16px 间距;与控件之间隔着已收起行的回复保持 12px 回复间距,因此折叠不会改变它的间距。 +组头显示类别图标,悬停或键盘聚焦时替换为向下箭头,展开时显示向上箭头。手动展开的组正文使用 4px 行间距、`min(400px, 50vh)` 限高及 24px 方向渐隐;到达边缘时滚轮可以继续滚动外层文本记录。详细模式对运行中轮次取消组级限高;完全展开模式对历史轮次也采用该布局。两种模式下过程行均保留 4px 间距,Assistant 回复周围保留 12px 间距。组头与过程行同高。展开组头与内容相隔 8px,整轮过程控件与其输入、以及紧随其后的可见行之间保留 16px 间距;与控件之间隔着已收起行的回复保持 12px 回复间距,因此折叠不会改变它的间距。 已展开的限高组仅在自己的滚动位置处于底部时跟随内容增长。滚离底部暂停该组跟随,滚回底部后恢复,与外层文本记录的跟随状态独立。手动展开未关闭的组时定位到底部并跟随增长;手动展开已关闭的组时从顶部开始且不启用跟随,即使初始内容无需滚动即可容纳。数据中的组变为已关闭,或通过模式切换恢复限高时,不重置已展开组的阅读位置。浏览器查找沿用自身的展开定位。 diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 557b9e2a04..9687743249 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -531,7 +531,7 @@ describe('Chat inject API', () => { const loaded = await injected.loadImage(ATTACHMENT) expect(loaded).toEqual(expect.any(String)) - expect(b.session.readAttachment).toHaveBeenCalledWith(ATTACHMENT.attachmentId) + expect(b.session.readAttachment).toHaveBeenCalledWith(ATTACHMENT) expect(injected.loadImage.peek?.(ATTACHMENT)).toBe(loaded) await b.runtime.dispose() }) 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 index ae86bbed89..8bc6bcd08f 100644 --- a/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts +++ b/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts @@ -8,12 +8,18 @@ const read = (name: string): string => describe('Turn tail slot 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, 6px\)/) + expect(read('ChatView.module.css')).toMatch(/margin-top:\s*var\(--dsh-chat-flow-gap, 4px\)/) 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) }) + it('keeps process rows inside and outside a group on the same gap', () => { + const group = read('ChatGroupSeat.module.css') + expect(group).toMatch(/\.body \{[^}]*--dsh-chat-flow-gap: 4px;/s) + expect(group).toMatch(/margin-top:\s*var\(--dsh-chat-flow-gap, 4px\)/) + }) + it('keeps the tail slot above a marked steering batch start and out of neighbour gaps', () => { const view = read('ChatView.module.css') const marked = String.raw`:has\(> \[data-slot="conversation\.chat\.node"\] > \[data-chat-steering-batch` @@ -44,7 +50,7 @@ describe('Running status move below the first output', () => { const view = read('ChatView.module.css') // A process row or group header after a mid-Turn input: the default sits on the single-class // status rule, the lowest-specificity selector, so each variant below outranks it. - expect(view).toMatch(new RegExp(String.raw`\n\.running \{[^}]*${travel(6)}`)) + expect(view).toMatch(new RegExp(String.raw`\n\.running \{[^}]*${travel(4)}`)) expect(view.match(/--dsh-running-follow: calc\(/g)).toHaveLength(3) // A response keeps its 12px clearance. expect(view).toMatch(new RegExp(String.raw`\)\)\)\)\[data-chat-group-part="response"\] ~ \.running \{\s*${travel(12)}`)) diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index 0b3854264c..0f97864fff 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 7d9bf9f3c1..49c3fd957d 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -9,8 +9,8 @@ en: 77ecbd074ef3a3ce zh: 2c0e2b720e3b201e /deepseek-ai-dsh-client-ui-conversation/summary: - en: bac0bf0d7330f3b1 - zh: 84a895f4b26d8490 + en: 8811c28013fdc6dc + zh: 0a86edc422b38ba8 /deepseek-ai-dsh-client-ui-conversation/table-of-contents: en: eef4f18c3972ccc4 zh: 6be65c6ac84ce497 @@ -21,8 +21,8 @@ en: 7871f9fe6bcc4459 zh: 0d51565c600b3958 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: e910ac7fec4fd72d - zh: 741a399436dc98d9 + en: d0ff0a313e92df44 + zh: 5a2a44347348dc6b /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: bb1244310f5c0382 zh: 824b84077432b559 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 86a1ffd236..769bd7de23 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -11,7 +11,7 @@ Desktop product events use the optional [product analytics service](../product-a ## Summary -`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller `SessionEventLikeEntry` feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. It also owns the per-session durable image URL cache: `ctx.uiConversation.imageUrl(sessionId, attachment)` resolves one session-authorized browser URL per attachment and revokes it with the Session binding, so every Conversation target shares one `session.attachment` read. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers. +`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller `SessionEventLikeEntry` feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. It also owns the per-session durable image URL cache: `ctx.uiConversation.imageUrl(sessionId, attachment)` resolves one Gateway-authorized browser URL per attachment and revokes it with the Session binding, so every Conversation target shares one `session.attachment` read. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers. ## Table of Contents @@ -72,7 +72,7 @@ The shared image slot props keep display choices separate from durable reference The composer uses a minimum 16px draft font on coarse-pointer devices to avoid focus-triggered browser zoom; larger font preferences still apply. -The composer measures its expanded control groups after size, content, visibility, or font-loading changes. If they cannot share a line, the row sets `--dsh-composer-model-text-display: none` and `--dsh-composer-model-icon-display: block` for the model seat; their defaults are `block` and `none`. Wrapping remains available if even the icon cannot fit. +The composer measures its expanded control groups after size, content, visibility, or font-loading changes. If they cannot share a line, the row sets `--dsh-composer-model-text-display: none` and `--dsh-composer-model-icon-display: block` for the model seat; their defaults are `block` and `none`. The model seat is measured at its content width, capped at 360px, rather than at a share of the row, so a long name stays visible in the measurement. Wrapping remains available if even the icon cannot fit. The context meter follows the [statistics-control radius](../../../docs/ui-radius.md#radius-scale). @@ -82,7 +82,7 @@ The context-occupancy button shows a ring and percentage below the input card, a With Coding Tools off, the shell hides only Trajectory; other plugin-contributed Views remain available. The View tab bar is hidden when fewer than two Views are available. Turning Coding Tools off while Trajectory is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes Trajectory available again. View owners receive the available roster so their navigation actions can follow the same visibility. -While the root-scoped `conversation.session.header.views.actions` list has registrations, a **More conversation views** button with a tooltip follows the View tabs in the tab row. It opens a menu whose rows are that list's registrations. Each receives `entries` (Views outside the header in registration order with `id`, `label`, `pinned`, and `active`; Chat is never listed), `onOpen(id)`, which selects the View in this Session, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The header keeps an in-memory list of unpinned View ids shared by every Session; the tab bar omits unpinned Views and places registered Chat first so it always remains outside More. Plugin reload resets pin choices, and legacy localStorage values are ignored. The tab bar's visibility counts available Views, not pinned ones, so the bar and More stay while every other View is unpinned. Unpinning the active View hides its tab immediately without changing the selected content. More lists only Views outside the header and identifies the current View through `active`. The header reads its available row width, reserves 48px for More and padding, and estimates 120px per tab. Remaining Views keep registration order in More. A single ResizeObserver watches the tab row so window resizing and sidebar expansion update the split; active selection does not change the order. The observer disconnects when the row is removed. If ResizeObserver is unavailable, the initial width calculation still applies without subsequent observation. The More button highlights and names the active View in its tooltip when that View is folded. Individual tabs are not measured. At least one preferred tab remains outside, and long labels truncate with a full-name tooltip. Unregistered View ids remain in the in-memory pin list until plugin reload. +While the root-scoped `conversation.session.header.views.actions` list has registrations, a **More conversation views** button with a tooltip follows the View tabs in the tab row. It opens a menu whose rows are that list's registrations. Each receives `entries` (Views outside the header in registration order with `id`, `label`, `pinned`, and `active`; Chat is never listed), `onOpen(id)`, which selects the View in this Session, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The header keeps an in-memory list of unpinned View ids shared by every Session; the tab bar omits unpinned Views and places registered Chat first so it always remains outside More. Plugin reload resets pin choices, and legacy localStorage values are ignored. The tab bar's visibility counts available Views, not pinned ones, so the bar and More stay while every other View is unpinned. Unpinning the active View hides its tab immediately without changing the selected content. More lists only Views outside the header and identifies the current View through `active`. The header reads its available row width, reserves 48px for More and padding, and estimates 120px per tab. Remaining Views keep registration order in More. A single ResizeObserver watches the tab row so window resizing and sidebar expansion update the split; active selection does not change the order. The observer disconnects when the row is removed. If ResizeObserver is unavailable, the initial width calculation still applies without subsequent observation. The More button highlights and names the active View in its tooltip when that View is folded. Each tab label estimates its text width from the glyphs; its full-name tooltip appears only when that estimate exceeds the 88px cell. At least one preferred tab remains outside. Unregistered View ids remain in the in-memory pin list until plugin reload. 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. @@ -98,7 +98,7 @@ Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and c The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the resident `conversation.header` outside the optional-Session `conversation.content` Component Factory. The header hosts root-scoped navigation without a selected Session; titles, actions, and View tabs remain in its strict Session child. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. -A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident header keeps a 40px window strip on macOS desktop that it owns as its drag row, and no empty vertical space on Web, Windows, or Linux. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered. +A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident header keeps a 40px window strip on macOS desktop that it owns as its drag row, and no empty vertical space on Web, Windows, or Linux. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered. When the title and actions do not fit, the title area clips them and fades its trailing edge before the utilities, which stay fully visible and clickable; overlays in the title and actions slots portal to stay outside that clip. View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index ebfbaff130..ea02f35a88 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -11,7 +11,7 @@ kind: "package-reference" ## 概述 -`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller 的 `SessionEventLikeEntry` feed,通过 `ctx.uiConversation` 暴露不依赖 React 的注册表与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。它还拥有按会话的持久化图片 URL 缓存:`ctx.uiConversation.imageUrl(sessionId, attachment)` 为每个附件解析一个经会话授权的浏览器 URL,并随 Session binding 释放而撤销,因此所有 Conversation target 共享一次 `session.attachment` 读取。Chat 等具体 target 位于独立包,由各自包注册 Definition、快照 builder、View 和 renderer。 +`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller 的 `SessionEventLikeEntry` feed,通过 `ctx.uiConversation` 暴露不依赖 React 的注册表与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。它还拥有按会话的持久化图片 URL 缓存:`ctx.uiConversation.imageUrl(sessionId, attachment)` 为每个附件解析一个经 Gateway 授权的浏览器 URL,并随 Session binding 释放而撤销,因此所有 Conversation target 共享一次 `session.attachment` 读取。Chat 等具体 target 位于独立包,由各自包注册 Definition、快照 builder、View 和 renderer。 ## 目录 @@ -72,7 +72,7 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS 在粗指针设备上,composer 的草稿字号至少为 16px,以避免获得焦点时浏览器自动放大;更大的字号偏好仍然生效。 -控件组的尺寸、内容、可见性或字体加载状态变化后,composer 测量展开状态下的控件组。若无法排在同一行,控制栏为模型位设置 `--dsh-composer-model-text-display: none` 和 `--dsh-composer-model-icon-display: block`;两者默认值分别为 `block` 和 `none`。若连图标也放不下,仍允许换行。 +控件组的尺寸、内容、可见性或字体加载状态变化后,composer 测量展开状态下的控件组。若无法排在同一行,控制栏为模型位设置 `--dsh-composer-model-text-display: none` 和 `--dsh-composer-model-icon-display: block`;两者默认值分别为 `block` 和 `none`。模型位按内容宽度(最多 360px)参与测量,而不是按行宽比例,因此长模型名在测量中保持可见。若连图标也放不下,仍允许换行。 上下文占用率入口遵循[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。 @@ -82,7 +82,7 @@ composer 在挂载、切换 Session 和解除锁定时使用共享的[文字输 关闭代码工作工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭代码工作工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。 -root 作用域的 `conversation.session.header.views.actions` list 有注册项时,标签行中的 View 标签之后显示带提示框的**更多会话视图**按钮。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的头部之外的 View,含 `id`、`label`、`pinned` 与 `active`;不含 Chat)、在本 Session 选择 View 的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。头部在内存中保存所有 Session 共用的已收起 View id 列表;标签栏不显示已收起的 View,并将已注册的 Chat 放在首位,确保它始终留在更多菜单外。插件重新加载时 Pin 选择重置,不读取旧 localStorage 值。标签栏是否显示按可用 View 计算,不按已固定的 View 计算,因此其他 View 全部收起时,标签栏和“更多”仍然显示。收起当前激活的 View 会立即隐藏其标签,但不会切走其内容。更多菜单仅包含头部之外的 View,通过 `active` 标识当前 View。头部读取当前行可用宽度,为更多按钮与内边距预留 48px,每个标签估算 120px,其余 View 按注册顺序进入更多。单个 ResizeObserver 观察标签行,使窗口缩放和侧栏展开能够更新分组;当前选中项变化不改变顺序。标签行移除时断开观察。如果环境没有 ResizeObserver,仍按初始宽度计算,但不继续观察。当前 View 收起时,更多按钮高亮并在提示框中显示其名称。不逐项测量标签。至少保留一个优先展示的外部标签,长标题截断并通过提示框提供全名。未注册 View 的 id 保留在内存 Pin 列表中,直到插件重新加载。 +root 作用域的 `conversation.session.header.views.actions` list 有注册项时,标签行中的 View 标签之后显示带提示框的**更多会话视图**按钮。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的头部之外的 View,含 `id`、`label`、`pinned` 与 `active`;不含 Chat)、在本 Session 选择 View 的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。头部在内存中保存所有 Session 共用的已收起 View id 列表;标签栏不显示已收起的 View,并将已注册的 Chat 放在首位,确保它始终留在更多菜单外。插件重新加载时 Pin 选择重置,不读取旧 localStorage 值。标签栏是否显示按可用 View 计算,不按已固定的 View 计算,因此其他 View 全部收起时,标签栏和“更多”仍然显示。收起当前激活的 View 会立即隐藏其标签,但不会切走其内容。更多菜单仅包含头部之外的 View,通过 `active` 标识当前 View。头部读取当前行可用宽度,为更多按钮与内边距预留 48px,每个标签估算 120px,其余 View 按注册顺序进入更多。单个 ResizeObserver 观察标签行,使窗口缩放和侧栏展开能够更新分组;当前选中项变化不改变顺序。标签行移除时断开观察。如果环境没有 ResizeObserver,仍按初始宽度计算,但不继续观察。当前 View 收起时,更多按钮高亮并在提示框中显示其名称。每个标签按字符宽度估算文本宽度;只有估算结果超过 88px 单元格时,才通过提示框提供全名。至少保留一个优先展示的外部标签。未注册 View 的 id 保留在内存 Pin 列表中,直到插件重新加载。 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。 @@ -98,7 +98,7 @@ root 作用域的 `conversation.session.header.views.actions` list 有注册项 本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` 外壳将常驻的 `conversation.header` 放在可选 Session 的 `conversation.content` Component Factory 外。未选中 Session 时,头部仍承载根作用域导航;标题、操作和 View 标签保留在严格 Session 子组件中。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 -blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻 header 在 macOS 桌面保留 40px 的窗口顶条,由它自己作为拖拽行持有,在 Web、Windows 或 Linux 上不预留空白高度。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。 +blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻 header 在 macOS 桌面保留 40px 的窗口顶条,由它自己作为拖拽行持有,在 Web、Windows 或 Linux 上不预留空白高度。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。标题与 actions 放不下时,标题区域会裁剪它们,并在 utilities 之前渐隐其末端;utilities 始终完整可见且可点击;标题与 actions slot 中的浮层通过 portal 渲染,不受该裁剪影响。 View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific 快照。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 09c12d692d..050319168e 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 3bf918336e..caec4849b4 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -126,7 +126,7 @@ export type MessageImageLoader = ((attachment: ImageAttachmentRef) => Promise { - return entry.binding.session.readAttachment(attachment.attachmentId) + return entry.binding.session.readAttachment(attachment) .then((result) => { if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) this.assertLive(key, entry) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 1f0380680c..ac6cb69f70 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -81,12 +81,29 @@ padding-inline-start: max(0px, calc(var(--dsh-frame-leading-clearance, 0px) - 20px)); } +/* Header actions do not shrink, so on a narrow row they overflow the cluster. + The cluster clips them, hit testing included, so the next control keeps + every click. The trailing padding spans the gap before that control and + holds the fade: content that fits ends before it, and only overflowing + content reaches it. The 4px block and start padding, offset by negative + margins, keeps focus rings inside the clip. `clip` rather than `hidden` + keeps the cluster from becoming a scroll container that focus could scroll. */ .titleCluster { display: flex; flex: 1; align-items: center; gap: 10px; min-width: 0; + --title-cluster-fade: 20px; + margin: -4px calc(-1 * var(--title-cluster-fade)) -4px -4px; + padding: 4px var(--title-cluster-fade) 4px 4px; + overflow: clip; + mask-image: linear-gradient(to left, transparent calc(0.3 * var(--title-cluster-fade)), #000 var(--title-cluster-fade)); +} + +/* Without utilities the corner's 8px margin is the gap the fade spans. */ +.titleCluster:has(+ .headerUtilities:empty) { + --title-cluster-fade: 8px; } /* The auto grid column collapses with an empty or CSS-hidden occupant. diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 3089b083df..896d953c9f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -43,6 +43,29 @@ function ViewsMoreMenu({ label, current, entries, onOpen, onPinnedChange, render ) } +/** Approximate 13px text advance: full-width glyphs take one em, other glyphs about half. */ +const WIDE_GLYPH = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/u +const WIDE_GLYPH_WIDTH = 13 +const NARROW_GLYPH_WIDTH = 7 +/** Mirrors the `.tabLabel` max-width in `ConversationRoot.module.css`. */ +const TAB_LABEL_MAX_WIDTH = 88 + +/** Estimate whether a View tab label exceeds its 88px cell. */ +function labelNeedsTooltip(label: string): boolean { + let width = 0 + for (const glyph of label) width += WIDE_GLYPH.test(glyph) ? WIDE_GLYPH_WIDTH : NARROW_GLYPH_WIDTH + return width > TAB_LABEL_MAX_WIDTH +} + +/** One View tab label; its full-name tooltip is offered only when the text is expected to overflow. */ +function ViewTabLabel({ label }: { readonly label: string }) { + return ( + + {label} + + ) +} + interface Breadcrumb { readonly id: SessionId readonly displayTitle: string @@ -205,7 +228,7 @@ export function ConversationSessionHeader({ className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)} onClick={() => { selectView(viewTab.id) }} > - {viewTab.label} + ))} diff --git a/packages/client/ui-conversation/tests/header-styles.client.spec.ts b/packages/client/ui-conversation/tests/header-styles.client.spec.ts index 3c964950d0..aa9a983654 100644 --- a/packages/client/ui-conversation/tests/header-styles.client.spec.ts +++ b/packages/client/ui-conversation/tests/header-styles.client.spec.ts @@ -14,6 +14,18 @@ describe('conversation header geometry', () => { expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('padding: 10px var(--dsh-tab-hit-inline) 9px') }) + it('clips overflowing title actions and fades them inside the gap before the next control', () => { + const cluster = css.match(/\.titleCluster\s*\{([^}]*)\}/)?.[1] + expect(cluster).toContain('overflow: clip') + expect(cluster).toContain('--title-cluster-fade: 20px') + expect(cluster).toContain('margin: -4px calc(-1 * var(--title-cluster-fade)) -4px -4px') + expect(cluster).toContain('padding: 4px var(--title-cluster-fade) 4px 4px') + expect(cluster).toContain('mask-image: linear-gradient(to left, transparent calc(0.3 * var(--title-cluster-fade)), #000 var(--title-cluster-fade))') + expect(css.match(/^\.headerUtilities\s*\{([^}]*)\}/m)?.[1]).toContain('margin-left: 20px') + expect(css.match(/\.titleCluster:has\(\+ \.headerUtilities:empty\)\s*\{([^}]*)\}/)?.[1]).toContain('--title-cluster-fade: 8px') + expect(css.match(/^\.headerCorner\s*\{([^}]*)\}/m)?.[1]).toContain('margin-left: 8px') + }) + it('uses 32px between view labels without changing the action gap', () => { expect(css.match(/\.viewTabs\s*\{([^}]*)\}/)?.[1]).toContain('gap: 32px') expect(css.match(/\.tabs\s*\{([^}]*)\}/)?.[1]).toContain('gap: 16px 20px') diff --git a/packages/client/ui-conversation/tests/historical-images.client.spec.ts b/packages/client/ui-conversation/tests/historical-images.client.spec.ts index bdf7c0f0f1..6da7a7eee2 100644 --- a/packages/client/ui-conversation/tests/historical-images.client.spec.ts +++ b/packages/client/ui-conversation/tests/historical-images.client.spec.ts @@ -9,9 +9,10 @@ describe('HistoricalImageCache', () => { it('invalidates a pending image load when its Session binding is released', async () => { const read = Promise.withResolvers>>() const runtime = await SlotTestRuntime.create() + const load = vi.fn(() => read.promise) const sessionId = await runtime.sessions.add({ id: 's1', - session: { readAttachment: () => read.promise }, + session: { readAttachment: load }, }) const reference = runtime.sessions.retain(sessionId) await reference.ready @@ -21,6 +22,7 @@ describe('HistoricalImageCache', () => { } as const const pending = cache.resolve(sessionId, attachment) + expect(load).toHaveBeenCalledExactlyOnceWith(attachment) reference.release() await runtime.flush() read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } }) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index bda16c8a08..8d6084b1fe 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -2,7 +2,7 @@ import type { GlobalStandardProps, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createContext, useContext, type ReactNode } from 'react' -import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import type { Context } from '@deepseek-ai/cordis' import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' @@ -735,6 +735,29 @@ describe('ConversationRoot resident composer', () => { expect(b.view.queryByRole('menu')).toBeNull() }) + it('offers a View tab tooltip only when the label is expected to overflow', () => { + const b = mount(sessionSnapshotOf(), undefined, undefined, { + viewTabs: [ + { id: 'chat', label: 'Chat' }, + { id: 'short', label: '轨迹' }, + { id: 'long-cjk', label: '很长的视图名称' }, + { id: 'long-latin', label: 'A very long view name' }, + ], + headerOnly: true, + }) + const tooltipFor = (label: string): string | null => { + const span = within(b.view.getByRole('tab', { name: label })).getByText(label) + fireEvent.pointerEnter(span) + const tooltip = screen.queryByRole('tooltip', { hidden: true })?.textContent ?? null + fireEvent.pointerLeave(span) + return tooltip + } + expect(tooltipFor('Chat')).toBeNull() + expect(tooltipFor('轨迹')).toBeNull() + expect(tooltipFor('很长的视图名称')).toBe('很长的视图名称') + expect(tooltipFor('A very long view name')).toBe('A very long view name') + }) + it('reuses the width budget on View roster and selection changes without a window resize listener', () => { let width = 480 const readWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => width) diff --git a/packages/client/ui-deepseek-model-label-1017/package.json b/packages/client/ui-deepseek-model-label-1017/package.json index 4a44b82db6..ea4d6f1b60 100644 --- a/packages/client/ui-deepseek-model-label-1017/package.json +++ b/packages/client/ui-deepseek-model-label-1017/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deepseek-model-label-1017", "description": "Temporary 1017 release badge for official DeepSeek Flash model labels", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index c8f9724f93..c224a85d36 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": "Changed-files card with per-file comparison tabs, delivery cards, and clickable final-response file references for Web", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 63526ccec8..745b17dd9e 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse", "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index ff25c9ae17..e9d140b9d3 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the local Desktop or Host OS chooser", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index f631fbecc2..3dafc30f52 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -21,8 +21,8 @@ en: c4ba08038bfa6f30 zh: 95db30618b52babe /deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping: - en: 7e5c1e9a9f9f244f - zh: 7e3a8fcef86bc7dd + en: cf2d00ce2b7d5719 + zh: 5510420f8872e23a /deepseek-ai-dsh-client-ui-dockkit/build-shape: en: 21216ba5798d7f5f zh: ac505fdcd5c114d4 diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index 976148bde2..bc29b54c08 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -74,7 +74,7 @@ The split control accepts localized tooltip text, separate effective keys throug These are not stylistic; each one fixes a defect found in a real browser. - **Capture the pointer** when a gesture starts. Without it any scroll container the pointer crosses can claim the gesture, which the browser reports as a cancelled pointer and an abandoned drag. Capture is hardening — the window listeners carry the gesture either way, so an environment without the API still works. -- **The chips give way; the strip's end controls never do.** The chip box is the strip's one shrinking part (`flex: 0 1 auto; min-width: 0; overflow-x: auto`): chips shrink down to an 80px floor and then scroll on the wheel with no scrollbar drawn, and the box fades its chips out over 24px at each side that hides some (`data-dockkit-strip-scroll`, written from the box's scroll reading after each commit, scroll, and resize). Whenever the active tab or the row of chips changes, the box scrolls so the active chip stands clear of the fade band; a chip already in view moves nothing. A chip's title is never ellipsized: `TabTitle` reads its text against its box and sets `data-dockkit-tab-clipped` while the text is wider, which fades the text out over its last 16px. A chip's close control shows while the chip is active, hovered, or holds focus, over the title's last 14px, which fade under it, so the chip is the same width either way. The two slots beside the active chip draw no hairline, so the filled capsule stands between bare chips. The add, split, and chrome controls are `flex: none`, so they keep their width and place in any pane at least as wide as they are (about 130px with the chrome, 72px without). The surface's `min-width: 0` and the pane's `overflow: hidden` stop a body's longest unwrapped line from widening the pane past its box, which is what carried the controls and the body's scrollbar off-screen. +- **The chips give way; the strip's end controls never do.** The chip box is the strip's one shrinking part (`flex: 0 1 auto; min-width: 0; overflow-x: auto`): chips shrink down to an 80px floor and then scroll on the wheel with no scrollbar drawn, and the box fades its chips out over 24px at each side that hides some (`data-dockkit-strip-scroll`, written from the box's scroll reading after each commit, scroll, and resize). Whenever the active tab or the row of chips changes, the box scrolls so the active chip stands clear of the fade band; a chip already in view moves nothing. Where `DockLayout` draws a new strip for each newly selected tab, that strip first jumps to the offset the pane's previous strip recorded, so only the remaining distance animates; a strip with no recorded offset jumps straight to the active chip instead of scrolling from the row's start. A chip's title is never ellipsized: `TabTitle` reads its text against its box and sets `data-dockkit-tab-clipped` while the text is wider, which fades the text out over its last 16px. A chip's close control shows while the chip is active, hovered, or holds focus, over the title's last 14px, which fade under it, so the chip is the same width either way. The two slots beside the active chip draw no hairline, so the filled capsule stands between bare chips. The add, split, and chrome controls are `flex: none`, so they keep their width and place in any pane at least as wide as they are (about 130px with the chrome, 72px without). The surface's `min-width: 0` and the pane's `overflow: hidden` stop a body's longest unwrapped line from widening the pane past its box, which is what carried the controls and the body's scrollbar off-screen. - **The chip box scrolls, but never claims a gesture.** A horizontal scroller would take press-and-move for itself and cancel the pointer; the box, the chips, and the strip set `touch-action: none` and the gesture captures the pointer, so a press-and-move on a chip is a drag and only the wheel scrolls the box. - **A split needs room for two working halves.** A pane splits into equal halves, so each half must hold what cannot shrink: the strip's fixed part — measured as the strip's width minus the chip box and the fill, which is the padding, the gaps, and every control that pane draws (its own chrome included, so the top-right pane asks more) — plus one chip at its minimum — `.tab` declares `min-width: 80px` on a content-box, so its footprint is 80px plus 10px + 10px of padding, 100px, read from a rendered chip's computed style (the stylesheet figure when none can be read); the divider between the halves takes its rendered thickness (0 — its hairline paints over the seam without taking layout room, so a body's own rules run unbroken past it). A column split, which only an edge drop makes, needs each half to hold the strip (34px) plus a 48px body: one 13px secondary line at 1.6 line-height inside 12px of the body's own insets — the pane body itself is unpadded, so a tab's body reaches the strip's bottom edge and the pane's edges and draws its own. `halvesFit` in `geometry.ts` is the arithmetic; `measure.ts` reads the rectangles after every commit and whenever the surface resizes, because the layout state carries fractions, never pixels, and the engine's planners stay that way. A pane without room keeps its split control, disabled with `splitPaneNarrow` (hidden instead under `hideSplitWhenBlocked`), and offers no edge drop zone for that axis (the release is then not a move). Under `hideSplitWhenBlocked` the split control's own footprint — its box plus the strip's gap — is left out of the fixed part: hiding the control sheds exactly that footprint from the strip, so a reading that counted it would flip with the control's visibility and re-render forever; leaving it out is also what the half being asked about would carry, since a half too narrow to split hides its own control. A pane the user narrows afterwards — a divider or the embedder's column dragged — keeps its size: the rule only decides its next split. - **Focus lands on click, not on press.** A state change between `pointerdown` and the first `pointermove` rebuilds the pressed subtree, and a replaced element cancels the pointer. It also keeps a drag from recording a redundant focus operation first. Clicks on the chips, the strip's controls, and the embedder's chrome stop at the strip: the intent each reports already decides the active pane, or is the embedder's own, so the pane's click-to-focus records nothing extra. A floating panel's grip and corner report through their gesture the same way — a press released in place is a click that raises the panel, and a drag records only the move or resize, whose operation raises it — while a press on the panel's body raises it directly. A click on the pane that is active already, a click or key on that pane's selected chip, or a press on the panel that is active and on top already, changes nothing and records nothing. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 85ad53eb45..15c5d332d9 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -74,7 +74,7 @@ Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间 这些不是风格偏好;每一条都修复了在真实浏览器里发现的缺陷。 - **手势开始时捕获指针。** 不捕获的话,指针经过的任何滚动容器都可能接管手势,浏览器会将其报告为指针取消和拖动中止。捕获是加固——无论如何都由 window 监听器承载手势,所以没有该 API 的环境照样可用。 -- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow-x: auto`):chip 先缩到 80px 下限,再在盒内随滚轮横向滚动、不画滚动条,且盒在每个藏有 chip 的一侧把 chip 在 24px 内渐隐(`data-dockkit-strip-scroll`,在每次提交、滚动与尺寸变化后由盒的滚动读数写入)。每当活动 tab 或 chip 的排列变化,盒会滚动到让活动 chip 避开渐隐带;已在视野内的 chip 不动。chip 的标题从不加省略号:`TabTitle` 拿文字宽度对照它的盒子,文字更宽时置 `data-dockkit-tab-clipped`,让文字在末端 16px 内渐隐。chip 的关闭控件在 chip 活动、悬停或持有焦点时显示,压在标题末端 14px 之上、标题在其下渐隐,因此 chip 宽度两种情况下都一样。活动 chip 两侧的槽不画细线,让填色胶囊立在裸 chip 之间。添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。 +- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow-x: auto`):chip 先缩到 80px 下限,再在盒内随滚轮横向滚动、不画滚动条,且盒在每个藏有 chip 的一侧把 chip 在 24px 内渐隐(`data-dockkit-strip-scroll`,在每次提交、滚动与尺寸变化后由盒的滚动读数写入)。每当活动 tab 或 chip 的排列变化,盒会滚动到让活动 chip 避开渐隐带;已在视野内的 chip 不动。`DockLayout` 为每个新选中的 tab 画出新 tab 条时,新 tab 条先跳到该格上一条 tab 条记录的偏移,只让剩余距离带动画;没有记录偏移的 tab 条直接跳到活动 chip,而不是从排列起点滚动过去。chip 的标题从不加省略号:`TabTitle` 拿文字宽度对照它的盒子,文字更宽时置 `data-dockkit-tab-clipped`,让文字在末端 16px 内渐隐。chip 的关闭控件在 chip 活动、悬停或持有焦点时显示,压在标题末端 14px 之上、标题在其下渐隐,因此 chip 宽度两种情况下都一样。活动 chip 两侧的槽不画细线,让填色胶囊立在裸 chip 之间。添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。 - **chip 盒会滚动,但绝不认领手势。** 横向滚动容器会把按下并移动的手势据为己有并取消指针;盒、chip 与 tab 条都设 `touch-action: none`,手势又捕获了指针,所以在 chip 上按下并移动是拖动,只有滚轮滚动盒子。 - **分栏需要给两个可用的半格留出空间。** 格被等分成两半,因此每一半都必须容得下不可收缩的部分:tab 条的固定部分——按 tab 条宽减去 chip 盒与填充条测得,即内边距、间隙以及该格绘制的每个控件(含它自己的 chrome,所以右上格要求更多)——加上一枚最小尺寸的 chip——`.tab` 在 content-box 上声明 `min-width: 80px`,所以它的足印是 80px 加 10px + 10px 内边距,即 100px,从已渲染 chip 的计算样式读取(读不到时用样式表数值);两半之间的分隔条取其渲染厚度(0——它的细线画在接缝上、不占布局空间,因此正文自己画的分隔线能不断线地穿过接缝)。纵向分栏只由边缘落下产生,它要求每一半容得下 tab 条(34px)加 48px 正文:正文自留的 12px 内边距内一行 13px、行高 1.6 的次级文字——格的正文容器本身没有内边距,tab 的正文直接贴到 tab 条底边和格的边缘,由自己留白。`geometry.ts` 里的 `halvesFit` 是算术;`measure.ts` 在每次提交后与停靠面尺寸变化时读取矩形,因为布局状态只携带比例、从不携带像素,引擎的 planner 也保持如此。没有空间的格保留分栏控件,以 `splitPaneNarrow` 禁用(开启 `hideSplitWhenBlocked` 时改为隐藏),并且在该轴上不提供边缘落区(松手就不是移动)。开启 `hideSplitWhenBlocked` 时,分栏控件自己的占位——它的盒子加 tab 条的间隙——不计入固定部分:隐藏控件让 tab 条卸下的恰是这份占位,把它算进去的读数会随控件的可见性来回翻转、无限重渲染;不计入也正是被询问的那一半会承载的量,因为窄到无法分栏的一半会隐藏自己的控件。用户随后拖窄的格——通过拖动分隔条或嵌入方的列——会保持原尺寸:规则只决定它的下一次分栏。 - **焦点落在 click 而不是按下。** 在 `pointerdown` 与第一次 `pointermove` 之间的状态变化会重建被按下的子树,而被替换的元素会取消指针。这也避免拖动先记录一条多余的焦点操作。chip、标签条各控件以及嵌入方 chrome 上的 click 都止于标签条:它们各自上报的意图已决定了活动格,或本就是嵌入方自己的事,所以格自身的点击聚焦不再多记一条。浮动面板的抓手与角柄同样通过手势上报——原地松开的按下是一次 click,抬起面板;真正的拖动只记录移动或缩放,由该操作自己抬起面板——而按在面板主体上则直接抬起它。点击本已活动的格、点击或按键选中该格本已选中的 chip,或按下本已活动且在最上层的面板,什么都不改变,也什么都不记录。 diff --git a/packages/client/ui-dockkit/package.json b/packages/client/ui-dockkit/package.json index 52a4921602..c3c999c314 100644 --- a/packages/client/ui-dockkit/package.json +++ b/packages/client/ui-dockkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-dockkit", "description": "Docking layout kit: split-tree engine with invertible operations, and the React components that render and drive it (zero cordis)", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-dockkit/src/components/TabLayout.tsx b/packages/client/ui-dockkit/src/components/TabLayout.tsx index f3a2081870..f709744fb9 100644 --- a/packages/client/ui-dockkit/src/components/TabLayout.tsx +++ b/packages/client/ui-dockkit/src/components/TabLayout.tsx @@ -3,7 +3,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { MutableRefObject, ReactNode } from 'react' import clsx from 'clsx' import type { DockIntents } from '../contract/adapter.ts' -import type { LayoutState, PaneNode, TabId, TabRecord } from '../contract/types.ts' +import type { LayoutState, PaneId, PaneNode, TabId, TabRecord } from '../contract/types.ts' import { findTabPane, floatRect, getNode, getPane } from '../engine/tree.ts' import type { SizePreview } from './PaneTree.tsx' import type { PaneCallbacks } from './render.ts' @@ -32,10 +32,12 @@ interface TabHostProps extends LayoutProps { readonly column: number readonly floats: ReturnType readonly focusRequest: MutableRefObject<{ readonly tabId: TabId; readonly origin: Element | null } | undefined> + /** Chip-box offsets by pane, read and recorded by each selected tab's strip. */ + readonly offsets: Map } /** A tab's ancestors stay identical across selection, pane moves and floating. */ -function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRequest, keepMounted, +function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRequest, offsets, keepMounted, active = true }: TabHostProps): ReactNode { const floating = pane.host === 'float' const selected = floating || pane.activeTabId === tab.id @@ -96,7 +98,7 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe {selected && (floating ? - : )} + : )}
{visited && (retained || (active && selected)) ? callbacks.renderTab(tab) : null} @@ -118,6 +120,8 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe export function TabLayout(props: LayoutProps): ReactNode { const { state, callbacks, preview } = props const focusRequest = useRef<{ readonly tabId: TabId; readonly origin: Element | null }>() + // Each pane's strip remounts under the newly selected tab's host. + const [offsets] = useState(() => new Map()) const tabCallbacks: PaneCallbacks = { ...callbacks, onFocusTab: (tabId) => { @@ -142,7 +146,7 @@ export function TabLayout(props: LayoutProps): ReactNode { {Object.values(state.tabs).sort((a, b) => a.id.localeCompare(b.id)).map((tab) => { const pane = findTabPane(state, tab.id) return candidate.id === pane.id)} floats={floats} focusRequest={focusRequest} /> + column={panes.findIndex(candidate => candidate.id === pane.id)} floats={floats} focusRequest={focusRequest} offsets={offsets} /> })} {panes.filter(pane => pane.tabs.length === 0).map(pane => (
diff --git a/packages/client/ui-dockkit/src/components/TabPanel.tsx b/packages/client/ui-dockkit/src/components/TabPanel.tsx index d7190754d3..c0e3bcd888 100644 --- a/packages/client/ui-dockkit/src/components/TabPanel.tsx +++ b/packages/client/ui-dockkit/src/components/TabPanel.tsx @@ -26,7 +26,7 @@ import { Fragment, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode, RefObject } from 'react' import clsx from 'clsx' import { IconCloseFillRegular, IconPlusOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' -import type { DockZone, LayoutState, PaneNode, TabId } from '../contract/types.ts' +import type { DockZone, LayoutState, PaneId, PaneNode, TabId } from '../contract/types.ts' import { getTab } from '../engine/tree.ts' import type { PaneCallbacks, SplitBlock } from './render.ts' import { TabMenu } from './TabMenu.tsx' @@ -173,6 +173,16 @@ function useStripScrollFades(box: RefObject, tabs: readon }, [box, tabs]) } +/** + * Move the chip box to `left` at once, overriding the stylesheet's smooth + * `scroll-behavior` for this one assignment. + */ +function jumpTo(element: HTMLElement, left: number): void { + element.style.scrollBehavior = 'auto' + element.scrollLeft = left + element.style.removeProperty('scroll-behavior') +} + /** * Bring the active chip into the chip box's view whenever the active tab or * the row of chips changes: a tab opened or selected past the box's edge, or @@ -180,23 +190,50 @@ function useStripScrollFades(box: RefObject, tabs: readon * band (24px) cleared so the chip is not under it. A chip already in view * moves nothing. Direct DOM, like the fades above: the box's scroll position * renders nothing. + * + * A box newly mounted, or newly showing another pane, first jumps to that + * pane's offset in `offsets`, so a strip that replaces another for the same + * pane continues from where that one stood and glides only the remaining + * distance. With no recorded offset the first placement jumps rather than + * glides from the row's start. The box records its offset in `offsets` when + * it unmounts or leaves its pane. */ function useActiveChipInView( box: RefObject, chips: ReadonlyMap, tabs: readonly TabId[], activeTabId: TabId | undefined, + paneId: PaneId, + offsets: Map | undefined, ): void { + // The pane the box was last placed for: a strip moved to another pane places afresh. + const placed = useRef(undefined) + useLayoutEffect(() => { + if (offsets === undefined) return undefined + const element = box.current + /* v8 ignore next -- the box is rendered unconditionally with the strip. */ + if (element === null) return undefined + // Layout cleanup runs before React detaches the box, so it still reads the live offset. + return () => { offsets.set(paneId, element.scrollLeft) } + }, [box, paneId, offsets]) useLayoutEffect(() => { const element = box.current const chip = activeTabId === undefined ? undefined : chips.get(activeTabId) /* v8 ignore next -- the box and the active tab's chip are rendered with the strip. */ if (element === null || chip === undefined) return + const first = placed.current !== paneId + placed.current = paneId + const recorded = first ? offsets?.get(paneId) : undefined + if (recorded !== undefined) jumpTo(element, recorded) const bounds = element.getBoundingClientRect() const rect = chip.getBoundingClientRect() - if (rect.left < bounds.left) element.scrollLeft += rect.left - bounds.left - STRIP_FADE - else if (rect.right > bounds.right) element.scrollLeft += rect.right - bounds.right + STRIP_FADE - }, [box, chips, tabs, activeTabId]) + let delta = 0 + if (rect.left < bounds.left) delta = rect.left - bounds.left - STRIP_FADE + else if (rect.right > bounds.right) delta = rect.right - bounds.right + STRIP_FADE + if (delta === 0) return + if (first && recorded === undefined) jumpTo(element, element.scrollLeft + delta) + else element.scrollLeft += delta + }, [box, chips, tabs, activeTabId, paneId, offsets]) } /** Width of the chip box's fade at a hidden side; mirrors the stylesheet's 24px. */ @@ -210,8 +247,19 @@ function splitBlockedTitle(labels: PaneCallbacks['labels'], block: SplitBlock): } } -/** @param props - pane state and gestures. @returns its tab strip without a content container. */ -export function TabStrip({ state, pane, callbacks }: TabPanelProps): ReactNode { +/** A tab strip and where it reads and records its pane's chip-box offset. */ +interface TabStripProps extends TabPanelProps { + /** + * Chip-box offsets by pane, shared by the strips that replace one another + * as a pane's selected tab changes. Keys are valid within one layout only, + * and entries outlive their panes. Omitted where no strip continues from + * another's position. + */ + readonly offsets?: Map +} + +/** @param props - pane state, gestures and shared offsets. @returns its tab strip without a content container. */ +export function TabStrip({ state, pane, callbacks, offsets }: TabStripProps): ReactNode { // The open context menu and the chip that opened it; the menu positions // itself against that chip from its portal. const [menu, setMenu] = useState<{ readonly tabId: TabId; readonly anchor: HTMLElement } | undefined>(undefined) @@ -219,7 +267,7 @@ export function TabStrip({ state, pane, callbacks }: TabPanelProps): ReactNode { const [chips] = useState(() => new Map()) const stripTabs = useRef(null) useStripScrollFades(stripTabs, pane.tabs) - useActiveChipInView(stripTabs, chips, pane.tabs, pane.activeTabId) + useActiveChipInView(stripTabs, chips, pane.tabs, pane.activeTabId, pane.id, offsets) const block = callbacks.splitBlock(pane.id) const target = callbacks.dropTarget const stripIndex = target !== undefined && target.kind === 'strip' && target.paneId === pane.id diff --git a/packages/client/ui-dockkit/src/components/dockkit.module.css b/packages/client/ui-dockkit/src/components/dockkit.module.css index e57adb57cf..9aec2d8faa 100644 --- a/packages/client/ui-dockkit/src/components/dockkit.module.css +++ b/packages/client/ui-dockkit/src/components/dockkit.module.css @@ -263,7 +263,8 @@ min-width: 0; overflow-x: auto; overflow-y: hidden; - /* The scroll to a newly active chip glides rather than jumps. */ + /* The scroll to a newly active chip glides; `jumpTo` in TabPanel.tsx + overrides this for the placements that must not animate. */ scroll-behavior: smooth; scrollbar-width: none; touch-action: none; diff --git a/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx b/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx index 7b6e9215c3..7924776768 100644 --- a/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx +++ b/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx @@ -270,3 +270,86 @@ it('keeps a superseding gesture marker when an older follower detaches', () => { second() expect(element.dataset.dockkitPointer).toBeUndefined() }) + +/** + * Give each element its own `scrollLeft`, starting at 0, and log every + * assignment with the inline `scroll-behavior` it ran under: `auto` jumps, + * empty leaves the stylesheet's glide. + */ +function stubScrollLeft() { + const offsets = new WeakMap() + const writes: (readonly [number, string])[] = [] + const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollLeft') + Object.defineProperty(Element.prototype, 'scrollLeft', { + configurable: true, + get(this: Element) { return offsets.get(this) ?? 0 }, + set(this: HTMLElement, value: number) { + offsets.set(this, value) + writes.push([value, this.style.scrollBehavior]) + }, + }) + const restore = (): void => { + if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollLeft') + else Object.defineProperty(Element.prototype, 'scrollLeft', descriptor) + } + return { offsets, writes, restore } +} + +it('continues a selected tab\'s strip from its pane\'s last offset and jumps on a first placement', () => { + const { writes, restore } = stubScrollLeft() + const rect = (left: number, width: number): DOMRect => ({ + left, right: left + width, top: 0, bottom: 28, x: left, y: 0, width, height: 28, toJSON: () => ({}), + }) + // The chip box spans 0..200; the seeded chip sits at 40..130 and the opened one where `openedAt` says. + const controller = seededController() + const seeded = getPane(controller.getSnapshot().state, controller.getSnapshot().state.activePaneId).activeTabId + if (seeded === undefined) throw new Error('expected the seeded tab') + const opened = controller.openContent({ kind: 'file', contentId: 'file:b', title: 'B' }) + let openedAt = 250 + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.hasAttribute('data-dockkit-strip-tabs')) return rect(0, 200) + if (this.getAttribute('data-dockkit-tab') === opened) return rect(openedAt, 90) + if (this.hasAttribute('data-dockkit-tab')) return rect(40, 90) + return rect(0, 0) + }) + try { + const h = mounted(controller) + // The first strip has no recorded offset: it jumps to the chip. + expect(writes).toEqual([[164, 'auto']]) + // The seeded tab's new strip starts where the previous one stood, where its chip is in view. + fireEvent.click(h.node(`[data-dockkit-tab="${seeded}"]`)) + expect(writes.slice(1)).toEqual([[164, 'auto']]) + // Back on the opened tab, now 30 past the left edge: restored, then glided the rest. + openedAt = -30 + fireEvent.click(h.node(`[data-dockkit-tab="${opened}"]`)) + expect(writes.slice(2)).toEqual([[164, 'auto'], [164 - 30 - 24, '']]) + } finally { + restore() + } +}) + +it('places a selected tab\'s strip moved to another pane from that pane\'s last offset', () => { + // jsdom lays every chip and box out at 0x0, so only restores write offsets here. + const { offsets, writes, restore } = stubScrollLeft() + try { + const controller = seededController() + controller.setExpanded(true) + const moved = controller.openContent({ kind: 'file', contentId: 'file:a', title: 'A' }) + controller.openContent({ kind: 'file', contentId: 'file:b', title: 'B' }) + const h = mounted(controller) + act(() => { controller.splitPane(); h.redraw() }) + const state = controller.getSnapshot().state + const split = getNode(state, state.rootId) + if (split.kind !== 'split') throw new Error('expected a split') + const target = getPane(state, split.children[1]!).id + act(() => { controller.focusTab(moved); h.redraw() }) + offsets.set(h.node(`[data-dockkit-strip-tabs="${target}"]`), 70) + writes.length = 0 + act(() => { controller.placeTab(moved, target, 0); h.redraw() }) + expect(h.node(`[data-dockkit-strip-tabs="${target}"] [data-dockkit-tab="${moved}"]`).getAttribute('aria-selected')).toBe('true') + // The moved strip takes the target pane's 70; the source pane's next strip restores the 0 the moved one left there. + expect([...writes].sort((x, y) => x[0] - y[0])).toEqual([[0, 'auto'], [70, 'auto']]) + } finally { + restore() + } +}) diff --git a/packages/client/ui-entry-customization/package.json b/packages/client/ui-entry-customization/package.json index f98c8ed620..94cac01670 100644 --- a/packages/client/ui-entry-customization/package.json +++ b/packages/client/ui-entry-customization/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-entry-customization", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "description": "Customize plugin entries in the left sidebar, conversation views, and right sidebar, and quickly create new plugins.", "publishConfig": { "access": "public" }, "repository": { diff --git a/packages/client/ui-file-drag/package.json b/packages/client/ui-file-drag/package.json index 16e865db2a..2a066047f0 100644 --- a/packages/client/ui-file-drag/package.json +++ b/packages/client/ui-file-drag/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-file-drag", "description": "Application-owned file drag transfers and localized failure feedback", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 711af0de38..4e23521a01 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-goal", "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 6772d6516d..ca1a483eea 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-input-trigger", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index 95ea2ac2b1..31c56d7d94 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-jobs", "description": "Session-header background-job list with on-demand streaming record panels", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index a233d3410a..f92629e6c7 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-layout", "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 03e4b05a6c..bcd578c59f 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-message-feedback", "description": "The Web feedback surface: per-message Like/Dislike in the assistant-message action strip and the feedback dialog behind both ratings and /feedback, backed by the messageFeedback and sessionFeedback Host Remotes", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index 51277d84c6..594bef2c3a 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-model-selection", "description": "Model selection over the shared model catalog, Session projection, and session.selectModel", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css index 76532308c9..a4fbb31611 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -11,10 +11,11 @@ align-items: center; gap: 4px; min-width: 0; - /* Legacy fallback, then a row-relative cap that grants long names more - room without letting this trailing control consume the composer. */ - max-width: 220px; - max-width: min(360px, 45cqw); + /* Preferred width: the composer measures this expanded seat against the + space the other controls leave, so the cap must not shrink with the row. + A row-relative cap would let the measured demand fall with the available + width, and the expanded text would then always fit. */ + max-width: 360px; height: 28px; padding: 0 4px 0 8px; border: none; diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json index 63e93267b7..6088f56dd7 100644 --- a/packages/client/ui-open-in-app/package.json +++ b/packages/client/ui-open-in-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-open-in-app", "description": "Web \"Open In...\" controls: the Session-header split button opening the workspace directory in an installed application, and the document preview's default-application controls for one file", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 279acebe6c..8e5a256421 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-permission-presets", "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index a6a3dea5cf..7956c6b884 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plan", "description": "Plan mode controls, persistent transcript plan cards, and sidebar Markdown previews", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-plan/src/client/failure-line.ts b/packages/client/ui-plan/src/client/failure-line.ts index f0dd9f74a7..ef288ff1f4 100644 --- a/packages/client/ui-plan/src/client/failure-line.ts +++ b/packages/client/ui-plan/src/client/failure-line.ts @@ -11,7 +11,6 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' export function planFailureLine(t: TranslateNS<'plan'>, failure: RemoteFailure): string { switch (failure.code) { case 'plan/invalid-address': return t('preview.invalidAddress') - case 'plan/unavailable': return t('preview.historyUnavailable') case 'plan/not-found': return t('preview.notFound') // Transport failures, including plan/read-failed, retain the carrier's diagnostic. default: return failure.message diff --git a/packages/client/ui-plan/src/client/locales.ts b/packages/client/ui-plan/src/client/locales.ts index 5940519fa2..7dbf87ea84 100644 --- a/packages/client/ui-plan/src/client/locales.ts +++ b/packages/client/ui-plan/src/client/locales.ts @@ -12,7 +12,6 @@ export const zh = { 'preview.loading': '正在读取计划…', 'preview.failed': '无法读取计划', 'preview.invalidAddress': '计划地址无效', - 'preview.historyUnavailable': '无法读取会话历史', 'preview.notFound': '未找到这份计划', 'preview.unavailable': '计划预览不可用', 'preview.expired': '临时计划预览已失效,请从仍在等待审批的卡片重新打开。', @@ -36,7 +35,6 @@ export const en = { 'preview.loading': 'Loading plan…', 'preview.failed': 'Could not load plan', 'preview.invalidAddress': 'Invalid plan address', - 'preview.historyUnavailable': 'Session history is unavailable', 'preview.notFound': 'This plan was not found', 'preview.unavailable': 'Plan preview is unavailable', 'preview.expired': 'This temporary plan preview has expired. Reopen it from the pending review card.', @@ -57,7 +55,6 @@ export const ja = { 'preview.loading': '計画を読み込み中…', 'preview.failed': '計画を読み込めませんでした', 'preview.invalidAddress': '計画のアドレスが無効です', - 'preview.historyUnavailable': 'セッション履歴を利用できません', 'preview.notFound': 'この計画は見つかりませんでした', 'preview.unavailable': '計画のプレビューを利用できません', 'preview.expired': 'この一時的な計画プレビューは期限切れです。保留中のレビューカードから再度開いてください。', @@ -78,7 +75,6 @@ export const es = { 'preview.loading': 'Cargando plan…', 'preview.failed': 'No se pudo cargar el plan', 'preview.invalidAddress': 'Dirección del plan no válida', - 'preview.historyUnavailable': 'El historial de sesiones no está disponible', 'preview.notFound': 'Este plan no fue encontrado.', 'preview.unavailable': 'La vista previa del plan no está disponible', 'preview.expired': 'Esta vista previa del plan temporal ha caducado. Vuelva a abrirlo desde la tarjeta de revisión pendiente.', @@ -99,7 +95,6 @@ export const fr = { 'preview.loading': 'Plan de chargement…', 'preview.failed': 'Impossible de charger le plan', 'preview.invalidAddress': 'Adresse du forfait invalide', - 'preview.historyUnavailable': "L'historique des sessions n'est pas disponible", 'preview.notFound': "Ce plan n'a pas été trouvé", 'preview.unavailable': "L'aperçu du plan n'est pas disponible", 'preview.expired': 'Cet aperçu du plan temporaire a expiré. Rouvrez-le à partir de la carte de révision en attente.', @@ -120,7 +115,6 @@ export const de = { 'preview.loading': 'Ladeplan…', 'preview.failed': 'Plan konnte nicht geladen werden', 'preview.invalidAddress': 'Ungültige Planadresse', - 'preview.historyUnavailable': 'Der Sitzungsverlauf ist nicht verfügbar', 'preview.notFound': 'Dieser Plan wurde nicht gefunden', 'preview.unavailable': 'Die Planvorschau ist nicht verfügbar', 'preview.expired': 'Diese temporäre Planvorschau ist abgelaufen. Öffnen Sie es erneut über die ausstehende Überprüfungskarte.', @@ -141,7 +135,6 @@ export const ru = { 'preview.loading': 'План загрузки…', 'preview.failed': 'Не удалось загрузить план', 'preview.invalidAddress': 'Неверный адрес плана', - 'preview.historyUnavailable': 'История сеансов недоступна', 'preview.notFound': 'Этот план не найден', 'preview.unavailable': 'Предварительный просмотр плана недоступен.', 'preview.expired': 'Срок действия этой временной предварительной версии плана истек. Откройте его снова из карточки ожидающего рассмотрения.', @@ -162,7 +155,6 @@ export const pt = { 'preview.loading': 'Carregando plano…', 'preview.failed': 'Não foi possível carregar o plano', 'preview.invalidAddress': 'Endereço do plano inválido', - 'preview.historyUnavailable': 'O histórico da sessão não está disponível', 'preview.notFound': 'Este plano não foi encontrado', 'preview.unavailable': 'A visualização do plano não está disponível', 'preview.expired': 'Esta visualização temporária do plano expirou. Reabra-o no cartão de revisão pendente.', diff --git a/packages/client/ui-plan/src/client/plan-resource.ts b/packages/client/ui-plan/src/client/plan-resource.ts index 95ec5dff30..8a3a82a204 100644 --- a/packages/client/ui-plan/src/client/plan-resource.ts +++ b/packages/client/ui-plan/src/client/plan-resource.ts @@ -1,6 +1,5 @@ -/** Read immutable plan arguments from a Session snapshot and earlier history pages. */ +/** Read immutable plan arguments from a Session history page and earlier pages. */ import type { Context } from '@deepseek-ai/cordis' -import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types' import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client' import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' @@ -10,8 +9,6 @@ declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { /** The resource URL does not identify a plan invocation. */ 'plan/invalid-address': Record - /** Session history ended before an opening snapshot. */ - 'plan/unavailable': Record /** The Session has no readable plan for this invocation. */ 'plan/not-found': Record /** The Session history read failed. */ @@ -28,13 +25,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** * Bind plan reads to the generated Session Remote face. - * Opening a follow reads projections and may activate a prepared Session on the Host. - * Generated Remote streams can throw carrier failures; the provider reports failed - * reads as resource failure frames and preserves Remote error codes. + * A plan is a logged tool-call argument, so the provider reads one page at the current log + * end and pages backwards from its cut. It neither activates nor subscribes. + * Remote failures are reported as resource failure frames with their original codes. * @param remote - Existing Session history API. * @returns a provider whose reads stop after finding the exact invocation. */ -export function planResourceProvider(remote: Pick): ResourceProvider<'plan'> { +export function planResourceProvider(remote: Pick): ResourceProvider<'plan'> { return { protocol: 'plan', async *open(address, { signal }): AsyncIterable> { @@ -47,14 +44,12 @@ export function planResourceProvider(remote: Pick | undefined - for await (const frame of remote.follow({ address: sessionAddress }, signal)) { - if (frame.type === 'snapshot') { snapshot = frame; break } - } + // One page fixes the cut the later pages page back from. + const first = await remote.events({ address: sessionAddress }, signal) if (aborted()) return - if (snapshot === undefined) throw new RemoteError('plan/unavailable', 'Session history ended before the plan could be read.', {}) - let page = { records: snapshot.records, hasMore: snapshot.hasMore } + if (!first.ok) { yield { ok: false, error: first.error }; return } + const throughSeq = first.value.cursor + let page = { records: first.value.records, hasMore: first.value.hasMore } while (true) { for (const entry of page.records) { const plan = submittedPlan(entry.event) @@ -65,7 +60,7 @@ export function planResourceProvider(remote: Pick { expect(screen.getByRole('status').textContent).toBe(dictionary['preview.unavailable']) for (const [code, message] of [ ['plan/invalid-address', dictionary['preview.invalidAddress']], - ['plan/unavailable', dictionary['preview.historyUnavailable']], ['plan/not-found', dictionary['preview.notFound']], ['plan/read-failed', 'connection lost'], ['session/not-found', 'connection lost'], diff --git a/packages/client/ui-plan/tests/plan-resource.client.spec.ts b/packages/client/ui-plan/tests/plan-resource.client.spec.ts index 57be9acd36..de33d17d29 100644 --- a/packages/client/ui-plan/tests/plan-resource.client.spec.ts +++ b/packages/client/ui-plan/tests/plan-resource.client.spec.ts @@ -1,20 +1,28 @@ import { describe, expect, it, vi } from 'vitest' +import type { SessionEventEntry, SessionEventsPage } from '@deepseek-ai/dsh-api-session-controller/types' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { planResourceProvider } from '../src/client/plan-resource.ts' const address = 'dsh-resource://plan/session/call' -const event = { type: 'tool/call', seq: 1, data: { callId: 'call', name: 'exit_plan_mode', arguments: '{"plan":"# Saved plan"}' } } -const entry = { type: 'event', event } +const event: SessionEventEntry['event'] = { + type: 'tool/call', seq: 1, time: 1, + data: { callId: 'call', name: 'exit_plan_mode', arguments: '{"plan":"# Saved plan"}' }, +} +const entry: SessionEventEntry = { type: 'event', event } +const olderUserEntry: SessionEventEntry = { + type: 'event', event: { type: 'user/message', seq: 80, time: 1, data: {} }, +} -function setup(records: unknown[], hasMore = false) { - const closed = vi.fn() - const follow = vi.fn(async function* () { - try { yield { type: 'snapshot', cursor: 100, records, hasMore } } - finally { closed() } - }) - const page = vi.fn(async () => ({ ok: true, value: { records: [entry], hasMore: false } })) - const provider = planResourceProvider({ follow, page } as unknown as Parameters[0]) - return { provider, follow, page, closed } +type SessionRemote = Parameters[0] + +function setup(records: SessionEventsPage['records'], hasMore = false) { + const opening: SessionEventsPage = { cursor: 100, records, hasMore } + const events = vi.fn(async () => ({ ok: true, value: opening })) + const page = vi.fn(async () => ({ + ok: true, value: { cursor: 100, records: [entry], hasMore: false }, + })) + const provider = planResourceProvider({ events, page }) + return { provider, events, page } } async function read(provider: ReturnType, url = address, signal = new AbortController().signal) { @@ -24,82 +32,95 @@ async function read(provider: ReturnType, url = add } describe('plan history resource', () => { - it('restores the exact invocation and releases its snapshot stream', async () => { + it('restores the exact invocation from one page', async () => { const b = setup([entry]) expect(await read(b.provider)).toEqual([{ ok: true, value: { callId: 'call', markdown: '# Saved plan', title: 'Saved plan' } }]) - expect(b.closed).toHaveBeenCalledOnce() + expect(b.events).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' } }, expect.any(AbortSignal)) expect(b.page).not.toHaveBeenCalled() }) it('pages older history with the fixed opening cursor without changing the chat window', async () => { - const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true) + const b = setup([olderUserEntry], true) expect((await read(b.provider))[0]).toMatchObject({ ok: true }) - expect(b.page).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' }, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal)) - expect(b.closed).toHaveBeenCalledOnce() + expect(b.events).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' } }, expect.any(AbortSignal)) + expect(b.page).toHaveBeenLastCalledWith({ address: { kind: 'session', sessionId: 'session' }, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal)) }) it.each(['one-shot', 'continuable', 'unknown'] as const)('restores a %s subagent plan with its complete parent address on every page', async (mode) => { - const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true) - const address = `dsh-resource://plan/subagent/parent/child/${mode}/call` + const b = setup([olderUserEntry], true) + const url = `dsh-resource://plan/subagent/parent/child/${mode}/call` const session = { kind: 'subagent', parentSessionId: 'parent', childSessionId: 'child', mode } - expect(await read(b.provider, address)).toMatchObject([{ ok: true, value: { title: 'Saved plan' } }]) - expect(b.follow).toHaveBeenCalledWith({ address: session }, expect.any(AbortSignal)) - expect(b.page).toHaveBeenCalledWith({ address: session, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal)) - expect(b.closed).toHaveBeenCalledOnce() + expect(await read(b.provider, url)).toMatchObject([{ ok: true, value: { title: 'Saved plan' } }]) + expect(b.events).toHaveBeenCalledWith({ address: session }, expect.any(AbortSignal)) + expect(b.page).toHaveBeenLastCalledWith({ address: session, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal)) }) it('reports missing plans and invalid saved addresses', async () => { const b = setup([]) expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/not-found' } }]) expect(await read(b.provider, 'dsh-resource://plan/s/%')).toMatchObject([{ ok: false, error: { code: 'plan/invalid-address' } }]) - expect(b.follow).toHaveBeenCalledOnce() + expect(b.events).toHaveBeenCalledOnce() }) it('does not publish a response after cancellation', async () => { const b = setup([entry]) const controller = new AbortController() controller.abort() expect(await read(b.provider, address, controller.signal)).toEqual([]) - expect(b.follow).not.toHaveBeenCalled() + expect(b.events).not.toHaveBeenCalled() }) - it('reports stream failures and closes an empty stream', async () => { + it('reports an opening events failure with its Remote code', async () => { const b = setup([]) - b.follow.mockImplementationOnce(async function* () { throw new Error('connection lost') }) - expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/read-failed', message: 'connection lost' } }]) - b.follow.mockImplementationOnce(async function* () {}) - expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/unavailable' } }]) + const error = new RemoteError('plan/read-failed', 'connection lost', {}) + b.events.mockResolvedValueOnce({ ok: false, error }) + expect(await read(b.provider)).toEqual([{ ok: false, error }]) }) - it('preserves Remote failures from paging and stream reads', async () => { - const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true) - const error = new RemoteError('plan/unavailable', 'history unavailable', {}) - b.page.mockResolvedValueOnce({ ok: false, error } as never) + it('preserves Remote failures from paging', async () => { + const b = setup([olderUserEntry], true) + const error = new RemoteError('plan/read-failed', 'history unavailable', {}) + b.page.mockResolvedValueOnce({ ok: true, value: { cursor: 100, records: [olderUserEntry], hasMore: true } }) + b.page.mockResolvedValueOnce({ ok: false, error }) expect(await read(b.provider)).toEqual([{ ok: false, error }]) - b.follow.mockImplementationOnce(async function* () { throw error }) - expect(await read(b.provider)).toEqual([{ ok: false, error }]) - b.follow.mockImplementationOnce(async function* () { throw 'disconnected' }) + }) + it('reports a thrown page failure as plan/read-failed', async () => { + const b = setup([olderUserEntry], true) + b.page.mockResolvedValueOnce({ ok: true, value: { cursor: 100, records: [olderUserEntry], hasMore: true } }) + b.page.mockRejectedValueOnce(new Error('connection lost')) + expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/read-failed', message: 'connection lost' } }]) + }) + it('stringifies a non-Error page failure', async () => { + const b = setup([olderUserEntry], true) + b.page.mockResolvedValueOnce({ ok: true, value: { cursor: 100, records: [olderUserEntry], hasMore: true } }) + b.page.mockRejectedValueOnce('disconnected') expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/read-failed', message: 'disconnected' } }]) }) - it('waits for a snapshot and discards it when cancellation occurs during stream closure', async () => { + it('preserves a Remote failure thrown by page', async () => { + const b = setup([olderUserEntry], true) + const error = new RemoteError('plan/read-failed', 'history unavailable', {}) + b.page.mockResolvedValueOnce({ ok: true, value: { cursor: 100, records: [olderUserEntry], hasMore: true } }) + b.page.mockRejectedValueOnce(error) + expect(await read(b.provider)).toEqual([{ ok: false, error }]) + }) + it('discards an opening page that settles after cancellation', async () => { const b = setup([entry]) const controller = new AbortController() - b.follow.mockImplementationOnce(async function* () { - try { - yield entry as never - yield { type: 'snapshot', cursor: 100, records: [entry], hasMore: false } - } finally { b.closed(); controller.abort() } - }) - expect(await read(b.provider, address, controller.signal)).toEqual([]) - expect(b.closed).toHaveBeenCalledOnce() - expect(b.page).not.toHaveBeenCalled() + const started = Promise.withResolvers() + const pending = Promise.withResolvers>>() + b.events.mockImplementationOnce(() => { started.resolve(undefined); return pending.promise }) + const result = read(b.provider, address, controller.signal) + await started.promise + controller.abort() + pending.resolve({ ok: true, value: { cursor: 100, records: [entry], hasMore: false } }) + expect(await result).toEqual([]) }) - it.each(['resolve', 'reject'] as const)('discards a page that settles after cancellation: %s', async (settlement) => { - const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true) + it.each(['resolve', 'reject'] as const)('discards an older page that settles after cancellation: %s', async (settlement) => { + const b = setup([olderUserEntry], true) const controller = new AbortController() const started = Promise.withResolvers() const pending = Promise.withResolvers>>() + b.page.mockResolvedValueOnce({ ok: true, value: { cursor: 100, records: [olderUserEntry], hasMore: true } }) b.page.mockImplementationOnce(() => { started.resolve(undefined); return pending.promise }) const result = read(b.provider, address, controller.signal) await started.promise controller.abort() - if (settlement === 'resolve') pending.resolve({ ok: true, value: { records: [entry], hasMore: false } }) + if (settlement === 'resolve') pending.resolve({ ok: true, value: { cursor: 100, records: [entry], hasMore: false } }) else pending.reject(new Error('cancelled')) expect(await result).toEqual([]) - expect(b.closed).toHaveBeenCalledOnce() }) }) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 1fe97166b2..c1aab681db 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package: - en: 216dcea8d6903007 - zh: 160888842501694e + en: 535d6322bcfb329b + zh: fbf05155ccbb3e0e /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: en: fcb2e69c8b028c0a zh: d505a075679974eb @@ -33,8 +33,8 @@ en: e9da90f03691101b zh: 902cda935a4ce3c8 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/detail-page-extension-points: - en: 54d7666c630da471 - zh: d7ded257d0fd5559 + en: d5e3cad5c31cae59 + zh: 6dc38f09b410d247 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/page-tabs: en: fc469bab4fbd6028 zh: 8f5a04e1b9a46471 @@ -48,8 +48,8 @@ en: 21a0bbe8eb92229c zh: 096ff63db61992bb /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/configuration-slots: - en: 2b34cac9647abe63 - zh: 1929fc24aee6793c + en: 45e324f5663be635 + zh: 674ddf55c1581569 /deepseek-ai-dsh-client-ui-plugin-manager/further-exploration: en: f9a42dfed0360d6a zh: 455a70bcb6c69624 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index bc7802a592..397dd2495f 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -41,6 +41,8 @@ On-demand Official cards are visible offline before installation. Turning one On The first list read shows a short group-heading placeholder and four skeleton cards, using the real cards' row, icon, and text-line spacing, with the action area left blank. They use the same opacity pulse as Session search and remain static with reduced motion. The accessible loading label and read timing are unchanged; refreshes retain the existing list, and installation and component-loading indicators keep their own states. +A bundle unavailable on the DSH Host operating system shows the reason on its card and details page. Its disabled switch prevents enabling it; an already selected bundle can still be switched off. [Plugin Manager](../../boot/plugin-manager/README.md#use-this-package) enforces the same restriction for direct requests. + The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard focus. It reads the list again, then checks every updatable bundle for newer versions one after another; a failed read checks nothing. During a manual refresh, the button shows a spinner and stays disabled until all pending reads and those checks settle and at least 400 ms have elapsed, preventing a brief spinner flash; repeated refresh requests are ignored and the existing list remains visible. Success shows no toast. With a loaded list, a failed manual refresh keeps the cached content and shows a failure toast, including after leaving Plugins; select the button again to retry. Before any successful list read, failures show an in-place message with **Retry**. Background reads do not show the refresh spinner. ### Installing a bundle @@ -108,6 +110,8 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ A row's page exists only while a `plugins.row.config` entry names the row, so a contribution meant for a row renders on the page that configuration opens. +Component lists and detail pages show current startup diagnostics from the Host inventory. The diagnostic remains visible after reopening the page and disappears when the component recovers. Component and source rows use l2 separators by default; the Computer Use detail page uses l3. + ### Page tabs Another plugin adds a tab after the page's own **Installed** tab by registering into the `plugins.tab` list slot; the entry's `label` names the tab and `order` places it. The tabs appear under the page header once one entry exists and are hidden on a bundle's, row's, or official plugin's page; the selected tab survives leaving **Plugins**, and a tab whose entry leaves falls back to **Installed**. The page renders the selected entry in place of its groups and hides its own refresh and add buttons there. The owner props carry `install({ spec, registry?, title, version })` and `openPackage(name)`. `install` is for a version the person already confirmed in the tab: the install dialog opens at its check screen, without the spec form, names the plugin with `title` and `version` above the spec, which it shows in smaller monospace text, keeps that name on the progress and result screens, with the installed version once done, asks `registry` first for that install only and never remembers it, and offers **Retry** after a refusal; a hidden installation reopens instead. `openPackage` switches to **Installed** and opens the bundle's page when that tab lists it, and that page's back button returns to the tab that opened it; a bundle the page leaves out, such as a built-in profile bundle, leaves the Installed list open. A confirmed install offers no GitHub mirror recovery, since that recovery reopens the spec form, and its back button cancels a running install. [`ui-plugin-market`](../ui-plugin-market/README.md) contributes the **Discover** tab here. @@ -144,7 +148,7 @@ Custom item pages use the Host entry id as their registration id; row pages use The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the main list, the full experimental plugin list, a bundle, an official plugin, or a bundle row; switching away from the Plugins panel resets it to the list, while React remounts preserve the selected target. A fresh registration starts on the main list. Installation highlighting scrolls to a card only when it is on the current page; it does not change the selected page. Other Client plugins read `pluginNavigation` optionally, through `ctx.get` or `ctx.inject`, and call its `openBundle(packageName)` to open bundle details without changing the current Session; listing it in a plugin's `inject` keeps that plugin pending on a page without access. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Cards and detail pages show the Experimental marker only for packages in `EXPERIMENTAL_BUNDLES` in [`dsh-official-bundles`](../../util/official-bundles/README.md#plugin-manager-lists); package-name prefixes do not select the marker. -`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. Client plugins can subscribe to the read-only `ctx.pluginChanges.bundle(packageName)` source to pause their queries while a bundle or one of its rows is being changed. Notifications precede the management request and continue when the page closes; subscribers release their observation on disposal. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from the list, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed. +`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. Client plugins can subscribe to the read-only `ctx.pluginChanges.bundle(packageName)` source to pause their queries while a bundle or one of its rows is being changed. Notifications precede the management request and continue when the page closes; subscribers release their observation on disposal. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from either the list or detail page, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed.
diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 5be88a24b2..dccd4cacd3 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -41,6 +41,8 @@ kind: "package-reference" 首次读取列表时,页面显示一条较短的分组标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。 +组合包不支持 DSH Host 的操作系统时,卡片和详情页会显示原因,关闭状态的开关不可启用;已选中的组合包仍可关闭。[插件管理器](../../boot/plugin-manager/README.zh.md#use-this-package)对直接请求执行同一限制。 + **刷新**按钮在悬停或键盘聚焦 500 毫秒后显示提示。它重新读取列表,再依次检查每个可更新组合包的新版本;读取失败时不做检查。手动刷新期间,按钮显示旋转加载指示并保持禁用,直到所有待处理读取和这些检查结束且至少经过 400 毫秒,避免加载指示一闪而过;重复刷新请求会被忽略,已有列表保持可见。成功不显示 toast。已有列表时,手动刷新失败保留缓存内容并显示失败 toast,即使已离开插件页也能看到;再次选择该按钮即可重试。在首次成功读取列表之前,失败仍原位显示提示与**重试**。后台读取不显示刷新按钮的加载指示。 ### 安装一个组合包 @@ -108,6 +110,8 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ 行页只在某个 `plugins.row.config` 条目点名这一行时存在,因此给行的贡献渲染在该配置打开的页面上。 +组件列表和详情页显示 Host 清单中的当前启动诊断。重新打开页面后仍能看到原因,组件恢复后诊断消失。组件列表与来源信息默认使用 l2 分割线,Computer Use 详情页使用 l3。 + ### 页面标签页 其他插件向 `plugins.tab` 列表 slot 注册条目,即可在页面自带的**已安装**标签页之后添加一个标签页;条目的 `label` 为标签页命名,`order` 决定其位置。至少存在一个条目时,标签页显示在页头下方;在组合包、行或官方插件的页面上隐藏。离开**插件**后,所选标签页保持不变;所选标签页的条目被移除时,回到**已安装**。页面渲染所选条目以代替自身的分组,并在此时隐藏自身的刷新和添加按钮。owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`。`install` 用于用户已在标签页中确认的版本:安装对话框在检查界面打开,不显示 spec 表单,在以较小等宽字显示的 spec 上方用 `title` 和 `version` 标明插件,并在进度和结果界面保持该名称,完成后显示已安装的版本;只在这一次安装中首先使用 `registry`,且从不记住它;被拒绝后提供**重试**;如果有隐藏中的安装任务,则改为重新打开该任务。`openPackage` 切换到**已安装**,并在该标签页列出该组合包时打开其页面,该页面的返回按钮回到打开它的标签页;页面不列出的组合包(例如内置 profile 组合包)只打开「已安装」列表。已确认的安装不提供 GitHub 镜像恢复,因为该恢复会重新打开 spec 表单;其返回按钮用于取消正在进行的安装。[`ui-plugin-market`](../ui-plugin-market/README.zh.md) 在这里贡献**发现**标签页。 @@ -144,7 +148,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()` 页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择主列表、完整的实验性插件列表、某个组合包、某个官方插件或组合包的某一行;切换离开插件面板时重置为列表,React 重新挂载则保留所选目标。新的注册从主列表开始。安装后的高亮仅在当前页面有对应卡片时滚动定位,不改变所选页面。其他 Client 插件经 `ctx.get` 或 `ctx.inject` 以可选方式读取 `pluginNavigation`,调用其 `openBundle(packageName)` 即可打开组合包详情,不改变当前 Session;把它写进插件的 `inject` 会让该插件在无权限的页面上一直等待。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 卡片和详情页仅为 [`dsh-official-bundles`](../../util/official-bundles/README.zh.md#plugin-manager-lists) 中 `EXPERIMENTAL_BUNDLES` 名单内的包显示实验性标签,包名前缀不决定标签。 -`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。Client 插件可订阅只读的 `ctx.pluginChanges.bundle(packageName)`,在组合包或其组件行变更期间暂停查询。通知先于管理请求发出,页面关闭后仍会更新;订阅方在卸载时释放观察。`plugins.bundle.activation` 在用户从列表显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。 +`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。Client 插件可订阅只读的 `ctx.pluginChanges.bundle(packageName)`,在组合包或其组件行变更期间暂停查询。通知先于管理请求发出,页面关闭后仍会更新;订阅方在卸载时释放观察。`plugins.bundle.activation` 在用户从列表或详情页显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。
diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json index 068208b79d..c8637e13ca 100644 --- a/packages/client/ui-plugin-manager/package.json +++ b/packages/client/ui-plugin-manager/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plugin-manager", "description": "Plugin management for the dsh web client: the sidebar Plugins panel installs, enables, disables, retries, and composes installed plugin packages", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 6e779d10df..b9516b2a10 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -1577,6 +1577,11 @@ label.registryOption { border-bottom: 0; } +.detail[data-plugin-detail='@deepseek-ai/dsh-cua-bundle'] .facts div, +.detail[data-plugin-detail='@deepseek-ai/dsh-cua-bundle'] .row { + border-bottom-color: var(--dsw-alias-border-l3); +} + .rowLine { display: flex; align-items: center; diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 50c0ede3d5..6acfce2d20 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -100,7 +100,7 @@ function AddPluginMenu({ t, disabled, openInstall, renderSlot }: { const HIGHLIGHT_MS = 2_400 /** - * Whether the main list shows an official optional bundle: the always-held ones, and any held-back one the profile enabled. + * Whether the main list shows an official optional bundle: any bundle outside `MORE_BUNDLES`, and any held-back one the profile enabled. * @param pkg - the official bundle as the Host reports it. * @returns whether the main list shows it. */ @@ -109,14 +109,14 @@ function pinnedToMainList(pkg: PackageView): boolean { } /** - * Where an optional bundle sits on both lists: the always-held ones in their order, then the bundles neither list + * Where an optional bundle sits on both lists: the `EXTENSION_BUNDLES` in their order, then the bundles neither list * ranks, then the held-back ones in theirs, so Codex and Claude Code stay last on the More list. * @param name - the bundle's package name. * @returns the rank; bundles of equal rank keep the order the Host reported. */ function optionalBundleRank(name: string): number { - const alwaysHeld = EXTENSION_BUNDLES.indexOf(name) - if (alwaysHeld >= 0) return alwaysHeld + const ranked = EXTENSION_BUNDLES.indexOf(name) + if (ranked >= 0) return ranked const heldBack = MORE_BUNDLES.indexOf(name) return heldBack < 0 ? EXTENSION_BUNDLES.length : EXTENSION_BUNDLES.length + 1 + heldBack } @@ -251,6 +251,11 @@ function MetadataError({ error, t }: { readonly error: string | undefined; reado return error === undefined ? null :

{t('metadataError', { error })}

} +/** A failed component keeps its current Host diagnostic beside its controls. */ +function RuntimeError({ error, t }: { readonly error: string | undefined; readonly t: Translate }): ReactNode { + return error === undefined ? null :

{t('reasonLabel')}: {error}

+} + /** * A pack's rows as a list in the order the pack declares them: a state dot, * the row id, one line saying its state, a configure control for a row that @@ -327,6 +332,7 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: { />} + ))} @@ -392,7 +398,8 @@ function EnableSwitch({ pkg, title, t, busy, onSetEnabled }: { @@ -402,7 +409,7 @@ function EnableSwitch({ pkg, title, t, busy, onSetEnabled }: { /** The status one card carries: running, off, or a problem the Host reported. */ function packageStatus(pkg: PackageView): 'running' | 'disabled' | 'problem' { - if (pkg.error !== undefined) return 'problem' + if (pkg.error !== undefined || (pkg.enabled && pkg.rows.some(row => row.phase === 'failed'))) return 'problem' return pkg.enabled ? 'running' : 'disabled' } @@ -548,6 +555,7 @@ function PackageCard({ pkg, t, resolveText, busy, highlighted, newer, onOpen, on end={} /> + {pkg.error?.code === 'unsupported-platform' &&

{managementText(pkg.error, t)}

} ) } @@ -662,6 +670,7 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot, form }: {

{description ?? renderSlot('plugins.row.config', { view: 'summary' }, { entryKey: key })}

+
{renderSlot('plugins.row.config', { view: 'page', form }, { entryKey: key })} {renderSlot('plugins.detail.section', { subject })} @@ -1569,6 +1578,10 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { ? {} : { role: 'tabpanel', id: `${tabsId}-${INSTALLED_TAB}-panel`, 'aria-labelledby': `${tabsId}-${INSTALLED_TAB}` } const activated = listed.find(pkg => pkg.name === activation && pkg.enabled && !state.busy.includes(pkg.name)) + const setBundleEnabled = (name: string, enabled: boolean): void => { + setActivation(enabled ? name : null) + props.setEnabled(name, enabled) + } const setRowEnabled = (row: PackageRow, enabled: boolean): void => { /* v8 ignore next -- a row without a live entry has its switch disabled */ if (row.entryId !== undefined) props.setRowEnabled(row.entryId, enabled) @@ -1590,7 +1603,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { highlighted={state.highlight === pkg.name} newer={versionUpdate(state.versions[pkg.name])} onOpen={() => { setActivation(null); setView({ kind: 'package', name: pkg.name, ...showsMoreList ? { from: 'more' } : {} }) }} - onSetEnabled={(enabled) => { setActivation(enabled ? pkg.name : null); props.setEnabled(pkg.name, enabled) }} + onSetEnabled={(enabled) => { setBundleEnabled(pkg.name, enabled) }} /> ) const basicCards = ledger.items.map(item => ( @@ -1761,7 +1774,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { if (returnTab !== undefined) setTab(returnTab) setView({ kind: backToMore ? 'more' : 'list' }) }} - onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }} + onSetEnabled={(enabled) => { setBundleEnabled(openPkg.name, enabled) }} onUninstall={() => { props.uninstall(openPkg.name) }} onUpdate={() => { props.update(openPkg.name) }} onSetRowEnabled={setRowEnabled} @@ -1806,8 +1819,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
) : null} - {/* Activation guidance belongs beside the Installed list that holds the enabled bundle's card. */} - {installedCards && activated !== undefined && !state.install.open + {onInstalled && activated !== undefined && !state.install.open ? renderSlot('plugins.bundle.activation', { packageName: activated.name, onDismiss: () => { setActivation(null) }, diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index a871ab3692..98bcf3ac3e 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -284,6 +284,7 @@ export const zh = { reasonStaleApproval: '待允许的安装脚本列表已变化,请重新安装以刷新', reasonIncompatibleVersion: '{plugin} 与 DSH {runtime} 不兼容(要求 {requirements}),运行它可能导致崩溃或数据丢失。', reasonIncompatibleVersionUnnamed: '这个插件与当前 DSH 版本不兼容,运行它可能导致崩溃或数据丢失。', + reasonUnsupportedPlatform: '此插件不支持运行 DSH 的主机操作系统', reasonIncompatibleInstall: '请安装与当前 DSH 兼容的插件版本。', reasonIncompatibleInstalled: '请卸载后重新安装与当前 DSH 兼容的版本。', reasonOperationError: 'Host 报告了一个错误', @@ -578,6 +579,7 @@ export const en = { reasonStaleApproval: 'The pending script approvals changed; install again to refresh them.', reasonIncompatibleVersion: '{plugin} is incompatible with DSH {runtime} (requires {requirements}); running it may cause crashes or data loss.', reasonIncompatibleVersionUnnamed: 'This plugin is incompatible with the running DSH version; running it may cause crashes or data loss.', + reasonUnsupportedPlatform: 'This plugin does not support the operating system running DSH.', reasonIncompatibleInstall: 'Install a plugin version compatible with this DSH.', reasonIncompatibleInstalled: 'Uninstall it and install a version compatible with this DSH.', reasonOperationError: 'The Host reported an error.', @@ -592,6 +594,7 @@ export const en = { /** Japanese copy, checked against the English keys. */ export const ja = { + 'reasonUnsupportedPlatform': 'このプラグインはDSHを実行しているホストOSに対応していません。', 'panel': 'プラグイン', 'title': 'プラグイン', 'intro': 'プラグインのインストール、有効化、設定', @@ -883,6 +886,7 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { + reasonUnsupportedPlatform: 'Este complemento no es compatible con el sistema operativo que ejecuta DSH.', panel: 'Complementos', title: 'Complementos', intro: 'Instalar, habilitar y configurar complementos', @@ -1174,6 +1178,7 @@ export const es = { /** French dictionary, checked against the English keys. */ export const fr = { + reasonUnsupportedPlatform: 'Ce plugin ne prend pas en charge le système d’exploitation sur lequel DSH s’exécute.', panel: 'Plugins', title: 'Plugins', intro: 'Installer, activer et configurer les plugins', @@ -1465,6 +1470,7 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { + reasonUnsupportedPlatform: 'Dieses Plugin unterstützt das Betriebssystem, auf dem DSH läuft, nicht.', panel: 'Plugins', title: 'Plugins', intro: 'Plugins installieren, aktivieren und konfigurieren', @@ -1756,6 +1762,7 @@ export const de = { /** Russian dictionary, checked against the English keys. */ export const ru = { + reasonUnsupportedPlatform: 'Этот плагин не поддерживает операционную систему, в которой работает DSH.', panel: 'Плагины', title: 'Плагины', intro: 'Установите, включите и настройте плагины', @@ -2047,6 +2054,7 @@ export const ru = { /** Portuguese dictionary, checked against the English keys. */ export const pt = { + reasonUnsupportedPlatform: 'Este plugin não oferece suporte ao sistema operacional em que o DSH está sendo executado.', panel: 'Plug-ins', title: 'Plug-ins', intro: 'Instalar, habilitar e configurar plug-ins', diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 4117366567..b4b982ebf5 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -94,6 +94,8 @@ export interface PackageRow { readonly enabled: boolean /** The entry's fiber phase, null without a live fiber. */ readonly phase: PluginInfo['fiberPhase'] + /** Current startup failure projected by the Host inventory. */ + readonly diagnostic?: string /** Why the Host refuses to switch the row, when it does. */ readonly readOnlyReason?: ReadOnlyReason } @@ -113,7 +115,7 @@ export interface PackageView { readonly meta?: PluginLocalizedMeta /** Whether the profile declares this dependency; availability separately describes its readable files. */ readonly installed: boolean - /** GUI listing before explicit selection or profile installation; omission means true. */ + /** Default GUI visibility after package and Host checks; selected or profile-installed bundles remain manageable. */ readonly listedByDefault?: boolean /** Present for a profile dependency the installation does not also supply: the spec `pnpm add` accepts. */ readonly source?: string @@ -127,7 +129,7 @@ export interface PackageView { readonly enabled: boolean /** Why the Host refuses to switch the bundle off or remove it, when it does. */ readonly readOnlyReason?: ReadOnlyReason - /** Why the Host cannot read the bundle, when it cannot. */ + /** Why the Host cannot read or enable the bundle. */ readonly error?: ManagementError readonly rows: readonly PackageRow[] } @@ -547,6 +549,7 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]): moduleName: row.moduleName, enabled: live?.enabled ?? false, phase: live?.fiberPhase ?? null, + ...live?.diagnostic === undefined ? {} : { diagnostic: live.diagnostic }, ...row.meta === undefined ? {} : { meta: row.meta }, ...row.entryId === undefined ? {} : { entryId: row.entryId }, ...live?.readOnlyReason === undefined ? {} : { readOnlyReason: live.readOnlyReason }, diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts index 23a95913bf..e532eadc9e 100644 --- a/packages/client/ui-plugin-manager/src/client/presentation.ts +++ b/packages/client/ui-plugin-manager/src/client/presentation.ts @@ -59,6 +59,7 @@ const CODE_KEYS = { 'bundle-in-use': 'reasonBundleInUse', 'stale-approval': 'reasonStaleApproval', 'incompatible-version': 'reasonIncompatibleVersionUnnamed', + 'unsupported-platform': 'reasonUnsupportedPlatform', 'operation-error': 'reasonOperationError', } satisfies Record diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index b2b196bfe1..041d5d2a39 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -837,6 +837,27 @@ describe('PluginManagerPage', () => { expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-experimental-agent-team-profile', true) }) + it('keeps the persisted selection while marking a failed component as a problem', () => { + renderTab({ packages: [pkg({ name: 'cua', enabled: true, rows: [row({ phase: 'failed', diagnostic: 'native preparation unavailable' })] })] }) + expect(document.querySelector('[data-plugin-package="cua"]')?.getAttribute('data-plugin-status')).toBe('problem') + expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'cua') }).getAttribute('aria-checked')).toBe('true') + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'cua') })) + expect(screen.getByText(`${en.reasonLabel}: native preparation unavailable`)).toBeTruthy() + }) + + it('shows Computer Use permission guidance on the experimental plugins page', () => { + const description = 'Review application access before use.' + const { actions } = renderTab({ packages: [pkg({ + name: '@deepseek-ai/dsh-cua-bundle', enabled: false, optional: true, installed: false, + meta: { title: 'Computer Use', description }, + })] }) + fireEvent.click(screen.getByRole('button', { name: en.moreAction })) + expect(screen.getByText(en.statusBeta)).toBeTruthy() + expect(screen.getByText(description)).toBeTruthy() + fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'Computer Use') })) + expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-cua-bundle', true) + }) + it.each([ ['@deepseek-ai/dsh-experimental-agent-team-profile', true], ['@deepseek-ai/dsh-experimental-auto-review', true], @@ -1179,6 +1200,7 @@ describe('PluginManagerPage', () => { }) it.each([ + '@deepseek-ai/dsh-cua-bundle', '@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle', '@acme/shipped-exploration', @@ -1209,6 +1231,33 @@ describe('PluginManagerPage', () => { expect(card()).not.toBeNull() }) + it.each([en, zh])('hides unsupported CUA by default and retains installed or selected entries for management', (dict) => { + const name = '@deepseek-ai/dsh-cua-bundle' + const entry = pkg({ name, installed: false, enabled: false, optional: true, listedByDefault: false, + error: { code: 'unsupported-platform' } }) + const other = pkg({ name: '@deepseek-ai/dsh-experimental-session-titles-bundle', installed: false, optional: true, enabled: false }) + const { actions, set, setLanguage } = renderTab({ packages: [other, entry] }) + setLanguage(dict) + const card = () => document.querySelector(`[data-plugin-package="${name}"]`) + expect(card()).toBeNull() + fireEvent.click(screen.getByRole('button', { name: dict.moreAction })) + expect(card()).toBeNull() + for (const availability of ['profile', 'missing'] as const) { + const installed = { ...entry, installed: true, availability, installTarget: { spec: `${name}@1.0.0`, version: '1.0.0' } } + set({ packages: [other, installed] }) + expect(card()).not.toBeNull() + expect(screen.getByText(dict.reasonUnsupportedPlatform)).toBeTruthy() + expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', name) })).toHaveProperty('disabled', true) + } + set({ packages: [other, { ...entry, enabled: true }] }) + fireEvent.click(screen.getByRole('button', { name: dict.openDetail.replace('{name}', name) })) + const toggle = screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', name) }) + expect(toggle).toHaveProperty('disabled', false) + expect(screen.getByText(`${dict.reasonLabel}: ${dict.reasonUnsupportedPlatform}`)).toBeTruthy() + fireEvent.click(toggle) + expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith(name, false) + }) + it.each([en, zh])('keeps disabled CUA on the secondary page with experimental labels', (dict) => { const name = '@deepseek-ai/dsh-cua-bundle' const entry = (enabled: boolean) => pkg({ name, official: true, enabled, meta: { title: 'Computer Use' } }) @@ -1237,8 +1286,10 @@ describe('PluginManagerPage', () => { expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeTruthy() }) - it('returns from package details to the secondary page and retains it across React remounts', () => { - const name = '@deepseek-ai/dsh-experimental-session-titles-bundle' + it.each([ + '@deepseek-ai/dsh-experimental-session-titles-bundle', + '@deepseek-ai/dsh-personalized-recommendations', + ])('returns %s details to the secondary page and retains it across React remounts', (name) => { const b = renderTab({ packages: [pkg({ name, official: true, enabled: false })] }) fireEvent.click(screen.getByRole('button', { name: en.moreAction })) expect(screen.getByRole('heading', { name: en.moreTitle, level: 1 })).toBeTruthy() @@ -1253,9 +1304,11 @@ describe('PluginManagerPage', () => { expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull() }) - it('pins a bundle the profile picks onto the main list, in its full-list place', () => { + it.each([ + '@deepseek-ai/dsh-experimental-tool-worktree', + '@deepseek-ai/dsh-personalized-recommendations', + ])('pins enabled %s onto the main list and removes it when disabled', (first) => { const team = '@deepseek-ai/dsh-experimental-agent-team-profile' - const first = '@deepseek-ai/dsh-experimental-tool-worktree' const second = '@deepseek-ai/dsh-experimental-cot-translation-bundle' const entry = (name: string, enabled: boolean): PackageView => pkg({ name, official: true, enabled }) const b = renderTab({ packages: [entry(second, true), entry(team, true), entry(first, false)] }) @@ -1509,6 +1562,11 @@ describe('PluginManagerPage', () => { expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonIncompatibleVersionUnnamed} ${en.reasonIncompatibleInstalled}`)).toBeTruthy() fireEvent.click(within(detail).getByRole('button', { name: en.backToList })) expect(document.querySelector('[data-plugin-detail]')).toBeNull() + set({ packages: [pkg({ enabled: false, error: { code: 'unsupported-platform' } })] }) + expect(screen.getByText(en.reasonUnsupportedPlatform)).toBeTruthy() + expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') })).toHaveProperty('disabled', true) + set({ packages: [pkg({ enabled: true, error: { code: 'unsupported-platform' } })] }) + expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') })).toHaveProperty('disabled', false) // A bundle that leaves the list drops back to the cards. const { version: _version, ...unversioned } = pkg() set({ packages: [unversioned] }) @@ -1550,7 +1608,7 @@ describe('PluginManagerPage', () => { fireEvent.click(toggle('row-2')) expect(actions.setRowEnabled).toHaveBeenCalledTimes(1) expect(toggle('include:row-5')).toHaveProperty('disabled', true) - expect(within(detail).getByText(en.rowPhaseFailed)).toBeTruthy() + expect(within(detail.querySelector('[data-plugin-row="include:row-3"]')!).getByText(en.rowPhaseFailed)).toBeTruthy() expect(within(detail).getByText(en.rowPhaseLoading)).toBeTruthy() expect(within(detail).getByText(en.rowPhaseUnloading)).toBeTruthy() expect(document.querySelector('[data-plugin-row="include:row-3"]')?.getAttribute('data-state')).toBe('failed') @@ -2532,7 +2590,6 @@ it('supplies the accepted entry values and atomic mutation action to a custom pl expect(mutate).toHaveBeenCalledWith([{ op: 'set', path: ['count'], value: 3 }], 7) }) - describe('on-demand Official bundles', () => { const name = '@deepseek-ai/dsh-subagent-codex' const title = 'Codex subagent' @@ -2640,6 +2697,28 @@ describe('on-demand Official bundles', () => { }) }) +it('uses the same activation guidance from a package detail switch', () => { + const name = 'dsh-better-sidebar' + const { set, actions } = renderTab({ packages: [pkg({ enabled: false })] }, { bundles: new Set([name]) }, { + [`plugins.bundle.activation:${name}`]: (_view, owner) => , + [`plugins.bundle.config:${name}`]: () =>
Bundle setup
, + }) + fireEvent.click(screen.getByRole('button', { name: `View ${name}` })) + expect(screen.getByText('Bundle setup')).toBeTruthy() + fireEvent.click(screen.getByRole('switch')) + expect(actions.setEnabled).toHaveBeenCalledWith(name, true) + set({ packages: [pkg()], busy: [name] }) + expect(screen.queryByText('Finish setup')).toBeNull() + set({ busy: [] }) + fireEvent.click(screen.getByText('Finish setup')) + expect(screen.queryByText('Finish setup')).toBeNull() + expect(screen.getByText('Bundle setup')).toBeTruthy() + fireEvent.click(screen.getByRole('switch')) + set({ packages: [pkg({ enabled: false })] }) + expect(screen.queryByText('Finish setup')).toBeNull() +}) + + it('keeps bundle configuration mounted while its operation is pending', () => { const name = 'dsh-better-sidebar' const { set } = renderTab({ packages: [pkg()] }, { bundles: new Set([name]) }, { diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 299cf77791..47dc13c330 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -120,6 +120,13 @@ it('hands a custom page the shared configuration form of its entry', () => { }) describe('packageView', () => { + it('carries the current inventory failure into a component row', () => { + const plugins = PLUGINS.map(plugin => plugin.entryId === ROW_ENTRY + ? { ...plugin, fiberPhase: 'failed' as const, diagnostic: 'native preparation unavailable' } : plugin) + expect(packageView(BUNDLE, plugins).rows[0]?.diagnostic).toBe('native preparation unavailable') + expect(packageView(BUNDLE, PLUGINS).rows[0]?.diagnostic).toBeUndefined() + }) + it('joins a bundle with the entries its rows run as', () => { expect(packageView(BUNDLE, PLUGINS)).toEqual({ name: 'dsh-better-sidebar', version: '0.16.0', description: 'A sidebar.', source: 'dsh-better-sidebar@^0.16.0', diff --git a/packages/client/ui-plugin-market/package.json b/packages/client/ui-plugin-market/package.json index 30883a8c70..9606553275 100644 --- a/packages/client/ui-plugin-market/package.json +++ b/packages/client/ui-plugin-market/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plugin-market", "description": "Discover tab of the dsh web Plugins page: browse configured plugin market indexes and install their bundles", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 16cef349a8..cad46c62fc 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -24,8 +24,8 @@ en: b3744373b4b2c1fb zh: 2dbde8ee0bfa5f28 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: - en: 31ed1a6239427512 - zh: cb489d77de109d3f + en: 2daec758b1f35563 + zh: 32684be1f8b1b4c3 /deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy: en: a943603b74080ef5 zh: 43f7a0188aff271b @@ -54,8 +54,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work: - en: 5a1e9d041b556b0e - zh: c66b4e7d5f77162d + en: 9c5bc17e35af9d0b + zh: 4f1a142c7d56cf72 /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 5fbf36b3b1..95db022946 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -108,7 +108,7 @@ The catalog above lists what each export is for; this section covers the behavio The pure Markdown helper `formatQuoteText` normalizes CRLF and CR line endings, then prefixes each line with `> `. -The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener. +The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. A destination with a backslash and no URL scheme (a drive letter is not one) is a Windows path when it has no `/` or has a backslash before a letter or digit. Such a path keeps every backslash, including one before punctuation, and still decodes character references, so `D:\repo\.github` is not read as an escaped `.`. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener. `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. Strikethrough requires `~~`; a single `~`, as in `10~20`, stays literal. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. Each mounted image appends a `load` query parameter to a resolved Host `api/file` URL, because browsers reuse a same-URL image within one document despite `no-store`; a newly rendered image of an overwritten file therefore loads its current bytes, and every remount requests the file again; the Host file route redirects that request to a cacheable URL for the current file version. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text). @@ -220,6 +220,7 @@ These limits define how the atoms behave at the edges; they are current package - **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it. - **A long highlighted fence retains its complete token DOM** — streaming avoids re-parsing, re-tokenizing, and reconciling the completed prefix, but it does not discard old colors or virtualize token spans. Final DOM cardinality therefore still follows the fence's token count; nested/container fences and a pathological single long line remain on the general tail path. - **Overlong lines stay uncolored** — a source line of 1,000 or more UTF-16 units renders as one plain run in every highlighted surface, because TextMate scanning cost grows superlinearly with line length and a single generated line can otherwise block the page for seconds. The grammar state skips that line, so a comment or template literal that opens or closes on it mis-colors the following lines. Highlighting still runs synchronously during render, so a file with thousands of ordinary lines costs proportionally more. +- **The Go struct-field rule is patched locally** — when the Go grammar loads, its repeated one-line struct-field rule is replaced with the rule [worlpaker/go-syntax#25](https://github.com/worlpaker/go-syntax/pull/25) merged, because the vendored rule backtracks exponentially on lines such as a brace-bearing comment after `struct {`. If a dependency upgrade removes that rule, loading the grammar throws and Go code renders as plain text; delete the patch once `@shikijs/langs` ships the upstream rule. - **The fish logo is a redrawn approximation** — its font glyph has no exportable vector geometry in the local design data, so a hand-authored recreation stands in until an exact export path exists. - **`Pill` and `Input` have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **Product icons need the ui-theme sprite** — each icon only references its symbol, so without ui-theme, or inside another document such as an iframe, the icon renders nothing. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 41d37d6f18..910185907b 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -108,7 +108,7 @@ kind: "package-library" 纯 Markdown 工具 `formatQuoteText` 规范化 CRLF 和 CR 换行符,再为每行添加 `> ` 前缀。 -最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。 +最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。含反斜杠且无 URL 协议(盘符不算 URL 协议)的目标,若不含 `/`,或在字母或数字前有反斜杠,即为 Windows 路径。此类路径保留所有反斜杠(包括标点前的反斜杠),仍会解码字符引用,因此 `D:\repo\.github` 不会被读作转义的 `.`。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。 `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。删除线需要 `~~`;单个 `~`(如 `10~20`)保持字面文本。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。每个挂载的图片会为解析得到的 Host `api/file` 地址追加 `load` 查询参数,因为浏览器在同一文档内会无视 `no-store` 复用相同 URL 的图片;因此覆盖后的文件在新渲染的图片中会加载当前内容,每次重新挂载都会重新请求该文件;Host 文件路由会把该请求重定向到当前文件版本的可缓存地址。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。 @@ -220,6 +220,7 @@ kind: "package-library" - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。 - **长高亮 fence 会保留完整 token DOM**:流式路径避免重新解析、重新 tokenize 和 reconcile 已完成前缀,但不会丢弃旧颜色或虚拟化 token span。因此最终 DOM 数量仍随 fence 的 token 数增长;嵌套/容器内 fence 与病态的单个超长行仍走通用尾部路径。 - **超长行不着色**:所有高亮界面中,长度达到 1,000 个 UTF-16 单元的源码行渲染为一段纯文本,因为 TextMate 扫描成本随行长超线性增长,单个生成行否则会阻塞页面数秒。语法状态会跳过该行,因此在该行开始或结束的注释、模板字符串会使后续行着色错误。高亮仍在渲染期间同步执行,因此数千个普通行的文件仍按比例增加开销。 +- **Go 结构体字段规则在本地打了补丁**:Go 语法加载时,重复匹配单行结构体字段的规则会替换为 [worlpaker/go-syntax#25](https://github.com/worlpaker/go-syntax/pull/25) 合入的规则,因为内置规则在 `struct {` 之后含花括号注释等行上会指数级回溯。若依赖升级后该规则消失,加载语法会抛错,Go 代码以纯文本显示;`@shikijs/langs` 发布上游规则后删除此补丁。 - **鱼形标志是重新绘制的近似版本**:它来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **`Pill` 与 `Input` 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 - **产品图标依赖 ui-theme 的 sprite**:每个图标只引用自己的 symbol,因此没有 ui-theme 时,或在 iframe 等另一个文档中,图标不渲染任何内容。 diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 73f1c57c1a..c186e06dc8 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, @@ -34,6 +34,7 @@ "katex": "^0.18.7", "micromark-core-commonmark": "^2.0.3", "micromark-util-character": "^2.1.1", + "micromark-util-decode-string": "^2.0.1", "micromark-util-classify-character": "^2.0.1", "micromark-util-symbol": "^2.0.1", "micromark-util-types": "^2.0.2", diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index 7a54506760..fcb6be90e2 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -41,6 +41,39 @@ type LangModule = { default: typeof langTs } */ const LANGS = [langTs, langBash, langJson] +/** + * Replace the Go grammar's repeated one-line struct-field rule with the rule + * worlpaker/go-syntax merged in 4a9b120a52c (https://github.com/worlpaker/go-syntax/pull/25): + * the repeated rule backtracks exponentially on lines such as a brace-bearing + * comment after `struct {`. Delete this patch once `@shikijs/langs` ships that + * commit; 4.5.0 does not. The rule is located as the only rule in + * `struct_variables_types_fields` containing the field-token literal below, + * taken from `@shikijs/langs` 4.4.3. + * @param grammars - the `@shikijs/langs/go` default export, left unmodified. + * @returns copies of `grammars` with that rule's match replaced in the `go` grammar. + * @throws When the module has no `go` grammar, the grammar lacks + * `struct_variables_types_fields`, or that entry has no or several such rules. + * The lazy load then rejects unhandled and Go code renders as plain text. + */ +export function patchGoGrammar(grammars: LangModule['default']): LangModule['default'] { + const go = grammars.find(grammar => grammar.name === 'go') + const fields = go?.repository.struct_variables_types_fields + const patterns = fields?.patterns ?? [] + const rules = patterns.filter(rule => typeof rule.match === 'string' && rule.match.includes('[^"/`\\s]+;?')) + if (go === undefined || fields === undefined || rules.length !== 1) { + throw new Error(`ui-primitives: expected exactly one Go struct-field rule to patch for worlpaker/go-syntax#25, found ${ + rules.length}; update or delete the patch`) + } + const repository = { ...go.repository } + repository.struct_variables_types_fields = { + ...fields, + patterns: patterns.map(rule => rule === rules[0] + ? { ...rule, match: '(?:(?<=\\{)([^/\\`"]*[^\\s/\\`"])\\s*(?=\\}))' } + : rule), + } + return grammars.map(grammar => grammar === go ? { ...go, repository } : grammar) +} + /** * The non-boot extension grammars, each behind a dynamic import so its module * stays out of the boot chunk until a code surface renders that language. @@ -53,7 +86,7 @@ const LANGS = [langTs, langBash, langJson] const LAZY_GRAMMARS = new Map Promise>([ ['python', () => import('@shikijs/langs/python')], ['ruby', () => import('@shikijs/langs/ruby')], - ['go', () => import('@shikijs/langs/go')], + ['go', async () => ({ default: patchGoGrammar((await import('@shikijs/langs/go')).default) })], ['rust', () => import('@shikijs/langs/rust')], ['java', () => import('@shikijs/langs/java')], ['c', () => import('@shikijs/langs/c')], diff --git a/packages/client/ui-primitives/src/markdown/parse.ts b/packages/client/ui-primitives/src/markdown/parse.ts index 6933ce389f..11cc358c33 100644 --- a/packages/client/ui-primitives/src/markdown/parse.ts +++ b/packages/client/ui-primitives/src/markdown/parse.ts @@ -17,6 +17,7 @@ import { gfm } from 'micromark-extension-gfm' import { math } from 'micromark-extension-math' import { cjkFriendlyStrong } from './cjkFriendlyStrong.ts' import { mathCompatibility } from './mathCompatibility.ts' +import { windowsPathDestinations } from './windows-path-destinations.ts' /** GFM strikethrough requires `~~`; a single `~` stays literal, as in ranges such as `10~20`. */ const gfmOptions = { singleTilde: false } @@ -30,7 +31,7 @@ const gfmOptions = { singleTilde: false } export function parseGfm(text: string): Root { return recoverLocalImages(fromMarkdown(text, { extensions: [gfm(gfmOptions), cjkFriendlyStrong()], - mdastExtensions: [gfmFromMarkdown()], + mdastExtensions: [gfmFromMarkdown(), windowsPathDestinations()], }), text) } @@ -46,6 +47,6 @@ export function parseGfmWithMath(text: string): Root { // registered last first, so its dollar flow takes precedence over `math()` // and keeps the opening line's text as math instead of upstream's fence meta. extensions: [gfm(gfmOptions), cjkFriendlyStrong(), math(), mathCompatibility()], - mdastExtensions: [gfmFromMarkdown(), mathFromMarkdown()], + mdastExtensions: [gfmFromMarkdown(), mathFromMarkdown(), windowsPathDestinations()], }), text) } diff --git a/packages/client/ui-primitives/src/markdown/windows-path-destinations.ts b/packages/client/ui-primitives/src/markdown/windows-path-destinations.ts new file mode 100644 index 0000000000..2a95125898 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/windows-path-destinations.ts @@ -0,0 +1,34 @@ +/** Keep backslash separators in Markdown destinations written as Windows paths. */ +import type { Definition, Image, Link } from 'mdast' +import type { CompileContext, Extension, Token } from 'mdast-util-from-markdown' +import { decodeString } from 'micromark-util-decode-string' + +/** + * A raw destination is a Windows path when it has a backslash and no URL + * scheme, and either has no `/` or has a backslash before a letter or digit, + * which CommonMark cannot read as an escape. A drive letter is not a scheme. + */ +function isWindowsPath(raw: string): boolean { + return raw.includes('\\') && !/^[a-z][a-z\d+.-]+:/iu.test(raw) + && (!raw.includes('/') || /\\[\p{L}\p{N}]/u.test(raw)) +} + +function exitDestination(this: CompileContext, token: Token): void { + const parsed = this.resume() + const raw = this.sliceSerialize(token) + const node = this.stack.at(-1) as Link | Image | Definition + // Doubling each backslash makes every one literal; character references still decode. + node.url = isWindowsPath(raw) ? decodeString(raw.replaceAll('\\', '\\\\')) : parsed +} + +/** + * CommonMark reads `\.` or `\_` in a destination as an escaped character, so + * `D:\repo\.github` would parse as `D:\repo.github`. For a destination that + * is a Windows path, this `fromMarkdown` extension keeps every backslash + * literally, including one before punctuation such as `\(`, while character + * references such as `&` still decode; other destinations parse unchanged. + * @returns The mdast extension. + */ +export function windowsPathDestinations(): Extension { + return { exit: { resourceDestinationString: exitDestination, definitionDestinationString: exitDestination } } +} diff --git a/packages/client/ui-primitives/tests/go-highlighting.client.spec.ts b/packages/client/ui-primitives/tests/go-highlighting.client.spec.ts new file mode 100644 index 0000000000..bd18f005ad --- /dev/null +++ b/packages/client/ui-primitives/tests/go-highlighting.client.spec.ts @@ -0,0 +1,72 @@ +/** Go regex matching runs in a terminable source-mode worker, not on the test runner's event loop. */ +import { Worker } from 'node:worker_threads' +import { expect, it } from 'vitest' +import { patchGoGrammar } from '../src/markdown/highlight.ts' +import { GO_SOURCE } from './go-source.ts' + +it('highlights struct comments with braces without blocking settled or streaming code', async () => { + // The worker and highlight.ts use erasable syntax and only package-name imports, so Node loads them directly. + const worker = new Worker(new URL('./go-highlighting.worker.ts', import.meta.url)) + let timer: ReturnType | undefined + try { + const result = await new Promise((resolve, reject) => { + const fail = (message: string): void => { reject(new Error(message)) } + timer = setTimeout(() => { fail('Go grammar did not load within 120 seconds') }, 120_000) + worker.on('message', (reply: unknown) => { + if (reply !== 'loaded') { + resolve(reply) + return + } + clearTimeout(timer) + // Bound runaway synchronous matching once imports and grammar loading are done. + timer = setTimeout(() => { fail('Go highlighting did not complete within 10 seconds') }, 10_000) + }) + worker.once('error', reject) + worker.once('exit', (code) => { fail(`Go highlighting worker exited before its reply: ${code}`) }) + }) + expect(result).toMatchObject({ text: GO_SOURCE, streamingMatchesSettled: true }) + expect(result).toHaveProperty('html', expect.stringContaining('var(--shiki-')) + const keyword = (...texts: string[]): string[] => texts.map(text => `${text}:keyword`) + expect(result).toHaveProperty('colored', [ + ['package:keyword', 'example:function'], + [], + ['type:keyword', 'Record:function', 'struct:keyword'], + ['// docs: {id} explains the ordinary field names for an example record ending here:comment'], + ['int:keyword'], + [], + [], + ['type:keyword', 'Inline:function', ...keyword('struct', 'string', 'int')], + ['type:keyword', 'Nested:function', ...keyword('struct', 'map', 'string', 'struct', 'struct', 'int')], + ['type:keyword', 'OneLineFields:function', ...keyword('struct', 'int', 'chan<-', 'struct', 'string'), + 'sync:function', 'Mutex:function'], + ['type:keyword', 'SplitOneLine:function', ...keyword('struct', 'int'), 'B:function', 'string:keyword'], + [], + [...keyword('var', '=', 'struct', 'string'), 'Port:function', 'int:keyword', 'Tags:function', 'string:keyword'], + [], + ['type:keyword', 'Many:function', ...keyword('struct', ...Array(24).fill('int'), 'struct', 'int')], + ['type:keyword', 'Open:function', 'struct:keyword', ...Array(47).fill('field:function')], + ]) + } finally { + clearTimeout(timer) + await worker.terminate() + } +}, 150_000) + +it('patches exactly the vendored struct-field rule and rejects a grammar without it', async () => { + const { default: grammars } = await import('@shikijs/langs/go') + const other = { name: 'go-embedded', scopeName: 'source.go.embedded', patterns: [], repository: {} } + const before = grammars[0]?.repository.struct_variables_types_fields?.patterns + const patched = patchGoGrammar([...grammars, other]) + const after = patched[0]?.repository.struct_variables_types_fields?.patterns + const changed = after?.flatMap((rule, index) => rule === before?.[index] ? [] : [[before?.[index]?.match, rule.match]]) + expect(changed).toEqual([[ + expect.stringContaining('[^"/`\\s]+;?'), + '(?:(?<=\\{)([^/\\`"]*[^\\s/\\`"])\\s*(?=\\}))', + ]]) + expect(patched[1]).toBe(other) + for (const repository of [{}, { struct_variables_types_fields: {} }]) { + expect(() => patchGoGrammar([{ name: 'go', scopeName: 'source.go', patterns: [], repository }])) + .toThrow('expected exactly one Go struct-field rule to patch for worlpaker/go-syntax#25, found 0') + } + expect(() => patchGoGrammar([other])).toThrow('found 0') +}) diff --git a/packages/client/ui-primitives/tests/go-highlighting.worker.ts b/packages/client/ui-primitives/tests/go-highlighting.worker.ts new file mode 100644 index 0000000000..a1b65fe67d --- /dev/null +++ b/packages/client/ui-primitives/tests/go-highlighting.worker.ts @@ -0,0 +1,31 @@ +/** Source-mode highlighter fixture; the parent owns termination during synchronous regex matching. */ +import { parentPort } from 'node:worker_threads' +import { + highlightLines, highlightToHtml, StreamingHighlightSession, subscribeGrammarLoaded, +} from '../src/markdown/highlight.ts' +import { GO_SOURCE } from './go-source.ts' + +const ready = Promise.withResolvers() +const dispose = subscribeGrammarLoaded(() => { + if (highlightLines('package example', 'go') !== undefined) ready.resolve(undefined) +}) +try { + if (highlightLines('package example', 'go') === undefined) await ready.promise +} finally { + dispose() +} +// The parent times matching separately from imports and grammar loading. +parentPort?.postMessage('loaded') +const lines = highlightLines(GO_SOURCE, 'go') +const stream = new StreamingHighlightSession() +const split = GO_SOURCE.indexOf('\t//') +if (split <= 0) throw new Error('GO_SOURCE no longer contains the brace-bearing struct comment') +stream.update(GO_SOURCE.slice(0, split), 'go') +const streamed = stream.update(GO_SOURCE, 'go') +parentPort?.postMessage({ + text: lines?.map(line => line.map(span => span.text).join('')).join('\n'), + colored: lines?.map(line => line.flatMap(span => + span.style.color === 'var(--shiki-foreground)' ? [] : [`${span.text}:${String(span.style.color).replace('var(--shiki-token-', '').replace(')', '')}`])), + streamingMatchesSettled: JSON.stringify(streamed) === JSON.stringify(new StreamingHighlightSession().update(GO_SOURCE, 'go')), + html: highlightToHtml(GO_SOURCE, 'go'), +}) diff --git a/packages/client/ui-primitives/tests/go-source.ts b/packages/client/ui-primitives/tests/go-source.ts new file mode 100644 index 0000000000..62ac1b74ee --- /dev/null +++ b/packages/client/ui-primitives/tests/go-source.ts @@ -0,0 +1,21 @@ +/** Synthetic Go struct comments and declarations for syntax-highlighting regressions. */ +export const GO_SOURCE = [ + 'package example', + '', + 'type Record struct {', + '\t// docs: {id} explains the ordinary field names for an example record ending here', + '\tValue int', + '}', + '', + 'type Inline struct { Name string; Count int }', + 'type Nested struct { Flags map[string]struct{}; Child struct { Value int } }', + // Shapes from worlpaker/go-syntax#25's test/semantic_tokens.go and test/stress.go. + 'type OneLineFields struct{ A, B int; C chan<- struct{}; D []string; sync.Mutex }', + 'type SplitOneLine struct { A int; B string', + '}', + 'var stressFoo14 = struct{ Host string; Port int; Tags []string', + '}{}', + `type Many struct { ${Array.from({ length: 24 }, (_, index) => `F${index} int;`).join(' ')} Child struct { X int } }`, + // Last: its unclosed brace keeps the struct body open to the end of the source. + `type Open struct { ${'field '.repeat(48)}`, +].join('\n') diff --git a/packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx index a4c0536885..1b29020f20 100644 --- a/packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx @@ -2,6 +2,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { MarkdownDelegateProvider } from '../src/index.ts' +import { parseGfm, parseGfmWithMath } from '../src/markdown/parse.ts' import { MarkdownText } from './markdown-test-components.tsx' afterEach(cleanup) @@ -29,6 +30,41 @@ describe('Markdown file links', () => { expect(view.container.querySelector('a')).toBeNull() }) + it.each([ + ['D:\\dsh\\toc-agent\\.deploy\\notes.md', 'D:\\dsh\\toc-agent\\.deploy\\notes.md', undefined], + ['', 'D:\\My Files\\.config\\a_b.md', undefined], + ['D:\\.deploy\\notes.md#L4', 'D:\\.deploy\\notes.md', { line: 4 }], + ['D:/repo\\.github\\ci.yml', 'D:/repo\\.github\\ci.yml', undefined], + ['D:\\R&D\\a b.md', 'D:\\R&D\\a b.md', undefined], + ['D:\\a\\(b\\).md', 'D:\\a\\(b\\).md', undefined], + ['src\\.github\\ci.yml', 'src\\.github\\ci.yml', undefined], + ['.\\.env', '.\\.env', undefined], + ['..\\.gitignore', '..\\.gitignore', undefined], + ['docs\\_draft.md', 'docs\\_draft.md', undefined], + ['docs/\\_draft.md', 'docs/_draft.md', undefined], + ['C:/work/\\_draft.md', 'C:/work/_draft.md', undefined], + ])('keeps backslashes only where %s is a Windows path', (target, path, options) => { + const openFile = vi.fn() + const view = render( + + + , + ) + expect(view.getAllByRole('button').map(button => button.getAttribute('title'))).toEqual([path, path]) + fireEvent.click(view.getByRole('button', { name: 'source' })) + expect(openFile).toHaveBeenCalledExactlyOnceWith(path, options) + }) + + it.each([parseGfm, parseGfmWithMath])('keeps Windows image destinations in %o', (parse) => { + const [paragraph] = parse('![chart](D:\\img\\.cache\\a.png)').children + expect(paragraph).toMatchObject({ children: [{ type: 'image', url: 'D:\\img\\.cache\\a.png' }] }) + }) + + it('reads backslash escapes in URLs', () => { + const view = render() + expect(view.getByRole('link').getAttribute('href')).toBe('https://example.com/a%5Cb_c') + }) + it.each(['/workspace/chart.png', 'C:/work/chart.png'])('keeps %s accessible when inline images are unavailable', (path) => { const openFile = vi.fn() const view = render( diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index ebd3bb8842..e9308929ed 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index c7e8e86283..5617eeafb0 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index 5d149a8d39..ce7a2fd5e2 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": "Host task management page and Session reminder catalog", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-secret-mask/package.json b/packages/client/ui-secret-mask/package.json index 2cb9797e79..ae847e99f2 100644 --- a/packages/client/ui-secret-mask/package.json +++ b/packages/client/ui-secret-mask/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-secret-mask", "description": "Web display masking for recognized credentials in sent user text", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index ace9d2c5d2..7eaf775e80 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-account/package.json b/packages/client/ui-settings-account/package.json index 41b221656b..b8911c8b2a 100644 --- a/packages/client/ui-settings-account/package.json +++ b/packages/client/ui-settings-account/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-account", "description": "Manage DeepSeek login and open Platform billing pages", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-agent-loop/package.json b/packages/client/ui-settings-agent-loop/package.json index 81dd20ded1..46cff84c48 100644 --- a/packages/client/ui-settings-agent-loop/package.json +++ b/packages/client/ui-settings-agent-loop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-agent-loop", "description": "Settings page of the agent loop on the dsh web client's Plugins page: the parallel tool-call cap of the agent-loop namespace", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 944ce57932..ddea103e9f 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index b1967ec8e7..2350ad7182 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index df46aef757..6b92e4095a 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 023e38a5c2..2d9ec1b082 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": "Built-in plugins settings section for the dsh web client: the Settings navigation entry and the tab chrome feature-owned tabs register into", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-session-log/package.json b/packages/client/ui-settings-session-log/package.json index f4ed749062..c268254b01 100644 --- a/packages/client/ui-settings-session-log/package.json +++ b/packages/client/ui-settings-session-log/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-session-log", "description": "General settings control for Session-log upload with DeepSeek API requests", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-shell/package.json b/packages/client/ui-settings-shell/package.json index 1ff33eb641..0f13b113e1 100644 --- a/packages/client/ui-settings-shell/package.json +++ b/packages/client/ui-settings-shell/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-shell", "description": "Settings page of the shell executor on the dsh web client's Plugins page: the command timeout and the per-stream output cap of the shell namespace", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-subagent/package.json b/packages/client/ui-settings-subagent/package.json index 30b6fe6fcb..0242eb0d5a 100644 --- a/packages/client/ui-settings-subagent/package.json +++ b/packages/client/ui-settings-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-subagent", "description": "Settings page of Subagent delegation on the dsh web client's Plugins page: recursion depth, parallel capacity, and the models agents may choose for subagents", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-web-search/package.json b/packages/client/ui-settings-web-search/package.json index 1d1b50fa4d..d0914d7b31 100644 --- a/packages/client/ui-settings-web-search/package.json +++ b/packages/client/ui-settings-web-search/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-web-search", "description": "Settings page of the DeepSeek web-search provider on the dsh web client's Plugins page: its API key, endpoint, and per-request search budget", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 44193c54ff..a8ac4e05bc 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: shared configuration forms and the canonical settings slot-type contract", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-shortcuts/package.json b/packages/client/ui-shortcuts/package.json index 04cb270923..b6fa63255a 100644 --- a/packages/client/ui-shortcuts/package.json +++ b/packages/client/ui-shortcuts/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-shortcuts", "description": "Keyboard shortcut reference, recording, and local preference editing", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar-browser/package.json b/packages/client/ui-sidebar-browser/package.json index cf21af3052..90d9b9719b 100644 --- a/packages/client/ui-sidebar-browser/package.json +++ b/packages/client/ui-sidebar-browser/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar-browser", "description": "Sandboxed Web browser tabs for the right Sidebar", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar-documentpreview/package.json b/packages/client/ui-sidebar-documentpreview/package.json index b52bfe1c21..cc791ac9cb 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 Sidebar previews for Office documents, spreadsheets, Markdown, code, images, PDF, HTML, and plain text", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar-files/package.json b/packages/client/ui-sidebar-files/package.json index d093121243..f3cc2f36f8 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar-right/package.json b/packages/client/ui-sidebar-right/package.json index 286461dec0..30943ba233 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar-terminal/package.json b/packages/client/ui-sidebar-terminal/package.json index 2b44e6e162..1b666b52fa 100644 --- a/packages/client/ui-sidebar-terminal/package.json +++ b/packages/client/ui-sidebar-terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar-terminal", "description": "Interactive shell tabs for the right Sidebar", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 9277d4aa87..527adba101 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 1f7fe22db5..afdc304eb1 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 0765810448..ee1df6a92d 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: typed ordinary Slots and reusable Component Factories, derived props, Store seats, and renderer installation", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 80e533e46c..5a51deb500 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index e7872502b8..84e156e575 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; ThemeRuntime for light/dark/system state and icon replacements; --dsw-* token styles and Appearance settings row", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 86d8299c11..1a7e784eda 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -18,8 +18,8 @@ en: c6d4e666191521b2 zh: 2498020e7080e172 /deepseek-ai-dsh-client-ui-tool/use-this-package/registering-a-business-tool-view: - en: 2e3c306b13d3bc25 - zh: 77127f01763b3976 + en: e458aff61e634190 + zh: 650e417f450b3edf /deepseek-ai-dsh-client-ui-tool/use-this-package/built-in-views: en: 3643ffbb8cf80e4c zh: b49654dc3b94b2be diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 32d394cdaf..48ed13c53b 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -43,7 +43,7 @@ ctx.slots.inject('tool.call.toolview', () => }, BusinessToolRow)) ``` -The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the `phase` discriminant and its stage-specific `block`, optional `cwd` and `home`, the session-authorized `loadImage` loader (for a view whose result carries durable images), and plain `openFile`/`inspect` callbacks. A PTC dispatch block retains its event's `parentCallId`; a root Session call has no such field, so descendants route through the same keyed dispatch — a registered view such as `read_image` renders its card there, and unregistered descendants keep the generic flattened form. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open prefer the operation-time absolute result path while displayed labels keep the authored argument. Successful older root results retain their argument fallback; pending relative paths wait for a result. The registration receives the normal Session slot runtime share but no React node or Runtime service. +The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the `phase` discriminant and its stage-specific `block`, optional `cwd` and `home`, the Gateway-authorized `loadImage` loader (for a view whose result carries durable images), and plain `openFile`/`inspect` callbacks. A PTC dispatch block retains its event's `parentCallId`; a root Session call has no such field, so descendants route through the same keyed dispatch — a registered view such as `read_image` renders its card there, and unregistered descendants keep the generic flattened form. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open prefer the operation-time absolute result path while displayed labels keep the authored argument. Successful older root results retain their argument fallback; pending relative paths wait for a result. The registration receives the normal Session slot runtime share but no React node or Runtime service. ### Built-in views diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 7f3d0ef79d..8ac4a366c2 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -43,7 +43,7 @@ ctx.slots.inject('tool.call.toolview', () => }, BusinessToolRow)) ``` -owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字段及对应阶段的 `block`、可选 `cwd` 与 `home`、会话授权的 `loadImage` loader(供结果携带持久图像的视图使用),以及普通的 `openFile`/`inspect` 回调。PTC dispatch 块保留事件的 `parentCallId`;根会话调用没有该字段,因此后代调用都走同一条按 key 分发路径:已注册视图的调用(如 `read_image`)也会在嵌套处渲染对应卡片,未注册的后代调用则保持通用压平形式。路径摘要先相对会话 cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开优先使用操作发生时记录的绝对结果路径,显示标签仍保留原始参数。成功的旧根调用结果继续回退到原参数;运行中的相对路径等待结果。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React 节点或运行时服务。 +owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字段及对应阶段的 `block`、可选 `cwd` 与 `home`、经 Gateway 授权的 `loadImage` loader(供结果携带持久图像的视图使用),以及普通的 `openFile`/`inspect` 回调。PTC dispatch 块保留事件的 `parentCallId`;根会话调用没有该字段,因此后代调用都走同一条按 key 分发路径:已注册视图的调用(如 `read_image`)也会在嵌套处渲染对应卡片,未注册的后代调用则保持通用压平形式。路径摘要先相对会话 cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开优先使用操作发生时记录的绝对结果路径,显示标签仍保留原始参数。成功的旧根调用结果继续回退到原参数;运行中的相对路径等待结果。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React 节点或运行时服务。 ### 内置视图 diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index a836628ab0..22799ab39d 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index 6ad2204aaa..9b16052b89 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -32,7 +32,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * Durable images of a settled image-bearing Tool call, rendered through * the attachment presentation plugin. The Tool layer never imports an * attachment implementation: a toolview declares this slot as a child and - * renders it with the image card's references plus the session-authorized + * renders it with the image card's references plus the Gateway-authorized * loader it received in its owner, and the attachment plugin fills the * gallery. Composing no attachment presentation plugin renders nothing, * which is why the image card keeps its own envelope text beside the @@ -49,7 +49,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { export interface ToolImagesOwnerProps { /** Durable references or submission-echo previews in result order. */ images: readonly MessageImageSource[] - /** Session-authorized image URL loader for the durable arm. */ + /** Gateway-authorized image URL loader for the durable arm. */ loadImage: MessageImageLoader /** Horizontal placement inside the owning record. */ align: 'start' | 'end' @@ -69,7 +69,7 @@ export interface ToolCallCommonProps { home?: string | undefined /** Open an argument path at its optional requested line. */ openFile: (path: string, options?: OpenFileOptions) => void - /** Chat-supplied, session-authorized loader for durable images; Tool views do not manage attachment URLs. */ + /** Chat-supplied, Gateway-authorized loader for durable images; Tool views do not manage attachment URLs. */ loadImage: MessageImageLoader /** Inspect this call in the trajectory view when available. */ inspect?: (() => void) | undefined diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 694ce16446..fb1a92af5f 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -71,10 +71,10 @@ export interface ToolRowProps { /** * Dispatch the image gallery through the tool-owned `tool.call.images` * slot, supplied by the toolview that owns this row together with the - * session-authorized loader. + * Gateway-authorized loader. */ renderSlot?: PropsRenderSlots<'tool.call.images'>['renderSlot'] | undefined - /** Session-authorized image URL loader for the gallery slot. */ + /** Gateway-authorized image URL loader for the gallery slot. */ loadImage?: MessageImageLoader | undefined search?: SearchCardModel | null | undefined web?: WebCardModelProps | null | undefined diff --git a/packages/client/ui-tool/src/client/tool/models/image-card-model.ts b/packages/client/ui-tool/src/client/tool/models/image-card-model.ts index b2d0f36073..f4bd7e660b 100644 --- a/packages/client/ui-tool/src/client/tool/models/image-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/image-card-model.ts @@ -8,7 +8,7 @@ import { parsedToolCall } from './raw-tool-call.ts' * durable references the attachment slot renders as a gallery. * * The bytes are not here. `attachmentId` is opaque and provider-owned, so a UI - * resolves it to a session-authorized URL at render time; this model never parses + * resolves it to a Gateway-authorized URL at render time; this model never parses * it nor derives a path from it. */ export interface ImageCardModel { diff --git a/packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx index b2cd520281..14a41fb714 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx @@ -4,7 +4,7 @@ // the Tool-owned `tool.call.images` slot inside the collapsed-by-default expanded // body — the same unified interaction every other card row has. The attachment // presentation plugin fills that slot; the tool layer only ever supplies the -// references and the session-authorized loader it received from the chat node. +// references and the Gateway-authorized loader it received from the chat node. // // Claiming the `read_image` key suppresses the generic fallback for EVERY // read_image result, so this component must cover all of the tool's shapes, not diff --git a/packages/client/ui-tool/tests/image-card.client.spec.tsx b/packages/client/ui-tool/tests/image-card.client.spec.tsx index 621e4cbcad..5343cfed91 100644 --- a/packages/client/ui-tool/tests/image-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/image-card.client.spec.tsx @@ -7,7 +7,7 @@ // text that stays readable when the attachment slot renders nothing. // // The image card differs from every other card in one load-bearing way: its bytes -// are a session-authorized attachment, so the row cannot draw them itself. It +// are a Gateway-authorized attachment, so the row cannot draw them itself. It // renders through the tool-owned `tool.call.images` slot, and an empty slot must // still leave the path and image size visible. @@ -107,7 +107,7 @@ const stubRenderSlot = (): PropsRenderSlots<'tool.call.images'>['renderSlot'] => )) as unknown as PropsRenderSlots<'tool.call.images'>['renderSlot'] ) -/** Session-authorized loader stand-in; the stub gallery never resolves it. */ +/** Authenticated loader stand-in; the stub gallery never resolves it. */ const loadImage: MessageImageLoader = vi.fn(() => Promise.reject(new Error('not used'))) describe('imageCardModel', () => { diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index cbf1118f14..f994ebc8c4 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 977b1544e2..c1d0889dca 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index c658b4cd99..884ebef889 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 2db12fdf09..5f161360f4 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -18,8 +18,8 @@ en: 650a0b951c92626e zh: 8b059c0678656a79 /deepseek-ai-dsh-client-ui-workspace/use-this-package/reordering-and-view-options: - en: 8673afc1d72ad077 - zh: 9fbb83a4029c5d10 + en: 55054408b8593c70 + zh: 9fdf9861a48abba3 /deepseek-ai-dsh-client-ui-workspace/use-this-package/workspace-hierarchy: en: a94394a5a9f70ed8 zh: 62ce596a95395ad8 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 5048b50c93..eea0b6fcee 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -35,7 +35,7 @@ History rows without a stored title use the localized unnamed label (未命名 / Pinned Sessions lead ordinary Sessions in both grouped and flat views. **Last updated** sorts each partition strictly by the latest user prompt or steer time, newest first; pin time does not affect it. **Manual** uses the relative positions in one complete Session sequence, including hidden archives. Returning to Last updated discards the manual layout, and entering Manual again freezes the then-current chronological order. The browser defaults to Last updated and remembers the selected mode across reloads. -Pinning moves the Session to the front of its complete saved sequence without changing the selected mode. It leads the pinned partition in Manual but need not lead it in Last updated. Unpinning leaves the saved position unchanged. Dragging either a pinned or ordinary row edits that same complete sequence and selects Manual; hidden archived members are retained. Missing pinned members prepend in pin-array order. New ordinary forks precede their sources in the complete sequence without inheriting pin membership; visible pins still lead ordinary rows. Other missing members append by recency with missing archives last. Supplementation stays in memory until a Session-order write records the complete result. Archive filtering alone changes neither saved positions nor account membership. +Pinning moves the Session to the front of its complete saved sequence without changing the selected mode. It leads the pinned partition in Manual but need not lead it in Last updated. Unpinning leaves the saved position unchanged. Dragging either a pinned or ordinary row edits that same complete sequence and selects Manual; hidden archived members are retained. Missing pinned members prepend in pin-array order. New ordinary forks follow the same supplementation rule as other Sessions and do not inherit pin membership. Missing ordinary members append by recency with missing archives last; visible pins still lead ordinary rows. Supplementation stays in memory until a Session-order write records the complete result. Archive filtering alone changes neither saved positions nor account membership. The selected blank **New Session** retains its provisional first slot and cannot be dragged; after its first prompt it becomes an ordinary draggable row, retaining that position in Manual or following its current timestamp in Last updated. A collapsed-group drag uses the target Session identity and keeps the source visible. Session display orders for real Workspaces, Ungrouped, and the flat list are browser-local; Workspace group drag order remains Host-durable. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index e28bcfda6e..a0f91b30a0 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference" 分组和平铺视图都先显示置顶 Session,再显示普通 Session。**最近更新**在各分区内严格按最近一次用户提示词或 steering(中途引导)时间降序排列,置顶时间不影响它。**手动排序**使用同一条完整 Session 序列中的相对位置,包括隐藏的归档项。返回最近更新会丢弃手动布局,再次进入手动排序时冻结当时的时间顺序。浏览器默认采用最近更新,并在重新加载后记住所选模式。 -置顶把 Session 移到完整保存序列的首位,但不切换所选模式。它在手动模式下排在置顶区最前,在最近更新模式下则不一定。取消置顶不改变保存的位置。拖拽置顶行或普通行都修改同一条完整序列并选择手动模式,隐藏的归档成员始终保留。缺失的置顶成员按置顶数组顺序补到头部。新增的普通 fork 在完整序列中插在来源之前,不继承置顶成员关系;可见置顶行仍排在普通行之前。其他缺失成员按最近更新时间追加,缺失归档项放在最后。补齐仅在内存中完成,直到一次 Session 顺序写入保存完整结果。单纯切换归档筛选既不改变保存的位置,也不改变成员关系。 +置顶把 Session 移到完整保存序列的首位,但不切换所选模式。它在手动模式下排在置顶区最前,在最近更新模式下则不一定。取消置顶不改变保存的位置。拖拽置顶行或普通行都修改同一条完整序列并选择手动模式,隐藏的归档成员始终保留。缺失的置顶成员按置顶数组顺序补到头部。新增的普通 fork 使用与其他 Session 相同的补齐规则,不继承置顶成员关系。缺失的普通成员按最近更新时间追加,缺失归档项放在最后;可见置顶行仍排在普通行之前。补齐仅在内存中完成,直到一次 Session 顺序写入保存完整结果。单纯切换归档筛选既不改变保存的位置,也不改变成员关系。 当前选中的空白**新会话**保留临时首位且无法拖拽;首条提示词落地后,它成为可拖拽的普通行,在手动排序中保留该位置,在最近更新中按当前时间戳排列。折叠分组的拖拽使用目标 Session 身份,并保持来源行可见。真实 Workspace、Ungrouped 与单列表的 Session 显示顺序都保留在浏览器本地;Workspace 分组的拖拽顺序仍由 Host 持久化。 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index f247a5e01d..3d8d5b77c0 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 765ece2d4b..054c584d4b 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -160,12 +160,12 @@ export function orderByRecency( /** * Reconcile a browser-local manual order with current account membership. - * New ordinary forks precede their sources without changing saved entries' relative order. + * Saved entries retain their relative order; new ordinary members follow them by recency. * @param memberIds - authoritative account membership. * @param savedOrder - previously saved browser-local order. * @param summaries - current Session metadata; unknown new members wait for their summaries. * @param rowState - global pin and archive membership; only account members can supplement the order. - * @returns saved relative positions plus missing members ordered by pin, fork source, recency, and archive status. + * @returns saved relative positions plus missing members ordered by pin, recency, and archive status. */ export function reconcileManualOrder( memberIds: readonly SessionId[], @@ -196,18 +196,7 @@ export function reconcileManualOrder( if (archived.has(id)) archives.push(id) else ordinary.push(id) } - const result = [...pins, ...ordered, ...ordinary, ...archives] - const pending = new Set(ordinary) - const placeFork = (id: SessionId): void => { - if (!pending.delete(id)) return - const parentId = summaries[id]?.parentId - if (parentId === undefined || parentId === id || !result.includes(parentId)) return - placeFork(parentId) - result.splice(result.indexOf(id), 1) - result.splice(result.indexOf(parentId), 0, id) - } - for (const id of [...ordinary].reverse()) placeFork(id) - return result + return [...pins, ...ordered, ...ordinary, ...archives] } /** diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index 6ae374bd75..b754980ca5 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -184,26 +184,26 @@ describe('Session ordering', () => { expect(saved).toEqual(['saved-archive', 'saved-plain', 'saved-pin']) }) - it('inserts new forks before their saved source while preserving saved positions', () => { + it('appends new forks by recency while preserving saved positions', () => { const sessions = list( summary('source', 1), summary('other', 2), { ...summary('older-fork', 3), parentId: sid('source') }, { ...summary('newer-fork', 4), parentId: sid('source') }, ) const order = reconcileManualOrder(sessions.ids, ['other', 'source'], sessions.byId) - expect(order).toEqual(['other', 'older-fork', 'newer-fork', 'source']) + expect(order).toEqual(['other', 'source', 'newer-fork', 'older-fork']) expect(reconcileManualOrder(sessions.ids, ['source', 'other', 'older-fork', 'newer-fork'], sessions.byId)) .toEqual(['source', 'other', 'older-fork', 'newer-fork']) }) - it('places nested new forks before their parents even when catalog recency differs', () => { + it('orders unsaved nested forks by recency regardless of historical sources', () => { const sessions = list( summary('source', 1), summary('other', 2), { ...summary('parent', 4), parentId: sid('source') }, { ...summary('child', 3), parentId: sid('parent') }, ) expect(reconcileManualOrder(sessions.ids, ['source', 'other'], sessions.byId)) - .toEqual(['child', 'parent', 'source', 'other']) + .toEqual(['source', 'other', 'parent', 'child']) }) it('appends forks with absent or self-referencing parents without duplicating rows', () => { @@ -229,7 +229,7 @@ describe('Session ordering', () => { sessions, mode === 'workspace' ? [workspace('alpha', order)] : [], state, noAttention, view([mode === 'workspace' ? 'alpha' : UNGROUPED_KEY], order), )[0]!.sessions - expect(rows.map(row => row.id)).toEqual(['source', 'other-pin', 'fork', 'ordinary']) + expect(rows.map(row => row.id)).toEqual(['source', 'other-pin', 'ordinary', 'fork']) }) it('keeps archive filtering out of complete flat membership', () => { diff --git a/packages/client/web/package.json b/packages/client/web/package.json index ce46e10f05..9f6c4da199 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/command-compact/package.json b/packages/compaction/command-compact/package.json index 9d47deb597..7c55297ae5 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index 88b07cd3e9..c7c82c86f2 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-image-offload/package.json b/packages/compaction/compaction-image-offload/package.json index 093579fb80..c1edf06d27 100644 --- a/packages/compaction/compaction-image-offload/package.json +++ b/packages/compaction/compaction-image-offload/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-image-offload", "description": "Durable image offload for image-capable routes: replace over-budget request images with placeholders and retry", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json index bc60e3b78e..6753069fbb 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction/package.json b/packages/compaction/compaction/package.json index f1c71e02a0..7bce02cdce 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/computer-use/computer-use/package.json b/packages/computer-use/computer-use/package.json index eb8ed07713..08da32b6f8 100644 --- a/packages/computer-use/computer-use/package.json +++ b/packages/computer-use/computer-use/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-computer-use", "description": "Exclusive named computer-use provider registration", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 26cc233da0..6618a57805 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json index 5d10f64970..2edb8d503c 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index 4e526edbf2..0167529ba3 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/harness-dev-context/package.json b/packages/context/harness-dev-context/package.json index 0b960e82c2..e27375b4b2 100644 --- a/packages/context/harness-dev-context/package.json +++ b/packages/context/harness-dev-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-harness-dev-context", "description": "Model context naming the DeepSeek Harness checkout and Web GUI URL without letting either change the system prompt", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 8cefe88f74..52c59c4114 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/time-context/README.i18n.yaml b/packages/context/time-context/README.i18n.yaml index 00bb63502d..1f6517a961 100644 --- a/packages/context/time-context/README.i18n.yaml +++ b/packages/context/time-context/README.i18n.yaml @@ -36,8 +36,8 @@ en: 269d7aa1f64c4278 zh: 086018a60cc3a351 /deepseek-ai-dsh-time-context/understand-the-implementation/main-flow: - en: f09994721526e830 - zh: 6f1a463355111513 + en: 9ec32c75627991bb + zh: 7ee417dda0aa90a4 /deepseek-ai-dsh-time-context/further-exploration: en: e1cd55f40edbb7ed zh: 8a2abf22ef859841 @@ -63,8 +63,8 @@ en: 2fbf2175168e8244 zh: f681f89596f500f5 /deepseek-ai-dsh-time-context/known-limitations-and-deferred-work: - en: 8fcf131f1f00626b - zh: a99b928aca0b1fab + en: 825ebd4015f57a5c + zh: 08defcdd0fa17b81 /deepseek-ai-dsh-time-context/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index cc5e40fda8..225d8e05b5 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -78,7 +78,7 @@ The plugin prepends an `agent/pre-step` listener that delegates first and append The pre-step listener is installed before awaiting projection registration. Steps arriving during activation wait for the reader; registration failure or owner disposal stops the request, and cancellation while waiting produces no reading. -When an injection is due, the plugin samples the wall clock, derives the browser-zone policy from the open turn's `user-rpc` messages, resolves the display zone (request-local or fallback), and renders the three-line reading. Positive-interval scheduling scans raw durable session events for the latest plugin-attributed message — including one shadowed by compaction — so the schedule survives resume without a process-local cache. A reading records an entered step, not a completed or transmitted request; a later preparation failure can leave it in history. +When an injection is due, the plugin samples the wall clock, derives the browser-zone policy, resolves the display zone (request-local or fallback), and renders the three-line reading. The private `timeContext` Projection stores message and injection times, the latest entered turn, and its distinct reported browser zones in encounter order. It retains zone facts from messages shadowed by compaction. Zone validation stays after rejection and refresh checks; replay does not invoke formatters or hooks. JSON checkpoints plus suffix replay match a full replay. Older-version cache rows rebuild from the full journal. A reading records an entered step, not a completed or transmitted request; a later preparation failure can leave it in history.
@@ -135,6 +135,7 @@ Append-only; newly visible content follows the reusable request prefix and does These limits define when clock context is a poor fit. They are current package constraints. +- **State and replay cost** — timestamps and the current turn number have fixed size. Zone state grows with distinct reported strings in the latest entered turn, not all messages; each new distinct zone copies that list. Formatters remain discardable process caches. The standard AgentLoop requests its latest entered turn. If a typed pre-step caller requests another turn, the original full-prefix synchronous scan includes later entered messages. Retiring this fallback requires limiting pre-step requests to the latest entered turn and updating callers together. - **Prompt guidance only** — browser-zone context guides natural-language interpretation but does not silently supply another tool's required zone field. - **Mixed turns ask** — if one open turn contains prompts from different browser zones, the model is told to clarify rather than guess which one owns an unqualified time. - **Fallback is not user authority** — the configured or process zone formats the clock when current-turn browser zones are missing or mixed, but the model-facing policy still says to clarify. diff --git a/packages/context/time-context/README.zh.md b/packages/context/time-context/README.zh.md index f392be5216..09afe4cd03 100644 --- a/packages/context/time-context/README.zh.md +++ b/packages/context/time-context/README.zh.md @@ -78,7 +78,7 @@ kind: "package-reference" pre-step 监听器在等待投影注册前安装。激活期间到达的步骤会等待读取句柄;注册失败或所属插件释放会终止请求,等待期间取消不会产生读数。 -需要注入时,插件采样挂钟时间,从开放轮次的 `user-rpc` 消息派生浏览器时区策略,解析显示时区(请求本地或回退),并渲染三行读数。正数间隔调度会扫描原始持久会话事件,查找最新一条归因于插件的消息——包括被压缩(compaction)遮蔽的读数——因此调度无需进程本地缓存也能在恢复后存续。读数记录的是已进入的步骤,不是已完成或已传输的请求;后续准备失败时,该读数可能留在历史中。 +需要注入时,插件采样挂钟时间,确定浏览器时区策略,解析显示时区(请求本地或回退),并渲染三行读数。私有 `timeContext` Projection 保存消息及注入时间、最近进入的轮次,以及按出现顺序去重的浏览器时区值,也保留被压缩遮蔽消息的时区事实。时区校验仍在拒绝和刷新间隔检查之后执行;重放不调用格式化器或 hook。JSON 检查点加后缀重放与完整重放一致;旧版本缓存行通过完整日志重建。读数记录的是已进入的步骤,不是已完成或已传输的请求;后续准备失败时,该读数可能留在历史中。
@@ -135,6 +135,7 @@ Elapsed since the preceding step context: . 这些限制说明时钟上下文何时不合适。它们是当前包约束。 +- **状态与重放成本**:时间戳和当前轮次编号占固定空间。时区状态随最近进入轮次中不同的时区字符串数量增长,不随所有消息增长;每次增加新时区时复制该列表。格式化器仍是可丢弃的进程缓存。标准 AgentLoop 请求最近进入的轮次。若类型化 pre-step 调用方请求其他轮次,原同步完整前缀扫描会包含目标轮次之后已进入的消息。只有将 pre-step 请求明确限定为最近进入的轮次并同步更新调用方后,才可移除此回退。 - **仅限提示词来源信息**:浏览器时区上下文用于指导自然语言解释,但不会悄然填入另一工具所要求的时区字段。 - **混合轮次会询问**:如果同一个开放轮次包含来自不同浏览器时区的提示词,模型会收到要求澄清的指令,而不会猜测哪个时区拥有未限定的时间。 - **回退值不代表用户权威**:浏览器来源信息缺失或混杂时,配置或进程时区用于格式化时钟,但面向模型的策略仍要求澄清。 diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index b56d424e66..e3164339ca 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": "Durable per-step context with the current time and elapsed time", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index c1d959ecd2..160beb9e8a 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -23,6 +23,7 @@ import { Duration } from '@deepseek-ai/dsh-util-time' import type {} from '@deepseek-ai/dsh-session-projection' import { deriveBrowserTimeZoneContext, + reportedBrowserTimeZone, renderBrowserTimeZoneContext, } from './request-zone.ts' import type { BrowserTimeZoneContext } from './request-zone.ts' @@ -38,6 +39,10 @@ const timeContextStateSchema = zod.object({ lastInjectionTime: zod.number().nullable(), /** Latest injection time in the open turn, or null before that turn receives one. */ lastTurnInjectionTime: zod.number().nullable(), + /** Latest entered turn; null before any turn/start record. */ + browserTurn: zod.number().nullable(), + /** Distinct reported zones in encounter order; validation remains request-time. */ + browserTimeZones: zod.array(zod.string()), }) /** The agent registry that owns pre-step processing. */ @@ -76,7 +81,7 @@ function formatDuration(elapsedMs: number): string { return parts.join(' ') } -/** Collect already-entered and proposed user messages belonging to one open turn. */ +/** Preserve the original scan for typed pre-step callers requesting a nonlatest turn. */ function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] { const entered: UserMessage[] = [] for (let seq = agent.session.seq - 1; seq >= 0; seq -= 1) { @@ -152,18 +157,31 @@ export async function apply(ctx: Context, config: Config): Promise { const projectionReady = ctx.sessionProjections.register({ key: 'timeContext', - stateVersion: 2, + stateVersion: 3, stateSchema: timeContextStateSchema, - init: () => ({ lastMessageTime: null, lastInjectionTime: null, lastTurnInjectionTime: null }), + init: () => ({ + lastMessageTime: null, + lastInjectionTime: null, + lastTurnInjectionTime: null, + browserTurn: null, + browserTimeZones: [], + }), apply: (state, event) => { - if (event.type === 'turn/start' || event.type === 'turn/end') { + if (event.type === 'turn/start') { + return { ...state, lastTurnInjectionTime: null, browserTurn: event.data.turn, browserTimeZones: [] } + } + if (event.type === 'turn/end') { return state.lastTurnInjectionTime === null ? state : { ...state, lastTurnInjectionTime: null } } if (event.type === 'user/message') { const injected = event.data.source.kind === name - const withMessage = state.lastMessageTime === event.time + let withMessage = state.lastMessageTime === event.time ? state : { ...state, lastMessageTime: event.time } + const timeZone = reportedBrowserTimeZone(event.data) + if (state.browserTurn !== null && timeZone !== undefined && !state.browserTimeZones.includes(timeZone)) { + withMessage = { ...withMessage, browserTimeZones: [...state.browserTimeZones, timeZone] } + } if (!injected) return withMessage return { ...withMessage, @@ -194,12 +212,13 @@ export async function apply(ctx: Context, config: Config): Promise { && now >= lastInjection && now - lastInjection < refreshIntervalMs) return decision } - /* v8 ignore next 6 -- every later step follows a recorded injection in the same turn */ + /* v8 ignore next 3 -- every later step follows a recorded injection in the same turn */ const previous = step === 1 ? state.lastMessageTime ?? undefined : state.lastTurnInjectionTime ?? undefined - const messages = requestMessages(agent, turn, decision.messages) - const browser = deriveBrowserTimeZoneContext(messages) + const browser = state.browserTurn === turn + ? deriveBrowserTimeZoneContext(decision.messages, state.browserTimeZones) + : deriveBrowserTimeZoneContext(requestMessages(agent, turn, decision.messages)) const selectedTimeZone = browser.kind === 'resolved' ? browser.timeZone : fallbackTimeZone const text = renderText( now, diff --git a/packages/context/time-context/src/request-zone.ts b/packages/context/time-context/src/request-zone.ts index 800cd04124..b59072b7d5 100644 --- a/packages/context/time-context/src/request-zone.ts +++ b/packages/context/time-context/src/request-zone.ts @@ -1,32 +1,33 @@ -/** Browser-zone derivation and model-facing policy text for one open request turn. */ +/** Browser-zone facts from committed or proposed user-rpc messages and model-facing policy text. */ import { canonicalizeTimeZone } from '@deepseek-ai/dsh-util-time' import type { UserMessage } from '@deepseek-ai/dsh-llm' import { assertNever } from '@deepseek-ai/dsh-util-values' -/** Browser-zone facts derived from user-rpc messages in one open turn. */ +/** Browser-zone facts for the messages selected while preparing a request. */ export type BrowserTimeZoneContext = | { readonly kind: 'resolved'; readonly timeZone: string } | { readonly kind: 'mixed'; readonly timeZones: readonly string[] } | { readonly kind: 'missing' } /** - * Read and validate a Host-canonicalized browser zone from one ordinary user-rpc message. - * - * A request may only name a zone another process resolves back to the same - * identity, so the shared vocabulary's canonical answer must equal the reported - * value; an alias is refused here rather than silently rewritten. + * Read the raw zone reported by a user-rpc source; validation belongs to request preparation. + * @param message - user message; only user sources with a string rpcId and clientTimeZone contribute. + * @returns the reported zone, or undefined when no user-rpc zone was reported. */ -function browserTimeZone(message: UserMessage): string | undefined { +export function reportedBrowserTimeZone(message: UserMessage): string | undefined { const source = message.source - const value = source.kind === 'user' + return source.kind === 'user' && 'rpcId' in source && typeof source.rpcId === 'string' && 'clientTimeZone' in source && typeof source.clientTimeZone === 'string' ? source.clientTimeZone : undefined - if (value === undefined) return undefined +} + +/** Validate a reported zone at request preparation, preserving the platform refusal. */ +function browserTimeZone(value: string): string { const resolved = canonicalizeTimeZone(value) if (resolved.kind === 'accepted') { if (resolved.timeZone !== value) { @@ -51,18 +52,21 @@ function browserTimeZone(message: UserMessage): string | undefined { } /** - * Derive the unique, mixed, or missing browser zone for one open turn. - * @param messages - Entered and proposed user messages belonging to the turn. + * Derive the unique, mixed, or missing browser zone for the selected request messages. + * @param messages - proposed messages, or entered plus proposed messages selected by the legacy reader. + * @param enteredTimeZones - reported zones from committed messages, before the proposed messages. * @returns Sorted, duplicate-free browser-zone facts. * @throws TypeError when a user-rpc source carries an invalid or noncanonical zone. */ export function deriveBrowserTimeZoneContext( messages: readonly UserMessage[], + enteredTimeZones: readonly string[] = [], ): BrowserTimeZoneContext { - const timeZones = [...new Set(messages.flatMap((message) => { - const timeZone = browserTimeZone(message) + const reported = messages.flatMap((message) => { + const timeZone = reportedBrowserTimeZone(message) return timeZone === undefined ? [] : [timeZone] - }))].sort() + }) + const timeZones = [...new Set([...enteredTimeZones, ...reported].map(browserTimeZone))].sort() const [timeZone, ...remaining] = timeZones if (timeZone === undefined) return { kind: 'missing' } if (remaining.length === 0) return { kind: 'resolved', timeZone } @@ -71,7 +75,7 @@ export function deriveBrowserTimeZoneContext( /** * Render the model instruction for one browser-zone context. - * @param context - Browser-zone facts for the open turn. + * @param context - browser-zone facts selected for this request. * @returns One durable policy line. */ export function renderBrowserTimeZoneContext(context: BrowserTimeZoneContext): string { diff --git a/packages/context/time-context/tests/request-zone.spec.ts b/packages/context/time-context/tests/request-zone.spec.ts index 74eb828337..2ef4ecd919 100644 --- a/packages/context/time-context/tests/request-zone.spec.ts +++ b/packages/context/time-context/tests/request-zone.spec.ts @@ -53,6 +53,13 @@ describe('browser request-zone context', () => { ])).toThrow(/browser time zone must be canonical/) }) + it('combines committed reported zones with proposed messages before validation', () => { + expect(deriveBrowserTimeZoneContext([browserMessage('America/New_York')], ['Asia/Shanghai'])) + .toEqual({ kind: 'mixed', timeZones: ['America/New_York', 'Asia/Shanghai'] }) + expect(() => deriveBrowserTimeZoneContext([browserMessage('Not/A_Real_Zone')], ['+08:00'])) + .toThrow('browser time zone must be canonical UTC or IANA Area/Location: "+08:00"') + }) + it('keeps the exact refusal and the platform cause for every unusable zone', () => { const refusal = (timeZone: string): Error => { try { diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index ec0595d197..bd4883a13e 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -1,10 +1,10 @@ -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 { createUserMessage, ToolCallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' -import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import { Session, SessionId, SessionLogOffset, type SessionEvent } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -422,6 +422,115 @@ describe('configuration and lifecycle', () => { }) describe('time-context projection fold edges', () => { + it('restores browser zones from a JSON checkpoint and suffix without reading the prefix', async () => { + const { ctx } = await mount({ refreshIntervalMs: 0 }) + onTestFinished(() => ctx.fiber.dispose()) + const session = Session.create(SessionId('browser-checkpoint')) + openMessageTurn(session, 1, 'Asia/Shanghai') + const cut = session.seq + const rows = structuredClone(ctx.sessionProjections.checkpoint(session)) + session.append('user/message', createUserMessage({ + content: [], + source: { kind: 'user', rpcId: 'steer', clientTimeZone: 'America/New_York' } as never, + }), { surfaceOp: 'append' }) + const events = session.snapshotEvents() + const suffix = session.snapshotEvents(cut) + const jsonRows = JSON.parse(JSON.stringify(rows)) as typeof rows + const restored = ctx.sessionProjections.restore(jsonRows, suffix, cut, session.header, session.inheritedEventCount) + expect(restored.checkpoint.timeContext).toEqual(ctx.sessionProjections.checkpoint(session).timeContext) + + const cold = Session.create(SessionId('browser-cold'), events) + const lookup = vi.spyOn(cold, 'eventAt') + const snapshot = vi.spyOn(cold, 'snapshotEvents') + const own = vi.spyOn(cold, 'ownEvents') + ctx.sessionProjections.hydrate(cold, jsonRows, suffix, cut) + await fire(ctx, sessionAgent(cold), 1, 1) + // The supplied suffix is already hydrated; later lifecycle records may still be read. + expect(lookup.mock.calls.every(([seq]) => seq >= events.length)).toBe(true) + expect(snapshot).not.toHaveBeenCalled() + expect(own).not.toHaveBeenCalled() + lookup.mockRestore() + snapshot.mockRestore() + own.mockRestore() + expect(contextTexts(cold).at(-1)).toContain('mixed ["America/New_York","Asia/Shanghai"]') + }) + + it('rebuilds an old checkpoint from the full journal and removes the unit on unload', async () => { + const { ctx, fiber } = await mount() + onTestFinished(() => ctx.fiber.dispose()) + const session = Session.create(SessionId('browser-old-row')) + openMessageTurn(session, 1, 'Asia/Shanghai') + const rows = ctx.sessionProjections.checkpoint(session) + const oldRows = { + ...rows, + timeContext: { + ver: 2, + seq: rows.timeContext!.seq, + val: { lastMessageTime: BASE, lastInjectionTime: null, lastTurnInjectionTime: null }, + }, + } + const cold = Session.create(SessionId('browser-old-cold'), session.snapshotEvents()) + ctx.sessionProjections.hydrate(cold, oldRows, cold.snapshotEvents(), SessionLogOffset(0)) + expect(ctx.sessionProjections.checkpoint(cold).timeContext).toEqual({ + ver: 3, seq: cold.seq - 1, val: rows.timeContext!.val, + }) + await fiber.dispose() + expect(ctx.sessionProjections.checkpoint(cold).timeContext).toBeUndefined() + }) + + it('retains reported invalid zones without validating rejected or throttled requests', async () => { + const { ctx } = await mount({ refreshIntervalMs: 1000 }) + onTestFinished(() => ctx.fiber.dispose()) + const session = Session.create(SessionId('browser-refusal-timing')) + const agent = sessionAgent(session) + openMessageTurn(session, 1, 'UTC') + await fire(ctx, agent, 1, 1) + session.append('user/message', createUserMessage({ + content: [], + source: { kind: 'user', rpcId: 'invalid', clientTimeZone: '+08:00' } as never, + }), { surfaceOp: 'append' }) + expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({ + browserTimeZones: ['UTC', '+08:00'], + }) + vi.setSystemTime(BASE + 500) + await expect(fire(ctx, agent, 1, 2)).resolves.toBeUndefined() + vi.setSystemTime(BASE + 1000) + const reject = ctx.on('agent/pre-step', () => Promise.resolve({ kind: 'reject' as const })) + await expect(fire(ctx, agent, 1, 2)).resolves.toBeUndefined() + reject() + await expect(fire(ctx, agent, 1, 2)).rejects.toThrow( + 'browser time zone must be canonical UTC or IANA Area/Location: "+08:00"', + ) + expect(contextTexts(session)).toHaveLength(1) + }) + + it('keeps the historical requested-turn fallback and resets distinct zones on the next turn', async () => { + const { ctx } = await mount({ refreshIntervalMs: 0 }) + onTestFinished(() => ctx.fiber.dispose()) + const session = Session.create(SessionId('browser-historical-turn')) + session.append('user/message', createUserMessage({ + content: [], + source: { kind: 'user', rpcId: 'before-turn', clientTimeZone: 'Europe/London' } as never, + }), { surfaceOp: 'append' }) + expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({ + browserTurn: null, browserTimeZones: [], + }) + openMessageTurn(session, 1, 'Asia/Shanghai') + session.append('user/message', createUserMessage({ + content: [], + source: { kind: 'user', rpcId: 'duplicate', clientTimeZone: 'Asia/Shanghai' } as never, + }), { surfaceOp: 'append' }) + expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({ + browserTurn: 1, browserTimeZones: ['Asia/Shanghai'], + }) + openMessageTurn(session, 2, 'America/New_York') + expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({ + browserTurn: 2, browserTimeZones: ['America/New_York'], + }) + await fire(ctx, sessionAgent(session), 1, 1) + expect(contextTexts(session).at(-1)).toContain('mixed ["America/New_York","Asia/Shanghai"]') + }) + it('clears the open-turn injection time at the next turn start', async () => { const { ctx } = await mount() const session = Session.create(SessionId('same-turn')) diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index c9f4d6f312..cf40f9967d 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index cc8142a710..187829f104 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index b4c7aaffa1..33c53afffa 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -45,8 +45,8 @@ en: 3b48a480036bd5df zh: 28cea2036b0dccee /deepseek-ai-dsh-agent-loop/understand-the-implementation/turn-and-step-flow: - en: 4926502ada48be6b - zh: 7b3ab909f771d2a2 + en: ad92c751515520ff + zh: ca6b24db5ab4e471 /deepseek-ai-dsh-agent-loop/understand-the-implementation/failure-and-cancellation: en: eea56903c4ff82f5 zh: c0d5abb5767780ed @@ -66,8 +66,8 @@ en: ad93d8d61968a25d zh: 268ff858b0fa96e8 /deepseek-ai-dsh-agent-loop/model-experience/complete-conversation-request/kv-cache-effect: - en: c002d27c825d04e8 - zh: b0dbdd95d3b3b6b5 + en: 12a2fc9e3654ddbf + zh: 133b7ca59925c82a /deepseek-ai-dsh-agent-loop/model-experience/retained-message-history: en: 50d33d852dec1e28 zh: ec15695304f25f38 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 725688ce04..28b862ff9d 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -119,7 +119,7 @@ The loop is the production acquisition point for session write handles. When `ct The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. `AgentLoop` registers the standard `inbox` projection for its service lifetime, so cold reads work before any Agent exists and after all Agents unload. It also owns `modelSelection` and constructs an `AgentModel` for each Agent, with the public [Model API](../agent/README.md#model-selection). Its package-internal `ReactLoopInbox` uses that shared projection for structural commands and loop-only claims. At a turn boundary it opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. The driver assembles the prompt and tools and runs `agent/pre-step`; the fallback projects runtime context after listeners run. A rejected decision or empty first batch opens no step. On the first attempt after acceptance, `step/start` precedes the `agent/request` waterfall and `prepareCall()`; neither async phase sees the pending system prompt or accepted users committed to history, and cancellation during either commits neither. For every attempt, the loop then synchronously reconciles the rendered prompt against the surviving `system/message` nodes using the prepared call capability, refreshes registered runtime facts without reassembling the bound prompt or tools, appends the accepted `user/message` batch only on the first attempt with its owned context candidate replaced in place, and restores removed runtime context, logs the header and context as needed, and derives and freezes the request before streaming through that bound prepared call. Retries reuse the same rendered assembly without repeating assembly, `agent/pre-step`, or user admission. Reconciliation sees pre-step and retry compaction; a broken series consolidates the prompt at the head rather than appending an update after already-committed users. The request is `header.config`, `deriveMessages()`, and `header.tools`; it carries no `system` field. Each model attempt emits one process-local `start`, emits every `chunk` only after the matching durable assistant-frame settlement, and emits exactly one terminal `end`; final assembly or message-append failure settles it as `aborted`, while `committed` follows the durable `assistant/message`. Each successful model call appends one message anchor, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results... (line truncated to 2000 chars) -Prompt admission uses the actual `prepareCall()` result, not the preceding `request/context`. With no system node, even an empty prompt is appended (reserving node 0 without a wire message). On an incapable route or at a new request series, a non-empty rendering is consolidated at the first system node: each non-empty later system node receives a logged per-node empty replacement, then the head is rewritten if needed. Dormant empty tails need no replacement and do not determine the effective text. Consolidation applies even when the latest effective text is unchanged. On a continuing `in-history` series, an unchanged effective prompt produces no event and a non-empty change appends. An empty rendering clears every non-empty later system node through a logged per-node empty replacement, then empties the head if needed, regardless of route or series state. No older instructions remain model-visible. An empty head with no active later system node means no prompt; repeated clears and resume leave it empty. A restored non-empty prompt follows the same route/series rule: a continuing capable route may append it, while an incapable route or new series refills the head. A step starts a series when the pre-step decision declares `startsRequestSeries` or `session.surface.contentGeneration` changed since attachment or the last request (a replacement or image-offload decision). Visible tool-schema changes force prompt consolidation only when the prepared route lacks `toolUpdate`. Routes supporting both tool and system-prompt updates can append a changed prompt and activate added tools without rewriting earlier messages. If another admission condition replaces a system node, the request starts a new series with current tool declarations. Resume and a provider or model swap alone continue the series; the prepared route still governs admission. Per-node empty replacements preserve intervening history without a surface delete operation. +Prompt admission uses the actual `prepareCall()` result, not the preceding `request/context`. With no system node, even an empty prompt is appended (reserving node 0 without a wire message). On an incapable route, or at a new request series whose effective text differs, a non-empty rendering is consolidated at the first system node: each non-empty later system node receives a logged per-node empty replacement, then the head is rewritten if needed. Dormant empty tails need no replacement and do not determine the effective text. A series start whose latest surviving node already holds the rendering produces no system event, so the request keeps the prefix it had. On a continuing `in-history` series, an unchanged effective prompt produces no event and a non-empty change appends. An empty rendering clears every non-empty later system node through a logged per-node empty replacement, then empties the head if needed, regardless of route or series state. No older instructions remain model-visible. An empty head with no active later system node means no prompt; repeated clears and resume leave it empty. A restored non-empty prompt follows the same route/series rule: a continuing capable route may append it, while an incapable route or a new series whose effective text differs refills the head. A step starts a series when the pre-step decision declares `startsRequestSeries` or `session.surface.contentGeneration` changed since attachment or the last request (a replacement or image-offload decision). Visible tool-schema changes force prompt consolidation only when the prepared route lacks `toolUpdate`. Routes supporting both tool and system-prompt updates can append a changed prompt and activate added tools without rewriting earlier messages. If another admission condition replaces a system node, the request starts a new series with current tool declarations. Resume and a provider or model swap alone continue the series; the prepared route still governs admission. Per-node empty replacements preserve intervening history without a surface delete operation. ### Failure and cancellation @@ -161,7 +161,7 @@ System text and schemas are paid again on every step, and on an `in-history` rou #### KV Cache effect -Prefix reuse requires earlier messages and declarations to stay byte-identical under the same provider and model route. An unchanged rendered prompt keeps the cached prefix unless an incapable route or a new request series must consolidate retained in-history system nodes. A prompt change that replaces a system node in place makes the request differ from that node's first token — in full when the node is node 0 — so the provider prefix cache misses from there; when the prepared call declares `systemPromptUpdate: 'in-history'`, a non-empty prompt change inside a continuing request series is appended after the cached history, so the prefix through that history stays reusable. Changing a retained tool definition or rebuilding declarations invalidates reuse from the first altered request token. +Prefix reuse requires earlier messages and declarations to stay byte-identical under the same provider and model route. An unchanged rendered prompt keeps the cached prefix: a capable route leaves retained in-history system nodes in place even when a new series starts, and only an incapable route, or a series start whose effective text differs, consolidates them. A prompt change that replaces a system node in place makes the request differ from that node's first token — in full when the node is node 0 — so the provider prefix cache misses from there; when the prepared call declares `systemPromptUpdate: 'in-history'`, a non-empty prompt change inside a continuing request series is appended after the cached history, so the prefix through that history stays reusable. Changing a retained tool definition or rebuilding declarations invalidates reuse from the first altered request token. ### Retained message history diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 97835c11cd..f872d4289e 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -119,7 +119,7 @@ loop 在发布工厂前等待 inbox 与轮次边界注册完成,并将句柄 驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。`AgentLoop` 在服务生命周期内注册标准 `inbox` 投影,因此冷读取在没有 Agent 和所有 Agent 卸载后都可用。它还拥有 `modelSelection`,并为每个 Agent 构建 `AgentModel`,实现公开的 [Model API](../agent/README.zh.md#model-selection)。其包内部 `ReactLoopInbox` 使用该共享投影执行结构化命令与仅供 loop 使用的领取操作。在轮次边界,它先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。驱动器组装提示词与工具,并运行 `agent/pre-step`;fallback 在监听器执行后投影运行时上下文。被拒绝的决定或空的首批输入不打开步骤。接纳后的首次尝试先记录 `step/start`,再运行 `agent/request` waterfall 与 `prepareCall()`;这两个异步阶段都看不到待提交的系统提示词与已接纳用户消息进入历史,在任一阶段取消都不会提交这两者。每次尝试时,循环随后依据已准备调用的能力,同步将渲染后的提示词与存活的 `system/message` 节点协调一致、刷新已注册运行时事实而不重新组装绑定的提示词或工具、仅在首次尝试追加已接纳的 `user/message` 批次并原位替换循环自有上下文候选、恢复被移除的运行时上下文、按需记录 header 与 context,再派生并冻结请求,通过该绑定的已准备调用发起流式请求。重试复用同一份已渲染组装结果,不重复组装、`agent/pre-step` 或用户消息准入。协调过程可见 pre-step 与重试中的压缩;序列中断时将提示词归并到头部,而非在已提交用户消息之后追加更新。请求由 `header.config`、`deriveMessages()` 与 `header.tools` 构成,不携带 `system` 字段。每次模型尝试会发出一个进程本地 `start`,仅在匹配的持久 assistant-frame 结算之后发出各个 `chunk`,并恰好发出一个终态 `end`;最终组装或消息追加失败时以 `aborted` 结算,`committed` 则出现在持久 `assistant/message` 之后。每次成功的模型调用都恰好追加一个 message 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。 -提示词准入依据实际的 `prepareCall()` 结果,而非先前的 `request/context`。没有系统节点时,即使提示词为空也追加(预留第 0 号节点,但不产生协议消息)。在不具备能力的路由上或新请求序列开始时,非空渲染文本归并到首个系统节点:每个非空的后续系统节点分别收到有日志记录的空内容替换,随后按需重写头节点。未生效的空尾节点无需替换,也不决定有效文本。即使最新有效文本未变,也执行归并。延续中的 `in-history` 序列在有效提示词不变时不产生事件,非空变更则追加。无论路由或序列状态如何,空渲染文本都会通过有日志记录的逐节点空内容替换清除每个非空的后续系统节点,再按需清空头节点。模型不会继续看到旧指令。空头节点且没有生效的后续系统节点表示没有提示词;重复清除与恢复会话都保持为空。重新提供的非空提示词遵循同一路由/序列规则:延续中的具备能力路由可以追加它,不具备能力的路由或新序列则重新填充头节点。以下情况开启序列:pre-step 决定声明 `startsRequestSeries`,或 `session.surface.contentGeneration` 自附接或上次请求以来发生变化(替换或图片省略决定)。仅当已准备路由未声明 `toolUpdate` 时,可见工具 schema 变化才强制归并提示词。同时支持工具和系统提示词更新的路由可以追加变更后的提示词并激活新增工具,无需改写此前消息。如果其他准入条件替换系统节点,请求会以当前工具声明开启新序列。恢复与单纯的提供方或模型切换都延续序列;准入仍由已准备的路由决定。逐节点的空内容替换保留其间历史,无需 surface 删除操作。 +提示词准入依据实际的 `prepareCall()` 结果,而非先前的 `request/context`。没有系统节点时,即使提示词为空也追加(预留第 0 号节点,但不产生协议消息)。在不具备能力的路由上,或有效文本不同的新请求序列开始时,非空渲染文本归并到首个系统节点:每个非空的后续系统节点分别收到有日志记录的空内容替换,随后按需重写头节点。未生效的空尾节点无需替换,也不决定有效文本。新序列开始时若最新存活节点已经持有该渲染文本,则不产生系统事件,请求保留原有前缀。延续中的 `in-history` 序列在有效提示词不变时不产生事件,非空变更则追加。无论路由或序列状态如何,空渲染文本都会通过有日志记录的逐节点空内容替换清除每个非空的后续系统节点,再按需清空头节点。模型不会继续看到旧指令。空头节点且没有生效的后续系统节点表示没有提示词;重复清除与恢复会话都保持为空。重新提供的非空提示词遵循同一路由/序列规则:延续中的具备能力路由可以追加它,不具备能力的路由或有效文本不同的新序列则重新填充头节点。以下情况开启序列:pre-step 决定声明 `startsRequestSeries`,或 `session.surface.contentGeneration` 自附接或上次请求以来发生变化(替换或图片省略决定)。仅当已准备路由未声明 `toolUpdate` 时,可见工具 schema 变化才强制归并提示词。同时支持工具和系统提示词更新的路由可以追加变更后的提示词并激活新增工具,无需改写此前消息。如果其他准入条件替换系统节点,请求会以当前工具声明开启新序列。恢复与单纯的提供方或模型切换都延续序列;准入仍由已准备的路由决定。逐节点的空内容替换保留其间历史,无需 surface 删除操作。 ### 失败与取消 @@ -161,7 +161,7 @@ loop 在发布工厂前等待 inbox 与轮次边界注册完成,并将句柄 #### KV Cache 影响 -前缀复用要求在同一提供方与模型路由下,此前消息和声明保持逐字节一致。渲染后的提示词未变时,缓存前缀得以保留,除非不具备能力的路由或新请求序列必须归并保留的历史内系统节点。原地替换某个系统节点的提示词变更会使请求从该节点的第一个 token 起就不同——该节点是第 0 号节点时则整个请求都不同——因此提供方前缀缓存从那里开始未命中;当已准备调用声明 `systemPromptUpdate: 'in-history'` 时,同一请求序列延续期间的非空提示词变更会追加到已缓存历史之后,因此直到该历史末尾的前缀仍可复用。更改保留工具的定义或重建声明,会从第一个改变的请求 token 起使复用失效。 +前缀复用要求在同一提供方与模型路由下,此前消息和声明保持逐字节一致。渲染后的提示词未变时,缓存前缀得以保留:具备能力的路由即使开始新序列也保留历史内系统节点,只有不具备能力的路由,或有效文本不同的新序列开始,才归并它们。原地替换某个系统节点的提示词变更会使请求从该节点的第一个 token 起就不同——该节点是第 0 号节点时则整个请求都不同——因此提供方前缀缓存从那里开始未命中;当已准备调用声明 `systemPromptUpdate: 'in-history'` 时,同一请求序列延续期间的非空提示词变更会追加到已缓存历史之后,因此直到该历史末尾的前缀仍可复用。更改保留工具的定义或重建声明,会从第一个改变的请求 token 起使复用失效。 ### 保留的消息历史 diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index a5d470f452..e5349bbec4 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-loop/src/runtime-context.ts b/packages/core/agent-loop/src/runtime-context.ts index cb959b055a..1df199c9f8 100644 --- a/packages/core/agent-loop/src/runtime-context.ts +++ b/packages/core/agent-loop/src/runtime-context.ts @@ -41,9 +41,9 @@ export interface SystemPromptDecisionInput { /** Whether the prepared route for this attempt reads a later `system` message as the effective prompt. */ inHistory: boolean /** - * Whether prompt admission must consolidate: a pre-step listener declared a - * new series, the surface changed since the last request, or assembled tools - * changed on a route without tool-update support. + * Whether this step starts a new request series: a pre-step listener declared + * one, the surface changed since the last request, or assembled tools changed + * on a route without tool-update support. */ startsSeries: boolean } @@ -58,9 +58,12 @@ function eventsNewestFirst(session: Session): readonly SessionEvent[] { * Decides how a rendered system prompt reaches the surface without owning the * commit. The first prompt, even empty, reserves surface node 0. * A capable continuing series appends changed nonempty text after the - * cached history. An incapable route, broken series, or cleared prompt instead - * normalizes the first system node and empties later active nodes. Dormant empty - * tails do not supply effective text or require repeated replacements. + * cached history. An incapable route, a cleared prompt, or a new series whose + * effective text differs instead normalizes the first system node and empties + * later active nodes. A series start whose latest surviving node already holds + * the rendering emits nothing: moving the same text to node 0 would change the + * request prefix without changing the prompt. Dormant empty tails do not supply + * effective text or require repeated replacements. */ export class SystemPromptProjection { constructor(private readonly session: Session) {} @@ -81,6 +84,8 @@ export class SystemPromptProjection { /** * Reconcile effective text and retained nodes with the prepared route and series. + * A series start re-baselines node 0 only when the effective text differs; a + * capable continuing series keeps appended nodes in place to preserve the prefix. * @param rendered - the fully rendered system prompt; `''` when none is active. * @param input - the route capability and series facts for this step. * @returns ordered per-node updates; an empty list means no update is needed. @@ -92,7 +97,7 @@ export class SystemPromptProjection { return [{ message: createSystemMessage(rendered), intent: { surfaceOp: 'append' } }] } const latest = nodes.findLast(node => node.text !== '') ?? head - if (!input.inHistory || input.startsSeries || rendered.length === 0) { + if (!input.inHistory || rendered.length === 0 || (input.startsSeries && latest.text !== rendered)) { const updates = nodes.slice(1).filter(node => node.text !== '') .map(node => this.replace(node.seq, '')) if (head.text !== rendered) updates.push(this.replace(head.seq, rendered)) diff --git a/packages/core/agent-loop/tests/fixtures/serial-created.mjs b/packages/core/agent-loop/tests/fixtures/serial-created.mjs index c4ed2df17c..8cc624caae 100644 --- a/packages/core/agent-loop/tests/fixtures/serial-created.mjs +++ b/packages/core/agent-loop/tests/fixtures/serial-created.mjs @@ -3,17 +3,31 @@ import { randomUUID } from 'node:crypto' import { setImmediate } from 'node:timers/promises' export const name = 'serial-created-fixture' -export const inject = ['agents'] +export const inject = ['agents', 'sessions'] /** Install ordered creation listeners whose context must precede the first turn. */ export function apply(ctx) { const ready = new WeakSet() ctx.on('agent/created', async ({ agent }) => { await setImmediate() + let forkSummary = '' + if (agent.session.header.origin !== 'subagent') { + const fork = ctx.sessions.fork(agent.session) + const nested = ctx.sessions.fork(fork) + for (const independent of [fork, nested]) { + if (independent.header.parentSession !== undefined || independent.header.origin !== undefined + || !independent.header.isSeeded) throw new Error('ordinary fork retained a creator relationship') + } + ctx.sessions.create(undefined, { + seed: agent.session.snapshotEvents(), inheritedEventCount: agent.session.seq, + meta: { parentSession: agent.id, isSeeded: true }, + }) + forkSummary = ' Ordinary and nested forks are independent; historical fork metadata remains readable.' + } agent.inject({ id: randomUUID(), role: 'user', - content: [{ type: 'text', text: 'Serial agent creation completed.' }], + content: [{ type: 'text', text: 'Serial agent creation completed.' + forkSummary }], source: { kind: `plugin:${name}` }, }) ready.add(agent) diff --git a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts index 641a51951b..206648bb9c 100644 --- a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts +++ b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts @@ -123,7 +123,7 @@ describe('prepared-route prompt admission', () => { expect(request.messages.filter(message => message.role === 'developer')).toEqual([]) }) - it.each(['explicit', 'replacement'] as const)('rebuilds prompt and declarations at a %s series start', async (reason) => { + it.each(['explicit', 'replacement'] as const)('rebuilds declarations without re-baselining an unchanged prompt at a %s series start', async (reason) => { const h = await harness() h.capable.toolUpdate = toolUpdate await send(h.agent, 'first') @@ -144,7 +144,13 @@ describe('prepared-route prompt admission', () => { await send(h.agent, 'add fetch') const request = h.capable.requests.at(-1)! - expectPlain(request, 'prompt two') + const previous = h.capable.requests.at(-2)! + // The rendering is unchanged: the surviving prompt node keeps its place and the new series + // only rebuilds declarations. Rewriting node 0 would invalidate the provider prefix. + expect(systemTexts(request)).toEqual([ + [{ type: 'text', text: 'prompt one' }], [{ type: 'text', text: 'prompt two' }], + ]) + expect(request.messages[0]).toEqual(previous.messages[0]) expect(request.tools?.find(tool => tool.name === 'fetch')?.deferLoading).not.toBe(true) expect(request.messages.filter(message => message.role === 'developer')).toEqual([]) const header = h.agent.session.snapshotEvents().filter(event => event.type === 'request/header').at(-1) @@ -217,7 +223,7 @@ describe('prepared-route prompt admission', () => { expect(JSON.stringify(adapter.requests.at(-1)!)).not.toContain('prompt ') }) - it.each(['explicit', 'tools'] as const)('normalizes surviving prompt versions with unchanged text at a %s series start', async (reason) => { + it.each(['explicit', 'tools'] as const)('keeps surviving prompt versions with unchanged text at a %s series start', async (reason) => { const h = await harness() await send(h.agent, 'first') h.setPrompt('prompt two') @@ -234,10 +240,50 @@ describe('prepared-route prompt admission', () => { })) } await send(h.agent, 'third') - expectPlain(h.capable.requests[2]!, 'prompt two') + const previous = h.capable.requests[1]! + const request = h.capable.requests[2]! + // The rendering is unchanged, so the new series keeps the surviving prompt node: the whole + // previous request stays a wire prefix instead of moving the prompt into message 0. + expect(systemTexts(request)).toEqual([ + [{ type: 'text', text: 'prompt one' }], [{ type: 'text', text: 'prompt two' }], + ]) + expect(request.messages.slice(0, previous.messages.length)).toEqual(previous.messages) const header = h.agent.session.snapshotEvents().filter(event => event.type === 'request/header').at(-1) expect(header?.data.reason).toBe(reason === 'explicit' ? 'series' : 'change') - if (reason === 'tools') expect(header?.data.startsSeries).toBe(true) + if (reason === 'tools') { + // A tool-schema change starts the prompt series without a system event. The header records + // the declaration change without `startsSeries`; the unsupported-route projection supplies + // the complete active set, while tool history keeps its incremental baseline. + expect(header?.data.startsSeries).toBeUndefined() + expect(request.tools?.map(tool => tool.name)).toEqual(['extra']) + } + }) + + it('keeps the cached prefix at a compaction series start when the prompt text is unchanged', async () => { + const h = await harness() + await send(h.agent, 'first') + h.setPrompt('prompt two') + await send(h.agent, 'second') + const previous = h.capable.requests[1]! + + // A prune/compaction replaces the last admitted user message: the surface changes, so the + // next step starts a new request series while the appended prompt node still holds the + // rendered text. + const seq = h.agent.session.surface.nodes.findLast(node => h.agent.session.eventAt(node)?.type === 'user/message')! + h.agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'compacted history' }], source: { kind: 'test-compaction' }, + }), { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] }) + await send(h.agent, 'third') + + // Folding the prompt back into node 0 would move the system segment to wire message 0 and + // miss the provider prefix cache from the first token, although no prompt text changed. + const request = h.capable.requests[2]! + expect(systemTexts(request)).toEqual([ + [{ type: 'text', text: 'prompt one' }], [{ type: 'text', text: 'prompt two' }], + ]) + // The replacement shadows the previous request's last admitted message, so the shared wire + // prefix is everything before it. + expect(request.messages.slice(0, previous.messages.length - 1)).toEqual(previous.messages.slice(0, -1)) }) it.each([false, true])('reconciles compaction retries without replaying admission, older tail survives=%s', async (retainOlder) => { @@ -353,9 +399,15 @@ describe('prepared-route prompt admission', () => { return next() }) await send(resumed, 'resume') - expect(systemTexts(h.capable.requests[2]!)).toEqual(replace - ? [[{ type: 'text', text: 'prompt two' }]] - : [[{ type: 'text', text: 'prompt one' }], [{ type: 'text', text: 'prompt two' }]]) + const previous = h.capable.requests[1]! + const request = h.capable.requests[2]! + // The first resumed pre-step replacement still starts a series, but an unchanged rendering + // keeps both prompt versions in place instead of re-baselining node 0. + expect(systemTexts(request)).toEqual([ + [{ type: 'text', text: 'prompt one' }], [{ type: 'text', text: 'prompt two' }], + ]) + expect(request.messages[0]).toEqual(previous.messages[0]) + if (!replace) expect(request.messages.slice(0, previous.messages.length)).toEqual(previous.messages) expect(resumed.session.snapshotEvents().filter(event => event.type === 'request/header').map(event => event.data.reason)) .toEqual(['initial', 'resume']) }) diff --git a/packages/core/agent-loop/tests/system-prompt-projection.spec.ts b/packages/core/agent-loop/tests/system-prompt-projection.spec.ts index 65ec74c486..9b58a2e8d9 100644 --- a/packages/core/agent-loop/tests/system-prompt-projection.spec.ts +++ b/packages/core/agent-loop/tests/system-prompt-projection.spec.ts @@ -177,6 +177,23 @@ describe('SystemPromptProjection', () => { .toEqual([{ type: 'text', text: 'v1' }, { type: 'text', text: 'v2' }, { type: 'text', text: 'v3' }]) }) + it('keeps the surviving prompt node at a series start when its text already matches', async () => { + const ctx = await sessionStore() + const session = ctx.sessions.create(SessionId('system-prompt-series-start-current')) + const projection = new SystemPromptProjection(session) + const head = commit(session, 1, projection.project('v1', CONTINUING)[0]) + appendUser(session, 'hello') + const appended = commit(session, 2, projection.project('v2', CONTINUING)[0]) + + // The effective text is already current: re-baselining node 0 would move the same + // text into the request's first message and invalidate the provider prefix. + expect(projection.project('v2', NEW_SERIES)).toEqual([]) + expect(session.surface.nodes[0]).toBe(head.seq) + expect(session.surface.nodes).toContain(appended.seq) + expect(session.deriveMessages().flatMap(message => message.role === 'system' ? [message.content[0]] : [])) + .toEqual([{ type: 'text', text: 'v1' }, { type: 'text', text: 'v2' }]) + }) + it('re-baselines node 0 and empties later active nodes at a series start', async () => { const ctx = await sessionStore() const session = ctx.sessions.create(SessionId('system-prompt-series-start')) diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json index 00554b1d37..c1292429f7 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": "Deprecated tool presentation selector; use dsh-tool-executor-native or dsh-tool-executor-ptc directly", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 4346e73f12..19406c7fe1 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 7c4400a695..ac35e6ca09 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 55bf1f876b..7221e1de95 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -18,8 +18,8 @@ en: 0882d4056c45aa75 zh: fcee78e9845b78b3 /deepseek-ai-dsh-session/use-this-package/create-and-inspect-sessions: - en: 05dac2b797457bab - zh: 9dbbcfcb9ea7d2d2 + en: 971e583b3d6c1af8 + zh: c7da934ecb8f1e87 /deepseek-ai-dsh-session/use-this-package/append-and-derive: en: c9ad40c8a8f2cd0b zh: 0b76ae73f39bbb18 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 9c3bd6e0d2..bcebe26075 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -29,7 +29,7 @@ Mount `dsh-session` wherever a session must exist. It creates and holds event-so ### Create and inspect sessions -`ctx.sessions.create()` builds a live session bound to the calling fiber; `get(id)` and `list()` find sessions, and `fork()` creates a child session from a stable prefix of a live one. +`ctx.sessions.create()` builds a live session bound to the calling fiber; `get(id)` and `list()` find sessions, and `fork()` creates an independent session from a stable prefix of a live one. It copies history, `cwd`, `isSeeded`, and the inherited event count without recording a source relationship. `parentSession` records a subagent’s direct creator; historical ordinary forks may retain their source there, so classification uses `origin`. Independent `Session.create()` and `Session.fromRestore()` instances accept events and plugin records without sending `session/event`. A `SessionStore` takes ownership through `prepare()` or `enter()`; writes then require store membership and a completed synchronous `session/created` announcement. Creation listeners are read-only and see all existing history; entering an independent instance does not replay that history as `session/event`. Failed announcement or removal leaves writes disabled. Removal emits `session/disposed` only if `session/created` began, including a failed announcement; an entered but never announced instance emits neither notification. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index e134202d01..700289094e 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 创建与检查会话 -`ctx.sessions.create()` 构建绑定到调用方 fiber 的实时会话;`get(id)` 与 `list()` 查找会话,`fork()` 从实时会话的精确前缀创建子会话。 +`ctx.sessions.create()` 构建绑定到调用方 fiber 的实时会话;`get(id)` 与 `list()` 查找会话,`fork()` 从实时会话的精确前缀创建独立会话。它复制历史、`cwd`、`isSeeded` 和继承事件数,不记录来源关系。`parentSession` 记录子代理的直接创建者;历史普通 fork 可能仍在此字段保留来源,因此分类使用 `origin`。 独立的 `Session.create()` 和 `Session.fromRestore()` 实例接受事件和插件记录,但不发送 `session/event`。`SessionStore` 通过 `prepare()` 或 `enter()` 接管实例;此后只有实例仍在 Store 中且同步 `session/created` 宣告已完成时才能写入。创建监听器只能读取,并能看到全部已有历史;独立实例进入 Store 时不会把已有历史重放为 `session/event`。宣告失败或移除后仍禁止写入。只有开始过 `session/created` 宣告的实例在移除时发出 `session/disposed`,宣告失败也不例外;进入 Store 但从未宣告的实例不发出这两个通知。 diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 4b51d4fc79..4be25b91f8 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 9df05fb6f5..e9baee1b82 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -1385,7 +1385,7 @@ export class SessionStore extends Service { } /** - * Create a live child session from an exact prefix of a live source. + * Create an independent live session from an exact prefix of a live source. * `boundary` is an inclusive source event seq; omitted means the source's * current last event. An open tail receives synthetic tool results and * step/turn closers with the forked cause. Closed steps and turns remain @@ -1414,7 +1414,6 @@ export class SessionStore extends Service { inheritedEventCount: SessionLogOffset(resolved === undefined ? 0 : resolved + 1), meta: { ...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {}, - parentSession: liveSource.id, isSeeded: true, }, }) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index f88777ae0d..7787dd9bd0 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -104,7 +104,11 @@ export interface SessionHeader { readonly createdAt: number /** Absolute working directory the session was created in (if any). */ readonly cwd?: string - /** The session this one was forked from (seed lineage), if any. */ + /** + * Direct creator of a subagent Session. Ordinary forks omit this field; + * historical ordinary forks may retain their source id here. Presence alone + * does not classify a Session as a subagent; use `origin` for that distinction. + */ readonly parentSession?: SessionId /** * Whether this Session contains a fork-inherited event prefix. The exact prefix @@ -321,8 +325,10 @@ export interface SessionEventMap { * The rendered system prompt on the model-visible surface. The loop appends * the first one as surface node 0 before the step's first `user/message`. * A prepared in-history route can append nonempty changes in a continuing - * series. An incapable route or new series normalizes text to the first system - * node. Normalization empties nonempty later nodes, then rewrites the head if + * series. An incapable route, or a new series whose effective text differs, + * normalizes text to the first system node; a series start whose latest + * surviving node already holds the rendering leaves it in place. + * Normalization empties nonempty later nodes, then rewrites the head if * needed, through logged per-node replacements. An empty rendering always * clears all active system nodes, leaving no older instructions model-visible. * Empty later nodes are dormant and project to no message; an empty head with diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 1fa38bc2e1..d5855b626d 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -59,17 +59,17 @@ function inherited(session: Session): readonly SessionEvent[] { } describe('SessionStore.fork', () => { - it('forks an empty live session as an empty child with lineage metadata', async () => { + it('forks an empty live session as an independent empty seeded session', async () => { const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('empty-parent'), { meta: { cwd: '/workspace' } }) const child = sessions.fork(source, undefined, SessionId('empty-child')) expect(inherited(child)).toEqual([]) + expect(child.header).not.toHaveProperty('parentSession') expect(child.header).toMatchObject({ id: SessionId('empty-child'), cwd: '/workspace', - parentSession: SessionId('empty-parent'), isSeeded: true, }) expect(child.inheritedEventCount).toBe(0) @@ -91,10 +91,10 @@ describe('SessionStore.fork', () => { }).toThrow(TypeError) expect(firstUserMessage(source.snapshotEvents()).data.content).toEqual([{ type: 'text', text: 'hello' }]) expect(firstUserMessage(child.snapshotEvents()).data.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(child.header).not.toHaveProperty('parentSession') expect(child.header).toMatchObject({ id: SessionId('child'), cwd: '/workspace', - parentSession: SessionId('parent'), isSeeded: true, }) expect(child.inheritedEventCount).toBe(source.seq) diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index 757f00eec7..293a721008 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/README.i18n.yaml @@ -66,8 +66,8 @@ en: 29722e212137deaa zh: 92cd0f1a16b10315 /deepseek-ai-dsh-system-prompt/model-experience/system-prompt/kv-cache-effect: - en: ae980c28b57dc060 - zh: 331eea5b204fa9ac + en: 52fa5a522891ec6b + zh: 5c5c18359aaaf3db /deepseek-ai-dsh-system-prompt/model-experience/tool-schemas: en: 3039bc5e7a796b21 zh: 6b7a9eaf69c9ea53 diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 777f4202d2..48b43666ee 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -153,7 +153,7 @@ Identity is a fixed per-request cost when enabled. Persona prefixes, suffixes, a #### KV Cache effect -Prefix-stable while identity, persona, variables, section text, and order render identically: an unchanged rendering leaves the system nodes untouched unless an incapable route or a new request series must consolidate retained in-history prompts. Without `systemPromptUpdate`, non-empty prompt text is consolidated at the first system node through logged per-node replacements, so a head rewrite loses prefix reuse from its first changed token; when the prepared call declares `systemPromptUpdate: 'in-history'`, the agent loop appends a non-empty changed prompt after the cached history inside a continuing request series, so the prefix through that history stays reusable ([decision rule](../agent-loop/README.md#understand-the-implementation)). With the same model, persona prefix, tools, and preceding instructions, different source paths, local Web URLs, or persona suffix values leave the reusable first-party prefix unchanged. Persona prefix changes can alter the early prefix. Any change may invalidate reuse from the first changed token; provider cache sharing and measured hit rates are not guaranteed. +Prefix-stable while identity, persona, variables, section text, and order render identically: an unchanged rendering leaves the system nodes untouched, including at a new request series on a capable route; only an incapable route, or a series start whose effective text differs, consolidates retained in-history prompts. Without `systemPromptUpdate`, non-empty prompt text is consolidated at the first system node through logged per-node replacements, so a head rewrite loses prefix reuse from its first changed token; when the prepared call declares `systemPromptUpdate: 'in-history'`, the agent loop appends a non-empty changed prompt after the cached history inside a continuing request series, so the prefix through that history stays reusable ([decision rule](../agent-loop/README.md#understand-the-implementation)). With the same model, persona prefix, tools, and preceding instructions, different source paths, local Web URLs, or persona suffix values leave the reusable first-party prefix unchanged. Persona prefix changes can alter the early prefix. Any change may invalidate reuse from the first changed token; provider cache sharing and measured hit rates are not guaranteed. ### Tool schemas diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index 07c04be1a3..3964b4af17 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -153,7 +153,7 @@ You are an AI agent powered by DeepSeek Harness. #### KV Cache 影响 -只要身份、persona、变量、段文本与顺序的渲染完全相同,前缀就保持稳定:渲染未变时系统节点保持不动,除非不支持该能力的路由或新请求序列必须归并保留的历史内提示词。没有 `systemPromptUpdate` 时,非空提示词文本通过有日志记录的逐节点替换归并到首个系统节点,因此头节点重写会从首个变化的 token 起失去前缀复用;当已准备调用声明 `systemPromptUpdate: 'in-history'` 时,agent loop(智能体循环)会在同一请求序列延续期间把变化后的非空提示词追加到已缓存历史之后,因此直到该历史末尾的前缀仍可复用([决策规则](../agent-loop/README.zh.md#understand-the-implementation))。模型、persona 前缀、工具与前置指令一致时,不同源码路径、本地 Web URL 或 persona 后缀值不会改变可复用的第一方前缀。persona 前缀变化可能改变靠前的前缀。任何变更都可能从第一个变化的 token 起使复用失效;不保证提供方共享缓存或实际命中率。 +只要身份、persona、变量、段文本与顺序的渲染完全相同,前缀就保持稳定:渲染未变时系统节点保持不动,具备能力路由的新请求序列也不例外;只有不支持该能力的路由,或有效文本不同的新序列开始,才归并保留的历史内提示词。没有 `systemPromptUpdate` 时,非空提示词文本通过有日志记录的逐节点替换归并到首个系统节点,因此头节点重写会从首个变化的 token 起失去前缀复用;当已准备调用声明 `systemPromptUpdate: 'in-history'` 时,agent loop(智能体循环)会在同一请求序列延续期间把变化后的非空提示词追加到已缓存历史之后,因此直到该历史末尾的前缀仍可复用([决策规则](../agent-loop/README.zh.md#understand-the-implementation))。模型、persona 前缀、工具与前置指令一致时,不同源码路径、本地 Web URL 或 persona 后缀值不会改变可复用的第一方前缀。persona 前缀变化可能改变靠前的前缀。任何变更都可能从第一个变化的 token 起使复用失效;不保证提供方共享缓存或实际命中率。 ### 工具 schema diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 0998a2aa15..22d6ede357 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/tool-executor-native/package.json b/packages/core/tool-executor-native/package.json index 2ce899c77a..848555ad8d 100644 --- a/packages/core/tool-executor-native/package.json +++ b/packages/core/tool-executor-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-executor-native", "description": "Native tool execution plugin", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/tool-executor-ptc/package.json b/packages/core/tool-executor-ptc/package.json index c52063e814..c4e5a51d49 100644 --- a/packages/core/tool-executor-ptc/package.json +++ b/packages/core/tool-executor-ptc/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-executor-ptc", "description": "PTC program execution, tool bindings, and generated SDK prompts", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index f87e90cbee..633a872912 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index d571a19a80..4948fdb66f 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -31,7 +31,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { 'create_goal', 'create_worktree', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_agents', 'list_mcp_resource_templates', 'list_mcp_resources', - 'list_subagent_models', 'load_workspace_dependencies', 'lsp', 'plugin_manager', 'plugin_market', 'present', 'pwsh', 'pwsh', 'ralph', + 'list_subagent_models', 'load_workspace_dependencies', 'lsp', 'plugin_manager', 'plugin_market', 'present', 'propose_action', 'pwsh', 'pwsh', 'ralph', 'read', 'read_image', 'read_mcp_resource', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'schedule_update', 'send_message', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'spawn_teammate', diff --git a/packages/credentials/authorization/package.json b/packages/credentials/authorization/package.json index c2d5d895fa..944439105b 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index f84235fef9..d36941dd48 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index 724cf68acf..0779e30160 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/deepseek-account-platform/package.json b/packages/credentials/deepseek-account-platform/package.json index f033e54f58..62451d7d04 100644 --- a/packages/credentials/deepseek-account-platform/package.json +++ b/packages/credentials/deepseek-account-platform/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deepseek-account-platform", "description": "Authorize DeepSeek accounts through browser PKCE", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/deepseek-account/package.json b/packages/credentials/deepseek-account/package.json index e3207d57d8..c7fb418d6d 100644 --- a/packages/credentials/deepseek-account/package.json +++ b/packages/credentials/deepseek-account/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deepseek-account", "description": "Read account state and resolve official API credentials", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/deliverables/deliverable-file-references/package.json b/packages/deliverables/deliverable-file-references/package.json index 12ef353746..83e6910ff9 100644 --- a/packages/deliverables/deliverable-file-references/package.json +++ b/packages/deliverables/deliverable-file-references/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deliverable-file-references", "description": "Shared model guidance for file links and deliverables across DeepSeek Harness hosts", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/deliverables/tool-present/package.json b/packages/deliverables/tool-present/package.json index ba38ec84c8..021b9fd26b 100644 --- a/packages/deliverables/tool-present/package.json +++ b/packages/deliverables/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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/deliverables/workspace-changes/package.json b/packages/deliverables/workspace-changes/package.json index 7a5e83f25e..9339ce365e 100644 --- a/packages/deliverables/workspace-changes/package.json +++ b/packages/deliverables/workspace-changes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workspace-changes", "description": "Per-turn workspace file changes recorded from git working-tree snapshots and whole-file captures, with per-file comparisons, for the DeepSeek Harness", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/document/office-to-pdf/package.json b/packages/document/office-to-pdf/package.json index ca84e66374..c610e7c7f6 100644 --- a/packages/document/office-to-pdf/package.json +++ b/packages/document/office-to-pdf/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-office-to-pdf", "description": "Shared Office-to-PDF conversion with bounded queues and caching", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml index 7345f148c8..21f50eee7a 100644 --- a/packages/experimental/README.i18n.yaml +++ b/packages/experimental/README.i18n.yaml @@ -18,8 +18,8 @@ en: b55aeb927737b90d zh: 0b215fcb5c2c7908 /packages-experimental/packages: - en: 9fd1c2ac5ede5d47 - zh: 9e8fc6c374b6f5e9 + en: 036d034ac12d686c + zh: c748a9230fefee86 /packages-experimental/related-documentation: en: 1cb9d3448d44e48d zh: cd326d70a746b896 diff --git a/packages/experimental/README.md b/packages/experimental/README.md index 58d3351b40..4d89dd0faf 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -36,6 +36,13 @@ Promotion requires a named product role, documented public behavior and limitati | Package | Role | ctx key | |---|---|---| +| [`action-cards`](action-cards/README.md) | Optional action cards below answers: the proposal tool, card state, user actions and status notices | `ctx.actionCards` | +| [`client-ui-action-cards`](client-ui-action-cards/README.md) | Card frame below completed answers, kind bodies and the proposal process row | — | +| [`creation-recommendations`](creation-recommendations/README.md) | Explicit-need creation of persistent removable interface plugins | — | +| [`client-ui-creation-recommendations`](client-ui-creation-recommendations/README.md) | Personalized page card body with location selection and undo | — | +| [`plugin-recommendations`](plugin-recommendations/README.md) | Explicit-need official plugin recommendations in conversations | `ctx.pluginRecommendations` | +| [`client-ui-plugin-recommendations`](client-ui-plugin-recommendations/README.md) | Official plugin card body with catalog text and enabling | — | +| [`personalized-recommendations`](personalized-recommendations/README.md) | Default-disabled bundle of personalized pages, plugin recommendations and their action cards | — | | [`tool-session-query`](tool-session-query/README.md) | Workspace-authorized model session search, trace, and event reads | — | | [`session-search`](session-search/README.md) | Optional experimental global session search tools | — | | [`ralph-bundle`](ralph-bundle/README.md) | Optional repeated delegation with an isolated workflow engine | — | @@ -59,6 +66,7 @@ Promotion requires a named product role, documented public behavior and limitati | [`api-speech-to-text`](api-speech-to-text/README.md) | Authenticated transient transcription Remote | `ctx.speechController` | | [`client-ui-voice-input`](client-ui-voice-input/README.md) | Microphone capture and guarded draft insertion | — | | [`plugin-market-bundle`](plugin-market-bundle/README.md) | Default-disabled optional custom plugin indexes: the index service and the Discover tab | — | +| [`liveshare-protocol`](liveshare-protocol/README.md) | Wire frames and the identity contract shared by the owner bundle and the Relay | — | | [`voice-input-bundle`](voice-input-bundle/README.md) | Default-disabled optional voice input composition | — | | [`agent-team-profile`](agent-team-profile/README.md) | Agent Teams collaboration, tools, and Web UI bundle | — | | [`agent-team`](agent-team/README.md) | Named teammates with direct messages and a durable shared task board | `ctx.agentTeams` | diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md index 3cd0f05350..d979466bd1 100644 --- a/packages/experimental/README.zh.md +++ b/packages/experimental/README.zh.md @@ -36,6 +36,13 @@ kind: "package-group" | 包 | 职责 | ctx 键 | |---|---|---| +| [`action-cards`](action-cards/README.zh.md) | 回答下方的可选操作卡片:提议工具、卡片状态、用户操作与状态通知 | `ctx.actionCards` | +| [`client-ui-action-cards`](client-ui-action-cards/README.zh.md) | 已完成回答下方的卡片框架、种类正文与提议过程行 | — | +| [`creation-recommendations`](creation-recommendations/README.zh.md) | 针对明确需求创建持续可用、可移除的界面插件 | — | +| [`client-ui-creation-recommendations`](client-ui-creation-recommendations/README.zh.md) | 个性化页面卡片正文、位置选择和撤销 | — | +| [`plugin-recommendations`](plugin-recommendations/README.zh.md) | 在对话中针对明确需求推荐官方插件 | `ctx.pluginRecommendations` | +| [`client-ui-plugin-recommendations`](client-ui-plugin-recommendations/README.zh.md) | 官方插件卡片正文:目录文本与启用 | — | +| [`personalized-recommendations`](personalized-recommendations/README.zh.md) | 默认禁用的个性化页面、插件推荐及其操作卡片组合 | — | | [`tool-session-query`](tool-session-query/README.zh.md) | 按工作区授权的模型会话搜索、追踪与事件读取 | — | | [`session-search`](session-search/README.zh.md) | 可选实验性全局会话搜索工具 | — | | [`ralph-bundle`](ralph-bundle/README.zh.md) | 带隔离 workflow 引擎的可选重复委派 | — | @@ -59,6 +66,7 @@ kind: "package-group" | [`api-speech-to-text`](api-speech-to-text/README.zh.md) | 带认证的临时转写 Remote | `ctx.speechController` | | [`client-ui-voice-input`](client-ui-voice-input/README.zh.md) | 麦克风录音与版本检查后的草稿插入 | — | | [`plugin-market-bundle`](plugin-market-bundle/README.zh.md) | 默认禁用的可选自定义插件索引:索引服务和「发现」标签页 | — | +| [`liveshare-protocol`](liveshare-protocol/README.zh.md) | owner bundle 与 Relay 共用的线路帧与身份契约 | — | | [`voice-input-bundle`](voice-input-bundle/README.zh.md) | 默认禁用的可选语音输入组合 | — | | [`agent-team-profile`](agent-team-profile/README.zh.md) | Agent Teams 协作、工具与 Web UI 组合包 | — | | [`agent-team`](agent-team/README.zh.md) | 具名 teammate,成员之间直接消息与持久共享任务板 | `ctx.agentTeams` | diff --git a/packages/experimental/action-cards/README.i18n.yaml b/packages/experimental/action-cards/README.i18n.yaml new file mode 100644 index 0000000000..142162f890 --- /dev/null +++ b/packages/experimental/action-cards/README.i18n.yaml @@ -0,0 +1,67 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/experimental/action-cards/README.md +/: + en: 1a68a7a733a2676d + zh: 01f72eb61a0626b7 +/deepseek-ai-dsh-action-cards: + en: 63bf9077e164f6fe + zh: 8b696bb8c3d6fcd4 +/deepseek-ai-dsh-action-cards/summary: + en: 7621088773d073f4 + zh: 57674e6dcee15997 +/deepseek-ai-dsh-action-cards/table-of-contents: + en: d152484eb41ac6b4 + zh: b00dddf4c88f0a45 +/deepseek-ai-dsh-action-cards/use-this-package: + en: e239e255a6692f95 + zh: ae8e359e37e484b3 +/deepseek-ai-dsh-action-cards/use-this-package/register-a-card-kind: + en: 8432c02ef18eda11 + zh: 6d43a0a32edd7e30 +/deepseek-ai-dsh-action-cards/use-this-package/card-rules: + en: 2f5b3ccb91f3b30e + zh: 9bd877d9d92615da +/deepseek-ai-dsh-action-cards/understand-the-implementation: + en: d92243d71d2106c1 + zh: 47f0840fa723bb31 +/deepseek-ai-dsh-action-cards/further-exploration: + en: 1966ab8d7063c450 + zh: 033c2c0d258a257f +/deepseek-ai-dsh-action-cards/model-experience: + en: 215e7ba838619b7b + zh: a311da3843709f90 +/deepseek-ai-dsh-action-cards/model-experience/card-tool: + en: 4925bc5f7153825a + zh: 3a7bfb2210d9c6aa +/deepseek-ai-dsh-action-cards/model-experience/card-tool/what-the-model-sees: + en: 58521d3992ce42ec + zh: 45681f7b43e67613 +/deepseek-ai-dsh-action-cards/model-experience/card-tool/token-effect: + en: 7efaaad09be98a07 + zh: 86c7fa13d60c3588 +/deepseek-ai-dsh-action-cards/model-experience/card-tool/kv-cache-effect: + en: d4192e35d47b5fa3 + zh: c786828e123de657 +/deepseek-ai-dsh-action-cards/model-experience/card-status-notice: + en: 407ebca5eb94992e + zh: 779819ea83974103 +/deepseek-ai-dsh-action-cards/model-experience/card-status-notice/what-the-model-sees: + en: a14013ca3ddb5ee3 + zh: 18cd7ffc62fd6590 +/deepseek-ai-dsh-action-cards/model-experience/card-status-notice/what-the-model-sees/notice-preamble: + en: 04dd9469bfaff052 + zh: 35110b231b8e3928 +/deepseek-ai-dsh-action-cards/model-experience/card-status-notice/token-effect: + en: 4e73a2cff60b5e88 + zh: 4887212815e7425d +/deepseek-ai-dsh-action-cards/model-experience/card-status-notice/kv-cache-effect: + en: 58785543e2428dba + zh: 4b082f77b3b2f482 +/deepseek-ai-dsh-action-cards/known-limitations-and-deferred-work: + en: 8e3eba4e9fda82cb + zh: afaf74aad9a01533 +/deepseek-ai-dsh-action-cards/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: bd8a8936229e035e diff --git a/packages/experimental/action-cards/README.md b/packages/experimental/action-cards/README.md new file mode 100644 index 0000000000..c6ae933f1e --- /dev/null +++ b/packages/experimental/action-cards/README.md @@ -0,0 +1,137 @@ +--- +description: "Shared Host service of optional action cards: the propose_action tool, card state, user actions and status notices that experiment packages register card kinds into." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-action-cards + +English | [中文](README.zh.md) + +## Summary + +An experiment can offer one optional action below an answer, such as creating a page, without adding a tool of its own. The model loads the experiment's skill, then calls `propose_action`; the card appears after the turn ends, and nothing runs until the user acts on it. Each choice is recorded and reported to the model on its next request. The tool costs nothing until a card skill loads in the conversation. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The [Personalized recommendations](../personalized-recommendations/README.md) bundle inserts this service and its [browser half](../client-ui-action-cards/README.md). The service has one configuration field: + +| Field | Default | Meaning | +|---|---|---| +| `queuedMessageLimit` | `2` | Queued next-turn user messages at or above which `propose_action` refuses the call, so no card is offered. | + +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-action-cards) is the exhaustive source for accepted fields. + +### Register a card kind + +A kind package injects `actionCards` and calls `ctx.actionCards.register(kind)` from its own plugin; unloading that plugin removes the kind, and actions on its cards are then refused as unavailable, except that Not now still dismisses an unstarted card. The kind names its action, the skill that proposes it, the closing invitation guidance that an accepted call's result gives the model, a zod schema for the card's `details`, the user actions it accepts and the shared checks each needs (`global` Remote access, `writes` refused in a read-only Session), the recorded phases that still offer dismissal, optional status guidance, the decision data fields the status notice reports, an optional `reconcile` that updates the card's recorded decision from state outside the Session before every action, and the handler for every action except `dismiss`. The handler records each new decision with `ctx.actionCards.record()`, which refuses an action no kind registered and the derived `replaced` phase, and returns `accepted`, `settled` with the decision in effect, `refused` or `unconfirmed`. + +### Card rules + +- The first accepted `propose_action` call of a turn becomes the card. The tool refuses a second call in the same turn, an unregistered action, a call whose kind skill was not loaded in the current turn, a blank title or brief, details that fail the kind schema, calls dispatched from code, calls from a subagent, and any call while `queuedMessageLimit` or more user messages wait for later turns. +- A newer card of any kind replaces every unstarted earlier card. Replacement is derived from the log and never recorded. +- Events a forked Session inherits create no cards. +- Before every action, including Not now, the kind's `reconcile` updates the card from state outside the Session. Not now is then recorded for an unstarted card and for the phases the kind lists; dismissed and replaced cards accept no further action. Without its registered kind, Not now still dismisses an unstarted card, and every other action is refused as unavailable. +- Concurrent actions on one card share one operation. Actions require the card's live root Agent. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The package default-exports the `actionCards` Remote service. It registers `propose_action` once, globally, with a `when` condition: a live root Agent becomes eligible when its own direct `skill` call loads the skill of a registered kind, while loads by subagents or from code are ignored. The flag lives in memory until the Agent is disposed. The call's `execute` checks the live root, the queued messages the projection counts against `queuedMessageLimit` and the current turn's skill loads through the turn boundary, and keeps an in-memory mark of the turn's accepted call; the logged `tool/call` and its successful `tool/result` are the durable proposal. + +The `actionCards` Session projection (state version 2) pairs each `propose_action` call with its successful result, records decisions from `plugin:action-cards/decision`, counts queued next-turn messages from inbox splices for the tool's limit, and keeps unreported decisions until a status notice whose `through` covers them enters the log. Its Client view `{ proposals, decisions }` is excluded from Session summaries. + +The Remote `act(sessionId, callId, verb, payload?)` checks the owner, the live root and the card; Not now continues without a registered kind, while other verbs need the kind, the verb and Remote access. It then shares one operation per card. The kind's `reconcile` runs first and the live root is checked again; a dismissed or replaced card then settles unchanged, Not now is recorded, a writing verb is refused in a read-only Session, and every other verb goes to the kind handler under the Agent's initiator. `record()` also accepts an action whose kind has unregistered, because Cordis disposes a plugin's effects concurrently and an unloading kind still records the outcome of work it started. An `agent/pre-step` listener adds the status notice after later listeners prepared the step. + +| Source | Contents | +|---|---| +| [`src/index.ts`](src/index.ts) | Service, tool, kind registry, Remote action and status notice | +| [`src/projection.ts`](src/projection.ts) | Session projection | +| [`src/types.ts`](src/types.ts) | Proposal, decision, view and result types | +| [`tests/`](tests/) | Activation, validation, projection, action and notice tests | + +
+ +----- + + +## Further Exploration + +- [Action card display](../client-ui-action-cards/README.md) — the browser frame, kind bodies and the process row. +- [Personalized pages](../creation-recommendations/README.md) and [Plugin recommendations](../plugin-recommendations/README.md) — the shipped card kinds. +- [Tools](../../core/tools/README.md) — conditional registration and step admission. +- [Session projection](../../session/session-projection/README.md) — the card state the browser reads. + + +## Model Experience + +### Card tool + +#### What the model sees + +The [`propose_action` declaration](../../../docs/tool-catalog.md#deepseek-aidsh-action-cards) appears only to a live root Agent whose own direct call loaded the skill of a registered kind in this Session, from the next step on, for that Agent's lifetime. Subagents never see it, and neither do PTC presets, whose skill loads are dispatched from code, so they show no cards. A kind's skill names the action and the fields of `details`. An accepted call returns `Card shown below your answer. Do not restate it. `, where `` is the kind's one- or two-sentence guidance on inviting the user to the card in the user's language, so the model writes an invitation only after a card exists. A refused call carries no invitation and returns its reason, for example `Load skill first, then call propose_action.`, `One card per turn; the earlier card stands.`, `The user has queued more messages; show no card and answer briefly.`, `Cards are offered in the main conversation only.`, `Unknown action "". Registered actions: .`, where the list reads `none` once the last kind unregistered, or the failing `details` fields. + +#### Token effect + +Before a card skill loads, the tool adds no tokens. Afterwards each request of that Agent declares it: 574 characters of JSON, about 144 tokens at the token meter's four characters per token. A card costs one extra model round trip, the call before the final answer, and its tool result: a 49-character prefix plus the kind's invitation. The Personalized pages result is 305 characters, about 77 tokens at the same rate. + +#### KV Cache effect + +Activation is append-only on routes that declare tool updates, such as DeepSeek-V4.1-Flash: when the condition turns true the loop logs a header change and a `tool-addition` developer message, and those routes receive the definition as a deferred declaration referenced at that position, so the cached prefix is kept. A route without tool updates receives the tool in its declaration list from that step on, which changes the request prefix once. Disposing the Agent drops the activation; a reopened Session declares the tool again only after a card skill loads again, logging a removal and a later addition. + +### Card status notice + +#### What the model sees + +After a decision is recorded, the next request carries one `action-cards` notice: the preamble below, the guidance sentences of each involved kind, `Continue with the user’s current request.`, and a JSON array of the changed cards with `action`, `callId`, `title`, `phase` and the data fields their kind reports. + +##### Notice preamble + +```markdown +Latest status of action cards from user actions and background work. These are status facts, not a request to start or retry work. Use these receipts instead of the earlier proposal when describing progress. +``` + +#### Token effect + +One notice per request that follows new decisions, sized by the changed cards; unchanged cards add nothing, and several decisions of one card collapse to its latest. + +#### KV Cache effect + +The notice is appended after the request's other new context and never rewrites earlier messages. The notice neither starts a turn nor wakes an idle conversation. + +## Known Limitations and Deferred Work + + + +- Activation is per live Agent and kept in memory, so after a Session reopens the tool appears again only after a card skill loads again. +- PTC presets show no cards: skill loads and calls dispatched from code never activate or reach the tool, because the log records them as program dispatches rather than tool calls. +- Cards are offered in the main conversation only. A subagent's skill load, including a Creator child's, activates nothing and its calls are refused, because only a live root can act on a card. +- Only one card is offered per turn across all kinds. A later card of any kind replaces every unstarted earlier card, so a second kind's card replaces an unstarted card of the first. +- Refusal reasons of user actions are plain strings by design: the service defines `closing`, `not-live-root`, `unknown`, `unavailable`, `access-denied` and `read-only`, each kind adds its own, and a browser body names the reasons it knows and treats others as unavailable. +- Each kind validates `details` on both sides, with its Host schema when the model proposes and before each action, and with its browser body's own schema, so the two schemas change together. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/experimental/action-cards/README.zh.md b/packages/experimental/action-cards/README.zh.md new file mode 100644 index 0000000000..c2202d58d8 --- /dev/null +++ b/packages/experimental/action-cards/README.zh.md @@ -0,0 +1,137 @@ +--- +description: "可选操作卡片的共享 Host 服务:propose_action 工具、卡片状态、用户操作和状态通知,各实验包把自己的卡片种类注册进来。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-action-cards + +[English](README.md) | 中文 + +## 概述 + +实验可以在回答下方提供一个可选操作,例如创建页面,而不必新增自己的工具。模型先加载该实验的 Skill,再调用 `propose_action`;本轮结束后卡片出现在回答下方,用户操作之前不会执行任何事。用户的每次选择都会被记录,并在模型的下一次请求中告知模型。对话中加载卡片 Skill 之前,这个工具不占用任何 token。 + +## 目录 + +- [使用本包](#use-this-package) +- [实现原理](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与后续工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +[个性化推荐](../personalized-recommendations/README.zh.md) Bundle 会插入本服务及其[浏览器端](../client-ui-action-cards/README.zh.md)。本服务只有一个配置字段: + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `queuedMessageLimit` | `2` | 排队到后续轮次的用户消息达到该数量时,`propose_action` 拒绝调用,因此不会展示卡片。 | + +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-action-cards)是可接受字段的完整来源。 + +### 注册卡片种类 + +种类所在的包注入 `actionCards`,并在自己的插件中调用 `ctx.actionCards.register(kind)`;该插件卸载时种类随之移除,此后对其卡片的操作会以 unavailable 拒绝,只有“暂不需要”仍可忽略尚未开始的卡片。种类需要给出:动作名、提议该动作的 Skill、被接受的调用结果交给模型的结尾邀请指引、卡片 `details` 的 zod 校验、接受的用户操作及各自需要的共享检查(`global` 表示需要全局 Remote 权限,`writes` 表示只读会话中拒绝)、仍可忽略卡片的已记录阶段、可选的状态说明、状态通知要报告的决定数据字段、可选的 `reconcile`(每次操作前根据会话之外的状态更新卡片的已记录决定),以及处理 `dismiss` 以外所有操作的处理器。处理器用 `ctx.actionCards.record()` 记录每个新决定(该方法拒绝没有种类注册过的动作和推导得出的 `replaced` 阶段),并返回 `accepted`、带当前决定的 `settled`、`refused` 或 `unconfirmed`。 + +### 卡片规则 + +- 一轮中第一个被接受的 `propose_action` 调用成为卡片。工具会拒绝同一轮的第二次调用、未注册的动作、本轮没有加载对应种类 Skill 的调用、空白的标题或说明、未通过种类校验的 details、从代码中发起的调用、来自 subagent 的调用,以及排队到后续轮次的用户消息达到 `queuedMessageLimit` 条时的任何调用。 +- 任何种类的新卡片都会替换此前所有尚未开始的卡片。替换从日志推导,从不单独记录。 +- fork 出的会话从父会话继承的事件不会产生卡片。 +- 每次操作(包括“暂不需要”)之前,种类的 `reconcile` 先根据会话之外的状态更新卡片。之后,对尚未开始的卡片,以及处于种类列出阶段的卡片,“暂不需要”会被记录;已忽略、已替换的卡片不再接受任何操作。卡片的种类未注册时,“暂不需要”仍可忽略尚未开始的卡片,其他操作一律以 unavailable 拒绝。 +- 同一张卡片上的并发操作共用一次处理。操作要求卡片所在会话的根 Agent 仍然在线。 + +----- + + +## 实现原理 + +
+实现细节——点击展开 + +本包默认导出 `actionCards` Remote 服务。它在全局注册一次 `propose_action`,并附带 `when` 条件:在线的根 Agent 自己直接调用 `skill` 加载了已注册种类的 Skill 后,该 Agent 才满足条件,subagent 或代码中发起的加载一律忽略;这个标记保存在内存中,直到 Agent 被释放。调用的 `execute` 检查在线根 Agent、投影累计的排队消息是否达到 `queuedMessageLimit`,并通过轮次边界检查本轮加载过的 Skill,同时在内存中标记本轮已接受的调用;日志中的 `tool/call` 及其成功的 `tool/result` 就是持久的提议。 + +`actionCards` 会话投影(状态版本 2)把每个 `propose_action` 调用与其成功结果配对,从 `plugin:action-cards/decision` 读取决定,从收件箱拼接事件累计排队到下一轮的消息供工具上限使用,并保留尚未报告的决定,直到 `through` 覆盖它们的状态通知写入日志。投影的客户端视图 `{ proposals, decisions }` 不进入会话摘要。 + +Remote `act(sessionId, callId, verb, payload?)` 先检查服务所有者、在线根 Agent 和卡片;“暂不需要”在种类未注册时也继续处理,其他操作还需要种类、操作和 Remote 权限。然后同一张卡片共用一次处理。种类的 `reconcile` 先运行,之后再次检查在线根 Agent;已忽略或已替换的卡片原样返回,“暂不需要”被记录,写入类操作在只读会话中被拒绝,其余操作在该 Agent 的发起方作用域内交给种类处理器。`record()` 也接受种类已注销的动作,因为 Cordis 并发释放插件的各个 effect,卸载中的种类仍要记录已开始工作的结果。一个 `agent/pre-step` 监听器在后续监听器准备好本步之后追加状态通知。 + +| 源码 | 内容 | +|---|---| +| [`src/index.ts`](src/index.ts) | 服务、工具、种类注册表、Remote 操作与状态通知 | +| [`src/projection.ts`](src/projection.ts) | 会话投影 | +| [`src/types.ts`](src/types.ts) | 提议、决定、视图与结果类型 | +| [`tests/`](tests/) | 激活、校验、投影、操作与通知测试 | + +
+ +----- + + +## 延伸阅读 + +- [操作卡片显示](../client-ui-action-cards/README.zh.md) — 浏览器端卡片框架、种类正文与过程行。 +- [个性化页面](../creation-recommendations/README.zh.md)和[插件推荐](../plugin-recommendations/README.zh.md) — 随产品提供的卡片种类。 +- [工具](../../core/tools/README.zh.md) — 条件注册与每步准入。 +- [会话投影](../../session/session-projection/README.zh.md) — 浏览器读取的卡片状态。 + + +## 模型体验 + +### 卡片工具 + +#### 模型看到什么 + +[`propose_action` 声明](../../../docs/tool-catalog.zh.md#deepseek-aidsh-action-cards)只对在本会话中自己直接调用加载过已注册种类 Skill 的在线根 Agent 出现,从下一步开始,持续到该 Agent 被释放。subagent 看不到它;PTC 预设的 Skill 加载由代码发起,也看不到它,因此不会展示卡片。种类的 Skill 给出动作名和 `details` 的字段。被接受的调用返回 `Card shown below your answer. Do not restate it. `,其中 `` 是种类给出的一两句指引,说明如何用用户的语言邀请用户使用卡片,因此模型只会在卡片已经存在之后写邀请。被拒绝的调用不带邀请,只返回原因,例如 `Load skill first, then call propose_action.`、`One card per turn; the earlier card stands.`、`The user has queued more messages; show no card and answer briefly.`、`Cards are offered in the main conversation only.`、`Unknown action "". Registered actions: .`(最后一个种类注销后列表为 `none`),或未通过校验的 `details` 字段。 + +#### Token 影响 + +加载卡片 Skill 之前,工具不增加任何 token。加载之后,该 Agent 的每次请求都会声明它:574 个字符的 JSON,按 token 计量器每 4 个字符计 1 个 token 约 144 个 token。出一张卡片多一次模型往返(最终回答前的这次调用)以及一条工具结果:49 个字符的前缀加上种类的邀请。个性化页面的结果为 305 个字符,按同样的比例约 77 个 token。 + +#### KV Cache 影响 + +在声明支持工具更新的路由上(例如 DeepSeek-V4.1-Flash),激活只追加内容:条件变为真时,循环记录一次请求头变化和一条 `tool-addition` 开发者消息,这些路由以延迟加载声明的形式在该位置引用新定义,因此已缓存的前缀保持不变。不支持工具更新的路由从这一步起在声明列表中收到该工具,请求前缀因此改变一次。Agent 被释放后激活随之失效;重新打开的会话要再次加载卡片 Skill 才会再次声明该工具,并依次记录一次移除和一次添加。 + +### 卡片状态通知 + +#### 模型看到什么 + +记录决定之后,下一次请求会带一条 `action-cards` 通知:下方的开头段落、所涉及各种类的说明句、`Continue with the user’s current request.`,以及一个 JSON 数组,列出发生变化的卡片的 `action`、`callId`、`title`、`phase` 和种类要报告的数据字段。 + +##### 通知开头 + +```markdown +Latest status of action cards from user actions and background work. These are status facts, not a request to start or retry work. Use these receipts instead of the earlier proposal when describing progress. +``` + +#### Token 影响 + +有新决定之后的每次请求带一条通知,大小取决于发生变化的卡片;没有变化的卡片不增加内容,同一张卡片的多次决定只报告最新一次。 + +#### KV Cache 影响 + +通知追加在本次请求其他新上下文之后,从不改写之前的消息。通知既不会开始新轮次,也不会唤醒空闲的对话。 + +## 已知限制与后续工作 + + + +- 激活按在线 Agent 记在内存中,因此会话重新打开后,要再次加载卡片 Skill 工具才会再次出现。 +- PTC 预设不会展示卡片:从代码中发起的 Skill 加载和调用既不会激活工具,也到达不了工具,因为日志把它们记为程序派发而不是工具调用。 +- 卡片只在主对话中提供。subagent(包括 Creator 子 Agent)加载 Skill 不会激活任何东西,其调用也会被拒绝,因为只有在线的根 Agent 才能对卡片进行操作。 +- 所有种类合计每轮只展示一张卡片。任何种类的新卡片都会替换此前所有尚未开始的卡片,因此第二个种类的卡片会替换第一个种类尚未开始的卡片。 +- 用户操作的拒绝原因有意设计为普通字符串:服务定义 `closing`、`not-live-root`、`unknown`、`unavailable`、`access-denied` 和 `read-only`,各种类可以添加自己的原因,浏览器端正文为已知原因显示文案,其余按 unavailable 处理。 +- 每个种类在两端各自校验 `details`:Host 端的 schema 在模型提议时和每次操作前校验,浏览器端正文用自己的 schema 校验,因此两个 schema 要一起修改。 + + +### 开发备注 + +
+维护者工作背景——点击展开 + +无。 + +
diff --git a/packages/experimental/action-cards/locale/de.json b/packages/experimental/action-cards/locale/de.json new file mode 100644 index 0000000000..1ff66c5a5a --- /dev/null +++ b/packages/experimental/action-cards/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Aktionskarten", + "description": "Zeigt für aktivierte Experimente optionale Aktionskarten unter Antworten an und meldet die Entscheidungen des Benutzers an das Gespräch zurück." + } +} diff --git a/packages/experimental/action-cards/locale/en.json b/packages/experimental/action-cards/locale/en.json new file mode 100644 index 0000000000..9b5b1fdc95 --- /dev/null +++ b/packages/experimental/action-cards/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Action cards", + "description": "Show optional action cards below answers for enabled experiments, and report the user's choices back to the conversation." + } +} diff --git a/packages/experimental/action-cards/locale/es.json b/packages/experimental/action-cards/locale/es.json new file mode 100644 index 0000000000..71c4adb3ad --- /dev/null +++ b/packages/experimental/action-cards/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Tarjetas de acción", + "description": "Muestra tarjetas de acción opcionales debajo de las respuestas para los experimentos habilitados e informa a la conversación de las elecciones del usuario." + } +} diff --git a/packages/experimental/action-cards/locale/fr.json b/packages/experimental/action-cards/locale/fr.json new file mode 100644 index 0000000000..5b8fef7ac1 --- /dev/null +++ b/packages/experimental/action-cards/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Cartes d'action", + "description": "Affiche des cartes d'action facultatives sous les réponses pour les expériences activées et transmet les choix de l'utilisateur à la conversation." + } +} diff --git a/packages/experimental/action-cards/locale/ja.json b/packages/experimental/action-cards/locale/ja.json new file mode 100644 index 0000000000..5882383b52 --- /dev/null +++ b/packages/experimental/action-cards/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "アクションカード", + "description": "有効な実験のために回答の下に任意のアクションカードを表示し、ユーザーの選択を会話に伝えます。" + } +} diff --git a/packages/experimental/action-cards/locale/pt.json b/packages/experimental/action-cards/locale/pt.json new file mode 100644 index 0000000000..384e7146fb --- /dev/null +++ b/packages/experimental/action-cards/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Cartões de ação", + "description": "Mostra cartões de ação opcionais abaixo das respostas para experimentos ativados e informa as escolhas do usuário à conversa." + } +} diff --git a/packages/experimental/action-cards/locale/ru.json b/packages/experimental/action-cards/locale/ru.json new file mode 100644 index 0000000000..28d428e532 --- /dev/null +++ b/packages/experimental/action-cards/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Карточки действий", + "description": "Показывает необязательные карточки действий под ответами для включенных экспериментов и передает выбор пользователя в разговор." + } +} diff --git a/packages/experimental/action-cards/locale/zh.json b/packages/experimental/action-cards/locale/zh.json new file mode 100644 index 0000000000..2ddbdd8262 --- /dev/null +++ b/packages/experimental/action-cards/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "操作卡片", + "description": "为已启用的实验在回答下方显示可选操作卡片,并把用户的选择告知对话。" + } +} diff --git a/packages/experimental/action-cards/package.json b/packages/experimental/action-cards/package.json new file mode 100644 index 0000000000..363428560d --- /dev/null +++ b/packages/experimental/action-cards/package.json @@ -0,0 +1,75 @@ +{ + "name": "@deepseek-ai/dsh-action-cards", + "description": "Optional action cards below answers: one shared proposal tool, card state, user actions and status notices", + "version": "0.2.1-rc.3", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/action-cards" + }, + "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", + "./locale/*.json": "./locale/*.json", + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + } + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-skill": "workspace:*", + "@deepseek-ai/dsh-tool-skill": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*" + }, + "dependencies": { + "zod": "4.4.3" + }, + "files": [ + "locale/*.json", + "lib/index.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ] +} diff --git a/packages/experimental/action-cards/src/index.ts b/packages/experimental/action-cards/src/index.ts new file mode 100644 index 0000000000..1a5e75a4fc --- /dev/null +++ b/packages/experimental/action-cards/src/index.ts @@ -0,0 +1,451 @@ +/** Optional action cards below an answer: the `propose_action` tool, card state, user actions and status notices for registered kinds. */ +import { FiberState, Service, type Context } from '@deepseek-ai/cordis' +import { z, type ZodError, type ZodType } from 'zod' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { createUserMessage, HarnessError, type ToolCallId } from '@deepseek-ai/dsh-llm' +import { defineTool, type ToolRunContext } from '@deepseek-ai/dsh-tools' +import { appendPluginRecord, SessionSeq, type Session } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import type {} from '@deepseek-ai/dsh-api-remote-access' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import { actionCardProjection, PROPOSE_ACTION, type ActionCardState } from './projection.ts' +import type { ActionCardDecision, ActionCardFields, ActionCardProposal, ActionCardResult, ActionCardView } from './types.ts' +export type * from './types.ts' + +/** Deployment-tunable limits for offering cards. */ +export const Config = z.object({ + /** Queued next-turn user messages at or above which `propose_action` refuses, so no card is offered. */ + queuedMessageLimit: z.number().int().min(1).default(2), +}).prefault({}) +/** Validated card limits after schema defaults. */ +export type Config = z.infer + +declare module '@deepseek-ai/cordis' { + interface Context { actionCards: ActionCards } +} + +/** The user action every card offers; the shared service records it. */ +const DISMISS = 'dismiss' +/** Phases the projection derives from the log; no decision may record them. */ +const DERIVED_PHASES: readonly string[] = ['replaced'] +/** Phases after which no user action changes the card. */ +const FINAL_PHASES: readonly string[] = ['dismissed', ...DERIVED_PHASES] +/** Tool whose successful loads activate `propose_action`; its `name` argument names the skill. */ +const SKILL_TOOL = 'skill' +const skillArguments = z.object({ name: z.string() }) + +const STATUS_PREAMBLE = 'Latest status of action cards from user actions and background work. ' + + 'These are status facts, not a request to start or retry work. Use these receipts instead of the earlier proposal when describing progress.' +const STATUS_CLOSING = 'Continue with the user’s current request.' + +/** The fiber fields the stopping check reads. */ +export interface PluginFiber { + readonly uid: number | null + readonly state: FiberState + readonly parent: { readonly fiber: PluginFiber } +} + +/** + * Whether a plugin must refuse new work: its fiber or an ancestor is disposed or unloading, since a parent's disposal + * begins before the plugin's own effects run, or the plugin's own drain has begun. + * @param owner - the plugin's fiber. + * @param draining - whether the plugin's drain effect has started. + * @returns whether the plugin is stopping. + */ +export function isPluginStopping(owner: PluginFiber, draining: boolean): boolean { + for (let fiber = owner; ; fiber = fiber.parent.fiber) { + if (fiber.uid === null || fiber.state === FiberState.UNLOADING) return true + if (fiber.parent.fiber === fiber) return draining + } +} + +/** A refused `propose_action` call; the message tells the model what to do instead. */ +export class ActionCardError extends HarnessError {} + +/** Shared checks one user action of a kind requires before the kind handles it. */ +export interface ActionCardVerb { + /** The connection must hold global Remote access, as for profile changes. */ + readonly global?: true + /** Refused while the Session sandbox mode is read-only. */ + readonly writes?: true +} + +/** A proposal whose details passed its kind's schema. */ +export type ActionCardProposalOf
= Omit & { readonly details: Details } + +/** Host half of one action kind: the skill that proposes it, its fields and its user actions. */ +export interface ActionCardKind
{ + /** Value of the `action` argument. */ + readonly action: string + /** Skill whose instructions propose this action; a load in the current turn admits the call. */ + readonly skill: string + /** + * Model-facing closing guidance that the result of an accepted call gives: one or two sentences on how to invite + * the user to the card, in the user's language. Refused calls never carry it, so the model invites the user only to a + * card that exists. + */ + readonly invitation: string + /** Validates `details` when the model proposes and before each user action. */ + readonly details: ZodType
+ /** User actions besides `dismiss`, with the shared checks each requires; other verbs are refused as unavailable. */ + readonly verbs: Readonly> + /** Recorded phases that still offer the action; dismissal is recorded for these and for an unstarted card. */ + readonly openPhases?: readonly string[] + /** Sentences the status notice adds when it reports this kind's decisions. */ + readonly statusGuidance?: string + /** Decision data fields the status notice reports; omitted reports every field. */ + readonly statusFields?: readonly string[] + /** + * Fields recorded with a dismissal. + * @param proposal - the dismissed card. + * @param payload - fields the user's client sent. + * @returns decision data, or undefined to record none. + */ + dismissal?(proposal: ActionCardProposalOf
, payload: ActionCardFields | undefined): ActionCardFields | undefined + /** + * Bring the card's recorded decision up to date with state outside the Session, such as an installation that + * finished while the Session was closed, recording each change with {@link ActionCards.record}. The service awaits it + * before every user action, including `dismiss`, then reads the card's decision again, so a card that is no longer + * open settles instead of being dismissed. + * @param agent - live root of the card's Session. + * @param proposal - the card with validated details. + * @throws when that state cannot be read; the action is then refused as unavailable. + */ + reconcile?(agent: Agent, proposal: ActionCardProposalOf
): Promise + /** + * Handle one user action other than `dismiss` after the shared checks passed. The handler records each new + * decision with {@link ActionCards.record}. + * @param agent - live root of the card's Session. + * @param proposal - the card with validated details. + * @param verb - one of {@link verbs}. + * @param payload - fields the user's client sent; the kind validates them. + * @returns the action's result. + */ + act(agent: Agent, proposal: ActionCardProposalOf
, verb: string, payload: ActionCardFields | undefined): Promise +} + +/** A registered kind with its details type erased behind validating closures. */ +interface RegisteredKind { + readonly skill: string + readonly invitation: string + readonly verbs: Readonly> + readonly openPhases: readonly string[] + readonly statusGuidance: string | undefined + readonly statusFields: readonly string[] | undefined + /** Describe why details fail the kind's schema; undefined when they pass. */ + readonly detailsIssues: (details: ActionCardFields) => string | undefined + readonly dismissal: (proposal: ActionCardProposal, payload: ActionCardFields | undefined) => ActionCardFields | undefined + /** Absent when the kind keeps no state outside the Session. */ + readonly reconcile: ((agent: Agent, proposal: ActionCardProposal) => Promise) | undefined + readonly act: ( + agent: Agent, proposal: ActionCardProposal, verb: string, payload: ActionCardFields | undefined, + ) => Promise +} + +/** Own entries only: browser-supplied ids such as `toString` never reach inherited object members. */ +function ownEntry(record: Readonly>, key: string): T | undefined { + return Object.hasOwn(record, key) ? record[key] : undefined +} + +function refused(reason: string): ActionCardResult { + return { status: 'refused', reason } +} + +function issuesOf(error: ZodError): string { + return 'Invalid details: ' + error.issues.map(issue => `${['details', ...issue.path.map(String)].join('.')}: ${issue.message}`).join('; ') +} + +/** Card tool, kind registry, Session projection, user actions and status notices. */ +export class ActionCards extends TypertRemoteService { + static inject = ['tools', 'agents', 'sessionProjections', 'remoteAccess'] + static Config = Config + private projection!: ProjectionHandle + private readonly kinds = new Map() + /** + * Actions whose kind unregistered. Cordis disposes a plugin's effects concurrently, so a kind's unload drain can + * still record the outcome of its in-flight work after its registration is gone. + */ + private readonly retired = new Set() + /** Skills of registered kinds each live agent has loaded; `propose_action` is declared while one is still registered. */ + private readonly loaded = new Map>() + /** Kind skills each agent loaded in its latest turn. */ + private readonly turnLoads = new Map }>() + /** The call accepted in each agent's latest turn, until its result fails. */ + private readonly accepted = new Map() + private readonly actions = new Map>() + private closing = false + private readonly owner: Context['fiber'] + + /** + * @param ctx - Owning plugin context. + * @param config - Validated card limits; direct composition without a config gets the schema defaults. + */ + constructor(ctx: Context, private readonly config: Config = Config.parse({})) { + super(ctx, 'actionCards') + this.owner = ctx.fiber + } + + protected async [Service.init](): Promise { + this.projection = await this.ctx.sessionProjections.register(actionCardProjection) + this.ctx.tools.register(defineTool({ + name: PROPOSE_ACTION, + description: 'Show the user one optional action card under this answer. Call only as the skill that names the action instructs.', + parameters: { + action: { type: 'string', required: true, description: 'Action name from the skill.' }, + title: { type: 'string', required: true, description: 'Card title.' }, + brief: { type: 'string', required: true, description: 'What the action does, in the user\'s language.' }, + details: { type: 'object', additionalProperties: true, required: true, description: 'Fields the skill lists for this action.' }, + }, + output: { + schema: { + type: 'object', additionalProperties: false, + properties: { + action: { type: 'string', required: true }, title: { type: 'string', required: true }, invitation: { type: 'string', required: true }, + }, + }, + render: (_args, value) => [{ type: 'text', text: `Card shown below your answer. Do not restate it. ${value.invitation}` }], + }, + execute: (args, exec) => Promise.resolve(this.propose(args, exec)), + }), { when: ({ agent }) => this.declares(agent) }) + this.ctx.on('tools/result', (exec, result) => { + const agent = exec.agent + if (agent === undefined) return + if (exec.name === PROPOSE_ACTION) { + if (result.isError && this.accepted.get(agent)?.callId === exec.callId) this.accepted.delete(agent) + return + } + // Loads by subagents or from code activate nothing: `propose_action` refuses both callers. + if (exec.parent !== undefined || !this.ctx.agents.roots().includes(agent)) return + const skill = exec.name === SKILL_TOOL && !result.isError ? skillArguments.safeParse(exec.arguments) : undefined + if (skill?.success !== true || ![...this.kinds.values()].some(kind => kind.skill === skill.data.name)) return + const turn = this.ctx.agents.turnBoundaryOf(agent.session).lastTurn + const loads = this.turnLoads.get(agent) + if (loads?.turn === turn) loads.skills.add(skill.data.name) + else this.turnLoads.set(agent, { turn, skills: new Set([skill.data.name]) }) + this.loaded.set(agent, new Set([...this.loaded.get(agent) ?? [], skill.data.name])) + }) + this.ctx.on('agent/disposed', ({ agent }) => { + this.loaded.delete(agent) + this.turnLoads.delete(agent) + this.accepted.delete(agent) + }) + this.ctx.on('agent/pre-step', async ({ agent, signal }, next) => { + const decision = await next() + if (decision.kind === 'reject' || signal.aborted || this.isStopping()) return decision + const state = this.projection.stateOf(agent.session) + const pending = Object.values(state.pendingStatus) + if (pending.length === 0) return decision + const receipts = Object.values(state.view.decisions).filter(receipt => Object.hasOwn(state.pendingStatus, receipt.callId)) + const guidance = new Set(receipts.flatMap(receipt => this.kinds.get(receipt.action)?.statusGuidance ?? [])) + const updates = receipts.map((receipt) => { + const fields = this.kinds.get(receipt.action)?.statusFields + const data = receipt.data === undefined || fields === undefined ? receipt.data + : Object.fromEntries(Object.entries(receipt.data).filter(([key]) => fields.includes(key))) + return { action: receipt.action, callId: receipt.callId, title: ownEntry(state.view.proposals, receipt.callId)?.title, + phase: receipt.phase, ...data === undefined ? {} : { data } } + }) + return { ...decision, messages: [...decision.messages, createUserMessage({ + source: { kind: 'action-cards', through: SessionSeq(Math.max(...pending)), form: 'notice', summary: 'Action card status updated' }, + content: [{ type: 'text', text: [STATUS_PREAMBLE, ...guidance, STATUS_CLOSING].join(' ') + '\n' + JSON.stringify(updates) }], + })] } + }, { prepend: true }) + this.ctx.effect(() => async () => { + this.closing = true + await Promise.allSettled([...this.actions.values()]) + }, 'action-cards: drain user actions') + } + + /** + * Register one action kind for the caller's lifetime. + * @param kind - the kind's skill, details schema, verbs and handler. + * @returns disposer removing the kind; actions on its cards are then unavailable. + * @throws when another kind already registered the same action. + */ + register
(kind: ActionCardKind
): () => void { + const typed = (proposal: ActionCardProposal): ActionCardProposalOf
| undefined => { + const details = kind.details.safeParse(proposal.details) + return details.success ? { ...proposal, details: details.data } : undefined + } + const reconcile = kind.reconcile?.bind(kind) + const entry: RegisteredKind = { + skill: kind.skill, invitation: kind.invitation, verbs: kind.verbs, openPhases: kind.openPhases ?? [], + statusGuidance: kind.statusGuidance, statusFields: kind.statusFields, + detailsIssues: (details) => { + const parsed = kind.details.safeParse(details) + return parsed.success ? undefined : issuesOf(parsed.error) + }, + dismissal: (proposal, payload) => { + const card = typed(proposal) + return card === undefined ? undefined : kind.dismissal?.(card, payload) + }, + // A card whose details no longer validate never reached the kind, so the kind has nothing to reconcile. + reconcile: reconcile === undefined ? undefined : async (agent, proposal) => { + const card = typed(proposal) + if (card !== undefined) await reconcile(agent, card) + }, + act: (agent, proposal, verb, payload) => { + const card = typed(proposal) + return card === undefined ? Promise.resolve(refused('unavailable')) : kind.act(agent, card, verb, payload) + }, + } + const dispose = this.ctx.effect(() => { + if (this.kinds.has(kind.action)) throw new Error(`action-cards: action "${kind.action}" is already registered`) + this.kinds.set(kind.action, entry) + return () => { + this.kinds.delete(kind.action) + this.retired.add(kind.action) + } + }, `action-cards: kind ${kind.action}`) + return () => { void dispose() } + } + + /** + * Record a card decision in the Session log; the next model request reports it. + * @param agent - agent whose Session owns the card. + * @param decision - the card's new state. + * @throws when no kind ever registered the decision's action with this service, or its phase is one the projection derives. + */ + record(agent: Agent, decision: ActionCardDecision): void { + if (!this.kinds.has(decision.action) && !this.retired.has(decision.action)) { + throw new Error(`action-cards: cannot record a decision for unregistered action "${decision.action}"`) + } + if (DERIVED_PHASES.includes(decision.phase)) throw new Error(`action-cards: phase "${decision.phase}" is derived from the log and cannot be recorded`) + this.append(agent, decision) + } + + /** + * Read the cards of one Session. + * @param session - a Session whose projections are prepared. + * @returns proposals and decisions by call id. + */ + view(session: Session): ActionCardView { + return this.projection.stateOf(session).view + } + + /** + * Apply a user action to a logged card. Shared checks run first, then the kind reconciles the card; `dismiss` is + * recorded here and every other verb goes to the card's kind. `dismiss` also works on an unstarted card whose kind + * is no longer registered. Concurrent actions on one card share one operation. + * @param agent - live root resolved from the displayed Session. + * @param callId - the card's `propose_action` call id. + * @param verb - `dismiss` or one of the kind's verbs. + * @param payload - kind-defined fields such as a chosen location. + * @returns acceptance of background work, the decision in effect, or a refusal. + */ + @Remote + act(agent: Agent, callId: ToolCallId, verb: string, payload?: Record): Promise { + if (this.isStopping()) return Promise.resolve(refused('closing')) + if (!this.ctx.agents.roots().includes(agent)) return Promise.resolve(refused('not-live-root')) + const proposal = ownEntry(this.projection.stateOf(agent.session).view.proposals, callId) + if (proposal === undefined) return Promise.resolve(refused('unknown')) + const kind = this.kinds.get(proposal.action) + if (verb === DISMISS) return this.coalesce(agent, callId, () => this.dismiss(agent, kind, proposal, payload)) + const policy = kind === undefined ? undefined : ownEntry(kind.verbs, verb) + if (kind === undefined || policy === undefined) return Promise.resolve(refused('unavailable')) + if (policy.global === true && !this.ctx.remoteAccess.allows({ kind: 'global' })) return Promise.resolve(refused('access-denied')) + return this.coalesce(agent, callId, () => this.perform(agent, kind, proposal, verb, policy, payload)) + } + + /** Share one operation per card, run under the Agent's initiator, until it settles. */ + private coalesce(agent: Agent, callId: ToolCallId, run: () => Promise): Promise { + const key = JSON.stringify([agent.id, callId]) + const existing = this.actions.get(key) + if (existing !== undefined) return existing + const operation = this.ctx.agents.withInitiator(agent, run).finally(() => { this.actions.delete(key) }) + this.actions.set(key, operation) + return operation + } + + /** + * Await the kind's reconciliation. A kind that unloads meanwhile still handles the action and refuses it itself. + * @returns a refusal when reconciliation failed or the root closed meanwhile; undefined to continue. + */ + private async reconciled( + agent: Agent, kind: RegisteredKind, proposal: ActionCardProposal, verb: string, + ): Promise { + if (kind.reconcile === undefined) return undefined + try { await kind.reconcile(agent, proposal) } + catch (error) { + this.ctx.logger.warn('Cannot reconcile action card %s before %s: %s', proposal.callId, verb, String(error)) + return refused('unavailable') + } + return this.ctx.agents.roots().includes(agent) ? undefined : refused('not-live-root') + } + + private async dismiss( + agent: Agent, kind: RegisteredKind | undefined, proposal: ActionCardProposal, payload: ActionCardFields | undefined, + ): Promise { + const refusal = kind === undefined ? undefined : await this.reconciled(agent, kind, proposal, DISMISS) + if (refusal !== undefined) return refusal + const decision = ownEntry(this.projection.stateOf(agent.session).view.decisions, proposal.callId) + // Without its kind no recorded phase is known to be open, and the kind may return to act on it. + if (decision !== undefined && (FINAL_PHASES.includes(decision.phase) || kind?.openPhases.includes(decision.phase) !== true)) { + return { status: 'settled', decision } + } + const data = kind?.dismissal(proposal, payload) + const dismissed: ActionCardDecision = { callId: proposal.callId, action: proposal.action, phase: 'dismissed', ...data === undefined ? {} : { data } } + this.append(agent, dismissed) + return { status: 'settled', decision: dismissed } + } + + private async perform( + agent: Agent, kind: RegisteredKind, proposal: ActionCardProposal, verb: string, policy: ActionCardVerb, + payload: ActionCardFields | undefined, + ): Promise { + const refusal = await this.reconciled(agent, kind, proposal, verb) + if (refusal !== undefined) return refusal + const decision = ownEntry(this.projection.stateOf(agent.session).view.decisions, proposal.callId) + if (decision !== undefined && FINAL_PHASES.includes(decision.phase)) return { status: 'settled', decision } + if (policy.writes === true && this.ctx.get('sandboxPolicy')?.resolve({ session: agent.session }).mode === 'read-only') return refused('read-only') + return await kind.act(agent, proposal, verb, payload) + } + + private append(agent: Agent, decision: ActionCardDecision): void { + appendPluginRecord(agent.session, 'plugin:action-cards/decision', decision) + } + + /** Whether `propose_action` is declared for this agent's next step. */ + private declares(agent: Agent): boolean { + const skills = this.loaded.get(agent) + return skills !== undefined && [...this.kinds.values()].some(kind => skills.has(kind.skill)) + } + + private propose( + args: { readonly action: string; readonly title: string; readonly brief: string; readonly details: ActionCardFields }, + exec: ToolRunContext, + ): { action: string; title: string; invitation: string } { + const agent = exec.agent + // Step admission declares the tool only to an agent; calls dispatched from code are not logged as tool calls, + // so no card could follow them. + if (agent === undefined || exec.parent !== undefined) throw new ActionCardError('Call propose_action directly, not from code.', 'ACTION_CARD_NESTED') + // Only a live root can act on a card, so a subagent's card could never be used. + if (!this.ctx.agents.roots().includes(agent)) throw new ActionCardError('Cards are offered in the main conversation only.', 'ACTION_CARD_ROOT') + if (this.projection.stateOf(agent.session).queued >= this.config.queuedMessageLimit) { + throw new ActionCardError('The user has queued more messages; show no card and answer briefly.', 'ACTION_CARD_QUEUED') + } + const kind = this.kinds.get(args.action) + // Admission is decided when the step starts, so the last kind can unregister before this call runs. + if (kind === undefined) throw new ActionCardError(`Unknown action "${args.action}". Registered actions: ${[...this.kinds.keys()].join(', ') || 'none'}.`, 'ACTION_CARD_UNKNOWN') + const turn = this.ctx.agents.turnBoundaryOf(agent.session).lastTurn + const loads = this.turnLoads.get(agent) + if (loads?.turn !== turn || !loads.skills.has(kind.skill)) { + throw new ActionCardError(`Load skill ${kind.skill} first, then call propose_action.`, 'ACTION_CARD_SKILL') + } + if (args.title.trim() === '' || args.brief.trim() === '') throw new ActionCardError('Write a title and a brief.', 'ACTION_CARD_BLANK') + const issues = kind.detailsIssues(args.details) + if (issues !== undefined) throw new ActionCardError(issues, 'ACTION_CARD_DETAILS') + if (this.accepted.get(agent)?.turn === turn || Object.values(this.view(agent.session).proposals).some(card => card.turn === turn)) { + throw new ActionCardError('One card per turn; the earlier card stands.', 'ACTION_CARD_LIMIT') + } + this.accepted.set(agent, { turn, callId: exec.callId }) + return { action: args.action, title: args.title.trim(), invitation: kind.invitation } + } + + private isStopping(): boolean { + return isPluginStopping(this.owner, this.closing) + } +} + +export default ActionCards diff --git a/packages/experimental/action-cards/src/projection.ts b/packages/experimental/action-cards/src/projection.ts new file mode 100644 index 0000000000..3d1be18a97 --- /dev/null +++ b/packages/experimental/action-cards/src/projection.ts @@ -0,0 +1,124 @@ +/** Recover card proposals from logged `propose_action` calls and decisions from plugin records. */ +import { z } from 'zod' +import { ToolCallId } from '@deepseek-ai/dsh-llm' +import { pluginRecordOf, SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' + +/** Model-facing name of the card tool. */ +export const PROPOSE_ACTION = 'propose_action' + +/** Kind-defined JSON object of a proposal or decision. */ +export const fieldsSchema = z.record(z.string(), z.json()) + +/** Arguments of a `propose_action` call; blank titles and briefs create no card. */ +export const argumentsSchema = z.object({ + action: z.string().min(1), + title: z.string().trim().min(1), + brief: z.string().trim().min(1), + details: fieldsSchema, +}) + +const proposalSchema = z.object({ + callId: z.string().min(1).transform(ToolCallId), + turn: z.number().int().nonnegative(), + action: z.string().min(1), + title: z.string().min(1), + brief: z.string().min(1), + details: fieldsSchema, +}).strict() + +/** Recorded decision of one card. */ +export const decisionSchema = z.object({ + callId: z.string().min(1).transform(ToolCallId), + action: z.string().min(1), + phase: z.string().min(1), + data: fieldsSchema.optional(), +}).strict() + +const viewSchema = z.object({ + proposals: z.record(z.string(), proposalSchema), + decisions: z.record(z.string(), decisionSchema), +}) + +const stateSchema = z.object({ + /** Fork-inherited prefix length; inherited events create no proposals or decisions. */ + inheritedEventCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionLogOffset), + view: viewSchema, + /** `propose_action` calls of the open turn still waiting for their result. */ + calls: z.record(z.string(), proposalSchema.omit({ callId: true })), + /** Turn of the latest admitted proposal; later calls of that turn create no card. */ + admittedTurn: z.number().int().nullable(), + /** Decisions not yet reported to the model, by call id, with the seq of their record. */ + pendingStatus: z.record(z.string(), z.number()), + /** User messages still queued for later turns, folded from inbox splices; the card tool compares it with its limit. */ + queued: z.number().int().nonnegative(), +}) + +/** Fold state; `view` is also the Client wire value. */ +export type ActionCardState = z.infer + +/** Type of {@link actionCardProjection}: the fold plus its Client view wire. */ +export type ActionCardProjectionDefinition = Omit, 'wire'> & { + readonly wire: NonNullable['wire']> +} + +/** + * Full-Session fold. A successful `propose_action` result admits the call as a card, at most one per turn; + * the first admitted call of a turn wins. A newer card of any kind replaces every unstarted earlier card. + * Events inherited by a forked Session create no proposals or decisions. + */ +export const actionCardProjection: ActionCardProjectionDefinition = { + key: 'actionCards', stateVersion: 2, stateSchema, + init: (_header, inheritedEventCount) => ({ + inheritedEventCount, view: { proposals: {}, decisions: {} }, calls: {}, admittedTurn: null, pendingStatus: {}, queued: 0, + }), + apply: (state, event) => { + if (event.seq < state.inheritedEventCount) return state + switch (event.type) { + case 'turn/start': + // A turn's calls settle inside it; a call left without a result never becomes a card. + return Object.keys(state.calls).length === 0 ? state : { ...state, calls: {} } + case 'agent/inbox/spliced': + if (event.data.target !== 'next-turn') return state + return { ...state, queued: Math.max(0, state.queued - (event.data.removedCount ?? 0) + event.data.inserted.length) } + case 'user/message': { + if (event.data.source.kind !== 'action-cards') return state + const through = event.data.source.through + return { ...state, pendingStatus: Object.fromEntries(Object.entries(state.pendingStatus).filter(([, seq]) => seq > through)) } + } + case 'tool/call': { + if (event.data.name !== PROPOSE_ACTION) return state + let raw: unknown + try { raw = JSON.parse(event.data.arguments) } + catch { return state } // Malformed model JSON failed in the tool too and creates no card. + const parsed = argumentsSchema.safeParse(raw) + if (!parsed.success) return state + return { ...state, calls: { ...state.calls, [event.data.callId]: { turn: event.data.turn, ...parsed.data } } } + } + case 'tool/result': { + const callId = event.data.message.toolCallId + const call = Object.hasOwn(state.calls, callId) ? state.calls[callId] : undefined + if (call === undefined) return state + const calls = Object.fromEntries(Object.entries(state.calls).filter(([id]) => id !== callId)) + if (event.data.message.isError === true || state.admittedTurn === call.turn) return { ...state, calls } + const decisions = { ...state.view.decisions } + // A newer card supersedes every unstarted card; the model made that choice, so no status is reported. + for (const earlier of Object.values(state.view.proposals)) { + if (decisions[earlier.callId] === undefined) decisions[earlier.callId] = { callId: earlier.callId, action: earlier.action, phase: 'replaced' } + } + return { ...state, calls, admittedTurn: call.turn, + view: { proposals: { ...state.view.proposals, [callId]: { callId, ...call } }, decisions } } + } + default: { + const record = pluginRecordOf(event) + if (record?.type !== 'plugin:action-cards/decision') return state + // A malformed or newer record is skipped; throwing here would fail every later read of this Session. + const decision = decisionSchema.safeParse(record.data) + if (!decision.success) return state + return { ...state, view: { ...state.view, decisions: { ...state.view.decisions, [decision.data.callId]: decision.data } }, + pendingStatus: { ...state.pendingStatus, [decision.data.callId]: event.seq } } + } + } + }, + wire: { summary: false, viewSchema, view: (state: ActionCardState) => state.view }, +} diff --git a/packages/experimental/action-cards/src/types.ts b/packages/experimental/action-cards/src/types.ts new file mode 100644 index 0000000000..d240f47126 --- /dev/null +++ b/packages/experimental/action-cards/src/types.ts @@ -0,0 +1,79 @@ +/** Card proposals, recorded decisions and user-action results shared by every action kind. */ +import type { ContextFormed } from '@deepseek-ai/dsh-llm' +import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionSeq } from '@deepseek-ai/dsh-session/types' +import type { SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' + +/** Kind-defined JSON fields of a proposal or decision; each kind validates its own fields. */ +export type ActionCardFields = Readonly> + +/** One card admitted from a successful `propose_action` call. */ +export interface ActionCardProposal { + /** Id of the logged tool call. */ + readonly callId: ToolCallId + /** Turn whose answer the card follows. */ + readonly turn: number + /** Registered action kind named by the call. */ + readonly action: string + readonly title: string + /** What the action does, in the user's language. */ + readonly brief: string + /** Fields the kind's skill asks for. */ + readonly details: ActionCardFields +} + +/** + * Latest state of one card. Kinds define their own phases; `dismissed` is shared. + * `replaced` (a later proposal superseded an unstarted card) is derived from the + * log and never recorded. + */ +export interface ActionCardDecision { + readonly callId: ToolCallId + readonly action: string + readonly phase: string + /** Kind-defined fields such as a chosen location or a created package. */ + readonly data?: ActionCardFields | undefined +} + +/** Open-Session proposals and decisions by call id, excluded from Session summaries. */ +export interface ActionCardView { + readonly proposals: Readonly> + readonly decisions: Readonly> +} + +/** + * Result of a user action. `accepted` starts background work that records its + * own progress; `settled` returns the decision now in effect; `refused` changed + * nothing; `unconfirmed` lost its outcome to an unloading owner, which the + * kind reconciles when the Session reopens. + */ +export type ActionCardResult = + | { readonly status: 'accepted' } + | { readonly status: 'settled'; readonly decision: ActionCardDecision } + | { readonly status: 'refused'; readonly reason: string } + | { readonly status: 'unconfirmed'; readonly reason: string } + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + /** Card status notice; other readers preserve the recorded text without this plugin. + * The producer uses through only to acknowledge delivered decisions. + * @persistenceAttribution + */ + 'action-cards': { kind: 'action-cards'; through: SessionSeq } & ContextFormed + } +} + +declare module '@deepseek-ai/dsh-session/types' { + interface PluginRecordMap { + /** Dismissals and kind-defined progress of action cards. */ + 'plugin:action-cards/decision': ActionCardDecision + } +} + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** Action card proposals and decisions for the currently open Session. */ + actionCards: SessionProjectionSummaryExcluded + } +} diff --git a/packages/experimental/action-cards/tests/__snapshots__/status.spec.ts.snap b/packages/experimental/action-cards/tests/__snapshots__/status.spec.ts.snap new file mode 100644 index 0000000000..4b6482d24f --- /dev/null +++ b/packages/experimental/action-cards/tests/__snapshots__/status.spec.ts.snap @@ -0,0 +1,11 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`reports recorded decisions once on the next request, with each kind guidance once, without waking the conversation > status notice 1`] = ` +[ + { + "text": "Latest status of action cards from user actions and background work. These are status facts, not a request to start or retry work. Use these receipts instead of the earlier proposal when describing progress. Failed runs can be retried from their card. Continue with the user’s current request. +[{"action":"test_action","callId":"call-card-1","title":"Counter","phase":"failed","data":{"reason":"timeout"}},{"action":"test_action","callId":"call-card-2","title":"Second","phase":"dismissed"},{"action":"retired_action","callId":"call-orphan","phase":"done"}]", + "type": "text", + }, +] +`; diff --git a/packages/experimental/action-cards/tests/__snapshots__/tool.spec.ts.snap b/packages/experimental/action-cards/tests/__snapshots__/tool.spec.ts.snap new file mode 100644 index 0000000000..2eec86e6ec --- /dev/null +++ b/packages/experimental/action-cards/tests/__snapshots__/tool.spec.ts.snap @@ -0,0 +1,36 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`declares propose_action from the step after a card skill loads, and logs the addition > tool declaration 1`] = ` +{ + "description": "Show the user one optional action card under this answer. Call only as the skill that names the action instructs.", + "name": "propose_action", + "parameters": { + "properties": { + "action": { + "description": "Action name from the skill.", + "type": "string", + }, + "brief": { + "description": "What the action does, in the user's language.", + "type": "string", + }, + "details": { + "additionalProperties": true, + "description": "Fields the skill lists for this action.", + "type": "object", + }, + "title": { + "description": "Card title.", + "type": "string", + }, + }, + "required": [ + "action", + "title", + "brief", + "details", + ], + "type": "object", + }, +} +`; diff --git a/packages/experimental/action-cards/tests/act.spec.ts b/packages/experimental/action-cards/tests/act.spec.ts new file mode 100644 index 0000000000..0b6e580943 --- /dev/null +++ b/packages/experimental/action-cards/tests/act.spec.ts @@ -0,0 +1,167 @@ +import { expect, it, onTestFinished, vi } from 'vitest' +import { z } from 'zod' +import { ToolCallId } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ActionCardProposalOf, ActionCardResult } from '../src/index.ts' +import { ACTION, callId, fixture, proposal, type TestDetails } from './fixture.ts' + +const dismissed = (data?: object) => ({ status: 'settled', decision: { callId, action: ACTION, phase: 'dismissed', ...data === undefined ? {} : { data } } }) + +it('refuses unknown cards, closed owners, verbs of removed kinds and undeclared verbs before any handler runs', async () => { + const f = await fixture() + expect(await f.ctx.actionCards.act(f.agent, callId, 'start')).toEqual({ status: 'refused', reason: 'unknown' }) + f.propose() + for (const id of ['toString', 'constructor']) { + expect(await f.ctx.actionCards.act(f.agent, ToolCallId(id), 'dismiss')).toEqual({ status: 'refused', reason: 'unknown' }) + } + for (const verb of ['toString', 'missing']) { + expect(await f.ctx.actionCards.act(f.agent, callId, verb)).toEqual({ status: 'refused', reason: 'unavailable' }) + } + const roots = vi.spyOn(f.ctx.agents, 'roots').mockReturnValue([]) + expect(await f.ctx.actionCards.act(f.agent, callId, 'start')).toEqual({ status: 'refused', reason: 'not-live-root' }) + roots.mockRestore() + await f.kindFiber.dispose() + expect(await f.ctx.actionCards.act(f.agent, callId, 'start')).toEqual({ status: 'refused', reason: 'unavailable' }) + const service = f.ctx.actionCards + await f.cards.dispose() + expect(await service.act(f.agent, callId, 'dismiss')).toEqual({ status: 'refused', reason: 'closing' }) + expect(f.act).not.toHaveBeenCalled() +}) + +it('applies the shared access and read-only checks the kind declares, then hands typed details to the kind', async () => { + const f = await fixture() + f.propose() + const access = vi.spyOn(f.ctx.remoteAccess, 'allows').mockReturnValue(false) + expect(await f.ctx.actionCards.act(f.agent, callId, 'publish')).toEqual({ status: 'refused', reason: 'access-denied' }) + expect(await f.ctx.actionCards.act(f.agent, callId, 'start', { note: 'x' })).toEqual({ status: 'settled', decision: { callId, action: ACTION, phase: 'start' } }) + access.mockRestore() + expect(f.act).toHaveBeenCalledWith(f.agent, { callId, turn: 1, ...proposal }, 'start', { note: 'x' }) + const resolve = vi.fn(() => ({ mode: 'read-only', workspaceRoot: '/' })) + f.ctx.provide('sandboxPolicy', { resolve }) + expect(await f.ctx.actionCards.act(f.agent, callId, 'write')).toEqual({ status: 'refused', reason: 'read-only' }) + expect(resolve).toHaveBeenCalledWith({ session: f.agent.session }) + expect((await f.ctx.actionCards.act(f.agent, callId, 'publish')).status).toBe('settled') + resolve.mockReturnValue({ mode: 'workspace-write', workspaceRoot: '/' }) + expect((await f.ctx.actionCards.act(f.agent, callId, 'write')).status).toBe('settled') + f.propose(ToolCallId('call-invalid'), { ...proposal, details: { value: 3 } }, f.agent, 2) + expect(await f.ctx.actionCards.act(f.agent, ToolCallId('call-invalid'), 'start')).toEqual({ status: 'refused', reason: 'unavailable' }) + expect(f.act).toHaveBeenCalledTimes(3) +}) + +it('records dismissal of an unstarted or open card with the kind data, and settles started or final cards', async () => { + const f = await fixture({ + openPhases: ['failed'], + dismissal: (card, payload) => ({ value: card.details.value, ...z.object({ choice: z.string() }).safeParse(payload).data }), + }) + f.propose() + expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss', { choice: 'later' })).toEqual(dismissed({ value: 'a', choice: 'later' })) + expect(f.view().decisions[callId]?.phase).toBe('dismissed') + for (const verb of ['dismiss', 'start']) expect(await f.ctx.actionCards.act(f.agent, callId, verb)).toEqual(dismissed({ value: 'a', choice: 'later' })) + f.ctx.actionCards.record(f.agent, { callId, action: ACTION, phase: 'failed' }) + expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss')).toEqual(dismissed({ value: 'a' })) + f.ctx.actionCards.record(f.agent, { callId, action: ACTION, phase: 'running', data: { step: 1 } }) + expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss')).toEqual({ status: 'settled', + decision: { callId, action: ACTION, phase: 'running', data: { step: 1 } } }) + f.propose(ToolCallId('call-invalid'), { ...proposal, details: { value: 3 } }, f.agent, 2) + expect(await f.ctx.actionCards.act(f.agent, ToolCallId('call-invalid'), 'dismiss')).toEqual({ status: 'settled', + decision: { callId: 'call-invalid', action: ACTION, phase: 'dismissed' } }) + expect(f.act).not.toHaveBeenCalled() +}) + +it('dismisses without data when the kind records none', async () => { + const f = await fixture() + f.propose() + expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss', { choice: 'later' })).toEqual(dismissed()) +}) + +it('shares one operation between concurrent actions on a card and drains it on unload', async () => { + const pending = Promise.withResolvers() + onTestFinished(() => { pending.resolve({ status: 'accepted' }) }) + const f = await fixture({ act: vi.fn(() => pending.promise) }) + f.propose() + const first = f.ctx.actionCards.act(f.agent, callId, 'start') + expect(f.ctx.actionCards.act(f.agent, callId, 'dismiss')).toBe(first) + const disposing = f.cards.dispose() + pending.resolve({ status: 'accepted' }) + expect(await first).toEqual({ status: 'accepted' }) + await disposing +}) + +it('rejects a second kind for the same action and releases a kind through its disposer', async () => { + const f = await fixture() + const kind = (action: string) => ({ action, skill: 'other-skill', invitation: 'Invite the user.', details: z.object({}), verbs: { start: {} }, + act: () => Promise.resolve({ status: 'accepted' }) }) + expect(() => f.ctx.actionCards.register(kind(ACTION))).toThrow('action-cards: action "test_action" is already registered') + const unregister = f.ctx.actionCards.register(kind('other_action')) + f.propose(ToolCallId('call-other'), { action: 'other_action', title: 'Other', brief: 'Other card.', details: {} }) + expect(await f.ctx.actionCards.act(f.agent, ToolCallId('call-other'), 'start')).toEqual({ status: 'accepted' }) + unregister() + expect(await f.ctx.actionCards.act(f.agent, ToolCallId('call-other'), 'start')).toEqual({ status: 'refused', reason: 'unavailable' }) +}) + +it('lets the kind reconcile before every action, so a card that is no longer open settles instead of being dismissed', async () => { + let finished = false + const reconcile = vi.fn(async (agent: Agent, card: ActionCardProposalOf) => { + if (finished) f.ctx.actionCards.record(agent, { callId: card.callId, action: ACTION, phase: 'installed' }) + }) + const f = await fixture({ openPhases: ['unconfirmed'], reconcile }) + f.propose() + f.ctx.actionCards.record(f.agent, { callId, action: ACTION, phase: 'unconfirmed' }) + finished = true + const installed = { status: 'settled', decision: { callId, action: ACTION, phase: 'installed' } } + expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss')).toEqual(installed) + expect(reconcile).toHaveBeenCalledWith(f.agent, { callId, turn: 1, ...proposal }) + expect(f.view().decisions[callId]?.phase).toBe('installed') + expect(await f.ctx.actionCards.act(f.agent, callId, 'start')).toEqual({ status: 'settled', decision: { callId, action: ACTION, phase: 'start' } }) + expect(reconcile).toHaveBeenCalledTimes(2) + f.propose(ToolCallId('call-invalid'), { ...proposal, details: { value: 3 } }, f.agent, 2) + expect((await f.ctx.actionCards.act(f.agent, ToolCallId('call-invalid'), 'dismiss')).status).toBe('settled') + expect(reconcile).toHaveBeenCalledTimes(2) +}) + +it('refuses the action when reconciliation fails or the root closes meanwhile', async () => { + const reconcile = vi.fn<(agent: Agent) => Promise>(async () => { throw new Error('profile read failed') }) + const f = await fixture({ reconcile }) + f.propose() + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss')).toEqual({ status: 'refused', reason: 'unavailable' }) + expect(warn).toHaveBeenCalledWith('Cannot reconcile action card %s before %s: %s', callId, 'dismiss', 'Error: profile read failed') + expect(await f.ctx.actionCards.act(f.agent, callId, 'start')).toEqual({ status: 'refused', reason: 'unavailable' }) + expect(warn).toHaveBeenLastCalledWith('Cannot reconcile action card %s before %s: %s', callId, 'start', 'Error: profile read failed') + expect(f.view().decisions).toEqual({}) + reconcile.mockImplementationOnce(async () => { await f.agentHandle.dispose() }) + expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss')).toEqual({ status: 'refused', reason: 'not-live-root' }) + expect(f.act).not.toHaveBeenCalled() +}) + +it('refuses to record decisions of actions no kind registered or of phases the projection derives', async () => { + const f = await fixture() + f.propose() + expect(() => { f.ctx.actionCards.record(f.agent, { callId, action: 'other_action', phase: 'done' }) }) + .toThrow('action-cards: cannot record a decision for unregistered action "other_action"') + expect(() => { f.ctx.actionCards.record(f.agent, { callId, action: ACTION, phase: 'replaced' }) }) + .toThrow('action-cards: phase "replaced" is derived from the log and cannot be recorded') + expect(f.view().decisions).toEqual({}) + // An unloading kind still records the outcome of work it had started. + await f.kindFiber.dispose() + f.ctx.actionCards.record(f.agent, { callId, action: ACTION, phase: 'interrupted' }) + expect(f.view().decisions[callId]?.phase).toBe('interrupted') +}) + +it('dismisses an unstarted card whose kind is no longer registered, without data, and settles recorded ones', async () => { + const f = await fixture({ openPhases: ['failed'], dismissal: () => ({ value: 'kind data' }) }) + f.propose(ToolCallId('call-failed'), proposal, f.agent, 1) + f.ctx.actionCards.record(f.agent, { callId: ToolCallId('call-failed'), action: ACTION, phase: 'failed' }) + f.propose(ToolCallId('call-done'), proposal, f.agent, 2) + f.ctx.actionCards.record(f.agent, { callId: ToolCallId('call-done'), action: ACTION, phase: 'dismissed' }) + f.propose(callId, proposal, f.agent, 3) + await f.kindFiber.dispose() + expect(await f.ctx.actionCards.act(f.agent, callId, 'dismiss', { choice: 'later' })).toEqual(dismissed()) + expect(f.view().decisions[callId]).toEqual({ callId, action: ACTION, phase: 'dismissed' }) + // The kind may return and act on its recorded phases, so they stay as they are. + expect(await f.ctx.actionCards.act(f.agent, ToolCallId('call-failed'), 'dismiss')).toEqual({ status: 'settled', + decision: { callId: 'call-failed', action: ACTION, phase: 'failed' } }) + expect(await f.ctx.actionCards.act(f.agent, ToolCallId('call-done'), 'dismiss')).toEqual({ status: 'settled', + decision: { callId: 'call-done', action: ACTION, phase: 'dismissed' } }) + expect(f.act).not.toHaveBeenCalled() +}) diff --git a/packages/experimental/action-cards/tests/fixture.ts b/packages/experimental/action-cards/tests/fixture.ts new file mode 100644 index 0000000000..5110f34676 --- /dev/null +++ b/packages/experimental/action-cards/tests/fixture.ts @@ -0,0 +1,73 @@ +/** Real Agent, Tools, skills and projections with the card service and one scripted test kind. */ +import { Context } from '@deepseek-ai/cordis' +import { onTestFinished, vi } from 'vitest' +import { z } from 'zod' +import { createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' +import Skills, { BUNDLED_SKILL_RANK } from '@deepseek-ai/dsh-skill' +import * as SkillTool from '@deepseek-ai/dsh-tool-skill' +import RemoteAccess from '@deepseek-ai/dsh-api-remote-access' +import type { Agent } from '@deepseek-ai/dsh-agent' +import ActionCards from '../src/index.ts' +import type { ActionCardFields, ActionCardKind, ActionCardProposalOf, ActionCardResult } from '../src/index.ts' + +export const ACTION = 'test_action' +export const SKILL = 'test-skill' +export const INVITATION = 'Invite the user to try the card in one sentence.' +export const callId = ToolCallId('call-card-1') +export const proposal = { action: ACTION, title: 'Counter', brief: 'Keep a counter visible.', details: { value: 'a' } } + +/** Details of the test kind. */ +export interface TestDetails { readonly value: string } + +/** Provide one model-invocable skill per name. */ +export function provideSkills(ctx: Context, ...names: string[]): void { + ctx.skills.registerProvider(() => ({ + name: 'card-skills', + list: () => Promise.resolve(names.map(name => ({ + name, description: `Use ${name}.`, provider: 'card-skills', source: 'bundled' as const, rank: BUNDLED_SKILL_RANK, locator: null, + invocation: { modelInvocable: true, userInvocable: false }, + }))), + get: candidate => Promise.resolve({ name: candidate.name, description: candidate.description, provider: 'card-skills', source: 'bundled' as const, + invocation: candidate.invocation, content: `Call propose_action for ${candidate.name}.` }), + })) +} + +/** Mount the card service with one registered kind whose handler is a spy. */ +export async function fixture(kind: Partial> = {}) { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + await mountAgentLoopTestDependencies(ctx, { workingDirectory: true }) + const harness = await mountAgentLoopTestHarness(ctx) + await ctx.plugin(RemoteAccess) + await ctx.plugin(Skills) + await ctx.plugin(SkillTool) + provideSkills(ctx, SKILL, 'other-skill') + const cards = await ctx.plugin(ActionCards) + type Handler = ( + agent: Agent, card: ActionCardProposalOf, verb: string, payload: ActionCardFields | undefined, + ) => Promise + const act = vi.fn(async (_agent, card, verb) => + ({ status: 'settled', decision: { callId: card.callId, action: ACTION, phase: verb } })) + const kindFiber = await ctx.plugin({ name: 'test-kind', inject: ['actionCards'], apply: (inner: Context) => { + inner.actionCards.register({ + action: ACTION, skill: SKILL, invitation: INVITATION, details: z.object({ value: z.string() }), + verbs: { start: {}, publish: { global: true }, write: { writes: true } }, + act, ...kind, + }) + } }) + const agentHandle = await ctx.agents.create({ sessionId: SessionId('cards-root') }) + const agent = agentHandle.agent + const events: SessionEvent[] = [] + ctx.on('session/event', (session, event) => { if (session === agent.session) events.push(event) }) + let step = 0 + /** Log a successful `propose_action` call, as the loop does after the tool accepts it. */ + const propose = (id: ToolCallId = callId, args: object = proposal, caller = agent, turn = 1) => { + caller.session.append('tool/call', { turn, step: ++step, callId: id, name: 'propose_action', arguments: JSON.stringify(args) }) + caller.session.append('tool/result', { turn, step, message: createToolResultMessage({ callId: id, content: [], isError: false }) }, { surfaceOp: 'append' }) + return id + } + const view = (caller = agent) => ctx.actionCards.view(caller.session) + return { ctx, harness, cards, kindFiber, agent, agentHandle, events, act, propose, view } +} diff --git a/packages/experimental/action-cards/tests/projection.spec.ts b/packages/experimental/action-cards/tests/projection.spec.ts new file mode 100644 index 0000000000..b734b1bfdd --- /dev/null +++ b/packages/experimental/action-cards/tests/projection.spec.ts @@ -0,0 +1,130 @@ +import { expect, it } from 'vitest' +import { createToolResultMessage, createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' +import { + appendPluginRecord, Session, SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq, type SessionEvent, type SessionHeader, +} from '@deepseek-ai/dsh-session' +import { actionCardProjection, type ActionCardState } from '../src/projection.ts' +import type { ActionCardDecision } from '../src/types.ts' + +const projection = actionCardProjection +const args = { action: 'test_action', title: 'Counter', brief: 'Keep count visible', details: { value: 'a' } } +let seq = 0 + +function init(inherited = 0): ActionCardState { + const header: SessionHeader = { version: SESSION_FORMAT_VERSION, id: SessionId('cards'), createdAt: 0, isSeeded: inherited > 0 } + return projection.init(header, SessionLogOffset(inherited)) +} + +function turn(value: number): SessionEvent<'turn/start'> { + return { type: 'turn/start', seq: SessionSeq(seq++), time: 0, data: { turn: value } } +} + +function call(callId: string, value: object | string = args, turnNumber = 1, name = 'propose_action'): SessionEvent<'tool/call'> { + return { type: 'tool/call', seq: SessionSeq(seq++), time: 0, + data: { turn: turnNumber, step: 1, callId: ToolCallId(callId), name, arguments: typeof value === 'string' ? value : JSON.stringify(value) } } +} + +function result(callId: string, isError = false, turnNumber = 1): SessionEvent<'tool/result'> { + return { type: 'tool/result', seq: SessionSeq(seq++), time: 0, surfaceOp: 'append', + data: { turn: turnNumber, step: 1, message: createToolResultMessage({ callId: ToolCallId(callId), content: [], isError }) } } +} + +function spliced(inserted: number, removedCount?: number, target: 'next-turn' | 'next-step' = 'next-turn'): SessionEvent<'agent/inbox/spliced'> { + return { type: 'agent/inbox/spliced', seq: SessionSeq(seq++), time: 0, data: { target, start: 0, ...removedCount === undefined ? {} : { removedCount }, + inserted: Array.from({ length: inserted }, () => createUserMessage({ content: [{ type: 'text', text: 'next' }], source: { kind: 'user' } })) } } +} + +function records(...data: ActionCardDecision[]): readonly SessionEvent[] { + const session = Session.create(SessionId('records')) + for (const value of data) appendPluginRecord(session, 'plugin:action-cards/decision', value) + return session.snapshotEvents() +} + +const fold = (events: readonly SessionEvent[], state = init()) => events.reduce(projection.apply, state) + +it('admits a successful propose_action call as a card in its turn and ignores failed, malformed and other calls', () => { + const state = fold([turn(1), call('c1'), result('c1')]) + expect(state.view.proposals).toEqual({ c1: { callId: 'c1', turn: 1, ...args } }) + expect(state.calls).toEqual({}) + for (const events of [ + [call('c2'), result('c2', true)], + [call('c3', '{broken')], + [call('c4', { ...args, title: ' ' })], + [call('c5', { ...args, details: 'text' })], + [call('c6', args, 1, 'other_tool'), result('c6')], + [result('unrelated')], + ]) { + expect(fold(events).view.proposals).toEqual({}) + } + const trimmed = fold([call('c7', { ...args, title: ' Counter ' }), result('c7')]) + expect(trimmed.view.proposals.c7?.title).toBe('Counter') + const pending = fold([call('c8')]) + expect(Object.keys(pending.calls)).toEqual(['c8']) + expect(projection.apply(pending, turn(2)).calls).toEqual({}) + const idle = fold([turn(3)]) + expect(projection.apply(idle, turn(4))).toBe(idle) +}) + +it('keeps the first admitted card of a turn and replaces only unstarted earlier cards', () => { + const first = fold([turn(1), call('a'), result('a')]) + const late = fold([call('b'), result('b')], first) + expect(Object.keys(late.view.proposals)).toEqual(['a']) + const next = fold([turn(2), call('c', args, 2), result('c', false, 2)], first) + expect(next.view.decisions).toEqual({ a: { callId: 'a', action: 'test_action', phase: 'replaced' } }) + const started = fold(records({ callId: ToolCallId('a'), action: 'test_action', phase: 'creating' }), first) + const afterStart = fold([turn(2), call('c', args, 2), result('c', false, 2)], started) + expect(afterStart.view.decisions.a?.phase).toBe('creating') + expect(afterStart.view.decisions.c).toBeUndefined() + // Replacement crosses kinds: a card of another action replaces the unstarted one too. + const other = fold([turn(2), call('d', { ...args, action: 'other_action' }, 2), result('d', false, 2)], first) + expect(other.view.decisions).toEqual({ a: { callId: 'a', action: 'test_action', phase: 'replaced' } }) +}) + +it('counts user messages queued for later turns for the card tool and leaves cards to it', () => { + const queued = fold([turn(1), spliced(1, undefined, 'next-step'), spliced(2), call('a'), result('a')]) + expect(queued.queued).toBe(2) + expect(Object.keys(queued.view.proposals)).toEqual(['a']) + expect(queued.view.decisions).toEqual({}) + expect(fold([spliced(0, 2)], queued).queued).toBe(0) + expect(fold([spliced(0, 5)]).queued).toBe(0) +}) + +it('ignores the events a forked Session inherits from its parent', () => { + const parent = [turn(1), call('a'), result('a')] + // Every event created so far precedes the fork point. + const inherited = init(seq) + expect(fold(parent, inherited)).toBe(inherited) + const own = fold([turn(2), call('b', args, 2), result('b', false, 2)], inherited) + expect(Object.keys(own.view.proposals)).toEqual(['b']) + expect(own.view.decisions).toEqual({}) +}) + +it('records valid decisions as pending status until a status notice acknowledges them', () => { + const [created, malformed, dismissed] = records( + { callId: ToolCallId('a'), action: 'test_action', phase: 'creating', data: { value: 1 } }, + { callId: ToolCallId('a'), action: 'test_action', phase: '' }, + { callId: ToolCallId('b'), action: 'test_action', phase: 'dismissed' }, + ) + const state = fold([created!, malformed!, dismissed!]) + expect(state.view.decisions).toEqual({ + a: { callId: 'a', action: 'test_action', phase: 'creating', data: { value: 1 } }, + b: { callId: 'b', action: 'test_action', phase: 'dismissed' }, + }) + expect(state.pendingStatus).toEqual({ a: created!.seq, b: dismissed!.seq }) + const notice = (through: number): SessionEvent<'user/message'> => ({ type: 'user/message', seq: SessionSeq(99), time: 0, surfaceOp: 'append', + data: createUserMessage({ content: [{ type: 'text', text: 'status' }], + source: { kind: 'action-cards', through: SessionSeq(through), form: 'notice', summary: 'status' } }) }) + expect(projection.apply(state, notice(created!.seq)).pendingStatus).toEqual({ b: dismissed!.seq }) + const user: SessionEvent<'user/message'> = { type: 'user/message', seq: SessionSeq(100), time: 0, surfaceOp: 'append', + data: createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) } + expect(projection.apply(state, user)).toBe(state) +}) + +it('publishes proposals and decisions as one view reused across internal state changes', () => { + const state = fold([turn(1), call('a'), result('a')]) + const view = projection.wire.view(state) + expect(view).toBe(state.view) + expect(projection.wire.viewSchema.parse(view)).toEqual(view) + expect(projection.wire.view(fold([spliced(1), call('pending')], state))).toBe(view) + expect(projection.stateSchema.parse(state)).toEqual(state) +}) diff --git a/packages/experimental/action-cards/tests/status.spec.ts b/packages/experimental/action-cards/tests/status.spec.ts new file mode 100644 index 0000000000..76a6727a95 --- /dev/null +++ b/packages/experimental/action-cards/tests/status.spec.ts @@ -0,0 +1,71 @@ +import { expect, it, vi } from 'vitest' +import { agentEvents, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent' +import { ToolCallId } from '@deepseek-ai/dsh-llm' +import { appendPluginRecord } from '@deepseek-ai/dsh-session' +import ActionCards from '../src/index.ts' +import { ACTION, callId, fixture } from './fixture.ts' + +type Fixture = Awaited> + +async function prepare(f: Fixture, agent: Agent = f.agent, signal = new AbortController().signal, admitted?: () => void | Promise) { + const decision = await agentEvents(f.ctx, agent).waterfall('agent/pre-step', { messages: [], turn: 2, step: 1, signal, modelSelection: { provider: 'mock', model: 'mock' } }, + async (): Promise => { await admitted?.(); return { kind: 'enter', messages: [] } }) + return decision.kind === 'reject' ? decision : { ...decision, messages: decision.messages.filter(message => message.source.kind === 'action-cards') } +} + +function enter(agent: Agent, decision: PreStepDecision): void { + if (decision.kind === 'enter') for (const message of decision.messages) agent.session.append('user/message', message, { surfaceOp: 'append' }) +} + +it('reports recorded decisions once on the next request, with each kind guidance once, without waking the conversation', async () => { + const f = await fixture({ statusGuidance: 'Failed runs can be retried from their card.', statusFields: ['reason'] }) + const followup = vi.spyOn(f.agent, 'followup') + expect(await prepare(f)).toEqual({ kind: 'enter', messages: [] }) + f.propose() + f.propose(ToolCallId('call-card-2'), { action: ACTION, title: 'Second', brief: 'Another card.', details: { value: 'b' } }, f.agent, 2) + expect(await prepare(f)).toEqual({ kind: 'enter', messages: [] }) + f.ctx.actionCards.record(f.agent, { callId, action: ACTION, phase: 'failed', data: { reason: 'timeout', internal: 'x' } }) + f.ctx.actionCards.record(f.agent, { callId: ToolCallId('call-card-2'), action: ACTION, phase: 'dismissed' }) + // A decision logged by a kind that is no longer registered is still reported. + appendPluginRecord(f.agent.session, 'plugin:action-cards/decision', { callId: ToolCallId('call-orphan'), action: 'retired_action', phase: 'done' }) + const decision = await prepare(f) + if (decision.kind !== 'enter') throw new Error('request was rejected') + expect(decision.messages).toHaveLength(1) + expect(decision.messages[0]!.source).toMatchObject({ kind: 'action-cards', form: 'notice', summary: 'Action card status updated' }) + expect(decision.messages[0]!.content).toMatchSnapshot('status notice') + enter(f.agent, decision) + expect(await prepare(f)).toEqual({ kind: 'enter', messages: [] }) + expect(followup).not.toHaveBeenCalled() +}) + +it('keeps undelivered status through rejection, cancellation and plugin reload', async () => { + const f = await fixture() + f.propose() + await f.ctx.actionCards.act(f.agent, callId, 'dismiss') + const cancelled = new AbortController() + await expect(prepare(f, f.agent, cancelled.signal, () => { cancelled.abort() })).rejects.toThrow() + const rejected = f.ctx.on('agent/pre-step', async (): Promise => ({ kind: 'reject' })) + expect((await prepare(f)).kind).toBe('reject') + rejected() + expect(await prepare(f, f.agent, undefined, async () => { await f.cards.dispose() })).toEqual({ kind: 'enter', messages: [] }) + await f.ctx.plugin(ActionCards, { queuedMessageLimit: 3 }) + const decision = await prepare(f) + if (decision.kind !== 'enter') throw new Error('request was rejected') + expect(JSON.stringify(decision.messages[0]!.content)).toContain('dismissed') +}) + +it('adds nothing when cancellation arrives after downstream preparation', async () => { + const f = await fixture() + f.propose() + await f.ctx.actionCards.act(f.agent, callId, 'dismiss') + await f.cards.dispose() + const controller = new AbortController() + // Prepended before the card service returns, so it runs right after the service's own listener. + f.ctx.on('agent/pre-step', async (_event, next) => { + const decision = await next() + controller.abort() + return decision + }, { prepend: true }) + await f.ctx.plugin(ActionCards) + expect(await prepare(f, f.agent, controller.signal)).toEqual({ kind: 'enter', messages: [] }) +}) diff --git a/packages/experimental/action-cards/tests/tool.spec.ts b/packages/experimental/action-cards/tests/tool.spec.ts new file mode 100644 index 0000000000..2bf978cd69 --- /dev/null +++ b/packages/experimental/action-cards/tests/tool.spec.ts @@ -0,0 +1,198 @@ +import { expect, it, vi } from 'vitest' +import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import { executeModelToolFixture, executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { z } from 'zod' +import type { ActionCardKind, ActionCardResult } from '../src/index.ts' +import { ACTION, fixture, INVITATION, proposal, SKILL, type TestDetails } from './fixture.ts' + +type Fixture = Awaited> +const signal = new AbortController().signal +const modelSelection = { provider: 'mock', model: 'mock' } + +/** Run one call through the executor with the step admission the loop would resolve. */ +async function call(f: Fixture, name: string, args: object, id: string, agent: Agent = f.agent, parent?: ToolExecutionToken) { + const toolSet = await f.ctx.tools.resolve({ agent, modelSelection, signal }) + try { + return parent === undefined + ? await executeModelToolFixture(f.ctx, { callId: ToolCallId(id), name, arguments: args, agent, signal, toolSet }) + : await executeToolFixture(f.ctx, { callId: ToolCallId(id), name, arguments: args, agent, signal, toolSet, parent }) + } finally { toolSet.dispose() } +} + +async function declared(f: Fixture, agent: Agent = f.agent): Promise { + const toolSet = await f.ctx.tools.resolve({ agent, modelSelection, signal }) + const visible = toolSet.view(agent).has('propose_action') + toolSet.dispose() + return visible +} + +function text(result: { content: readonly { type: string; text?: string }[] }): string { + return result.content.map(block => block.text ?? '').join('') +} + +it('declares propose_action from the step after a card skill loads, and logs the addition', async () => { + const f = await fixture() + const adapter = new MockAdapter([ + toolCallResponse('call-skill', 'skill', { name: SKILL }), + toolCallResponse('call-card-1', 'propose_action', proposal), + textResponse('The card is below.'), + ]) + f.ctx.llm.registerAdapter(['mock'], adapter) + const agent = await f.harness.create(SessionId('loop'), modelSelection) + expect(await declared(f, agent)).toBe(false) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'I want a counter.' }], source: { kind: 'user' } })) + await agent.whenIdle() + expect(adapter.requests.map(request => (request.tools ?? []).some(tool => tool.name === 'propose_action'))).toEqual([false, true, true]) + const tool = adapter.requests[1]!.tools!.find(candidate => candidate.name === 'propose_action') + expect(tool).toMatchSnapshot('tool declaration') + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'developer/message' ? event.data.message.content : [])) + .toEqual([{ type: 'tool-addition', toolName: 'propose_action' }]) + expect(await declared(f, agent)).toBe(true) + const result = agent.session.snapshotEvents().find(event => event.type === 'tool/result' && event.data.message.toolCallId === 'call-card-1') + expect(result?.type === 'tool/result' && result.data.message.content).toEqual([ + { type: 'text', text: `Card shown below your answer. Do not restate it. ${INVITATION}` }, + ]) + expect(f.view(agent).proposals['call-card-1']).toEqual({ callId: 'call-card-1', turn: 1, ...proposal }) +}) + +it('declares nothing for other skills and stops declaring when the kind unregisters', async () => { + const f = await fixture() + f.agent.session.append('turn/start', { turn: 1 }) + expect((await call(f, 'skill', { name: 'other-skill' }, 'other')).isError).toBe(false) + expect((await call(f, 'skill', { name: 'missing-skill' }, 'missing')).isError).toBe(true) + expect(await declared(f)).toBe(false) + expect((await call(f, 'propose_action', proposal, 'early')).isError).toBe(true) + expect((await call(f, 'skill', { name: SKILL }, 'load')).isError).toBe(false) + expect((await call(f, 'skill', { name: SKILL }, 'reload')).isError).toBe(false) + expect((await executeToolFixture(f.ctx, { callId: ToolCallId('detached'), name: 'skill', arguments: { name: SKILL }, signal })).isError).toBe(false) + expect(await declared(f)).toBe(true) + await f.kindFiber.dispose() + expect(await declared(f)).toBe(false) + await f.cards.dispose() + expect(f.ctx.tools.get('propose_action')).toBeUndefined() +}) + +it('refuses unknown actions, a skill not loaded in this turn, blank fields, invalid details and nested calls', async () => { + const f = await fixture() + f.agent.session.append('turn/start', { turn: 1 }) + await call(f, 'skill', { name: SKILL }, 'load') + expect(text(await call(f, 'propose_action', { ...proposal, action: 'nope' }, 'unknown'))).toBe('Error: Unknown action "nope". Registered actions: test_action.') + expect(text(await call(f, 'propose_action', { ...proposal, title: ' ' }, 'blank'))).toBe('Error: Write a title and a brief.') + expect(text(await call(f, 'propose_action', { ...proposal, brief: '' }, 'blank-brief'))).toBe('Error: Write a title and a brief.') + expect(text(await call(f, 'propose_action', { ...proposal, details: { value: 1 } }, 'invalid'))) + .toBe('Error: Invalid details: details.value: Invalid input: expected string, received number') + expect(text(await call(f, 'propose_action', proposal, 'nested', f.agent, Symbol('outer') as ToolExecutionToken))) + .toBe('Error: Call propose_action directly, not from code.') + f.agent.session.append('turn/start', { turn: 2 }) + expect(text(await call(f, 'propose_action', proposal, 'stale'))).toBe('Error: Load skill test-skill first, then call propose_action.') + await f.kindFiber.dispose() + await call(f, 'skill', { name: SKILL }, 'load-again') + expect(await declared(f)).toBe(false) + expect(Object.keys(f.view().proposals)).toEqual([]) +}) + +it('accepts one card per turn, admits a retry after a failed result and names the registered actions', async () => { + const f = await fixture() + f.agent.session.append('turn/start', { turn: 1 }) + await call(f, 'skill', { name: SKILL }, 'load') + const blocked = f.ctx.on('tools/post-execute', async (exec, _result, next) => exec.callId === 'blocked' + ? { kind: 'block', feedback: [{ type: 'text', text: 'blocked' }] } : await next()) + expect((await call(f, 'propose_action', proposal, 'blocked')).isError).toBe(true) + blocked() + const accepted = await call(f, 'propose_action', { ...proposal, title: ' Counter ' }, 'accepted') + expect(accepted).toMatchObject({ isError: false, value: { action: ACTION, title: 'Counter', invitation: INVITATION } }) + expect(text(accepted)).toBe(`Card shown below your answer. Do not restate it. ${INVITATION}`) + const second = text(await call(f, 'propose_action', proposal, 'second')) + // Only an accepted call tells the model how to invite the user to its card. + expect(second).toBe('Error: One card per turn; the earlier card stands.') + expect(second).not.toContain(INVITATION) + f.agent.session.append('turn/start', { turn: 2 }) + await call(f, 'skill', { name: SKILL }, 'load-2') + f.propose(ToolCallId('logged'), proposal, f.agent, 2) + expect(text(await call(f, 'propose_action', proposal, 'after-log'))).toBe('Error: One card per turn; the earlier card stands.') + // Disposing the agent releases its activation. + expect(await declared(f)).toBe(true) + await f.agentHandle.dispose() + expect(await declared(f)).toBe(false) +}) + +it('activates nothing for a skill load dispatched from code, so a PTC preset never declares the tool', async () => { + const f = await fixture() + f.agent.session.append('turn/start', { turn: 1 }) + const outer = Symbol('outer') as ToolExecutionToken + expect((await call(f, 'skill', { name: SKILL }, 'nested-load', f.agent, outer)).isError).toBe(false) + expect(await declared(f)).toBe(false) + expect((await call(f, 'propose_action', proposal, 'unadmitted')).isError).toBe(true) + expect((await call(f, 'skill', { name: SKILL }, 'direct-load')).isError).toBe(false) + expect(await declared(f)).toBe(true) +}) + +it('offers cards in the main conversation only: subagent skill loads activate nothing and non-root calls are refused', async () => { + const f = await fixture() + const child = (await f.ctx.agents.create({ sessionId: SessionId('cards-child'), parentAgent: f.agent, + meta: { parentSession: f.agent.id, origin: 'subagent' } })).agent + child.session.append('turn/start', { turn: 1 }) + expect((await call(f, 'skill', { name: SKILL }, 'child-load', child)).isError).toBe(false) + expect(await declared(f, child)).toBe(false) + expect((await call(f, 'propose_action', proposal, 'child-card', child)).isError).toBe(true) + f.agent.session.append('turn/start', { turn: 1 }) + await call(f, 'skill', { name: SKILL }, 'load') + // Admission is fixed when the step starts; the call still checks that its agent is a live root. + const toolSet = await f.ctx.tools.resolve({ agent: f.agent, modelSelection, signal }) + vi.spyOn(f.ctx.agents, 'roots').mockReturnValue([]) + const refused = await executeModelToolFixture(f.ctx, { callId: ToolCallId('detached-root'), name: 'propose_action', arguments: proposal, + agent: f.agent, signal, toolSet }) + toolSet.dispose() + expect(text(refused)).toBe('Error: Cards are offered in the main conversation only.') +}) + +it('names no actions when the last kind unregisters after the step admitted the tool', async () => { + const f = await fixture() + f.agent.session.append('turn/start', { turn: 1 }) + await call(f, 'skill', { name: SKILL }, 'load') + const toolSet = await f.ctx.tools.resolve({ agent: f.agent, modelSelection, signal }) + await f.kindFiber.dispose() + const result = await executeModelToolFixture(f.ctx, { callId: ToolCallId('late'), name: 'propose_action', arguments: proposal, + agent: f.agent, signal, toolSet }) + toolSet.dispose() + expect(text(result)).toBe('Error: Unknown action "test_action". Registered actions: none.') +}) + +it('refuses a card while the user has queued messages for later turns, so the log holds no card', async () => { + const f = await fixture() + const queued = () => createUserMessage({ content: [{ type: 'text', text: 'Next question.' }], source: { kind: 'user' } }) + const live: { agent?: Agent } = {} + const adapter = new MockAdapter([ + toolCallResponse('call-skill', 'skill', { name: SKILL }), + () => { + // The user queues two more messages while the model writes its answer. + live.agent!.inbox.append('next-turn', queued()) + live.agent!.inbox.append('next-turn', queued()) + return toolCallResponse('call-card-1', 'propose_action', proposal) + }, + textResponse('Short answer.'), + textResponse('Next answer.'), + textResponse('Last answer.'), + ]) + f.ctx.llm.registerAdapter(['mock'], adapter) + const agent = await f.harness.create(SessionId('queued'), modelSelection) + live.agent = agent + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'I want a counter.' }], source: { kind: 'user' } })) + await agent.whenIdle() + const result = agent.session.snapshotEvents().find(event => event.type === 'tool/result' && event.data.message.toolCallId === 'call-card-1') + expect(result?.type === 'tool/result' && result.data.message).toMatchObject({ isError: true, + content: [{ type: 'text', text: 'Error: The user has queued more messages; show no card and answer briefly.' }] }) + expect(f.view(agent)).toEqual({ proposals: {}, decisions: {} }) +}) + +it('requires every kind to give the invitation its accepted calls return', () => { + const kind = { action: 'other_action', skill: 'other-skill', details: z.object({ value: z.string() }), verbs: {}, + act: () => Promise.resolve({ status: 'accepted' }) } + // @ts-expect-error: a kind without its invitation does not compile. + const incomplete: ActionCardKind = kind + expect(incomplete).toBe(kind) +}) diff --git a/packages/experimental/action-cards/tsconfig.json b/packages/experimental/action-cards/tsconfig.json new file mode 100644 index 0000000000..c5e45d6303 --- /dev/null +++ b/packages/experimental/action-cards/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../typert/protocol" }, + { "path": "../../llm/llm" }, + { "path": "../../core/tools" }, + { "path": "../../core/agent" }, + { "path": "../../core/session" }, + { "path": "../../session/session-projection" }, + { "path": "../../api/remote-access/tsconfig.host.json" }, + { "path": "../../sandbox/sandbox-policy" }, + { "path": "../../util/values" } + ], + "files": [ + "src/index.ts", + "src/types.ts", + "src/projection.ts" + ] +} diff --git a/packages/experimental/action-cards/tsdown.config.ts b/packages/experimental/action-cards/tsdown.config.ts new file mode 100644 index 0000000000..46d896c05a --- /dev/null +++ b/packages/experimental/action-cards/tsdown.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsdown' + +/** Build the action card plugin. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index cb7b06d7bd..5f6ff6988f 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": "Agent Teams collaboration, tools, and Web UI in one experimental bundle", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "icon": "./icon.svg", "publishConfig": { "access": "public" diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index af9f829179..3e63072332 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, direct peer messaging, and shared task DAG", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/api-speech-to-text/package.json b/packages/experimental/api-speech-to-text/package.json index 5b34282933..7fd6730f0a 100644 --- a/packages/experimental/api-speech-to-text/package.json +++ b/packages/experimental/api-speech-to-text/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-api-speech-to-text", "description": "Authenticated experimental speech transcription for browser clients", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/auto-review/package.json b/packages/experimental/auto-review/package.json index f24edf48cb..72d462a7ad 100644 --- a/packages/experimental/auto-review/package.json +++ b/packages/experimental/auto-review/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-auto-review", "description": "Per-tool LLM authorization review for the DeepSeek Harness Auto permission preset", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "icon": "./icon.svg", "repository": { "type": "git", diff --git a/packages/experimental/badge-skill-bundle/package.json b/packages/experimental/badge-skill-bundle/package.json index 960895540d..4de3052489 100644 --- a/packages/experimental/badge-skill-bundle/package.json +++ b/packages/experimental/badge-skill-bundle/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-badge-skill-bundle", "description": "Let agents add a “powered by dsh” badge to documents, pull requests, and other outputs.", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "icon": "./icon.svg", "publishConfig": { "access": "public" diff --git a/packages/experimental/browser-use-chrome-devtools-mcp/package.json b/packages/experimental/browser-use-chrome-devtools-mcp/package.json index 5d617f5385..96f5e8b344 100644 --- a/packages/experimental/browser-use-chrome-devtools-mcp/package.json +++ b/packages/experimental/browser-use-chrome-devtools-mcp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp", "description": "Experimental per-Session Chromium browser tools through chrome-devtools-mcp", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/browser-use-playwright-mcp/package.json b/packages/experimental/browser-use-playwright-mcp/package.json index 727a8bccd3..45b94b4b8a 100644 --- a/packages/experimental/browser-use-playwright-mcp/package.json +++ b/packages/experimental/browser-use-playwright-mcp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-browser-use-playwright-mcp", "description": "Experimental per-Session Chromium browser tools through @playwright/mcp", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/browser-use-runtime/package.json b/packages/experimental/browser-use-runtime/package.json index 634de5372f..64ff3470c2 100644 --- a/packages/experimental/browser-use-runtime/package.json +++ b/packages/experimental/browser-use-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-browser-use-runtime", "description": "Session-owned browser resource lifecycles and MCP integration for experimental providers", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/browser-use-stagehand-native/package.json b/packages/experimental/browser-use-stagehand-native/package.json index 4805d55e82..ecdf47ff23 100644 --- a/packages/experimental/browser-use-stagehand-native/package.json +++ b/packages/experimental/browser-use-stagehand-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-browser-use-stagehand-native", "description": "Experimental Stagehand browser tools with separately configured native models", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/claude-code-mods/package.json b/packages/experimental/claude-code-mods/package.json index e38a0ea770..2580f8603a 100644 --- a/packages/experimental/claude-code-mods/package.json +++ b/packages/experimental/claude-code-mods/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-claude-code-mods", "description": "Experimental bridge: load Claude Code mods (hooks modules) and run their hook chains on DeepSeek Harness extension points", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/client-ui-action-cards/README.i18n.yaml b/packages/experimental/client-ui-action-cards/README.i18n.yaml new file mode 100644 index 0000000000..d750da7f5b --- /dev/null +++ b/packages/experimental/client-ui-action-cards/README.i18n.yaml @@ -0,0 +1,40 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/experimental/client-ui-action-cards/README.md +/: + en: eec43de9002f4ab1 + zh: 0d2fddca5a1f993a +/deepseek-ai-dsh-client-ui-action-cards: + en: c36a554629f96f79 + zh: 0ea837da87938e52 +/deepseek-ai-dsh-client-ui-action-cards/summary: + en: 89ea57734c42a481 + zh: c7a4f5f0b59b78ad +/deepseek-ai-dsh-client-ui-action-cards/table-of-contents: + en: d152484eb41ac6b4 + zh: b00dddf4c88f0a45 +/deepseek-ai-dsh-client-ui-action-cards/use-this-package: + en: 717a53d2170d253b + zh: 4e52852690601371 +/deepseek-ai-dsh-client-ui-action-cards/use-this-package/register-a-card-body: + en: 9ec9f9f8f3165d3e + zh: 2d8154ec2fe0c9ab +/deepseek-ai-dsh-client-ui-action-cards/understand-the-implementation: + en: 168ae39a83d6f4eb + zh: 312cc12876bca834 +/deepseek-ai-dsh-client-ui-action-cards/further-exploration: + en: 55e4d74a33cdeeb3 + zh: f601d5a4dcf63ec5 +/deepseek-ai-dsh-client-ui-action-cards/model-experience: + en: f80a4b656c108f22 + zh: a3e30f46fa7136ac +/deepseek-ai-dsh-client-ui-action-cards/model-experience/kv-cache-effect: + en: 9cb7131bde146341 + zh: 1c7d1af00f58004d +/deepseek-ai-dsh-client-ui-action-cards/known-limitations-and-deferred-work: + en: df94821940e4e2b8 + zh: 490441ab06b153c9 +/deepseek-ai-dsh-client-ui-action-cards/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: bd8a8936229e035e diff --git a/packages/experimental/client-ui-action-cards/README.md b/packages/experimental/client-ui-action-cards/README.md new file mode 100644 index 0000000000..930c5e0fcb --- /dev/null +++ b/packages/experimental/client-ui-action-cards/README.md @@ -0,0 +1,89 @@ +--- +description: "Browser half of optional action cards: the card frame below completed answers, the body registry that experiment packages fill, and the one-line process row of propose_action calls." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-action-cards + +English | [中文](README.zh.md) + +## Summary + +Users see an optional action card below a completed answer: its title, a brief that folds when long, and the controls of the experiment that proposed it. In the process area, the proposal shows as one row with the card title and whether the call was accepted; a refused call expands to its reason. A card whose experiment provides no controls in this browser offers only Not now. Showing, expanding and folding cards make no model request. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Enable the [Personalized recommendations](../personalized-recommendations/README.md) bundle, which inserts this package beside its [Host service](../action-cards/README.md). This browser contribution has no configuration fields. + +### Register a card body + +A kind's browser plugin injects `actionCards` and registers its body inside its own `ctx.effect`: `ctx.actionCards.register({ action, locale, Body })` returns the disposer. The frame renders `Body` for cards of that action with the card, its recorded or derived decision, the Session id, the frame's title and folded brief as `summary`, the frame's fold component as `Folded`, an `act(verb, payload?)` bound to the card, and a translator `t` for the body's own locale namespace. The body places `summary` in its own layout and owns its header, controls, status and footer. A second body for the same action is a registration error. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +One `conversation.chat.turnTail` entry reads the `actionCards` Session projection and shows the card admitted in its Turn after that Turn has ended with a final answer; a Turn that ends without answer text shows no card, and should several cards share a Turn, the lowest call id is shown. The frame sets `data-action-card` to the call id and names its region by the card title. Folding counts a wide grapheme, such as a Chinese character, as two Latin-letter widths and folds after about 260; it never measures layout or alters the full text. + +A `tool.call.toolview` entry keyed `propose_action` renders the process row from the recorded call arguments and result. The plugin provides the body registry at its top level, then mounts the generated `actionCards` Remote and registers its dictionaries and slot entries for the same lifetime. + +| Source | Contents | +|---|---| +| [`src/client/mount.ts`](src/client/mount.ts) | Registry, Remote, dictionaries and slot entries | +| [`src/client/Card.tsx`](src/client/Card.tsx) | Turn tail, frame, fold and fallback body | +| [`src/client/ProposeRow.tsx`](src/client/ProposeRow.tsx) | Process row | +| [`src/client/registry.ts`](src/client/registry.ts) | Body registry and body props | +| [`tests/`](tests/) | Placement, frame, fallback, row and registration tests | + +
+ +----- + + +## Further Exploration + +- [Action cards](../action-cards/README.md) — the tool, card state and user actions behind the frame. +- [Personalized page cards](../client-ui-creation-recommendations/README.md) and [recommendation cards](../client-ui-plugin-recommendations/README.md) — the shipped card bodies. +- [Slots](../../client/ui-slots/README.md) — Turn tail and tool row registration. + + +## Model Experience + +Indirectly, through the Host card service: card actions reach it as Remote calls, and it owns the logged tool, decisions and status notice. + +#### KV Cache effect + +Rendering cards and process rows does not change any model request. + +## Known Limitations and Deferred Work + + + +- A card whose action has no registered body offers only Not now, so its experiment's other actions need that experiment's browser plugin. Not now still works after the experiment's Host plugin is turned off. +- The Turn tail shows one card per Turn, matching the Host rule of one card per turn. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/experimental/client-ui-action-cards/README.zh.md b/packages/experimental/client-ui-action-cards/README.zh.md new file mode 100644 index 0000000000..70989022d6 --- /dev/null +++ b/packages/experimental/client-ui-action-cards/README.zh.md @@ -0,0 +1,89 @@ +--- +description: "可选操作卡片的浏览器端:已完成回答下方的卡片框架、供各实验包填入的正文注册表,以及 propose_action 调用的单行过程行。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-action-cards + +[English](README.md) | 中文 + +## 概述 + +用户在已完成的回答下方看到一张可选操作卡片:标题、过长时会折叠的说明,以及提议该卡片的实验所提供的控件。在过程区中,提议显示为一行,带卡片标题和调用是否被接受;被拒绝的调用可以展开查看原因。如果实验在当前浏览器中没有提供控件,卡片只提供“暂不需要”。展示、展开和折叠卡片都不会发起模型请求。 + +## 目录 + +- [使用本包](#use-this-package) +- [实现原理](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与后续工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +启用[个性化推荐](../personalized-recommendations/README.zh.md) Bundle,它会在本包旁插入对应的 [Host 服务](../action-cards/README.zh.md)。本浏览器组件没有配置字段。 + +### 注册卡片正文 + +种类的浏览器插件注入 `actionCards`,并在自己的 `ctx.effect` 中注册正文:`ctx.actionCards.register({ action, locale, Body })` 返回注销函数。框架为该动作的卡片渲染 `Body`,并传入:卡片、已记录或推导出的决定、会话 id、框架渲染的标题与折叠说明 `summary`、框架的折叠组件 `Folded`、绑定到该卡片的 `act(verb, payload?)`,以及正文自己语言命名空间的翻译函数 `t`。正文把 `summary` 放进自己的布局,并负责自己的标题栏、控件、状态和底部操作。为同一动作注册第二个正文属于注册错误。 + +----- + + +## 实现原理 + +
+实现细节——点击展开 + +一个 `conversation.chat.turnTail` 条目读取 `actionCards` 会话投影,在某一轮以最终回答结束后展示该轮接受的卡片;没有回答文本就结束的轮次不显示卡片,若同一轮有多张卡片,显示调用 id 最小的一张。框架把 `data-action-card` 设为调用 id,并以卡片标题命名所在区域。折叠时,宽字素(如汉字)按两个拉丁字母宽度计算,约 260 个单位后折叠;折叠不测量布局,也不改动完整文本。 + +一个以 `propose_action` 为键的 `tool.call.toolview` 条目,根据记录的调用参数和结果渲染过程行。插件在顶层提供正文注册表,然后挂载生成的 `actionCards` Remote,并在同一生命周期内注册词典和插槽条目。 + +| 源码 | 内容 | +|---|---| +| [`src/client/mount.ts`](src/client/mount.ts) | 注册表、Remote、词典与插槽条目 | +| [`src/client/Card.tsx`](src/client/Card.tsx) | 轮次尾部、框架、折叠与兜底正文 | +| [`src/client/ProposeRow.tsx`](src/client/ProposeRow.tsx) | 过程行 | +| [`src/client/registry.ts`](src/client/registry.ts) | 正文注册表与正文属性 | +| [`tests/`](tests/) | 位置、框架、兜底、过程行与注册测试 | + +
+ +----- + + +## 延伸阅读 + +- [操作卡片](../action-cards/README.zh.md) — 框架背后的工具、卡片状态与用户操作。 +- [个性化页面卡片](../client-ui-creation-recommendations/README.zh.md)和[推荐卡片](../client-ui-plugin-recommendations/README.zh.md) — 随产品提供的卡片正文。 +- [插槽](../../client/ui-slots/README.zh.md) — 轮次尾部与工具行的注册。 + + +## 模型体验 + +间接影响来自 Host 卡片服务:卡片操作以 Remote 调用到达该服务,由它负责记录在日志中的工具、决定和状态通知。 + +#### KV Cache 影响 + +渲染卡片和过程行不会改变任何模型请求。 + +## 已知限制与后续工作 + + + +- 动作没有注册正文的卡片只提供“暂不需要”,该实验的其他操作需要它的浏览器插件。即使该实验的 Host 插件已关闭,“暂不需要”仍然可用。 +- 轮次尾部每轮只显示一张卡片,与 Host 每轮一张卡片的规则一致。 + + +### 开发备注 + +
+维护者工作背景——点击展开 + +无。 + +
diff --git a/packages/experimental/client-ui-action-cards/locale/de.json b/packages/experimental/client-ui-action-cards/locale/de.json new file mode 100644 index 0000000000..5a480a2067 --- /dev/null +++ b/packages/experimental/client-ui-action-cards/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Anzeige von Aktionskarten", + "description": "Zeigt optionale Aktionskarten unter Antworten mit den eigenen Bedienelementen jedes Experiments an." + } +} diff --git a/packages/experimental/client-ui-action-cards/locale/en.json b/packages/experimental/client-ui-action-cards/locale/en.json new file mode 100644 index 0000000000..d11a646578 --- /dev/null +++ b/packages/experimental/client-ui-action-cards/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Action card display", + "description": "Show optional action cards below answers, with each experiment's own controls." + } +} diff --git a/packages/experimental/client-ui-action-cards/locale/es.json b/packages/experimental/client-ui-action-cards/locale/es.json new file mode 100644 index 0000000000..7b5f75daa3 --- /dev/null +++ b/packages/experimental/client-ui-action-cards/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Visualización de tarjetas de acción", + "description": "Muestra tarjetas de acción opcionales debajo de las respuestas, con los controles propios de cada experimento." + } +} diff --git a/packages/experimental/client-ui-action-cards/locale/fr.json b/packages/experimental/client-ui-action-cards/locale/fr.json new file mode 100644 index 0000000000..283dd32f1d --- /dev/null +++ b/packages/experimental/client-ui-action-cards/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Affichage des cartes d'action", + "description": "Affiche des cartes d'action facultatives sous les réponses, avec les commandes propres à chaque expérience." + } +} diff --git a/packages/experimental/client-ui-action-cards/locale/ja.json b/packages/experimental/client-ui-action-cards/locale/ja.json new file mode 100644 index 0000000000..01484f8e00 --- /dev/null +++ b/packages/experimental/client-ui-action-cards/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "アクションカード表示", + "description": "回答の下に任意のアクションカードを表示します。カードの内容は各実験が提供します。" + } +} diff --git a/packages/experimental/client-ui-action-cards/locale/pt.json b/packages/experimental/client-ui-action-cards/locale/pt.json new file mode 100644 index 0000000000..22534af903 --- /dev/null +++ b/packages/experimental/client-ui-action-cards/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Exibição de cartões de ação", + "description": "Mostra cartões de ação opcionais abaixo das respostas, com os controles próprios de cada experimento." + } +} diff --git a/packages/experimental/client-ui-action-cards/locale/ru.json b/packages/experimental/client-ui-action-cards/locale/ru.json new file mode 100644 index 0000000000..76cac2e24b --- /dev/null +++ b/packages/experimental/client-ui-action-cards/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Отображение карточек действий", + "description": "Показывает необязательные карточки действий под ответами с элементами управления каждого эксперимента." + } +} diff --git a/packages/experimental/client-ui-action-cards/locale/zh.json b/packages/experimental/client-ui-action-cards/locale/zh.json new file mode 100644 index 0000000000..1792abcaf9 --- /dev/null +++ b/packages/experimental/client-ui-action-cards/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "操作卡片显示", + "description": "在回答下方显示可选操作卡片,卡片内容由各实验提供。" + } +} diff --git a/packages/experimental/client-ui-action-cards/package.json b/packages/experimental/client-ui-action-cards/package.json new file mode 100644 index 0000000000..17ee00e968 --- /dev/null +++ b/packages/experimental/client-ui-action-cards/package.json @@ -0,0 +1,71 @@ +{ + "version": "0.2.1-rc.3", + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "license": "MIT", + "name": "@deepseek-ai/dsh-client-ui-action-cards", + "description": "Action cards below completed answers: the shared card frame, kind bodies and the compact tool row", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/client-ui-action-cards" + }, + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./locale/*.json": "./locale/*.json" + }, + "dsh": { + "client": { + "platform": "web", + "inject": [ + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-client-ui-tool" + ] + } + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-chat": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-tool": "workspace:*", + "react": "^18.2.0", + "@deepseek-ai/dsh-action-cards": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*" + }, + "files": [ + "locale/*.json", + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/experimental/client-ui-action-cards/src/client/Card.module.css b/packages/experimental/client-ui-action-cards/src/client/Card.module.css new file mode 100644 index 0000000000..cb8fd63989 --- /dev/null +++ b/packages/experimental/client-ui-action-cards/src/client/Card.module.css @@ -0,0 +1,7 @@ +.card { margin: 16px 0; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-lg); overflow: hidden; background: var(--dsw-alias-bg-layer-1); font-family: var(--dsw-font-family); color: var(--dsw-alias-label-primary); } +.body { padding: 12px 20px 20px; } +.title { display: block; font-size: 16px; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; } +.text { margin: 8px 0 0; font-size: 13px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--dsw-alias-label-secondary); } +.toggle { margin: 8px 0 0; padding-inline: 0; height: auto; min-height: 24px; font-size: 12px; } +.status { margin: 12px 0 0; font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary); } +.footer { display: flex; align-items: center; justify-content: flex-end; gap: 12px; margin-top: 12px; } diff --git a/packages/experimental/client-ui-action-cards/src/client/Card.tsx b/packages/experimental/client-ui-action-cards/src/client/Card.tsx new file mode 100644 index 0000000000..20e6f75700 --- /dev/null +++ b/packages/experimental/client-ui-action-cards/src/client/Card.tsx @@ -0,0 +1,150 @@ +/** The shared card frame below a completed answer: title, folded brief, and the kind's body or a dismiss-only fallback. */ +import { useCallback, useId, useMemo, useState, type ReactNode } from 'react' +import { Button } from '@deepseek-ai/dsh-client-ui-primitives' +import type { HostObservable, InjectFace, PropsLocale, PropsRuntime, Translate } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' +import type { + ActionCardDecision, ActionCardFields, ActionCardProposal, ActionCardResult, ActionCardView, +} from '@deepseek-ai/dsh-action-cards/types' +import type {} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { ActionCardKey } from './locales.ts' +import type { FoldedTextProps, RegisteredBody } from './registry.ts' +import css from './Card.module.css' + +/** Session-bound actions and registered bodies, without a service in the component. */ +export interface TailInjected { + /** + * Send one user action for a card of the displayed Session. + * @param callId - the card's call id. + * @param verb - `dismiss` or one of the kind's verbs. + * @param payload - kind-defined fields. + * @returns the Host result, or undefined when the reply was lost. + */ + act: (callId: ToolCallId, verb: string, payload?: ActionCardFields) => Promise + /** + * Bind a body's own locale namespace. + * @param locale - namespace the body declared. + * @returns its translator. + */ + bind: (locale: string) => Translate + hooks: { bodies: HostObservable> } + sessionId: SessionId +} + +type TailProps = Pick, 'turn' | 'useProjection'> + & PropsLocale<'actionCards'> & InjectFace + +/** Graphemes drawn about twice as wide as a Latin letter: CJK scripts, CJK punctuation and fullwidth forms. */ +const WIDE_GRAPHEME = /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul} -〿＀-⦆¢-₩]/u +/** Preview budget in Latin-letter widths, about two card lines at desktop width. */ +const FOLD_WIDTH = 260 + +/** + * Fold omitted text without measuring layout; a wide grapheme counts twice so Chinese and English fold near the + * same line. The full text is never altered. + * @param props - text and its localized toggle labels. + * @returns the paragraph and, when text was omitted, its toggle. + */ +export function FoldedText({ text, expandLabel, collapseLabel }: FoldedTextProps) { + const id = useId() + const [expanded, setExpanded] = useState(false) + const preview = useMemo(() => { + const compact = text.replace(/\s+/g, ' ').trim() + const characters = Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(compact), part => part.segment) + let width = 0 + for (const [index, character] of characters.entries()) { + width += WIDE_GRAPHEME.test(character) ? 2 : 1 + if (width > FOLD_WIDTH) return characters.slice(0, index).join('').trimEnd() + '…' + } + return undefined + }, [text]) + return <> +

{expanded || preview === undefined ? text : preview}

+ {preview !== undefined && } + +} + +/** The projection admits at most one card per turn; should several exist, the lowest call id is shown. */ +function proposalOf(view: ActionCardView | undefined, turn: number): ActionCardProposal | undefined { + let found: ActionCardProposal | undefined + for (const proposal of Object.values(view?.proposals ?? {})) { + if (proposal.turn === turn && (found === undefined || proposal.callId < found.callId)) found = proposal + } + return found +} + +function decisionOf(view: ActionCardView | undefined, callId: string): ActionCardDecision | undefined { + return view !== undefined && Object.hasOwn(view.decisions, callId) ? view.decisions[callId] : undefined +} + +/** + * Place the card admitted in a turn after that turn's final answer, once the turn has ended with one. + * @param props - Turn location, projection reader, registered bodies and Session-bound actions. + * @returns the card, or null while the turn is open or when it ended without answer text. + */ +export function ActionCardTail({ turn, useProjection, ...props }: TailProps) { + const view = useProjection('actionCards') + const proposal = proposalOf(view, turn.turn) + return proposal === undefined || turn.end === undefined || turn.data.get('turn-tail')?.closing == null + ? null : +} + +/** + * Render the frame around one card. + * @param props - the card, its state, registered bodies and actions. + * @returns the card section. + */ +export function ActionCard({ proposal, decision, sessionId, act, bind, useBodies, t }: { + proposal: ActionCardProposal + decision: ActionCardDecision | undefined +} & InjectFace & PropsLocale<'actionCards'>) { + const body = useBodies(bodies => bodies.get(proposal.action)) + const send = useCallback((verb: string, payload?: ActionCardFields) => act(proposal.callId, verb, payload), [act, proposal.callId]) + const summary = <> + {proposal.title} + + + // The frame re-renders on a locale change and hands the body a new summary, so the body re-renders with it. + const content = body === undefined + ? + : body.render({ proposal, decision, sessionId, summary, Folded: FoldedText, act: send }, bind(body.locale)) + return
+ {content} +
+} + +/** Phases the fallback can name; other kind phases leave only the summary. */ +const NAMED_PHASES: Readonly> = { dismissed: 'dismissed', replaced: 'replaced' } + +/** Title, brief and Not now for a card whose kind registered no body in this browser. */ +function FallbackBody({ summary, decision, act, t }: { + summary: ReactNode + decision: ActionCardDecision | undefined + act: (verb: string) => Promise +} & PropsLocale<'actionCards'>) { + const [pending, setPending] = useState(false) + const [error, setError] = useState() + const phase = decision === undefined || !Object.hasOwn(NAMED_PHASES, decision.phase) ? undefined : NAMED_PHASES[decision.phase] + const dismiss = async () => { + setPending(true) + setError(undefined) + try { + const result = await act('dismiss') + if (result === undefined) setError('transport') + else if (result.status === 'refused' || result.status === 'unconfirmed') setError('unavailable') + } catch { + // A lost reply does not prove failure; replay supplies the durable decision. + setError('transport') + } finally { setPending(false) } + } + return
+ {summary} + {phase !== undefined &&

{t(phase)}

} + {error !== undefined &&

{t(error)}

} + {decision === undefined &&
+ +
} +
+} diff --git a/packages/experimental/client-ui-action-cards/src/client/ProposeRow.module.css b/packages/experimental/client-ui-action-cards/src/client/ProposeRow.module.css new file mode 100644 index 0000000000..22915510e1 --- /dev/null +++ b/packages/experimental/client-ui-action-cards/src/client/ProposeRow.module.css @@ -0,0 +1,6 @@ +/** The card title stays readable beside the call state. */ +.summary { margin-left: 8px; display: flex; align-items: center; gap: 8px; min-width: 0; color: inherit; font-size: 12px; } +.summary > :first-child { flex-shrink: 0; } +.title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.reason { margin: 8px 0; padding: 12px; border-radius: var(--dsw-radius-lg); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; } +.inspect { align-self: flex-start; border: none; padding: 4px 0; background: transparent; color: var(--dsw-alias-link); font: inherit; font-size: 12px; cursor: pointer; } diff --git a/packages/experimental/client-ui-action-cards/src/client/ProposeRow.tsx b/packages/experimental/client-ui-action-cards/src/client/ProposeRow.tsx new file mode 100644 index 0000000000..acd3b1b972 --- /dev/null +++ b/packages/experimental/client-ui-action-cards/src/client/ProposeRow.tsx @@ -0,0 +1,43 @@ +/** One-line process row for a `propose_action` call: the card title and whether the card was shown. */ +import { useState } from 'react' +import { DisclosureRow, IconAppsAddOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import css from './ProposeRow.module.css' + +type ProposeRowProps = ToolCallViewProps & PropsLocale<'actionCards'> + +/** Call states with row copy; a preparing call has no complete arguments yet. */ +type RowState = 'preparing' | 'running' | 'ok' | 'error' | 'stopped' + +function stateOf(props: ProposeRowProps): RowState { + if (props.phase === 'preparing') return 'preparing' + if (props.phase === 'start') return 'running' + if (props.block.error?.code === 'interrupted') return 'stopped' + return props.block.isError ? 'error' : 'ok' +} + +/** + * Render a `propose_action` call from its recorded arguments and result. Only a refused call expands, to show + * why no card followed the answer. + * @param props - tool call and localized status copy. + * @returns the status row. + */ +export function ProposeRow(props: ProposeRowProps) { + const { block, inspect, t } = props + const [expanded, setExpanded] = useState(false) + const state = stateOf(props) + const title = block.args.text('title') ?? '' + const reason = props.phase === 'result' && props.block.isError + ? props.block.content.flatMap(item => item.type === 'text' ? [item.text] : []).join('\n') + : '' + return
+ } running={state === 'preparing' || state === 'running'} + open={expanded && reason !== ''} expandable={reason !== ''} expandOnRowClick keepContentWhenOpen + onToggle={() => { setExpanded(value => !value) }} + collapsedContent={{t(`row.${state}`)}{title}}> +
{reason}
+ {inspect && } +
+
+} diff --git a/packages/experimental/client-ui-action-cards/src/client/index.ts b/packages/experimental/client-ui-action-cards/src/client/index.ts new file mode 100644 index 0000000000..d8c3a96173 --- /dev/null +++ b/packages/experimental/client-ui-action-cards/src/client/index.ts @@ -0,0 +1,15 @@ +/** Bind generated Remote descriptors to the source-tested browser lifecycle. */ +import type { Context } from '@deepseek-ai/cordis' +import remote from '@deepseek-ai/dsh-action-cards/remote' +import { mountActionCards } from './mount.ts' +export { inject } from './mount.ts' +export type { ActionCardBodyProps, ActionCardDescriptor, ActionCardRegistry, FoldedTextProps } from './registry.ts' + +/** + * Mount action cards and their Remote. + * @param ctx - Client plugin context. + * @returns disposer for every contribution. + */ +export async function apply(ctx: Context): Promise<() => Promise> { + return await mountActionCards(ctx, remote) +} diff --git a/packages/experimental/client-ui-action-cards/src/client/locales.ts b/packages/experimental/client-ui-action-cards/src/client/locales.ts new file mode 100644 index 0000000000..0c98b67158 --- /dev/null +++ b/packages/experimental/client-ui-action-cards/src/client/locales.ts @@ -0,0 +1,82 @@ +/** English card frame, fallback and tool-row copy. */ +export const en = { + expandBrief: 'Show more', collapseBrief: 'Show less', + dismiss: 'Not now', dismissed: 'Dismissed', replaced: 'Replaced', + unavailable: 'This action is unavailable. Check the plugin in Plugins.', + transport: 'Could not confirm the result. Reconnect and check the card before retrying.', + 'row.title': 'Action card', 'row.preparing': 'Preparing a card', 'row.running': 'Proposing', + 'row.ok': 'Proposed', 'row.error': 'Not shown', 'row.stopped': 'Interrupted', 'row.inspect': 'Inspect call', +} + +/** Keys shared by every dictionary. */ +export type ActionCardKey = keyof typeof en + +/** Chinese card frame, fallback and tool-row copy. */ +export const zh: Record = { + expandBrief: '展开', collapseBrief: '收起', + dismiss: '暂不需要', dismissed: '已忽略', replaced: '已替换', + unavailable: '暂时无法执行,请到插件页面查看', + transport: '暂时无法确认结果,请重新连接并查看卡片状态后再重试', + 'row.title': '操作卡片', 'row.preparing': '正在准备卡片', 'row.running': '正在提议', + 'row.ok': '已提议', 'row.error': '未显示', 'row.stopped': '已中断', 'row.inspect': '查看调用', +} + +/** Japanese card frame, fallback and tool-row copy. */ +export const ja: Record = { + expandBrief: 'すべて表示', collapseBrief: '折りたたむ', + dismiss: '今はしない', dismissed: '却下済み', replaced: '置き換え済み', + unavailable: 'この操作は利用できません。プラグイン画面でプラグインを確認してください。', + transport: '結果を確認できませんでした。再接続してカードを確認してから再試行してください。', + 'row.title': 'アクションカード', 'row.preparing': 'カードを準備中', 'row.running': '提案中', + 'row.ok': '提案済み', 'row.error': '表示されませんでした', 'row.stopped': '中断されました', 'row.inspect': '呼び出しを確認', +} + +/** Spanish card frame, fallback and tool-row copy. */ +export const es: Record = { + expandBrief: 'Mostrar más', collapseBrief: 'Mostrar menos', + dismiss: 'Ahora no', dismissed: 'Descartado', replaced: 'Reemplazado', + unavailable: 'Esta acción no está disponible. Revisa el complemento en Complementos.', + transport: 'No se pudo confirmar el resultado. Vuelve a conectarte y revisa la tarjeta antes de reintentar.', + 'row.title': 'Tarjeta de acción', 'row.preparing': 'Preparando una tarjeta', 'row.running': 'Proponiendo', + 'row.ok': 'Propuesto', 'row.error': 'No se mostró', 'row.stopped': 'Interrumpido', 'row.inspect': 'Inspeccionar llamada', +} + +/** French card frame, fallback and tool-row copy. */ +export const fr: Record = { + expandBrief: 'Afficher plus', collapseBrief: 'Afficher moins', + dismiss: 'Pas maintenant', dismissed: 'Ignoré', replaced: 'Remplacé', + unavailable: "Cette action n'est pas disponible. Vérifiez le plugin dans Plugins.", + transport: 'Impossible de confirmer le résultat. Reconnectez-vous et vérifiez la carte avant de réessayer.', + 'row.title': "Carte d'action", 'row.preparing': "Préparation d'une carte", 'row.running': 'Proposition en cours', + 'row.ok': 'Proposé', 'row.error': 'Non affiché', 'row.stopped': 'Interrompu', 'row.inspect': "Inspecter l'appel", +} + +/** German card frame, fallback and tool-row copy. */ +export const de: Record = { + expandBrief: 'Mehr anzeigen', collapseBrief: 'Weniger anzeigen', + dismiss: 'Nicht jetzt', dismissed: 'Verworfen', replaced: 'Ersetzt', + unavailable: 'Diese Aktion ist nicht verfügbar. Prüfen Sie das Plugin unter Plugins.', + transport: 'Das Ergebnis konnte nicht bestätigt werden. Verbinden Sie sich erneut und prüfen Sie die Karte, bevor Sie es erneut versuchen.', + 'row.title': 'Aktionskarte', 'row.preparing': 'Karte wird vorbereitet', 'row.running': 'Wird vorgeschlagen', + 'row.ok': 'Vorgeschlagen', 'row.error': 'Nicht angezeigt', 'row.stopped': 'Unterbrochen', 'row.inspect': 'Aufruf prüfen', +} + +/** Russian card frame, fallback and tool-row copy. */ +export const ru: Record = { + expandBrief: 'Показать больше', collapseBrief: 'Показать меньше', + dismiss: 'Не сейчас', dismissed: 'Отклонено', replaced: 'Заменено', + unavailable: 'Это действие недоступно. Проверьте плагин в разделе «Плагины».', + transport: 'Не удалось подтвердить результат. Переподключитесь и проверьте карточку перед повторной попыткой.', + 'row.title': 'Карточка действия', 'row.preparing': 'Подготовка карточки', 'row.running': 'Предлагается', + 'row.ok': 'Предложено', 'row.error': 'Не показано', 'row.stopped': 'Прервано', 'row.inspect': 'Проверить вызов', +} + +/** Portuguese card frame, fallback and tool-row copy. */ +export const pt: Record = { + expandBrief: 'Mostrar mais', collapseBrief: 'Mostrar menos', + dismiss: 'Agora não', dismissed: 'Dispensado', replaced: 'Substituído', + unavailable: 'Esta ação não está disponível. Verifique o plug-in em Plug-ins.', + transport: 'Não foi possível confirmar o resultado. Reconecte-se e verifique o cartão antes de tentar novamente.', + 'row.title': 'Cartão de ação', 'row.preparing': 'Preparando um cartão', 'row.running': 'Propondo', + 'row.ok': 'Proposto', 'row.error': 'Não exibido', 'row.stopped': 'Interrompido', 'row.inspect': 'Inspecionar chamada', +} diff --git a/packages/experimental/client-ui-action-cards/src/client/mount.ts b/packages/experimental/client-ui-action-cards/src/client/mount.ts new file mode 100644 index 0000000000..9ffc9b4b17 --- /dev/null +++ b/packages/experimental/client-ui-action-cards/src/client/mount.ts @@ -0,0 +1,63 @@ +/** Register the card frame, the tool row, their copy and the body registry. */ +import type { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-tool/client' +import type {} from '@deepseek-ai/dsh-action-cards/remote' +import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' +import { ActionCardTail, type TailInjected } from './Card.tsx' +import { ProposeRow } from './ProposeRow.tsx' +import { ActionCardRegistry } from './registry.ts' +import { de, en, es, fr, ja, pt, ru, zh, type ActionCardKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { actionCards: ActionCardKey } +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Card bodies by action kind; kinds register theirs inside their own `ctx.effect`. */ + actionCards: ActionCardRegistry + } +} + +/** Registries available before this plugin mounts its Remote. */ +export const inject = ['slots', 'locale', 'remote'] + +/** + * Provide the body registry, then mount the Remote and the UI for the same plugin lifetime. + * @param ctx - Client plugin context. + * @param contribution - Generated Remote descriptors supplied by the built entry. + * @returns disposer removing the UI before the Remote contribution. + */ +export async function mountActionCards(ctx: Context, contribution: TypertRemoteContribution): Promise<() => Promise> { + // Built at the plugin's top level: kinds register into it from their own apply. + const registry = new ActionCardRegistry(ctx) + ctx.reflect.provide('actionCards', registry) + const unmount = await ctx.remote.$mount(contribution) + const ui = ctx.inject(['remote.actionCards'], (inner) => { registerUi(inner, registry) }) + try { await ui } catch (error) { await ui.dispose(); await unmount(); throw error } + return async () => { await ui.dispose(); await unmount() } +} + +function registerUi(ctx: Context, registry: ActionCardRegistry): void { + ctx.effect(() => ctx.locale.register('actionCards', { en, zh, ja, es, fr, de, ru, pt }), 'action-cards: locale') + ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({ + name: 'conversation.chat.turnTail', id: '@deepseek-ai/dsh-client-ui-action-cards', locale: 'actionCards', + inject: (sessionId: SessionId): TailInjected => ({ + sessionId, + hooks: { bodies: registry.source }, + bind: locale => ctx.locale.bind(locale), + act: async (callId, verb, payload) => { + const result = await ctx.remote.actionCards.act(sessionId, callId, verb, payload) + return result.ok ? result.value : undefined + }, + }), + }, ActionCardTail)) + ctx.slots.inject('tool.call.toolview', () => ctx.slots.register( + { name: 'tool.call.toolview', key: 'propose_action', locale: 'actionCards' }, ProposeRow, + )) +} diff --git a/packages/experimental/client-ui-action-cards/src/client/registry.ts b/packages/experimental/client-ui-action-cards/src/client/registry.ts new file mode 100644 index 0000000000..4933ee8dbb --- /dev/null +++ b/packages/experimental/client-ui-action-cards/src/client/registry.ts @@ -0,0 +1,106 @@ +/** Card bodies registered by action kind for the shared card frame. */ +import { createElement, type ComponentType, type ReactNode } from 'react' +import type { Context } from '@deepseek-ai/cordis' +import type { HostObservable, LocaleNamespaceMap, Translate, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { + ActionCardDecision, ActionCardFields, ActionCardProposal, ActionCardResult, +} from '@deepseek-ai/dsh-action-cards/types' + +/** Props of the frame's folded text, which bodies reuse for their own long fields. */ +export interface FoldedTextProps { + readonly text: string + readonly expandLabel: string + readonly collapseLabel: string +} + +/** Everything a kind's body receives from the shared frame. */ +export interface ActionCardBodyProps> { + /** The card as admitted; the body validates `details` with its own schema. */ + readonly proposal: ActionCardProposal + /** Recorded or derived card state; undefined while the card is unstarted. */ + readonly decision: ActionCardDecision | undefined + readonly sessionId: SessionId + /** Title and folded brief rendered by the frame; the body places them in its layout. */ + readonly summary: ReactNode + /** The frame's width-weighted text fold. */ + readonly Folded: ComponentType + /** + * Send one user action for this card. + * @param verb - `dismiss` or one of the kind's Host verbs. + * @param payload - fields the kind's Host handler validates. + * @returns the Host result, or undefined when the reply was lost. + */ + readonly act: (verb: string, payload?: ActionCardFields) => Promise + /** Translate the body's own locale namespace. */ + readonly t: TranslateNS +} + +/** Browser half of one action kind. */ +export interface ActionCardDescriptor> { + /** Value of the card's `action`. */ + readonly action: string + /** Locale namespace the body's copy belongs to. */ + readonly locale: N + /** Kind-owned header, controls, status and footer. */ + readonly Body: ComponentType> +} + +/** Frame-supplied body props before the kind's translator is bound. */ +export type ActionCardFrameProps = Omit, 't'> + +/** A registered body with its namespace type erased. */ +export interface RegisteredBody { + readonly locale: string + /** + * Render the body. + * @param props - frame-supplied props. + * @param t - translator bound to {@link locale}. + * @returns the body element. + */ + readonly render: (props: ActionCardFrameProps, t: Translate) => ReactNode +} + +/** Card bodies by action; the frame renders a dismiss-only fallback for an action without one. */ +export class ActionCardRegistry { + private bodies: ReadonlyMap = new Map() + private readonly listeners = new Set<() => void>() + /** Current bodies by action; the snapshot changes on every registration change. */ + readonly source: HostObservable> = { + getSnapshot: () => this.bodies, + subscribe: (listener) => { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + }, + } + + /** @param ctx - Context whose effects own registered bodies. */ + constructor(private readonly ctx: Context) {} + + /** + * Register one kind's body. The caller holds the returned disposer inside its own `ctx.effect`, so the body + * lives exactly as long as the plugin that contributed it. + * @param descriptor - the kind's action, locale namespace and body. + * @returns idempotent disposer. + * @throws when another body already serves the action. + */ + register>(descriptor: ActionCardDescriptor): () => void { + const { action, locale, Body } = descriptor + const body: RegisteredBody = { locale, render: (props, t) => createElement(Body, { ...props, t }) } + const dispose = this.ctx.effect(() => { + if (this.bodies.has(action)) throw new Error(`action-cards: a body for "${action}" is already registered`) + this.publish(new Map([...this.bodies, [action, body]])) + return () => { + const next = new Map(this.bodies) + next.delete(action) + this.publish(next) + } + }, `action-cards: body ${action}`) + return () => { void dispose() } + } + + private publish(bodies: ReadonlyMap): void { + this.bodies = bodies + for (const listener of [...this.listeners]) listener() + } +} diff --git a/packages/experimental/client-ui-action-cards/src/css-modules.d.ts b/packages/experimental/client-ui-action-cards/src/css-modules.d.ts new file mode 100644 index 0000000000..24a27bda3f --- /dev/null +++ b/packages/experimental/client-ui-action-cards/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Readonly> + export default classes +} diff --git a/packages/experimental/client-ui-action-cards/src/index.ts b/packages/experimental/client-ui-action-cards/src/index.ts new file mode 100644 index 0000000000..8524d1b9ae --- /dev/null +++ b/packages/experimental/client-ui-action-cards/src/index.ts @@ -0,0 +1,4 @@ +/** Browser registration is discovered through the package client declaration. */ + +/** Register the browser-only contribution without Host state. */ +export function apply(): void {} diff --git a/packages/experimental/client-ui-action-cards/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-action-cards/tests/browser-plugin.client.spec.ts new file mode 100644 index 0000000000..87f32a57c3 --- /dev/null +++ b/packages/experimental/client-ui-action-cards/tests/browser-plugin.client.spec.ts @@ -0,0 +1,74 @@ +/** Real registries bind actions to the displayed session and remove every contribution on unload. */ +import { isValidElement } from 'react' +import { Context } from '@deepseek-ai/cordis' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { ToolCallId } from '@deepseek-ai/dsh-llm/brand' +import { expect, it, onTestFinished, vi } from 'vitest' +import { mountActionCards, inject } from '../src/client/mount.ts' +import { apply as hostApply } from '../src/index.ts' +import type { TailInjected } from '../src/client/Card.tsx' + +const apply = (ctx: Context) => mountActionCards(ctx, { package: '@deepseek-ai/dsh-action-cards', descriptors: [] }) +const Body = () => null + +it('binds actions to the displayed session, serves registered bodies and removes registrations on unload', async () => { + hostApply() + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(SlotRegistry) + const slots = ctx.slots + slots.register({ name: 'root', children: { + 'conversation.chat.turnTail': { kind: 'list', scope: 'session' }, + 'tool.call.toolview': { kind: 'keyed', scope: 'session' }, + } }, (_props: { renderSlot?: unknown }) => null) + const act = vi.fn().mockResolvedValue({ ok: true, value: { status: 'accepted' } }) + const unmount = vi.fn(async () => {}) + ctx.provide('remote', { actionCards: { act }, $mount: vi.fn(async () => unmount) }) + ctx.provide('remote.actionCards', { act }) + const locale = new LocaleRuntime(ctx) + ctx.provide('locale', locale) + const fiber = await ctx.plugin({ inject, apply }) + const entry = slots.entries('conversation.chat.turnTail')[0]! + expect(slots.entries('tool.call.toolview').map(item => item.options.key)).toEqual(['propose_action']) + const resolve = entry.inject as NonNullable & ((sessionId: SessionId) => TailInjected) + const bound = resolve(SessionId('session-one')) + expect(bound.sessionId).toBe('session-one') + expect(await bound.act(ToolCallId('card'), 'create', { slot: 'page' })).toEqual({ status: 'accepted' }) + expect(act).toHaveBeenCalledWith('session-one', 'card', 'create', { slot: 'page' }) + act.mockResolvedValueOnce({ ok: false }) + expect(await bound.act(ToolCallId('card'), 'dismiss')).toBeUndefined() + expect(bound.bind('actionCards')('dismiss')).toBe('Not now') + const changes = vi.fn() + const unsubscribe = bound.hooks.bodies.subscribe(changes) + const kind = await ctx.plugin({ inject: ['actionCards'], apply: (inner: Context) => { + inner.effect(() => inner.actionCards.register({ action: 'test_action', locale: 'actionCards', Body })) + } }) + expect([...bound.hooks.bodies.getSnapshot().keys()]).toEqual(['test_action']) + const translate = (key: string) => key + const element = bound.hooks.bodies.getSnapshot().get('test_action')!.render({ proposal: { callId: ToolCallId('card'), turn: 1, + action: 'test_action', title: 'Counter', brief: 'Keep count.', details: {} }, decision: undefined, + sessionId: SessionId('session-one'), summary: null, Folded: Body, act: vi.fn() }, translate) + expect(isValidElement(element) && element.type === Body && (element.props as { t: unknown }).t === translate).toBe(true) + expect(() => ctx.actionCards.register({ action: 'test_action', locale: 'actionCards', Body })).toThrow('a body for "test_action" is already registered') + await kind.dispose() + expect(bound.hooks.bodies.getSnapshot().size).toBe(0) + expect(changes).toHaveBeenCalledTimes(2) + unsubscribe() + const release = ctx.actionCards.register({ action: 'other_action', locale: 'actionCards', Body }) + expect(bound.hooks.bodies.getSnapshot().has('other_action')).toBe(true) + release() + expect(bound.hooks.bodies.getSnapshot().has('other_action')).toBe(false) + expect(changes).toHaveBeenCalledTimes(2) + await fiber.dispose() + expect(unmount).toHaveBeenCalledOnce() + expect(slots.entries('conversation.chat.turnTail')).toHaveLength(0) + expect(slots.entries('tool.call.toolview')).toHaveLength(0) + expect(ctx.get('actionCards')).toBeUndefined() + vi.spyOn(locale, 'register').mockImplementation(() => { throw new Error('registration failed') }) + const failed = ctx.plugin({ inject, apply }) + await expect(failed.await()).rejects.toThrow('registration failed') + await failed.dispose() + expect(unmount).toHaveBeenCalledTimes(2) +}) diff --git a/packages/experimental/client-ui-action-cards/tests/card.client.spec.tsx b/packages/experimental/client-ui-action-cards/tests/card.client.spec.tsx new file mode 100644 index 0000000000..c286a52937 --- /dev/null +++ b/packages/experimental/client-ui-action-cards/tests/card.client.spec.tsx @@ -0,0 +1,141 @@ +// @vitest-environment jsdom +import { memo, type ComponentProps } from 'react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { ToolCallId } from '@deepseek-ai/dsh-llm/brand' +import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' +import type { ActionCardDecision, ActionCardResult, ActionCardView } from '@deepseek-ai/dsh-action-cards/types' +import { ActionCard, ActionCardTail, FoldedText } from '../src/client/Card.tsx' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { en, zh } from '../src/client/locales.ts' +import type { ActionCardBodyProps, RegisteredBody } from '../src/client/registry.ts' + +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) + +type CardProps = ComponentProps +const callId = ToolCallId('card-1') +const proposal = { callId, turn: 3, action: 'test_action', title: 'Counter', brief: 'Keep my task count visible.', details: { value: 'a' } } + +function props(bodies: ReadonlyMap = new Map(), decision?: ActionCardDecision, chinese = false): CardProps { + return { proposal, decision, sessionId: SessionId('session-1'), + act: vi.fn(async (): Promise => ({ status: 'settled', decision: { callId, action: 'test_action', phase: 'dismissed' } })), + bind: vi.fn(() => (key: string) => `body:${key}`), + useBodies: selector => selector(bodies), + t: makeTranslate(chinese ? zh : en) } +} + +it('places the card admitted in its turn once the turn has ended with an answer, and leaves other turns empty', () => { + type Tail = ComponentProps + const value = props() + let view: ActionCardView | undefined + const turnData = vi.fn().mockReturnValue({ closing: {} }) + const ended = { type: 'turn/end', seq: SessionSeq(1), time: 0, data: { turn: 3, reason: { kind: 'max-tokens' } } } as const + const tail = (end: typeof ended | undefined): Tail => ({ ...value, useProjection: () => view, + turn: { turn: 3, start: undefined, end, status: end === undefined ? 'open' : 'closed', steps: [], + data: { get: turnData, source: () => ({ getSnapshot: () => undefined, subscribe: () => () => {} }) } } }) + const card = (id: string, turn: number) => ({ ...proposal, callId: ToolCallId(id), turn }) + const { rerender } = render() + expect(screen.queryByRole('region')).toBeNull() + view = { decisions: {}, proposals: { earlier: card('earlier', 2) } } + rerender() + expect(screen.queryByRole('region')).toBeNull() + view = { decisions: {}, proposals: { ...view.proposals, b: card('b', 3), a: card('a', 3), c: card('c', 3) } } + rerender() + expect(screen.getAllByRole('region').map(region => region.getAttribute('data-action-card'))).toEqual(['a']) + expect(screen.getByRole('region', { name: 'Counter' }).getAttribute('data-action')).toBe('test_action') + expect(turnData).toHaveBeenCalledWith('turn-tail') + rerender() + expect(screen.queryByRole('region')).toBeNull() + turnData.mockReturnValueOnce({ closing: null }) + rerender() + expect(screen.queryByRole('region')).toBeNull() + view = { ...view, decisions: { a: { callId: ToolCallId('a'), action: 'test_action', phase: 'dismissed' } } } + rerender() + expect(screen.getByRole('status').textContent).toBe(en.dismissed) + // A call id naming an inherited object member reads no decision. + view = { decisions: {}, proposals: { toString: card('toString', 3) } } + rerender() + expect(screen.getByRole('region').getAttribute('data-action-card')).toBe('toString') + expect(screen.getByRole('button', { name: en.dismiss })).toBeTruthy() +}) + +it('renders a registered body with the frame summary, the fold, its own translator and an action bound to the card', async () => { + const seen: ActionCardBodyProps[] = [] + const Body = memo((body: ActionCardBodyProps) => { + seen.push(body) + return
{body.summary} +
+ }) + const bodies = new Map([['test_action', { locale: 'testCard', render: (frame, t) => }]]) + const value = props(bodies, { callId, action: 'test_action', phase: 'running' }) + render() + expect(value.bind).toHaveBeenCalledWith('testCard') + expect(screen.getByRole('region', { name: 'Counter' }).textContent).toContain('Keep my task count visible.') + expect(screen.getByText('Long details')).toBeTruthy() + expect(seen.at(-1)).toMatchObject({ proposal, decision: { phase: 'running' }, sessionId: 'session-1', Folded: FoldedText }) + fireEvent.click(screen.getByRole('button', { name: 'body:start' })) + expect(value.act).toHaveBeenCalledWith(callId, 'start', { value: 'x' }) +}) + +it('offers only Not now for a card whose kind registered no body, and reports refusals and lost replies', async () => { + const value = props(new Map(), undefined, true) + const pending = Promise.withResolvers() + value.act = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('disconnected')).mockResolvedValueOnce({ status: 'unconfirmed', reason: 'closing' }) + .mockResolvedValueOnce({ status: 'accepted' }) + render() + expect(screen.getByRole('region', { name: 'Counter' }).textContent).toContain('Keep my task count visible.') + const dismiss = screen.getByRole('button', { name: zh.dismiss }) + fireEvent.click(dismiss) + expect(dismiss.hasAttribute('disabled')).toBe(true) + expect(value.act).toHaveBeenCalledWith(callId, 'dismiss', undefined) + await act(async () => { pending.resolve({ status: 'refused', reason: 'unknown' }) }) + expect(screen.getByRole('alert').textContent).toBe(zh.unavailable) + for (const text of [zh.transport, zh.transport, zh.unavailable]) { + fireEvent.click(screen.getByRole('button', { name: zh.dismiss })) + await vi.waitFor(() => { expect(screen.getByRole('alert').textContent).toBe(text) }) + } + fireEvent.click(screen.getByRole('button', { name: zh.dismiss })) + await vi.waitFor(() => { expect(screen.queryByRole('alert')).toBeNull() }) +}) + +it('dismisses a card through the fallback Not now when the Host records the dismissal without the card kind', async () => { + const value = props() + render() + const dismiss = screen.getByRole('button', { name: en.dismiss }) + fireEvent.click(dismiss) + expect(value.act).toHaveBeenCalledWith(callId, 'dismiss', undefined) + await vi.waitFor(() => { expect(dismiss.hasAttribute('disabled')).toBe(false) }) + expect(screen.queryByRole('alert')).toBeNull() +}) + +it.each([['dismissed', en.dismissed], ['replaced', en.replaced], ['finished', undefined], ['toString', undefined]] as const)( + 'shows the fallback status of a %s card without actions', (phase, text) => { + render() + expect(screen.queryByRole('status')?.textContent).toBe(text) + expect(screen.queryByRole('button', { name: en.dismiss })).toBeNull() + }) + +it('only offers expansion for omitted text and preserves the full brief without layout measurement', () => { + vi.stubGlobal('ResizeObserver', undefined) + const short = 'Keep my task count visible.\n\nPersist across conversations.\nVerify removal.' + const view = render() + expect(screen.queryByRole('button', { name: zh.expandBrief })).toBeNull() + expect(screen.getByText(/Keep my task count visible/).textContent).toBe(short) + // 129 wide graphemes (258 units) plus one narrow emoji fill the 260-unit budget; the next grapheme folds. + const prefix = '计'.repeat(129) + '👩🏽‍💻' + const long = prefix + '\n\n保留完整方案,点击创建后交给 Creator。' + view.rerender() + const expand = screen.getByRole('button', { name: zh.expandBrief }) + const paragraph = document.getElementById(expand.getAttribute('aria-controls')!)! + expect(paragraph.textContent).toBe(prefix + '…') + expect(expand.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(expand) + expect(screen.getByRole('button', { name: zh.collapseBrief }).getAttribute('aria-expanded')).toBe('true') + expect(paragraph.textContent).toBe(long) + fireEvent.click(screen.getByRole('button', { name: zh.collapseBrief })) + expect(paragraph.textContent).toBe(prefix + '…') + view.rerender() + expect(screen.queryByRole('button', { name: zh.expandBrief })).toBeNull() + expect(paragraph.textContent).toBe(prefix) +}) diff --git a/packages/experimental/client-ui-action-cards/tests/row.client.spec.tsx b/packages/experimental/client-ui-action-cards/tests/row.client.spec.tsx new file mode 100644 index 0000000000..ed197519ae --- /dev/null +++ b/packages/experimental/client-ui-action-cards/tests/row.client.spec.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +/** The process row reads the card title from the recorded arguments and expands only a refused call. */ +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { PartialArguments } from '@deepseek-ai/dsh-util-values' +import { ProposeRow } from '../src/client/ProposeRow.tsx' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) +type Props = Parameters[0] +const argsRaw = JSON.stringify({ action: 'test_action', title: 'Counter', brief: 'Keep count.', details: {} }) +const running: StartedToolCall = { phase: 'start', args: PartialArguments.fromText(argsRaw), callId: 'c', name: 'propose_action', argsRaw, turn: 1, step: 1, time: 1, subCalls: [] } +const settled: ToolResultNode = { kind: 'tool-result', seq: 2, time: 2, callId: 'c', name: 'propose_action', args: PartialArguments.fromText(argsRaw), + call: { name: 'propose_action', argsRaw }, callTime: 1, content: [{ type: 'text', text: 'Card shown below your answer. Do not restate it. Invite the user.' }], isError: false, subCalls: [] } +function props(block: Props['block'], inspect?: () => void): Props { + return { ...('kind' in block ? { phase: 'result' as const, block } : { phase: block.phase, block }), + callId: 'c', toolName: 'propose_action', openFile: vi.fn(), inspect, t: makeTranslate(en) } as Props +} + +it('shows a proposed card on one line without exposing the model-facing result', () => { + const view = render() + expect(view.container.querySelector('[data-tool="propose_action"]')?.getAttribute('data-state')).toBe('ok') + expect(view.getByText('Proposed')).toBeTruthy() + expect(view.getByText('Counter')).toBeTruthy() + expect(view.queryByRole('button')).toBeNull() + expect(view.container.textContent).not.toContain('Card shown below your answer. Do not restate it. Invite the user.') +}) + +it('explains a refused call and offers call inspection', () => { + const inspect = vi.fn() + const view = render() + expect(view.getByText('Not shown')).toBeTruthy() + const row = view.getByRole('button') + fireEvent.click(row) + expect(view.container.querySelector('pre')?.textContent).toBe('Error: Load skill test-skill first, then call propose_action.') + fireEvent.click(view.getByRole('button', { name: 'Inspect call' })) + expect(inspect).toHaveBeenCalledOnce() + fireEvent.click(row) + expect(row.getAttribute('aria-expanded')).toBe('false') +}) + +it.each([ + ['preparing', { phase: 'preparing', args: PartialArguments.EMPTY, callId: 'c', name: 'propose_action', turn: 1, step: 1, time: 1, subCalls: [] }, 'Preparing a card'], + ['running', running, 'Proposing'], + ['stopped', { ...settled, isError: true, content: [], error: { name: 'Interrupted', code: 'interrupted' } }, 'Interrupted'], +] as const)('renders the %s call stage without claiming a card', (state, block, label) => { + const view = render() + expect(view.container.querySelector('[data-tool="propose_action"]')?.getAttribute('data-state')).toBe(state) + expect(view.getAllByText(label).length).toBeGreaterThan(0) + expect(view.queryByText('Proposed')).toBeNull() + expect(view.queryByRole('button')).toBeNull() +}) diff --git a/packages/experimental/client-ui-action-cards/tsconfig.json b/packages/experimental/client-ui-action-cards/tsconfig.json new file mode 100644 index 0000000000..fec07b193a --- /dev/null +++ b/packages/experimental/client-ui-action-cards/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { "path": "../../client/ui-renderer" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../api/remotes/tsconfig.client.json" }, + { "path": "../../client/locale/tsconfig.client.json" }, + { "path": "../../client/ui-conversation/tsconfig.client.json" }, + { "path": "../../client/ui-chat/tsconfig.client.json" }, + { "path": "../../client/ui-session" }, + { "path": "../../client/ui-slots" }, + { "path": "../../client/ui-primitives" }, + { "path": "../../client/ui-tool" }, + { "path": "../../llm/llm" }, + { "path": "../../core/session" }, + { "path": "../action-cards" }, + { "path": "../../typert/protocol" }, + { "path": "../../util/values" } + ] +} diff --git a/packages/experimental/client-ui-action-cards/tsdown.config.ts b/packages/experimental/client-ui-action-cards/tsdown.config.ts new file mode 100644 index 0000000000..71d5bd77f8 --- /dev/null +++ b/packages/experimental/client-ui-action-cards/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index fdde630b95..beb53d7d2a 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.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/client-ui-claude-code-mods/package.json b/packages/experimental/client-ui-claude-code-mods/package.json index 89b0ac8619..37a5656e69 100644 --- a/packages/experimental/client-ui-claude-code-mods/package.json +++ b/packages/experimental/client-ui-claude-code-mods/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-client-ui-claude-code-mods", "description": "Web band above the prompt for Claude Code mods: draws each session's mod tree and sends button presses back to the bridge", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/client-ui-cot-translation/package.json b/packages/experimental/client-ui-cot-translation/package.json index 7a9c0bcd27..1cc014a2d7 100644 --- a/packages/experimental/client-ui-cot-translation/package.json +++ b/packages/experimental/client-ui-cot-translation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-client-ui-cot-translation", "description": "Optional machine translation of expanded reasoning, preserving original Session text", - "version": "0.2.1-rc.2", + "version": "0.2.1-rc.3", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/client-ui-creation-recommendations/README.i18n.yaml b/packages/experimental/client-ui-creation-recommendations/README.i18n.yaml new file mode 100644 index 0000000000..6b6cffa9fd --- /dev/null +++ b/packages/experimental/client-ui-creation-recommendations/README.i18n.yaml @@ -0,0 +1,40 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/experimental/client-ui-creation-recommendations/README.md +/: + en: e332dc3625021ad9 + zh: a6637d7d8696ba5a +/deepseek-ai-dsh-client-ui-creation-recommendations: + en: b4af39c5d085f16e + zh: 19fd311580db7e2b +/deepseek-ai-dsh-client-ui-creation-recommendations/summary: + en: c2b93e3c50f80ecd + zh: da8aa68e686abddd +/deepseek-ai-dsh-client-ui-creation-recommendations/table-of-contents: + en: d152484eb41ac6b4 + zh: b00dddf4c88f0a45 +/deepseek-ai-dsh-client-ui-creation-recommendations/use-this-package: + en: 328d7d1d81bb2830 + zh: 425df912753bae83 +/deepseek-ai-dsh-client-ui-creation-recommendations/use-this-package/outcomes-and-recovery: + en: 5a3df9cd58832f91 + zh: 2068dca0a62394fe +/deepseek-ai-dsh-client-ui-creation-recommendations/understand-the-implementation: + en: 26b049c5e3f38846 + zh: 15768ae54aec3511 +/deepseek-ai-dsh-client-ui-creation-recommendations/further-exploration: + en: 6543c44016da2cdf + zh: fb0d365a5e411b04 +/deepseek-ai-dsh-client-ui-creation-recommendations/model-experience: + en: 176de8a02931b042 + zh: b1f1228f011ab056 +/deepseek-ai-dsh-client-ui-creation-recommendations/model-experience/kv-cache-effect: + en: 6a708541b49367ae + zh: 5722b25a6a8e6724 +/deepseek-ai-dsh-client-ui-creation-recommendations/known-limitations-and-deferred-work: + en: 93594f90162451d5 + zh: ca27b35469b537be +/deepseek-ai-dsh-client-ui-creation-recommendations/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: 8ec3e73528ddb1a6 diff --git a/packages/experimental/client-ui-creation-recommendations/README.md b/packages/experimental/client-ui-creation-recommendations/README.md new file mode 100644 index 0000000000..808e077ab3 --- /dev/null +++ b/packages/experimental/client-ui-creation-recommendations/README.md @@ -0,0 +1,88 @@ +--- +description: "Show completed-answer creation cards with location choice, Keep and Undo." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-creation-recommendations + +English | [中文](README.zh.md) + +## Summary + +Users see one creation proposal below the completed answer. Short plans appear in full; longer ones fold after about two lines and expand on request. A dropdown offers Right sidebar, Session tab, Global page, or Write your own. The cost notice opens until dismissed; this browser remembers the choice across sessions. The information button reopens it. Only Create starts background work; chatting can continue. Progress appears in the header. Keep confirms retention; Undo removes the plugin, and Restore reinstalls it. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Enable the [Personalized recommendations bundle](../personalized-recommendations/README.md). This browser contribution has no configuration fields; it fills the shared [action card frame](../client-ui-action-cards/README.md) for `create_personalized_page` cards. + +### Outcomes and recovery + +An unstarted card stays available until a newer proposal in the same conversation replaces it. The replaced card keeps its history with a Replaced label and no creation or location controls. Supplied initial content appears below the plan as folded data, so the user sees what the Creator will receive. Started tasks retain their progress and retry actions. The shared card projection owns this state, so reload does not reactivate a card. + +Creation, installation, retention and removal receipts come from the Session projection. The card restores them after reconnecting. A lost action reply shows an actionable error; retry uses the Host’s recorded state. Pending requests disable repeated actions. During generation, Cancel stops the child and waits for cleanup before retry. Accepted installations finish without cancellation. The information disclosure contains the source directory and explains that Undo keeps those files. The removed card offers Restore; when the retained files are gone, it asks for a new proposal. + +Write your own reveals a placement field, limited to 1,000 characters; blank input cannot start creation. Choosing a preset ignores that custom text. Accepted requests persist it with the receipt for reconnect and retry, and Creator receives it with the full brief. Editing the draft, opening the menu and reading the cost notice make no model request. Dismissing the notice or choosing Create/Not now acknowledges it in browser-local storage; a new browser or cleared/unavailable storage can show it again. Historical settled cards keep the notice collapsed. + +Plan disclosure depends on omitted text, not rendered height. Long previews collapse whitespace and preserve grapheme boundaries, including combined emoji; expanding restores the original brief. Short plans remain complete at every viewport width. Lines wrap naturally, with no fixed three-line limit, DOM measurement, or ResizeObserver. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The plugin registers its body with the shared card frame, which places the card after its Turn and supplies the title, the folded brief, the fold component and card-bound actions. The body validates the card's details and recorded data, then renders the header with the cost notice toggle, the location picker, written placement, folded initial content, progress, Keep, Undo and Restore. The cost notice preference is a browser-local store owned by the plugin. The shared Menu handles portal positioning, keyboard selection and dismissal. The body registration and its dictionaries share one plugin lifetime. + +- [Source](src/client/Card.tsx) +- [Tests](tests/) + +
+ +----- + + +## Further Exploration + +- [Creation service](../creation-recommendations/README.md) — creation ownership and persistence. +- [Action card display](../client-ui-action-cards/README.md) — the frame, fold and actions this body uses. +- [Slots](../../client/ui-slots/README.md) — completed-Turn presentation. + + +## Model Experience + +Indirectly, through an accepted Create action: the Host starts Creator with the full brief and selected or human-written placement, without adding browser tools or resuming the parent model. + +#### KV Cache effect + +Rendering and changing the location do not affect the model cache. + +## Known Limitations and Deferred Work + + + +- The displayed brief is model-authored. Necessary existing data travels separately in the proposal’s optional `initialContent`. +- Historical receipts do not continuously track later manual changes on the Plugins page. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/experimental/client-ui-creation-recommendations/README.zh.md b/packages/experimental/client-ui-creation-recommendations/README.zh.md new file mode 100644 index 0000000000..d6562250b5 --- /dev/null +++ b/packages/experimental/client-ui-creation-recommendations/README.zh.md @@ -0,0 +1,88 @@ +--- +description: "在完整回答末尾显示支持位置选择、保留和撤销的创建卡片。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-creation-recommendations + +[English](README.md) | 中文 + +## 概述 + +用户在完整回答下方看到一条创建建议。短方案完整显示,长方案约两行后折叠,可展开全文。下拉菜单提供右侧栏、会话 Tab、全局页面和自行撰写四项。消耗提示在用户收起前默认展开,本浏览器跨会话记住选择,信息按钮可重新打开。只有点击创建才启动后台任务,主对话可继续。进度显示在顶部,保留用于确认继续使用,撤销则移除插件,恢复会重新安装它。 + +## 目录 + +- [使用本包](#use-this-package) +- [实现原理](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与后续工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +启用[个性化推荐 Bundle](../personalized-recommendations/README.zh.md)。本浏览器组件没有配置字段;它为 `create_personalized_page` 卡片填充共享的[操作卡片框架](../client-ui-action-cards/README.zh.md)。 + +### 操作结果与恢复 + +尚未创建的卡片一直可用,直到同一会话里更新的方案替换它。被替换的卡片保留历史内容和“已替换”标签,没有创建和位置控件。提供的预填内容以折叠形式显示在方案下方,用户能看到交给 Creator 的内容。已开始的任务保留进度和重试操作。共享卡片投影拥有这个状态,所以刷新不会重新激活卡片。 + +创建、安装、保留和移除记录来自 Session 投影,重新连接后会在卡片中恢复。操作回复丢失时显示可采取行动的错误信息;重试以 Host 已记录的状态为准。请求处理中禁用重复操作。生成阶段可取消,等待子任务清理后才能重试;已接纳的安装不再取消。信息说明中包含源码目录,并明确撤销会保留这些文件。已移除的卡片提供“恢复”;保留的文件已不在时,提示需要重新提案。 + +自行撰写会显示位置输入框,最多 1,000 字,空白内容不能启动创建;改选预设位置后不提交自定义文字。已接受的请求将文字存入操作记录,供重连和重试恢复,并连同完整方案交给 Creator。编辑草稿、打开菜单或查看消耗说明都不会调用模型。收起提示或点击创建、暂不需要后,浏览器本地存储记住已读;更换浏览器、清除或无法使用存储时可能再次显示。历史已结束的卡片默认收起提示。 + +是否展开只取决于有没有省略内容,不取决于渲染高度。长方案预览合并空白,并保留组合 emoji 等字素边界;展开后恢复原始全文。短方案在任何窗口宽度都完整显示。文字自然换行,不固定三行,不读取 DOM 尺寸或使用 ResizeObserver。 + +----- + + +## 实现原理 + +
+实现细节 — 点击展开 + +插件把正文注册到共享卡片框架,由框架把卡片放在所属回合之后,并提供标题、折叠后的说明、折叠组件和绑定到卡片的操作。正文先校验卡片的 details 和已记录的数据,再渲染带消耗说明开关的标题栏、位置选择、手写位置、折叠的预填内容、进度,以及保留、撤销和恢复。消耗说明的偏好是插件自有的浏览器本地存储。共享 Menu 处理浮层定位、键盘选择和关闭。正文注册和语言字典使用同一插件生命周期。 + +- [源码](src/client/Card.tsx) +- [测试](tests/) + +
+ +----- + + +## 延伸阅读 + +- [创建服务](../creation-recommendations/README.zh.md) — 创建任务与持久化。 +- [操作卡片显示](../client-ui-action-cards/README.zh.md) — 本正文使用的框架、折叠和操作。 +- [Slots](../../client/ui-slots/README.zh.md) — 回合末尾呈现. + + +## 模型体验 + +间接影响来自用户接受的创建操作:Host 启动 Creator,传入完整方案和选定或手写的位置,不增加浏览器侧模型工具,也不恢复父模型执行。 + +#### KV Cache effect + +渲染卡片和修改位置不会影响模型缓存。 + +## 已知限制与后续工作 + + + +- 展示方案由模型编写,所需已有数据通过方案的可选 `initialContent` 单独交接。 +- 历史记录不会持续跟踪之后在插件页手动进行的修改。 + + +### 开发备注 + +
+维护者工作上下文 — 点击展开 + +无。 + +
diff --git a/packages/experimental/client-ui-creation-recommendations/locale/de.json b/packages/experimental/client-ui-creation-recommendations/locale/de.json new file mode 100644 index 0000000000..e9a5d99164 --- /dev/null +++ b/packages/experimental/client-ui-creation-recommendations/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Karten für personalisierte Seiten", + "description": "Zeigt die Vorschlagskarte nach einer Antwort mit Ortswahl, Erstellungsfortschritt, Behalten und Rückgängigmachen an." + } +} diff --git a/packages/experimental/client-ui-creation-recommendations/locale/en.json b/packages/experimental/client-ui-creation-recommendations/locale/en.json new file mode 100644 index 0000000000..05dbfd4e43 --- /dev/null +++ b/packages/experimental/client-ui-creation-recommendations/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Personalized page cards", + "description": "Show the proposal card after an answer with location choice, creation progress, retention and undo." + } +} diff --git a/packages/experimental/client-ui-creation-recommendations/locale/es.json b/packages/experimental/client-ui-creation-recommendations/locale/es.json new file mode 100644 index 0000000000..8f7e016149 --- /dev/null +++ b/packages/experimental/client-ui-creation-recommendations/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Tarjetas de páginas personalizadas", + "description": "Muestra la tarjeta de propuesta después de una respuesta, con elección de ubicación, progreso de creación, conservación y deshacer." + } +} diff --git a/packages/experimental/client-ui-creation-recommendations/locale/fr.json b/packages/experimental/client-ui-creation-recommendations/locale/fr.json new file mode 100644 index 0000000000..1890ba69ee --- /dev/null +++ b/packages/experimental/client-ui-creation-recommendations/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Cartes de pages personnalisées", + "description": "Affiche la carte de proposition après une réponse, avec le choix de l'emplacement, la progression de la création, la conservation et l'annulation." + } +} diff --git a/packages/experimental/client-ui-creation-recommendations/locale/ja.json b/packages/experimental/client-ui-creation-recommendations/locale/ja.json new file mode 100644 index 0000000000..4e776bc2de --- /dev/null +++ b/packages/experimental/client-ui-creation-recommendations/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "パーソナルページのカード", + "description": "回答の末尾に提案カードを表示し、配置の選択、作成の進行状況、保持と取り消しを扱います。" + } +} diff --git a/packages/experimental/client-ui-creation-recommendations/locale/pt.json b/packages/experimental/client-ui-creation-recommendations/locale/pt.json new file mode 100644 index 0000000000..37bd61d6a4 --- /dev/null +++ b/packages/experimental/client-ui-creation-recommendations/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Cartões de páginas personalizadas", + "description": "Mostra o cartão de proposta após uma resposta, com escolha de local, progresso da criação, manutenção e desfazer." + } +} diff --git a/packages/experimental/client-ui-creation-recommendations/locale/ru.json b/packages/experimental/client-ui-creation-recommendations/locale/ru.json new file mode 100644 index 0000000000..d9c778829a --- /dev/null +++ b/packages/experimental/client-ui-creation-recommendations/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Карточки персональных страниц", + "description": "Показывает карточку предложения после ответа с выбором места, ходом создания, сохранением и отменой." + } +} diff --git a/packages/experimental/client-ui-creation-recommendations/locale/zh.json b/packages/experimental/client-ui-creation-recommendations/locale/zh.json new file mode 100644 index 0000000000..debb5f1b11 --- /dev/null +++ b/packages/experimental/client-ui-creation-recommendations/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "个性化页面卡片", + "description": "在回答末尾显示方案卡片,含位置选择、创建进度、保留和撤销。" + } +} diff --git a/packages/experimental/client-ui-creation-recommendations/package.json b/packages/experimental/client-ui-creation-recommendations/package.json new file mode 100644 index 0000000000..f7198b8f1f --- /dev/null +++ b/packages/experimental/client-ui-creation-recommendations/package.json @@ -0,0 +1,64 @@ +{ + "version": "0.2.1-rc.3", + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "license": "MIT", + "name": "@deepseek-ai/dsh-client-ui-creation-recommendations", + "description": "Body of the personalized page card: location choice, creation progress, Keep, Undo and Restore", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/client-ui-creation-recommendations" + }, + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./locale/*.json": "./locale/*.json" + }, + "dsh": { + "client": { + "platform": "web", + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-action-cards" + ] + } + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "react": "^18.2.0", + "@deepseek-ai/dsh-creation-recommendations": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-ui-action-cards": "workspace:*", + "@deepseek-ai/dsh-action-cards": "workspace:*", + "zod": "4.4.3", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*" + }, + "files": [ + "locale/*.json", + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/experimental/client-ui-creation-recommendations/src/client/Card.module.css b/packages/experimental/client-ui-creation-recommendations/src/client/Card.module.css new file mode 100644 index 0000000000..b1cc0590ec --- /dev/null +++ b/packages/experimental/client-ui-creation-recommendations/src/client/Card.module.css @@ -0,0 +1,28 @@ +.header { display: flex; align-items: center; justify-content: space-between; gap: 8px 12px; padding: 12px 20px 0; font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-secondary); flex-wrap: wrap; } +.heading { display: inline-flex; align-items: center; gap: 4px; } +.infoButton { width: 28px; padding: 0; color: var(--dsw-alias-label-secondary); } +.info { margin: 12px 20px 0; padding: 12px; border-radius: var(--dsw-radius-md); background: var(--dsw-alias-bg-module-platform); font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; } +.info p { margin: 0; } +.info p + p { margin-top: 4px; } +.info strong { font-weight: 500; } +.phase { color: var(--dsw-alias-label-primary); } +.body { padding: 12px 20px 20px; } +.content { margin-top: 12px; } +.contentLabel { display: block; font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary); } +.description { margin: 16px 0 0; padding-top: 12px; border-top: 0.5px solid var(--dsw-alias-border-l1); font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; } +.footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; flex-wrap: wrap; } +.scope { font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-secondary); } +.actions { display: flex; align-items: center; gap: 8px; margin-left: auto; } +.status { margin: 12px 0 0; font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary); } +.controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-top: 16px; padding-top: 16px; border-top: 0.5px solid var(--dsw-alias-border-l1); } +.controls .footer { margin: 0 0 0 auto; } +.locationRow { display: flex; align-items: center; gap: 8px; min-width: 0; } +.locationLabel { font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary); } +.locationButton { gap: 8px; font-size: 13px; } +.locationMenu { width: 264px; max-width: calc(100vw - 24px); } +.locationOption { display: flex; flex-direction: column; gap: 2px; white-space: normal; line-height: 1.5; } +.locationOption > span + span { color: var(--dsw-alias-label-secondary); font-size: 12px; } +.customLocation { display: flex; flex-direction: column; gap: 6px; flex: 1 0 100%; min-width: 0; } +.customLocation textarea { box-sizing: border-box; width: 100%; min-height: 72px; resize: vertical; padding: 8px 10px; border: 0.5px solid var(--dsw-alias-border-l4); border-radius: var(--dsw-radius-md); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font: inherit; font-size: 13px; line-height: 1.7; } +.customLocation textarea:focus-visible { outline: 1px solid var(--dsw-alias-state-business-primary); outline-offset: 1px; } +.customLocation textarea::placeholder { color: var(--dsw-alias-label-secondary); } diff --git a/packages/experimental/client-ui-creation-recommendations/src/client/Card.tsx b/packages/experimental/client-ui-creation-recommendations/src/client/Card.tsx new file mode 100644 index 0000000000..5fffa80e65 --- /dev/null +++ b/packages/experimental/client-ui-creation-recommendations/src/client/Card.tsx @@ -0,0 +1,169 @@ +/** Body of a `create_personalized_page` card: location choice, creation progress, retention, undo and restore. */ +import { useCallback, useId, useState, useSyncExternalStore } from 'react' +import { z } from 'zod' +import { Button, IconChevronDownOutlineRegular, IconWarningTriangleOutlineRegular, Menu, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { ActionCardBodyProps } from '@deepseek-ai/dsh-client-ui-action-cards/client' +import type { CreationSlot, PageData, PageDetails, PageVerb } from '@deepseek-ai/dsh-creation-recommendations/types' +import type { RecommendationKey } from './locales.ts' +import css from './Card.module.css' + +/** Body props: the frame's card props plus this browser's cost-notice preference. */ +export type PageCardProps = ActionCardBodyProps<'creationRecommendations'> & { readonly infoDismissed: SnapshotStore } + +type LocationChoice = CreationSlot | 'custom' +type CardVerb = PageVerb | 'dismiss' + +const slotSchema = z.enum(['sidebar-right', 'page', 'sidebar-left']) +const detailsSchema = z.object({ slot: slotSchema, initialContent: z.string().optional() }) satisfies z.ZodType +const dataSchema = z.object({ + slot: slotSchema, customLocation: z.string().optional(), bundleName: z.string().optional(), + application: z.enum(['applied', 'restart-required', 'overridden', 'failed', 'cancelled']).optional(), +}) satisfies z.ZodType + +/** Phases with their own status copy. */ +const PHASE_KEYS: readonly RecommendationKey[] = [ + 'dismissed', 'replaced', 'creating', 'installing', 'cancelled', 'installed', 'kept', 'removed', 'failed', 'interrupted', 'unconfirmed', +] +/** Refusal reasons with their own copy; any other reason reads as unavailable. */ +const REASON_KEYS: readonly RecommendationKey[] = [ + 'unknown', 'access-denied', 'not-live-root', 'closing', 'unavailable', 'invalid-location', 'read-only', 'source-missing', +] + +/** Application outcomes have their own copy; the phase copy of failed and cancelled describes creation instead. */ +const APPLICATION_KEYS: Readonly, 'applied'>, RecommendationKey>> = { + 'restart-required': 'restart-required', overridden: 'overridden', failed: 'applicationFailed', cancelled: 'applicationCancelled', +} + +function keyOf(keys: readonly RecommendationKey[], value: string): RecommendationKey | undefined { + return keys.find(key => key === value) +} + +function LocationPicker({ value, disabled, onChange, t }: { + value: LocationChoice + disabled: boolean + onChange: (value: LocationChoice) => void +} & PropsLocale<'creationRecommendations'>) { + const [open, setOpen] = useState(false) + return { setOpen(false) }} + onSelect={(id) => { onChange(id as LocationChoice); setOpen(false) }} + items={(['sidebar-right', 'page', 'sidebar-left', 'custom'] as const).map(id => ({ + id, label: {t(id)}{t(`${id}Hint`)}, + }))} + anchor={} /> +} + +/** + * Display creation, persistent installation, reversible removal and restoration below the frame's summary. + * @param props - card, state, actions, folded-text component and notice preference. + * @returns the card body. + */ +export function PageCardBody({ proposal, decision, summary, Folded, act, infoDismissed, t }: PageCardProps) { + const details = detailsSchema.safeParse(proposal.details) + const parsed = decision === undefined ? undefined : dataSchema.safeParse(decision.data) + const data = parsed?.success === true ? parsed.data : undefined + const phase = decision === undefined ? undefined : keyOf(PHASE_KEYS, decision.phase) + const [selected, setSelected] = useState() + const [custom, setCustom] = useState() + const [info, setInfo] = useState() + const subscribe = useCallback((listener: () => void) => infoDismissed.subscribe(listener), [infoDismissed]) + const noticeDismissed = useSyncExternalStore(subscribe, () => infoDismissed.getSnapshot()) + const infoId = useId() + const customId = useId() + const [pending, setPending] = useState() + const [error, setError] = useState() + if (!details.success) return
{summary}
+ const busy = pending !== undefined + const slot = selected !== undefined && selected !== 'custom' ? selected : data?.slot ?? details.data.slot + const location = selected ?? (data?.customLocation === undefined ? slot : 'custom') + const customLocation = custom ?? data?.customLocation ?? '' + const offer = decision === undefined || phase === 'cancelled' || phase === 'failed' || phase === 'interrupted' || phase === 'unconfirmed' + const infoOpen = info ?? (offer && !noticeDismissed) + const installed = phase === 'installed' || phase === 'kept' + const run = async (verb: CardVerb) => { + setPending(verb) + setError(undefined) + if (verb === 'create' || verb === 'dismiss') { infoDismissed.set(true); setInfo(false) } + const placement = { slot, ...location === 'custom' ? { customLocation: customLocation.trim() } : {} } + try { + const result = await act(verb, verb === 'create' || verb === 'dismiss' ? placement : undefined) + if (result === undefined) setError('transport') + else if (result.status === 'refused' || result.status === 'unconfirmed') setError(keyOf(REASON_KEYS, result.reason) ?? 'unavailable') + } catch { + // A lost reply does not prove failure; replay supplies the durable decision. + setError('transport') + } finally { setPending(undefined) } + } + return <> +
{t('heading')} + + + + + {phase !== undefined && {t(phase)}} +
+ {infoOpen &&
+

{t('creationMethod')}

+

{t('creationCost')}

+

{t('creationBackground')}

+ {data?.bundleName !== undefined &&

{t('sourceRetained')}

} +
} +
+ {summary} + {details.data.initialContent !== undefined &&
+ {t('initialContent')} + +
} + {data?.application !== undefined && data.application !== 'applied' && phase !== 'failed' + &&

{t(APPLICATION_KEYS[data.application])}

} + {error !== undefined &&

{t(error)}

} + {offer ?
+
+ {t('location')} + +
+ {location === 'custom' &&
+ +