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 5b400d15a2..aaa8fdbedc 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 @@ -18,14 +18,14 @@ en: 5fdde26e9e8c6108 zh: 629559d733c1f0c5 /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/plugin-resolution-the-vfs-holds-a-real-package-tree-the-closure-manifest-is-the-deploy-root: - en: c35ea4b858448b49 - zh: 1f8ff0a9f82ca881 + en: 985e0eb63f3d1fd6 + zh: 337cc44fadc6227e /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/build-pipeline-and-artifacts: - en: d3bbd24ead02af4f - zh: 64623d88bee4314b + en: 921085cb9d894688 + zh: 12d99fe153d36738 /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/python-sdk-distribution-two-carriers-exe-for-production-node-for-development: - en: 9bd60549d4a52ec0 - zh: e2343b7210212ee0 + en: 02e6b36f9f92c83a + zh: edd8b2db7a787483 /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/naming-lineage: en: 31a5aecb8bf828bb zh: 2dffb6fd2e0f8194 @@ -33,8 +33,8 @@ en: 0426f598f3f44e64 zh: fc66728e17f3bcfb /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/testing: - en: b3c9820f4dc7c6c8 - zh: 3b5768ef1770bfcb + en: 8e7f46bbb595afa9 + zh: 4641894ed285d717 /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/alternatives-considered: en: 50d965ca81a401ba zh: 0c6296245b97b9ac 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 27d37ed541..60228735f5 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -44,19 +44,19 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers. The optional resource service provides MCP Resources; MCP Prompts remain unsupported. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. -The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed dependency tree live in `-office/node_modules`, preserving complete package contents and helper permissions. Top-level `pkg.ignore` excludes kit packages from the SEA archive, including assets selected by dependency `files` fields; small dependencies shared with other consumers remain available in the VFS. The private Python bootstrap uses Node `registerHooks` to resolve the kit entry and its package manifest from the real directory. Native executables and URL Workers therefore retain their ordinary paths without modifying the kit or the public CLI. Missing required packages and missing installed package manifests fail the copy; absent optionals preserve the kit's engine selection. +The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed dependency tree live in the explicitly downloaded sidecar’s `node_modules`, preserving complete package contents and helper permissions. Top-level `pkg.ignore` excludes kit packages from the SEA archive, including assets selected by dependency `files` fields; small dependencies shared with other consumers remain available in the VFS. The private Python bootstrap uses Node `registerHooks` to resolve the kit entry and its package manifest from the real directory. Native executables and URL Workers therefore retain their ordinary paths without modifying the kit or the public CLI. The build locks the selected engine and dependency locations to exact npm tarballs; downloads verify their integrity before publication. ### 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` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it with its target-specific Office directory 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` 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. 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. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar, `-office/` directory, and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar, lightweight download metadata and Office skills, and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/runtime-bootstrap.mjs` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, its Office directory, and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, `py3-none-macosx_14_0_arm64`, `py3-none-macosx_14_0_x86_64`, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. Both macOS tags deliberately declare a conservative 14.0 installation floor: the packaged Node 24 executables declare macOS 13.5, and the x64 PTY helper declares 10.7, but release validation proves the complete payload only against the 14.0 wheel claim rather than promising each observed component minimum as a supported host. The two macOS wheels remain architecture-specific; no universal2 wheel is published. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, lightweight resource metadata and skills, and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, `py3-none-macosx_14_0_arm64`, `py3-none-macosx_14_0_x86_64`, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. Both macOS tags deliberately declare a conservative 14.0 installation floor: the packaged Node 24 executables declare macOS 13.5, and the x64 PTY helper declares 10.7, but release validation proves the complete payload only against the 14.0 wheel claim rather than promising each observed component minimum as a supported host. The two macOS wheels remain architecture-specific; no universal2 wheel is published. The Python client launches the packaged `dsh` command with the selected profile (`sdk` by default), ordered patch files, and an explicit Harness home. The profile owns JSON-RPC serving and application composition; missing homes, profiles, bundles, patches, and server rows fail without an external complete-config fallback. @@ -74,7 +74,7 @@ The verification surface has three tiers. Mechanism tier: the measured conclusio Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. -The installed-wheel `sdk-office` scenario copies the target's complete payload to a private temporary directory, requires exactly one target backend (the declared native target, or WASM when no native target is declared), and converts DOCX once through the shipped SDK profile with that engine. It checks that the kit module resolves inside the relocated directory, that conversion reports the expected backend, and that the output contains PDF bytes. Wheel validation checks the declared engine assets and native helper execute bit; CI uploads the completed wheel, so nested helper permissions stay inside the ZIP archive. +The installed-wheel `sdk-office` scenario relocates the lightweight wheel payload and explicitly downloads the complete npm Office sidecar with its independent Node. It requires exactly one target backend, verifies that the SDK loads the kit from the downloaded sidecar, and converts DOCX through the skill CLI with an empty PATH. Python Office operations use the same CLI. Explicit downloads reuse the existing build preparation implementation; SDK startup performs no downloads. The [runtime reference](../../../../python/sdk-runtime/README.md) owns cache and activation behavior. ## Alternatives considered 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 4cc6e20459..272c0e80c9 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -44,19 +44,19 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server。可选资源服务提供 MCP Resources;MCP Prompts 仍不受支持。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 -[Office kit](2026-09-14-independent-libreoffice-kit.zh.md) 及其已安装依赖树位于 `-office/node_modules`,保留完整包内容与辅助程序权限。顶层 `pkg.ignore` 从 SEA 归档排除 kit 包,包括依赖 `files` 字段选中的资源;与其他使用方共享的小型依赖仍可在 VFS 中使用。Python 私有 bootstrap 使用 Node `registerHooks` 从真实目录解析 kit 入口及其包清单。因此原生可执行程序与 URL Worker 保留普通文件路径,无需修改 kit 或公开 CLI。必需包缺失或已安装包缺少清单会使复制失败;未安装的可选包保留 kit 自身的引擎选择规则。 +[Office kit](2026-09-14-independent-libreoffice-kit.zh.md) 及其已安装依赖树位于显式下载的 sidecar 的 `node_modules`,保留完整包内容与辅助程序权限。顶层 `pkg.ignore` 从 SEA 归档排除 kit 包,包括依赖 `files` 字段选中的资源;与其他使用方共享的小型依赖仍可在 VFS 中使用。Python 私有 bootstrap 使用 Node `registerHooks` 从真实目录解析 kit 入口及其包清单。因此原生可执行程序与 URL Worker 保留普通文件路径,无需修改 kit 或公开 CLI。构建将所选引擎和依赖位置锁定到确切的 npm 归档;下载会在发布缓存前校验完整性。 ### 构建流水线与产物 -[`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` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/`,并将它与对应目标的 Office 目录一同拷回运行时目录。该 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` → 将 `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 的明确排除。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件、`-office/` 目录和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。 +Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件、轻量下载元数据与 Office skills 和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/runtime-bootstrap.mjs`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe、其 Office 目录及架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、`py3-none-macosx_14_0_arm64`、`py3-none-macosx_14_0_x86_64` 或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。两个 macOS 标签都会特意声明保守的 14.0 安装下限:打包后的 Node 24 可执行文件声明 macOS 13.5,x64 PTY helper 声明 10.7,但发布验证只按照 14.0 wheel 声明证明完整载荷,不会把各组件实测的最低版本承诺为受支持宿主。两个 macOS wheel 包仍按架构分别发布,不发布 universal2 wheel 包。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe、轻量资源元数据与 skills 及架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、`py3-none-macosx_14_0_arm64`、`py3-none-macosx_14_0_x86_64` 或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。两个 macOS 标签都会特意声明保守的 14.0 安装下限:打包后的 Node 24 可执行文件声明 macOS 13.5,x64 PTY helper 声明 10.7,但发布验证只按照 14.0 wheel 声明证明完整载荷,不会把各组件实测的最低版本承诺为受支持宿主。两个 macOS wheel 包仍按架构分别发布,不发布 universal2 wheel 包。 Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和显式 Harness home 启动打包后的 `dsh` 命令。Profile 负责 JSON-RPC 服务和应用组合;缺失 home、profile、bundle、patch 或 server 配置项都会失败,不存在外部完整配置回退。 @@ -74,7 +74,7 @@ Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 -安装后 wheel 的 `sdk-office` 场景会将目标的完整载荷复制到私有临时目录,要求仅存在一个目标后端(使用已声明的原生目标,未声明原生目标时使用 WASM),并通过随附 SDK profile 使用该引擎转换一次 DOCX。它检查 kit 模块确实解析到迁移后的目录、转换报告预期后端,且输出包含 PDF 字节。wheel 校验会检查声明的引擎资源与原生辅助程序执行位;CI 上传已完成的 wheel,因此嵌套辅助程序权限保留在 ZIP 归档中。 +安装后 wheel 的 `sdk-office` 场景迁移轻量 wheel 载荷,并显式下载完整 npm Office sidecar 及其独立 Node。它要求仅有一个目标后端,验证 SDK 从下载的 sidecar 加载 kit,并通过 skill CLI 在空 PATH 下转换 DOCX。Python Office 操作使用同一 CLI。显式下载复用现有构建准备实现;SDK 启动不执行下载。[运行时参考](../../../../python/sdk-runtime/README.zh.md)拥有缓存和启用行为的说明。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml index 7354602835..4573f0a33f 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml @@ -27,8 +27,8 @@ en: fbd101d4576afa92 zh: 35f5d3c2f6cad6a8 /agent-note-cooperative-tool-cancellation-at-the-registry-boundary/alternatives-considered: - en: 1f205f1a78d4fd5b - zh: 6c17e35da2909302 + en: c4d86feb135d2ac4 + zh: 67c56c0bc2213949 /agent-note-cooperative-tool-cancellation-at-the-registry-boundary/consequences: en: 24a9ad2c4c7c86b5 zh: debdd29d272e07ea diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md index 5ca2b44b24..967c7285df 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md @@ -62,7 +62,7 @@ No registry test can prove that arbitrary third-party same-process code observes **Forbid around wrappers from replacing the signal.** Rejected because deadlines and nested operational scopes need lexical derivation. Capturing and fusing the caller signal preserves composition without allowing detachment. -**Race the tool promise against cancellation.** Rejected because it reports completion while side effects may remain live, violating the [quiescent-disposal rule](../../../../docs/defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it). +**Race the tool promise against cancellation.** Rejected because it reports completion while side effects may remain live, violating the [quiescent-disposal rule](../../../../docs/defensive-patterns/general.md#dispose-must-reach-quiescence-not-just-request-it). ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md index d6d49fb44c..87318c3316 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md @@ -62,7 +62,7 @@ Status: implemented **禁止环绕包装层替换信号。** 不予采纳,因为截止时间和嵌套操作作用域需要词法派生信号。捕获并融合调用方信号既保留组合能力,也不允许切断调用方取消。 -**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[dispose(资源释放)必须完全停稳的规则](../../../../docs/defensive-patterns.zh.md#dispose-must-reach-quiescence-not-just-request-it)。 +**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[dispose(资源释放)必须完全停稳的规则](../../../../docs/defensive-patterns/general.zh.md#dispose-must-reach-quiescence-not-just-request-it)。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml index 50fa27df10..9f174f4f54 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml @@ -18,5 +18,5 @@ en: fa503dcfaa6f438f zh: 26052212a124e133 /agent-note-compiler-independent-typert-type-model/consequences: - en: 4a472acf377f8361 - zh: cb83602b123a4aae + en: e1022cf9523fb28e + zh: 01dfb764fb98de0d diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md index 5083c55176..6ca9186f7f 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md @@ -51,3 +51,5 @@ For each supported node kind and literal category, Zod emitter tests run both su New generation targets and static checks can reuse the same TypeGraph, and business categories can extend PackageModel without parsing the AST again. Preserving pre-evaluation types and independent faces makes the model more complex than a flattened schema; emitters must explicitly declare their supported scope and fail on missing capabilities. Explicit package opt-in keeps artifact publication and exports under package ownership. Repository orchestration may still run the whole-workspace Host contract pass for every opted-in package; that pass remains owned by the later Remote Gateway Agent Note. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after materializing them. + +Ordinary Host and Remote artifacts reuse this model to emit one static method spec per endpoint. Each parameter has normal `codec` and `strictCodec` variants derived from the same checker-resolved type model; neither analysis nor the model carries a per-method input policy. A pure factory returns a lazy info instance whose direct method index and descriptor list share objects; the existing Registry retains those descriptors, while `TYPERT_REMOTE.methods` supplies exact static types. The [unified Typert/Remote metadata note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.md) records this delivery mechanism and the incomplete downstream adaptation. Declaration and face-isolation rules remain unchanged; separate Programs and processes do not share object identity. diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md index 70d6954fae..1a4a870fab 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md @@ -51,3 +51,5 @@ Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 pars 新增生成目标或静态检查可复用同一 TypeGraph,业务类目也可在 PackageModel 上扩展,而无需再次解析 AST。保留计算前类型和独立 face 的代价是模型比打平后的 schema 更复杂,emitter 必须显式声明支持范围并对缺失能力失败。 包级显式 opt-in 使产物发布与 exports 由各包自行管理。仓库编排仍可为每个已 opt-in 的包运行全仓 Host 约定 pass;该 pass 仍由后续 Remote Gateway Agent Note 负责说明。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方物化后仍持有的 Zod 实例,卸载流程无法控制。 + +普通 Host 与 Remote 产物复用此模型,为每个端点生成一份静态方法 spec。每个参数都有从同一 checker 解析后类型模型派生的普通 `codec` 和 `strictCodec` 变体;分析过程与模型均不携带逐方法输入策略。纯 factory 返回惰性 info 实例,其直接方法索引与描述符列表共享对象;既有 Registry 保留这些描述符,而 `TYPERT_REMOTE.methods` 提供精确静态类型。[统一 Typert/Remote 元数据 note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md)记录该交付机制及尚未完成的下游适配。声明与编译面隔离规则保持不变;独立 Program 和进程不共享对象身份。 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 5be7e083a3..82629e613f 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -12,14 +12,14 @@ en: 44d946b53496974c zh: 403447c25a5b2d7c /agent-note-typert-gateway-targeted-method-calls/components-and-cordis-services: - en: aa7f509189ade32e - zh: ef2841bc793f31b2 + en: 02c20706e48467e6 + zh: 2b0916ae0cee351b /agent-note-typert-gateway-targeted-method-calls/business-declarations: - en: c6b70d442b137d2e - zh: 6d0dbefe89937ef1 + en: b9394d5ef98cc82a + zh: 1fce07fe150722ee /agent-note-typert-gateway-targeted-method-calls/decorators-and-the-explicit-gateway-facet: - en: 155afbe4b8af600b - zh: 14705698a9cdbd84 + en: 161dccf2b4cf565d + zh: fa587fe0b46bf551 /agent-note-typert-gateway-targeted-method-calls/lookup-and-remote-scope-registration: en: 9f7448898cf5cadc zh: 419573c589ed6f7d @@ -33,8 +33,8 @@ en: a244f6a88fdabf3d zh: e4feedb7297f4865 /agent-note-typert-gateway-targeted-method-calls/three-artifact-kinds-and-two-typescript-programs: - en: 6ea4bddcf35a834d - zh: 4af474c6818b1cf0 + en: c5bcd6eb76bf3d42 + zh: 9df2fbb364643186 /agent-note-typert-gateway-targeted-method-calls/the-remote-package-entry: en: 75e977fe0fb71d59 zh: 00760c79da173cb7 @@ -48,8 +48,8 @@ en: 5d14e1b7afc53efb zh: e2f4b5814083738f /agent-note-typert-gateway-targeted-method-calls/src-and-lib-operating-modes: - en: 1e366dcfab74831b - zh: 14199f25f20a9352 + en: f1b8426d1cfaed35 + zh: a370376923c98d6e /agent-note-typert-gateway-targeted-method-calls/host-gateway-resolution: en: 88a95d7ab4621caf zh: 89b63fb6440fca79 @@ -66,8 +66,8 @@ en: b1c0c3dd77c56ce8 zh: 769ffd4aeefd949c /agent-note-typert-gateway-targeted-method-calls/alternatives-considered: - en: 0d68c499b7780f4f - zh: bdd1e57232b996e2 + en: eaa3ffe5ca14005c + zh: 1f0b455e30ade872 /agent-note-typert-gateway-targeted-method-calls/verification: en: 7433bdb522bb2083 zh: 545c17613b636f00 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 58135ff56e..ba4e8eb4c9 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -28,7 +28,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | Component | Cordis service | Responsibility | |---|---|---| -| `@deepseek-ai/dsh-typert-protocol` | Declares only the minimal `ctx.typert` protocol | `TypertRemoteService`, decorators, binding fallback, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | +| `@deepseek-ai/dsh-typert-protocol` | Declares only the minimal `ctx.typert` protocol | `TypertRemoteService`, decorators, binding fallback, descriptors, lookup/Context, the Remote map, and Zod-backed lazy info assembly; no dependency on the compiler, Connection, or Browser | | Typert registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | | Typert generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | | API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and projects unary results into carrier-neutral fields and attachments | @@ -81,16 +81,18 @@ export class ScopedGoalService extends TypertRemoteService { An endpoint selects exactly one invocation mode. A flow that needs an explicit `Agent` parameter uses `@Remote`. A flow that first switches to an Agent Context and then resolves a scoped receiver uses `@RemoteScope('agent')`. Typert does not infer either mode from the method body or from a missing parameter. -Business packages depend only on the lightweight `@deepseek-ai/dsh-typert-protocol`. It provides `TypertRemoteService` and declaration protocols for decorators, the binding fallback, lookup, Remote Scope, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. +Business packages depend only on `@deepseek-ai/dsh-typert-protocol`. It provides `TypertRemoteService` and declaration protocols for decorators, the binding fallback, lookup, Remote Scope, and descriptors, without depending on the TypeScript compiler, HTTP, or the Client runtime. Its pure `./remote-info` entry uses Zod to assemble lazy named-input schemas without importing Cordis; the root entry also re-exports it alongside Cordis-backed declarations. A method that cooperatively supports cancellation declares `signal: AbortSignal` as its final Host parameter. This reserved parameter is not a business value, lookup, or JSON field. The generated consumer method exposes it as a final optional parameter so ordinary calls remain unchanged while callers that own cancellation can pass a signal. ## Decorators and the explicit Gateway facet -A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteScope('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `TypertRemoteService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance. +A decorator states Remote participation, an optional export name, and stream delivery; `@Remote` options contain only `name` and `mode`. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteScope('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `TypertRemoteService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertRemote` field keeps the binding visible on the runtime instance. In SRC mode, the decorator records the method name and invocation mode in a versioned descriptor on the Service prototype. The descriptor uses a stable string property name, so `remoteMethods()` can read markers produced by another installed copy of `dsh-typert-protocol`; it writes nothing to the Service instance, constructor, or method function. +Ordinary artifacts construct a lazy Remote info instance from one static method spec per endpoint. Its direct `TypertRemoteMethod` objects retain descriptor fields and add named `input(options?)` and `invoke`. Consumers select matching strict parsing and projection with `input({ strict: true })`; `input()` preserves normal handling. Host parameter codecs and the single normal `result` codec are unchanged. Existing Registry queries retain those objects, while `TYPERT_REMOTE.methods` supplies exact static types. Strict source startup prebuilds and loads ordinary Typert artifacts; fallback cannot recover erased schemas. The [unified metadata note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.md) records this delivery mechanism and the incomplete consumer adaptation. + In LIB mode, the Typert compiler performs strict method discovery, type resolution, and descriptor generation. It accepts a literal service key in `TypertRemoteService`'s direct `super()` call or the explicit binding fallback; generation neither rewrites business source nor injects hidden registration metadata. ## Lookup and Remote Scope registration @@ -215,7 +217,7 @@ Host lib build → Vite 构建 Web ``` -The existing top-level `build` still runs `build:lib` before `build:web`, but `build:lib` must complete the Host and Remote artifacts before starting Client TypeScript compilation. A clean build must not depend on stale `.d.ts` files from an earlier build. +The top-level `build` runs the step graph in `scripts/build.ts`, which completes the Host bundle and the Remote artifacts before starting Client TypeScript compilation. `build:lib` remains the complete serial lib build for `build:bench`, and its Host half `build:lib:host` serves the vendor release workflows. `typecheck` writes the Remote artifacts through `scripts/generate-typert.ts`, beside Host compilation, before Client TypeScript compilation. A clean build must not depend on stale `.d.ts` files from an earlier build. Compiler-backed repository gates that resolve the consumer surface have the same prerequisite even when their primary inputs are source files. The public `typecheck`, `lint`, and `doc-typecheck` commands run the Host contract pass first. The gate scheduler may use their `*:contracts-ready` variants only after an explicit Typert-contract or complete-build dependency, so parallel lanes neither read missing declarations nor run concurrent generators against the same outputs. @@ -349,9 +351,9 @@ The Web already depends on build artifacts such as `lib/client.js`, so it requir ## SRC and LIB operating modes -SRC supports local source startup. The versioned prototype descriptors created by `@Remote` and `@RemoteScope()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. +SRC supports local source startup. For methods without registered strict definitions, the versioned prototype descriptors created by `@Remote` and `@RemoteScope()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. -For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. +For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. The conservative fallback does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. A signature that SRC cannot resolve unambiguously fails on the first invocation that resolves its descriptor; Service mounting records only the decorator marker and does not inspect the JavaScript signature. SRC does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types. @@ -474,7 +476,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h **Perform strict reflection through decorators at runtime.** JavaScript decorators cannot recover erased TypeScript types, public symbol identity, or complete Zod codecs. Injecting a compiler-private symbol into a constructor would also hide the business class's real dependencies, so Typert generates strict information at compile time. -**Use a preload, loader hook, or complete `ts.Program` during SRC startup.** This could reuse LIB analysis but would add requirements to every source startup entry. SRC needs only a usable permissive descriptor, so it uses decorator markers, function parameter names, and explicit providers; strict checks remain in the LIB contract pass. +**Use a preload, loader hook, or complete `ts.Program` during SRC startup.** This could reuse LIB analysis but would add requirements to every source startup entry. Source startup can load ordinary prebuilt artifacts or use conservative markers without loading a TypeScript Program. **Hand-write the Client interface.** A hand-written interface cannot guarantee that it contains only Remote-marked methods and can drift from Host signatures, lookup IDs, and Zod schemas. Client types are therefore projected automatically from the Host Program. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 21d51800ae..a6eb7dae73 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -28,7 +28,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | 组件 | Cordis 服务 | 职责 | |---|---|---| -| `@deepseek-ai/dsh-typert-protocol` | 只声明 `ctx.typert` 的最小协议 | `TypertRemoteService`、decorator、binding 回退、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | +| `@deepseek-ai/dsh-typert-protocol` | 只声明 `ctx.typert` 的最小协议 | `TypertRemoteService`、decorator、binding 回退、descriptor、lookup/Context、Remote map 和基于 Zod 的惰性 info 组装;不依赖 compiler、Connection 或 Browser | | Typert registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | | Typert generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | | API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法,并将一元结果投影为载体无关的字段与附件 | @@ -81,16 +81,18 @@ export class ScopedGoalService extends TypertRemoteService { 同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteScope('agent')`,两者不会由 Typert 根据方法体或参数缺失自动猜测。 -业务包只依赖轻量的 `@deepseek-ai/dsh-typert-protocol`。它提供 `TypertRemoteService`,以及 decorator、binding 回退、lookup、Remote Scope 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 +业务包只依赖 `@deepseek-ai/dsh-typert-protocol`。它提供 `TypertRemoteService`,以及 decorator、binding 回退、lookup、Remote Scope 和 descriptor 的声明协议,不依赖 TypeScript compiler、HTTP 或 Client runtime。其纯 `./remote-info` 入口使用 Zod 组装惰性具名输入 schema,不导入 Cordis;根入口在提供依赖 Cordis 的声明之余,也重新导出该能力。 支持协作式取消的方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。这个保留参数不是业务值、lookup 或 JSON 字段。生成的消费方方法将其暴露为最后一个可选参数,因此普通调用保持不变,而拥有取消控制权的调用方可以传入 signal。 ## Decorator 与显式 Gateway facet -Decorator 只表达“该方法参与 Remote 约定”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteScope('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `TypertRemoteService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。 +Decorator 表达 Remote 参与、可选导出名称和流式投递;`@Remote` 选项仅包含 `name` 和 `mode`。它不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteScope('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `TypertRemoteService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertRemote` 字段使运行时实例上的绑定保持可见。 SRC 模式下,decorator 把方法名和调用模式记录在 Service prototype 上的带版本描述符中。描述符使用稳定的字符串属性名,因此 `remoteMethods()` 可以读取 `dsh-typert-protocol` 另一个已安装副本生成的标记;它不会向 Service 实例、constructor 或方法函数写入任何内容。 +普通产物从每个端点唯一的静态方法 spec 构造惰性 Remote info 实例。其直接 `TypertRemoteMethod` 对象保留描述符字段,并增加具名 `input(options?)` 和 `invoke`。消费方通过 `input({ strict: true })` 选择配套的严格解析与投影;`input()` 保留普通处理。Host 参数 codec 与唯一的普通 `result` codec 保持不变。既有 Registry 查询保留这些对象,`TYPERT_REMOTE.methods` 则提供精确静态类型。严格源码启动预构建并加载普通 Typert 产物;fallback 无法恢复被擦除的 schema。[统一元数据 note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md)记录该交付机制及尚未完成的消费方适配。 + LIB 的严格方法发现、类型解析和 descriptor 生成由 Typert compiler 完成。它接受 `TypertRemoteService` 直接 `super()` 调用中的字面量 service key,或显式 binding 回退;生成过程不改写业务源码,也不注入隐藏注册元数据。 ## Lookup 与 Remote Scope 注册 @@ -215,7 +217,7 @@ Host lib build → Vite 构建 Web ``` -现有顶层 `build` 仍表现为先 `build:lib`、再 `build:web`,但 `build:lib` 内部必须先完成 Host 与 Remote artifact,再启动 Client TypeScript 编译。一次干净构建不能依赖上次残留的 `.d.ts`。 +顶层 `build` 运行 `scripts/build.ts` 中的步骤图,先完成 Host bundle 与 Remote artifact,再启动 Client TypeScript 编译。`build:lib` 仍是 `build:bench` 使用的完整串行 lib 构建,其 Host 部分 `build:lib:host` 供 vendor 发布 workflow 使用。`typecheck` 与 Host 编译并行,经 `scripts/generate-typert.ts` 写出 Remote artifact,再启动 Client TypeScript 编译。一次干净构建不能依赖上次残留的 `.d.ts`。 即使主要输入是源文件,需要通过编译器解析消费方 surface 的仓库门禁也有相同的前置条件。公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先执行 Host 约定 pass。门禁调度器仅可在显式的 Typert 约定依赖或完整构建依赖完成后使用对应的 `*:contracts-ready` 变体,使并行 lane 既不会读取缺失的声明,也不会针对同一输出并发运行多个生成器。 @@ -349,9 +351,9 @@ Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完 ## SRC 与 LIB 运行模式 -SRC 面向本地源码启动。`@Remote` 和 `@RemoteScope()` 创建的带版本 prototype 描述符给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 +SRC 面向本地源码启动。对于没有已注册严格定义的方法,`@Remote` 和 `@RemoteScope()` 创建的带版本 prototype 描述符给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 -例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 +例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。保守回退不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 SRC 无法明确解析的签名会在首次调用解析其 descriptor 时失败;Service 挂载只记录 decorator 标记,不检查 JavaScript 签名。SRC 不会猜测对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 或复杂类型。 @@ -474,7 +476,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS **让 decorator 在运行时完成严格反射。** JavaScript decorator 无法恢复擦除后的 TypeScript 类型、公共符号身份和完整 Zod codec;向 constructor 注入 compiler 私有 symbol 又会隐藏业务类的真实依赖,因此严格信息由 Typert compiler 生成。 -**SRC 启动时使用 preload、loader hook 或完整 `ts.Program`。** 这能复用 LIB 分析,但增加所有源码启动入口的要求。SRC 只需要可用的弱 descriptor,因此采用 decorator 标记、函数参数名和显式 provider;严格检查留给 LIB 约定 pass。 +**SRC 启动时使用 preload、loader hook 或完整 `ts.Program`。** 这能复用 LIB 分析,但增加所有源码启动入口的要求。源码启动可加载普通预构建产物或使用保守标记,而无需加载 TypeScript Program。 **手写 Client interface。** 手写接口不能保证只包含 Remote 标记的方法,也会与 Host 签名、lookup ID 和 Zod schema 漂移,因此 Client 类型从 Host Program 自动投影。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index 9cd1ebd5f3..bdd096c69d 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md /agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide: - en: 1f6ee603f36c2e09 - zh: e08197958663219c + en: 35dea8a3a8bdc842 + zh: 7a1cdf184ffd889f /agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide/problem: en: d94bb81479bfa4a1 zh: af44ceaecf84ee51 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index b8c146ed76..ee4ea2a5b9 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-08-04-configuration-source-ownership.zh.md) -The non-secret settings tier below is superseded by [profile-owned live configuration](2026-09-19-profile-owned-live-configuration.md). Credential ordering and environment restrictions remain current. +The non-secret settings tier below is superseded by [profile-owned live configuration](2026-09-19-profile-owned-live-configuration.md), and the credential ordering by [stored credential precedence](2026-10-09-stored-credential-precedence.md). Environment restrictions remain current. ## Problem diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index 22afc15aec..024c93d3d1 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-08-04-configuration-source-ownership.md) | 中文 -下文的非秘密 settings 层已由 [profile 所属实时配置](2026-09-19-profile-owned-live-configuration.zh.md) 替代。凭据优先级与环境限制仍然适用。 +下文的非秘密 settings 层已由 [profile 所属实时配置](2026-09-19-profile-owned-live-configuration.zh.md) 替代,凭据优先级已由[存储凭据优先级](2026-10-09-stored-credential-precedence.zh.md)替代。环境限制仍然适用。 ## Problem diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml index f32414668b..ebe9f20065 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml @@ -9,14 +9,14 @@ en: 1fe344c95a0de36d zh: 656c3e0f1117f64e /agent-note-forked-children-preserve-the-parent-request-prefix/decision: - en: b70aafc9ce30dd00 - zh: 74e3ff915bd968a9 + en: 3f5b3a4646c9466b + zh: 690172d6a4025b2a /agent-note-forked-children-preserve-the-parent-request-prefix/alternatives-considered: - en: 80504ffd8331e6c5 - zh: 193261bdde24b4bb + en: bc437309b64997ab + zh: 947010eed8d6fac4 /agent-note-forked-children-preserve-the-parent-request-prefix/consequences: - en: 0738d36f17c889b5 - zh: b71ee47f75d6d4e0 + en: 0f4d735328b3211d + zh: db4adb0f46b33bba /agent-note-forked-children-preserve-the-parent-request-prefix/consequences/accepted-risks: - en: f634caa9973eaedb - zh: 820291096c8e7352 + en: 72f4913b603e8596 + zh: 50924c62200070d1 diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md index 4b52122e86..728d9eb15b 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md @@ -18,7 +18,7 @@ The model-facing `send_message` tool is registered globally for every Agent in a The base bundle and the `cordis`, `standard`, and `ptc` CLI presets bind fork to the continuable lifecycle. Base-backed profiles, including headless, inherit that default. `ForkInProcessProvider.prepareContinuable()` and `ctx.subagents.startActivation()` preserve the same request-prefix behavior across these compositions. -Byte-identical prefix reuse is qualified by explicit deployment choices. A fork delegation that applies a child persona or `toolFilter` may still change the request head. In particular, filtering out `send_message` removes both the schema and the return guidance from the child; the runtime does not bypass an explicit allow-list. +Byte-identical prefix reuse depends on the assembled inputs. Structured output or generated SDK differences can change the request head. Historical child descriptors can also restore persona or tool restrictions during cold resume; filtering out `send_message` removes both its schema and return guidance. New fork starts inherit the parent preset without these override fields. ## Alternatives considered @@ -28,11 +28,11 @@ Byte-identical prefix reuse is qualified by explicit deployment choices. A fork **Add the return instruction to the system prompt.** This would place child-only bytes ahead of inherited messages. Appending it to the initial user task preserves the inherited prefix and keeps the parent id next to the task that needs it. -**Ignore an explicit child `toolFilter`.** Structural return tools previously bypassed the child allow-list. Rejected because a declared tool restriction must determine both schema visibility and guidance; hidden authority would make the model-facing roster inaccurate. +**Ignore a historical child `toolFilter` on resume.** A stored restriction must continue to determine both schema visibility and guidance; silently discarding it changes the restored agent’s behavior. ## Consequences -- Parent and continuable-fork child expose byte-identical ordered tool schemas when the delegation does not request a persona or tool filter. +- Parent and continuable-fork child expose byte-identical ordered tool schemas when they use the same preset and have no child-only structured output or restored historical overrides. - The inherited Session seed precedes the child's initial task and return guidance. - The base bundle and full CLI presets use local continuable fork for both parent and caller delivery, without a child-only request-head addition. - The child can send messages to its direct parent through `send_message`. Parent delivery adds a separate runtime settlement notice; caller delivery returns the result through the activation receipt. @@ -40,4 +40,4 @@ Byte-identical prefix reuse is qualified by explicit deployment choices. A fork ### Accepted risks -Provider-side prefix reuse still depends on the selected provider and model and on the absence of explicit persona or tool-filter differences. The harness proves equality of its assembled request-head inputs, not a provider's cache behavior. +Provider-side prefix reuse still depends on the selected provider and model and matching assembled inputs. The harness tests equality of its assembled request-head inputs, not a provider’s cache behavior. diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md index 2c426a1e7c..2ebaa9ddbc 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md @@ -18,7 +18,7 @@ fork 与 spawn 的差异在于:fork 会用 parent 已完成轮次的前缀作 base 组合包与 `cordis`、`standard` 和 `ptc` CLI preset 将 fork 绑定为可继续生命周期。基于 base 的 profile(包括 headless)继承这一默认值。`ForkInProcessProvider.prepareContinuable()` 与 `ctx.subagents.startActivation()` 在这些组合中保持相同的请求前缀行为。 -逐字节相同的前缀复用受显式部署选择约束。配置 child persona 或 `toolFilter` 的 fork 委派仍可能改变请求头。尤其是过滤掉 `send_message` 时,child 会同时失去该 schema 与返回指引;运行时不会绕过显式 allow-list。 +逐字节相同的前缀复用取决于组装输入。结构化输出或生成 SDK 差异可以改变请求头。历史子级 descriptor 也可在冷恢复时恢复 persona 或工具限制;过滤掉 `send_message` 会同时移除其 schema 和返回指引。新 fork 启动继承父级 preset,不含这些覆盖字段。 ## 考虑过的替代方案 @@ -28,11 +28,11 @@ base 组合包与 `cordis`、`standard` 和 `ptc` CLI preset 将 fork 绑定为 **把返回指令放进系统提示词。** 这会在继承消息之前加入 child-only 字节。将其追加到初始用户任务,可以保留继承前缀,并让 parent id 紧邻需要它的任务。 -**忽略显式 child `toolFilter`。** 结构性返回工具过去会绕过 child allow-list。否决该方案,因为声明的工具限制必须同时决定 schema 可见性与指引;隐藏权限会让面向模型的工具清单失真。 +**恢复时忽略历史子级 `toolFilter`。** 存储的限制必须继续决定 schema 可见性和指引;静默丢弃它会改变恢复 agent 的行为。 ## 后果 -- 未请求 persona 或工具过滤时,parent 与可继续 fork child 暴露逐字节相同且顺序一致的工具 schema。 +- 使用相同 preset 且没有子级专属结构化输出或恢复的历史覆盖时,父级与可继续的 fork 子级公开逐字节相同、顺序一致的工具 schema。 - 继承的 Session 种子位于 child 初始任务及其返回指引之前。 - base 组合包与完整 CLI preset 对 parent 和 caller 投递均使用本地可继续 fork,不增加 child-only 请求头字段。 - child 可以通过 `send_message` 向直接 parent 发消息。parent 投递会追加独立的运行时结算通知;caller 投递通过 activation 回执返回结果。 @@ -40,4 +40,4 @@ base 组合包与 `cordis`、`standard` 和 `ptc` CLI preset 将 fork 绑定为 ### 已接受风险 -提供方侧前缀复用仍取决于选定的提供方和模型,以及是否不存在显式 persona 或工具过滤差异。harness 证明的是自己组装出的请求头输入相等,而不是提供方的缓存行为。 +提供方侧前缀复用仍取决于所选提供方和模型,以及组装输入是否相同。harness 测试的是自身组装请求头输入相等,而非提供方的缓存行为。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml index 6512532f4a..6401383a39 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml @@ -18,5 +18,5 @@ en: ca7c9088d0793e1e zh: ea648bac9e726c64 /agent-note-what-stays-host-plane-once-presets-own-the-agent-plane/consequences: - en: de26cde94adda770 - zh: 7f39760b77958966 + en: 0a426923c2a1f774 + zh: 29626804b99b35ff diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md index 546423f32e..620692ee9f 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md @@ -38,6 +38,6 @@ Projection key presence is not a per-session capability signal ([session project ## Consequences -The context meter becomes a per-session fact instead of a function of mount history. A preset can no longer opt out of token accounting; no shipped preset did, and `minimal` now says it drops auto-compaction rather than the accounting. +The context meter is a per-session fact independent of mount history. Presets resolve the host meter for token accounting while selecting their own compaction backend; `minimal` supports both automatic compaction and the human `/compact` command. Production entry points must compose Agents explicitly; installing the registry alone does not bind them. An unjoined Agent receives no tools, prompt sections, or skills and reports no error. diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md index 59d318b410..325758c769 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md @@ -38,6 +38,6 @@ Status: implemented ## 后果 -context meter 成为逐会话的事实,而不再是挂载历史的函数。代价是 preset 不能再选择不做 token 记账;随附的 preset 没有一个这么做,`minimal` 现在也写明它放弃的是自动压缩而非记账。 +context meter 是独立于挂载历史的逐会话事实。Preset 使用宿主 meter 进行 token 记账,并选择自己的压缩后端;`minimal` 同时支持自动压缩和用户 `/compact` 命令。 生产入口必须显式组装 Agent;仅安装注册表不会自动绑定。未加入的 Agent 不会获得工具、提示词段落或 skill,也不会报告错误。 diff --git a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml index 3afca5571b..a5863d41c7 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml @@ -15,8 +15,8 @@ en: a1750de2d48551af zh: ffea0b75c292f186 /agent-note-client-shell-layering-and-dynamic-package-boundaries/decision/shared-module-requests: - en: cedddcca8a7bae6d - zh: a568a1130229f48c + en: 96661da17622132a + zh: dab3424093130c2f /agent-note-client-shell-layering-and-dynamic-package-boundaries/decision/parser-preloading-and-react-handoff: en: 4b503618b271fc28 zh: e2148f44efa769d2 diff --git a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md index ad2bb26b84..68c9ef1235 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md +++ b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md @@ -32,7 +32,7 @@ The `staticLinked` preset leaves every bare specifier as an external import in ` ### Shared module requests -Dynamic browser bundles implicitly externalize the common baseline: `PLATFORM_MODULES` names shell-seeded React, Cordis, and static UI identities, while `PRELOADED_CLIENT_EXTERNALS` is reserved for a dynamic identity that must arrive before shell boot and is currently empty. A package uses `dsh.client.external` only for an exact non-baseline value request. Type-only imports are erased and create no request; permitted third-party implementation libraries remain private bundle contents. +Dynamic browser bundles implicitly externalize the common baseline: `PLATFORM_MODULES` names shell-seeded React, Cordis, selected third-party libraries, and static UI identities, while `PRELOADED_CLIENT_EXTERNALS` is reserved for a dynamic identity that must arrive before shell boot and is currently empty. A package uses `dsh.client.external` only for an exact non-baseline value request. Type-only imports are erased and create no request; non-baseline third-party implementation libraries remain private bundle contents. Shared third-party libraries use the shell's declared versions without per-plugin version negotiation; promotion into the baseline requires a measured reduction across the shell and shipped plugin bundles. A request has exactly two suppliers: diff --git a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md index 70563962b3..79017823a7 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md @@ -32,7 +32,7 @@ Client npm 依赖区段描述安装和开发关系,但不能可靠描述 bundl ### 共享模块请求 -动态浏览器 bundle 会隐式 external 统一基座:`PLATFORM_MODULES` 命名由外壳播种的 React、Cordis 和静态 UI 身份,`PRELOADED_CLIENT_EXTERNALS` 则为必须先于 shell 启动到达的动态身份预留,当前为空。包只在精确请求基座外实体时使用 `dsh.client.external`。纯类型 import 会被擦除,不产生请求;允许的第三方实现库保留为 bundle 私有内容。 +动态浏览器 bundle 会隐式 external 统一基座:`PLATFORM_MODULES` 命名由外壳播种的 React、Cordis、选定的第三方库和静态 UI 身份,`PRELOADED_CLIENT_EXTERNALS` 则为必须先于 shell 启动到达的动态身份预留,当前为空。包只在精确请求基座外实体时使用 `dsh.client.external`。纯类型 import 会被擦除,不产生请求;基座外的第三方实现库保留为 bundle 私有内容。共享第三方库使用外壳声明的版本,不为单个插件协商版本;只有测量确认外壳与随包插件 bundle 的总体积减少,才将库纳入基座。 请求只有两种提供方: diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml index 5e193c9063..ba50248d0c 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml @@ -21,8 +21,8 @@ en: 8ee88035cf64eeb3 zh: 6184e50fc4d47348 /agent-note-one-dsh-launcher-for-application-profiles/decision/python-runtime: - en: 5962abadfdece709 - zh: 5db20e96deed49d1 + en: 4b4cf5071b740c85 + zh: 595e5fe8127f9067 /agent-note-one-dsh-launcher-for-application-profiles/decision/enforcement: en: 9ce8d86b65064f38 zh: 27b8ad426cb319a2 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md index 7d4bb1a9c2..27a8315fe6 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md @@ -46,7 +46,7 @@ Direct SDK use follows normal Harness-home resolution: explicit `dshHome`, inher ### Python runtime -The Python runtime wheel stages [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the `dsh-python-runtime-closure` entry. Its ordinary branch calls the public CLI export; a provider-private selector dispatches to the internal subprocess runner before CLI parsing and is not an application entry point. The [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private dispatch. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable example under `python/sdk/examples` selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar, including the `web` profile. +The Python runtime wheel stages [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the `dsh-python-runtime-closure` entry. Its ordinary branch calls the public CLI export; a provider-private selector dispatches to the internal subprocess runner before CLI parsing and is not an application entry point. A private `DSH_RUNTIME_DOWNLOAD` selector prepares optional resources before application launch and is removed before spawning preparation subprocesses; ordinary CLI arguments do not select downloads. The [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private dispatch. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable example under `python/sdk/examples` selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar, including the `web` profile. The executable family is `deepseek-harness-sdk-runtime--`. The SDK wire, wheel and import distribution names, sidecar names, and wire identity `deepseek-harness-sdk-runtime` remain stable. The SDK package family is `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-sdk-jsonrpc-server`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no Python-specific Node application, checked-in complete config, compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. [docs/architecture.md](../../../../docs/architecture.md) owns this launch, and the [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows carrier. diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md index eb7d5ff8eb..f9a50ebf5a 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md @@ -46,7 +46,7 @@ SDK 用户通过 profile 自定义插件。`dsh plugin --profile ...` 管 ### Python 运行时 -Python 运行时 wheel 将 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) 暂存为 `dsh-python-runtime-closure` 入口。其普通分支调用公开 CLI export;提供方私有选择会在 CLI 解析前分派到内部子进程 runner,而不是应用入口。[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责该私有分派。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;`python/sdk/examples` 下的可运行示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同的 profile 语法,包括 `web` profile。 +Python 运行时 wheel 将 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) 暂存为 `dsh-python-runtime-closure` 入口。其普通分支调用公开 CLI export;提供方私有选择会在 CLI 解析前分派到内部子进程 runner,而不是应用入口。私有 `DSH_RUNTIME_DOWNLOAD` 选择器在应用启动前准备可选资源,并在启动资源准备子进程前移除;普通 CLI 参数不选择下载。[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责该私有分派。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;`python/sdk/examples` 下的可运行示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同的 profile 语法,包括 `web` profile。 可执行文件族是 `deepseek-harness-sdk-runtime--`。SDK 协议格式、wheel 与 import 分发名称、伴随文件名称,以及协议 identity `deepseek-harness-sdk-runtime` 保持稳定。SDK 包族是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 与 `@deepseek-ai/dsh-sdk-jsonrpc-server`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留 Python 专用 Node 应用、检入的完整配置、兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。[docs/architecture.md](../../../../docs/architecture.zh.md)负责该启动方式,[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 载体。 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 bf89beeba0..e70e858963 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 @@ -9,11 +9,11 @@ en: 9f34c53e647c69ca zh: 551776c7cc9618b5 /agent-note-client-derived-presentation-from-raw-session-tool-events/decision: - en: bdbb99c01b4c4db0 - zh: 570040471ad1f7f4 + en: 5fcc5a3de9f757cd + zh: a693cefdb82784ee /agent-note-client-derived-presentation-from-raw-session-tool-events/decision/goals-and-non-goals: - en: 7e7a937dc4fe1b18 - zh: 530f508b7e17b607 + en: 40aceb58d71303b5 + zh: 6954e41ae2fd1efd /agent-note-client-derived-presentation-from-raw-session-tool-events/terminology: en: 021106dd41176715 zh: 131caff7bd37251b @@ -72,11 +72,11 @@ en: f40f0a8ee8c055c0 zh: 6957b7372956113c /agent-note-client-derived-presentation-from-raw-session-tool-events/client-card-model-design/generic-tool-row: - en: 20ff82b092325c5c - zh: 1afd5441a9816117 + en: d336c77d1b09f349 + zh: 6e613f9c9108058f /agent-note-client-derived-presentation-from-raw-session-tool-events/client-card-model-design/terminal-card: - en: eab7f366df331b6e - zh: d82499e2a982206f + en: 492669c2134169b2 + zh: 0247a38df2dd8a83 /agent-note-client-derived-presentation-from-raw-session-tool-events/client-card-model-design/diff-card: en: ea384415c94f82da zh: 5f146c6cb534e1db @@ -99,8 +99,8 @@ en: e5b42aa311079bd7 zh: ec4cf97037ece7e0 /agent-note-client-derived-presentation-from-raw-session-tool-events/presentation-equivalence-matrix: - en: 30915c26728eeafe - zh: eea04dea7b4604a5 + en: 7974f20a6ac687ac + zh: cb4c350325a6da67 /agent-note-client-derived-presentation-from-raw-session-tool-events/client-extension-contract: en: 96619dfd5ef299cf zh: 253df98eb42ad75e 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 ee1b54f6ee..8a0c4ea6fc 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 @@ -22,7 +22,7 @@ The required result is one raw Session journal and one Client presentation owner ## Decision -The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../../../../packages/client/ui-tool/README.md) and [recorded tool details](../../../../packages/client/ui-tool/README.md); all other presentation and ownership constraints remain. +The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../../../../packages/client/ui-tool/README.md), [recorded tool details](../../../../packages/client/ui-tool/README.md), and [background Shell command display](../bug-fix/2026-10-10-background-shell-command-display.md); all other presentation and ownership constraints remain. The Session Remote journal sends only raw, validated, persistable Session events. `session.page` and `session.follow` do not parse tool arguments, query the Tools registry, restore a presenter scope, execute `presentCall` or `presentResult`, or construct or clone any tool view. @@ -51,7 +51,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall | Retained | the Session log format, Remote journal lifecycle, and Conversation identity/topology | | Retained | the existing keyed slot, Generic fallback, and Chat, Details, and Trajectory structure | | Forbidden | a new Client presenter service, parallel registry, or wire renderer id | -| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../../../../packages/client/ui-tool/README.md) and [recorded tool details](../../../../packages/client/ui-tool/README.md) | +| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../../../../packages/client/ui-tool/README.md), [recorded tool details](../../../../packages/client/ui-tool/README.md), and [background Shell command display](../bug-fix/2026-10-10-background-shell-command-display.md) | | Forbidden | compatibility dual-writing, version negotiation, or retention of the old `view` field | ## Terminology @@ -294,15 +294,16 @@ Unknown tools continue to use `GenericToolCard` with the name, raw arguments, re The title, kind, rawInput, content, and locations from Generic Host `presentCall` do not currently drive an ordinary Web row. Generic `presentResult.content` also does not drive Web output, so the Client need not copy these unconsumed values. + ### Terminal card -The Client terminal model derives existing `TerminalBlock` props from the tool name, call arguments, result content, error, and Session cwd, independently of `parentCallId`. +The Client terminal model derives existing `TerminalBlock` props from the tool name, call arguments, result content, error, and Session cwd, independently of `parentCallId`. [Background Shell command display](../bug-fix/2026-10-10-background-shell-command-display.md) governs explicit background `bash`/`pwsh` launches; the remaining ownership and fallback rules below stay in force. | Input | Preserved result | |---|---| | running standard `bash`/`pwsh` foreground call | terminal prompt, description, cwd, and running state | | successful standard foreground call | terminal output, exit code/signal, and settled state presentation | -| `run_in_background:true` | Generic row and raw result | +| explicit background standard `bash`/`pwsh` | original `background` flag with call `running` at Start; acknowledgement clears `running` and retains a static command without output or job status | | tool execution error | Generic IN/OUT and error summary | | running persistent `bash`/`pwsh` | terminal prompt | | settled persistent `bash`/`pwsh` | Generic flattened result, with no new exit card | @@ -310,7 +311,7 @@ The Client terminal model derives existing `TerminalBlock` props from the tool n | background/error `terminal_send` | Generic result | | PTC dispatch child | same terminal eligibility and fallback rules as a root call | -Standard shell results parse trailing `[exit code: N]` and `[killed by signal: X]` markers. A final recognized spill-policy notice selects Generic output instead: expandable in shell rows and raw in Details, because the exit marker may be displaced or omitted. A parsed marker is removed from the terminal body; timeout, sandbox denial, and markers without a pill remain in the body. +Foreground standard shell results parse trailing `[exit code: N]` and `[killed by signal: X]` markers; explicit background launch acknowledgements do not enter output or exit-status parsing. A final recognized spill-policy notice selects Generic output instead: expandable in shell rows and raw in Details, because the exit marker may be displaced or omitted. A parsed marker is removed from the terminal body; timeout, sandbox denial, and markers without a pill remain in the body. Call `description` remains above the card and overrides the collapsed summary. Workdir continues handling absolute, relative, and missing values. Relative paths resolve against the Session cwd while preserving normalization for `.`, `..`, drive letters, and UNC roots. @@ -318,7 +319,7 @@ For `terminal_send`, non-empty input and the session id remain verbatim tool dat Standard and persistent providers sharing the same tool name are a special compatibility point. The Client uses currently valid argument and result features to preserve their delivered differences. Input that cannot be identified unambiguously uses a Generic settled result rather than gaining new presentation. -`TerminalBlock` ANSI handling, cursor replay, wide characters, line limits, expansion, copying, and assistive text remain unchanged. +Outside the explicit background command-only presentation, `TerminalBlock` ANSI handling, cursor replay, wide characters, line limits, expansion, copying, and assistive text remain unchanged. ### Diff card @@ -412,7 +413,7 @@ The scenario does not import Host tool packages to compute page presentation and | orphan result | callId title and Generic output | | interrupted call | warning/stopped state | | foreground bash/pwsh | current terminal prompt, body, cwd, and state | -| background/error bash/pwsh | current Generic IN/OUT | +| explicit background bash/pwsh | terminal Start and static command-only acknowledgement; launch errors retain Generic IN/OUT | | persistent shell | current running terminal and settled Generic form | | terminal_send | current foreground terminal and background/error Generic form | | write/edit | current intended/applied diff and error fallback | 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 332c30df9b..2dd4ad8bc9 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 @@ -22,7 +22,7 @@ Host presenter 与 Client keyed renderer 分担展示会形成对同一事件的 ## Decision -下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../../../../packages/client/ui-tool/README.zh.md)和[记录结果的工具详情](../../../../packages/client/ui-tool/README.zh.md);其他展示与所有权约束全部保留。 +下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../../../../packages/client/ui-tool/README.zh.md)、[记录结果的工具详情](../../../../packages/client/ui-tool/README.zh.md)和[后台 Shell 命令展示](../bug-fix/2026-10-10-background-shell-command-display.zh.md);其他展示与所有权约束全部保留。 Session Remote journal 只下发原始、已验证、可持久化的 Session event。`session.page` 和 `session.follow` 不解析工具参数,不查询 Tools registry,不恢复 presenter scope,不执行 `presentCall`/`presentResult`,也不构造或克隆任何 tool view。 @@ -51,7 +51,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa | 保留 | Session 日志格式、Remote journal 生命周期与 Conversation identity/topology | | 保留 | 现有 keyed slot、Generic fallback、Chat、Details 与 Trajectory 结构 | | 禁止 | 新 Client presenter service、平行 registry 或 wire renderer id | -| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../../../../packages/client/ui-tool/README.zh.md)和[记录结果的工具详情](../../../../packages/client/ui-tool/README.zh.md)除外 | +| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../../../../packages/client/ui-tool/README.zh.md)、[记录结果的工具详情](../../../../packages/client/ui-tool/README.zh.md)和[后台 Shell 命令展示](../bug-fix/2026-10-10-background-shell-command-display.zh.md)除外 | | 禁止 | 为兼容保留双写、版本协商或旧 `view` 字段 | ## 术语 @@ -294,15 +294,16 @@ Chat 与 Details 通过纯 helper 复用同一 block 的解析。未来缓存必 Generic Host `presentCall` 的 title、kind、rawInput、content 与 locations 当前并不驱动普通 Web 行;Generic `presentResult.content` 也不驱动 Web 输出,因此无需把这些未消费值复制到 Client。 + ### Terminal 卡片 -Client terminal model 从工具名称、调用参数、结果 content、error 与 Session cwd 派生现有 `TerminalBlock` props,不依赖 `parentCallId`。 +Client terminal model 从工具名称、调用参数、结果 content、error 与 Session cwd 派生现有 `TerminalBlock` props,不依赖 `parentCallId`。[后台 Shell 命令展示](../bug-fix/2026-10-10-background-shell-command-display.zh.md)拥有主动后台 `bash`/`pwsh` 启动的展示决定;下述其余所有权和 fallback 规则继续有效。 | 输入 | 保持的结果 | |---|---| | 标准 `bash`/`pwsh` 前台 running | terminal prompt、description、cwd、running 状态 | | 标准前台 success | terminal output、exit code/signal 与结算状态呈现 | -| `run_in_background:true` | Generic 行与原始结果 | +| 标准 `bash`/`pwsh` 主动后台调用 | Start 使用原 `background` 标志和调用 `running`;回执清除 `running` 后保留静态命令,不显示输出或任务状态 | | 工具执行 error | Generic IN/OUT 与错误摘要 | | persistent `bash`/`pwsh` running | terminal prompt | | persistent `bash`/`pwsh` settled | Generic flattened result,不新增 exit card | @@ -310,7 +311,7 @@ Client terminal model 从工具名称、调用参数、结果 content、error | `terminal_send` background/error | Generic 结果 | | PTC dispatch child | 与根调用相同的 terminal 适用规则与 fallback 规则 | -标准 shell 结果解析末尾 `[exit code: N]` 与 `[killed by signal: X]`。末尾已识别的 spill 策略提示会改用 Generic 输出:在 shell 行中可展开,在 Details 中显示原文,因为退出标记可能被移位或省略。已解析的 marker 从 terminal 正文移除;timeout、sandbox denial 与没有 pill 的 marker 留在正文。 +标准前台 shell 结果解析末尾 `[exit code: N]` 与 `[killed by signal: X]`;主动后台启动回执不进入输出或退出状态解析。末尾已识别的 spill 策略提示会改用 Generic 输出:在 shell 行中可展开,在 Details 中显示原文,因为退出标记可能被移位或省略。已解析的 marker 从 terminal 正文移除;timeout、sandbox denial 与没有 pill 的 marker 留在正文。 调用 `description` 继续显示在 card 上方并覆盖折叠摘要。workdir 继续按绝对、相对和缺失三种情况处理;相对路径基于 Session cwd,且保留 `.`、`..`、盘符与 UNC root 的归一化。 @@ -318,7 +319,7 @@ Client terminal model 从工具名称、调用参数、结果 content、error 同名普通与 persistent provider 是特殊兼容点。Client 使用当前有效参数与结果特征保留已交付差异;不足以无歧义识别的输入选择 Generic settled 结果,不增加新表现。 -TerminalBlock 的 ANSI、光标重放、宽字符、行数上限、展开、复制与辅助技术文本完全不变。 +除主动后台的仅命令展示外,TerminalBlock 的 ANSI、光标重放、宽字符、行数上限、展开、复制与辅助技术文本保持不变。 ### Diff 卡片 @@ -412,7 +413,7 @@ Deliverables Definition 按 callId 观察 root `tool/call` 与成功 `tool/resul | orphan result | callId 标题与 Generic output | | interrupted call | warning/stopped 状态 | | bash/pwsh 前台 | 当前 terminal prompt、正文、cwd 与状态 | -| bash/pwsh background/error | 当前 Generic IN/OUT | +| bash/pwsh 主动后台调用 | terminal Start 与静态仅命令回执展示;启动错误保留 Generic IN/OUT | | persistent shell | 当前 running terminal、settled Generic | | terminal_send | 当前前台 terminal、后台/error Generic | | write/edit | 当前 intended/applied diff 与 error fallback | diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index 981cb258f7..ad94eb0bd5 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml @@ -21,11 +21,11 @@ en: 29def175ab33b7d6 zh: 87e55ec28c20c7c1 /agent-note-package-and-update-the-electron-desktop-application/updates-and-recovery: - en: e9f140cd22670dfa - zh: bf85cfe9c12a0345 + en: 4cb7014acfe71762 + zh: 2178e0337cba0324 /agent-note-package-and-update-the-electron-desktop-application/security-and-release-policy: - en: 97ee608e96fc3602 - zh: 932b98e7d344cd87 + en: 5faaa53b58121311 + zh: e1c3fc8d21a27d1a /agent-note-package-and-update-the-electron-desktop-application/implementation: en: 041a6d35ed61b149 zh: 617c6b90a6f4fa4d diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index 26a6b9ba99..0241afef65 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -75,13 +75,13 @@ The [immediate-window reference](../../../../apps/desktop/README.md) owns the lo `DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm package operation remain separate operations. -Test releases require `DOWNLOAD_TEST_RELEASE_ID` from the platform dotenv file. Their feeds and binaries share `dsh-desk//`; production retains its fixed directories. The ID is part of the packaged update URL, so upload rejects a different ID. Internal distribution can supply a new installer after rotation; installed clients retain their existing feed. Random directories reduce guessing but provide no authorization for link holders. The [release configuration](../../../../apps/desktop/README.md#upload-updates) defines generation and reuse. +Test releases require `DOWNLOAD_TEST_RELEASE_ID` from the platform dotenv file. Their feeds and binaries share `dsh-desk//`; production retains its fixed directories. The ID is part of the packaged update URL, so upload rejects a different ID. Internal distribution can supply a new installer after rotation; installed clients retain their existing feed. Random directories reduce guessing but provide no authorization for link holders. The [release configuration](../../../../apps/desktop/publishing/README.md#upload-updates) defines generation and reuse. ## Security and release policy Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation forwards package specs to pnpm, including local and remote sources, but never accepts raw pnpm commands. pnpm owns dependency resolution and the profile’s `allowBuilds` policy; the Host loads activated bundles. -Electron release artifacts are signed; macOS artifacts are notarized. Release package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. The target file exclusively owns release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS release packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. +Electron release artifacts are signed; macOS artifacts are notarized. Release package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. Windows packaging can instead select a machine-owned public `presets/.env`, plus `secrets/windows-signing.env` for signed builds; unsigned and preparation-only modes omit the signing file. The selected files exclusively own release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS release packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The [pinned osx-sign patch](../../../../patches/@electron__osx-sign@1.3.3.patch) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup. @@ -91,7 +91,7 @@ Windows package invocations force `ELECTRON_BUILDER_7Z_FILTER=BCJ`. The bundled Local Windows and macOS installation testing uses an explicit `--unsigned` package invocation with the same build and runtime preparation. It strips certificate inputs, isolates artifacts in `unsigned-artifacts`, and omits updater configuration and the release completion record. macOS local builds ignore dotenv files and inherited release settings, use the standard application ID, and omit mandatory-update policy so local testing needs no deployment configuration. Their App receives a certificate-free ad-hoc signature; their DMG is unsigned and unnotarized. Windows local builds retain their platform configuration requirements. The regular package command explicitly selects signed mode even when its parent environment requests unsigned mode. This separation permits installation diagnosis without release credentials while preventing local test output from qualifying for release upload. -Windows application replacement follows the [directory-installation reference](../../../../apps/desktop/README.md): extract beside the destination with a command-line tool that returns failure status, then rename complete directories on the same volume. The installer retains the old directory through staging and restores it if promotion fails. Registration, shortcuts, and the signed uninstaller remain owned by electron-builder. +Windows application replacement follows the [directory-installation reference](../../../../apps/desktop/packaging/README.md#runtime-file-selection): extract beside the destination with a command-line tool that returns failure status, then rename complete directories on the same volume. The installer retains the old directory through staging and restores it if promotion fails. Registration, shortcuts, and the signed uninstaller remain owned by electron-builder. Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the pnpm entry, dsh resources, or active project. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index 21ef91d0e0..f3075a175d 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -75,13 +75,13 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b `DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm 包操作仍是两个独立操作。 -测试发布必须从平台 dotenv 文件提供 `DOWNLOAD_TEST_RELEASE_ID`。清单和安装包共用 `dsh-desk//`,生产环境保留固定目录。ID 是打包更新 URL 的一部分,因此上传会拒绝不同的 ID。轮换后可通过内部分发提供新安装包,已安装客户端保留原清单地址。随机目录降低被猜中的概率,但不对持有链接者进行鉴权。[发布配置](../../../../apps/desktop/README.zh.md#upload-updates)定义生成与复用方式。 +测试发布必须从平台 dotenv 文件提供 `DOWNLOAD_TEST_RELEASE_ID`。清单和安装包共用 `dsh-desk//`,生产环境保留固定目录。ID 是打包更新 URL 的一部分,因此上传会拒绝不同的 ID。轮换后可通过内部分发提供新安装包,已安装客户端保留原清单地址。随机目录降低被猜中的概率,但不对持有链接者进行鉴权。[发布配置](../../../../apps/desktop/publishing/README.zh.md#upload-updates)定义生成与复用方式。 ## 安全与发布策略 核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。 -Electron 发布产物必须签名;macOS 产物必须公证。发布打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 发布打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。 +Electron 发布产物必须签名;macOS 产物必须公证。发布打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。Windows 打包也可选择机器管理的公开 `presets/.env`,签名构建另读 `secrets/windows-signing.env`;未签名和仅准备模式不读取签名文件。所选文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 发布打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。 [固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。 @@ -91,7 +91,7 @@ Windows 打包调用强制设置 `ELECTRON_BUILDER_7Z_FILTER=BCJ`。内置的 7- 本地 Windows 和 macOS 安装测试使用显式的 `--unsigned` 打包调用,并执行相同的构建和运行时准备。它清除证书输入,将产物隔离到 `unsigned-artifacts`,并省略更新器配置和发布完成记录。macOS 本地构建忽略 dotenv 文件和继承的发布配置,使用标准应用 ID,并省略强制更新策略,使本地测试无需部署配置。其 App 使用无需证书的 ad-hoc 签名,DMG 不签名也不公证。Windows 本地构建保留平台配置要求。即使父进程环境请求未签名模式,常规打包命令也会显式选择签名模式。这样既能在没有发布凭据时诊断安装问题,也能防止本地测试产物通过发布上传校验。 -Windows 应用替换遵循[目录安装参考](../../../../apps/desktop/README.zh.md):使用能返回失败状态的命令行工具解压到目标旁边,再在同卷内改名替换完整目录。安装器在暂存期间保留旧目录,正式替换失败时恢复旧目录。注册信息、快捷方式和签名卸载器仍由 electron-builder 持有。 +Windows 应用替换遵循[目录安装参考](../../../../apps/desktop/packaging/README.zh.md#runtime-file-selection):使用能返回失败状态的命令行工具解压到目标旁边,再在同卷内改名替换完整目录。安装器在暂存期间保留旧目录,正式替换失败时恢复旧目录。注册信息、快捷方式和签名卸载器仍由 electron-builder 持有。 打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 pnpm 入口、dsh 资源 或活跃项目。 diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml index 207508cad2..260adf6610 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml @@ -12,8 +12,8 @@ en: 1188c699ce6e6ca2 zh: 4d5b974cd1216f06 /agent-note-adjacent-agents-share-one-steer-send-message-operation/decision/one-model-tool-and-one-return-instruction: - en: 8a4be155a4984ca8 - zh: 00c2a61da2443530 + en: 4b0a25389324fa91 + zh: c2cdd9f7e40ef43b /agent-note-adjacent-agents-share-one-steer-send-message-operation/decision/complete-removal-and-reintroduction-condition: en: 5a9ec6478cf7430c zh: f4c2f067cc72040e diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md index 43048f9a8c..4efb9d334e 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md @@ -46,7 +46,7 @@ interface SendMessageInput { } ``` -Parents and children inherit the same definition in the same registry order. The standard definition carries a process-stable internal identity that a scoped same-name tool does not satisfy. A child `toolFilter` may explicitly remove the inherited tool, and a scoped replacement may provide different semantics; neither case receives the standard call instruction. When the standard tool remains visible, the continuation manager appends the JSON-encoded direct parent id and the instruction to send one self-contained result before finishing, plus earlier actionable findings, to the child's initial user task. For a fork child this task follows the inherited completed-turn prefix; no child-only system-prompt section or tool schema precedes that prefix. +Parents and children inherit the same definition in the same registry order. The standard definition carries a process-stable internal identity that a scoped same-name tool does not satisfy. An independently selected preset may omit the tool, a historical descriptor filter may remove it on resume, and a scoped replacement may provide different semantics; neither case receives the standard call instruction. When the standard tool remains visible, the continuation manager appends the JSON-encoded direct parent id and the instruction to send one self-contained result before finishing, plus earlier actionable findings, to the child's initial user task. For a fork child this task follows the inherited completed-turn prefix; no child-only system-prompt section or tool schema precedes that prefix. The instruction is guidance, not settlement enforcement. Sending does not end the child's turn, zero or several calls remain mechanically valid, and the runtime never rejects a child for staying silent. The manager-owned `subagent-settled` notice remains unconditional and separately attributed because it records how an Activation ended and preserves terminal output when the child cannot cooperate. diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md index 6d1e2d4923..b7ed157f54 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md @@ -46,7 +46,7 @@ interface SendMessageInput { } ``` -parent 与 child 以相同注册表顺序继承相同定义。标准定义携带进程稳定的内部身份,同名的作用域工具不满足该身份。child `toolFilter` 可以显式移除继承的工具,作用域替代工具也可以提供不同语义;两种情况都不会收到标准调用指令。当标准工具仍可见时,继续执行管理器会把经过 JSON 编码的直接 parent id、结束前发送一份自包含结果的指令,以及更早发送可操作发现的指令追加到 child 初始用户任务。对 fork child 而言,该任务位于继承的已完成轮次前缀之后;没有 child 专属系统提示词 section 或工具 schema 位于此前缀之前。 +parent 与 child 以相同注册表顺序继承相同定义。标准定义携带进程稳定的内部身份,同名的作用域工具不满足该身份。独立选择的 preset 可以省略该工具,历史 descriptor 过滤器可在恢复时移除该工具,作用域替代工具也可以提供不同语义;两种情况都不会收到标准调用指令。当标准工具仍可见时,继续执行管理器会把经过 JSON 编码的直接 parent id、结束前发送一份自包含结果的指令,以及更早发送可操作发现的指令追加到 child 初始用户任务。对 fork child 而言,该任务位于继承的已完成轮次前缀之后;没有 child 专属系统提示词 section 或工具 schema 位于此前缀之前。 该指令是指导,不是结算强制。发送不会结束 child 轮次,机制仍允许零次或多次调用,runtime 绝不会因 child 保持沉默而拒绝它。由管理器负责的 `subagent-settled` 通知仍无条件发送并采用独立来源,因为它记录 Activation 如何结束,并在 child 无法配合时保留终态输出。 diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml index 2a8fdcb1f7..b66d66b8e0 100644 --- a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml @@ -15,5 +15,5 @@ en: 09be2a1cf1d52fd5 zh: 0bf0e18c73542012 /agent-note-one-output-ring-one-projection-one-event-stream-for-background-jobs/consequences: - en: 464eb843a71eb04e - zh: 783062f5b288b1b4 + en: 3263801e173b6b86 + zh: f6f51bb3f77a817e diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md index 0a75d42b30..04f106edf3 100644 --- a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md +++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md @@ -35,3 +35,5 @@ The job seam had grown by accretion. Producers declared their output twice — a The model-visible text is unchanged: status lines, tool descriptions and schemas, and notice wording are byte-identical, and the only new model-visible fact is the kill reason a `killed` status line now carries. A producer that needs to stream output picks pull sources or `append`; it never formats a consuming delta. The Web client renders every live job as expandable, so a job that never writes shows an empty running panel instead of a static row. `JobKindMap` merges are host-side, so a browser program types `JobView.kind` as the kinds it knows and treats the rest as opaque strings, which is all the roster does with it. Review follow-ups after the master merge tightened four of these edges. A job's first event is always `registered`: the announcement precedes the pump, whose first drain runs synchronously and may announce output. A settled ring keeps every byte the model cursor has not consumed until the first terminal model read, so a job that finishes before its first `job_output` loses nothing the live cap retained. `dsh-tool-jobs` holds one claim per wait call with a `live` flag that the caller's abort clears synchronously from the abort event, so a settlement announced before the registry's rejection reaches the tool still delivers its notice, and a timed-out wait drops only its own claim while a concurrent wait keeps the settlement covered. The browser records an observation failure that precedes the anchor as an errored view, so the panel shows the notice instead of staying blank. The spill file a pull source keeps is job metadata (`JobView.output.spillPaths`, reported by every pump read and withdrawn once a read stops naming it), not per-chunk metadata, and the model's read renders the shell tools' dropped-output notice for a producer-side gap as well as for ring eviction, naming those files even after the gap chunk itself was evicted — the `before this read` variant this change had introduced is gone. An observation generation that sees its job removed mid-stream (the owner's teardown) closes with the removed job's terminal projection instead of a failed read. A rejected background spawn produces no process output, so the shell providers now serve the `spawn failed: …` note as the whole observed stderr stream (the consuming read still folds it in once), and `ShellProcess.observed` with both readers is required: the model's first `job_output` after such a failure reads exactly what master's consuming read rendered. + +The [Session lifecycle decision](../bug-fix/2026-09-30-job-session-lifecycle.md) records registration and settlement for durable SDK consumers while preserving this live event stream and output ownership. diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md index 5cfc88927c..798f9d2b7c 100644 --- a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md @@ -35,3 +35,5 @@ job seam 是逐步堆出来的。生产者要把输出声明两次——模型 模型可见文本保持不变:状态行、工具描述与 schema、播报措辞逐字节相同,唯一新增的模型可见事实是 `killed` 状态行现在携带的 kill 原因。需要流式输出的生产者选择拉取源或 `append`,从不格式化消耗式增量。Web 客户端把每个进行中的 job 渲染为可展开,因此从不写输出的 job 显示为空的运行中面板而非静态行。`JobKindMap` 的合并发生在宿主侧,浏览器程序把 `JobView.kind` 视为它认识的 kind 集合,其余当作不透明字符串——名册对它也只做这些。 合并 master 之后的评审跟进收紧了其中四条边。job 的第一个事件永远是 `registered`:通告先于泵,而泵的首轮 drain 同步执行且可能通告输出。已结算的环在模型的首次终态读取之前保留模型游标尚未消费的全部字节,因此在首次 `job_output` 之前结束的 job 不会丢失运行期上限保留的任何内容。`dsh-tool-jobs` 为每次等待调用保存一条带 `live` 标记的认领,调用方 abort 时在 abort 事件里同步清掉该标记,因此注册表的拒绝尚未传回工具之前宣布的结算仍会投递通知;超时的等待只丢弃自己的认领,并发的另一个等待仍覆盖该结算。浏览器把先于锚点发生的观察失败记录为带错误的视图,面板显示提示而不是留白。拉取源保留的 spill 文件是 job 元数据(`JobView.output.spillPaths`,每次泵读取都会上报,读取不再点名时即撤回),不是逐块元数据;模型读取对生产者侧缺口与环淘汰都渲染 shell 工具的丢失输出提示并列出这些文件,即使缺口块本身已被淘汰——本次变更曾引入的 `before this read` 变体已删除。观察生成过程若在流中看到其 job 被移除(拥有者 teardown),以被移除 job 的终态投影收尾,而不是一次失败的读取。被拒绝的后台 spawn 不产生任何进程输出,因此 shell provider 现在把 `spawn failed: …` 提示作为 observed stderr 的整条流提供(消耗式读取仍只折入一次),且 `ShellProcess.observed` 及其两个读取器改为必填:这类失败之后模型的首次 `job_output` 读到的正是 master 的消耗式读取所渲染的内容。 + +[Session 生命周期决策](../bug-fix/2026-09-30-job-session-lifecycle.zh.md)为持久化 SDK 消费方记录注册与结算,同时保留这里的运行期事件流和输出归属。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml index 72e167ce59..40b064454f 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml @@ -12,14 +12,14 @@ en: e01df5ba880c2972 zh: e62074a6a9ba5685 /agent-note-bundle-the-desktop-runtime-and-retain-external-plugins/package-ownership: - en: 37cae6b28fd359ef - zh: 63118e361fa8f84c + en: f683605bf4655db8 + zh: de09d610ee1d4193 /agent-note-bundle-the-desktop-runtime-and-retain-external-plugins/transactions-and-upgrades: en: 53d48f71fcc1c52e zh: 7d61923484dbec1c /agent-note-bundle-the-desktop-runtime-and-retain-external-plugins/alternatives-considered: - en: ead5bbd94044c3de - zh: 057cf069413d2a49 + en: 67b3ba2b37e92f88 + zh: 785083e5de098af8 /agent-note-bundle-the-desktop-runtime-and-retain-external-plugins/consequences: en: 6dfa581ec6879c5b zh: f6c735ad6a00b2b3 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md index f8f6599fe6..a1aeb68f2d 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md @@ -26,7 +26,7 @@ The resource descriptor records the exact release, Node version, platform, archi The [Desktop file policy](../../../../apps/desktop/scripts/runtime-file-policy.ts) applies after production npm installation and before native signing or descriptor generation. npm publication lists serve library consumers and can include declarations, maps, tests, and native build inputs; they do not identify the files needed by the Desktop process. The Desktop copy omits declarations and recognized source maps because Host execution uses JavaScript and generated Typert artifacts. The Host inherits the user environment. Published npm packages and external plugin directories retain their own files. Source debugger navigation is a development-package capability. -Package-specific exclusions remove Domino tests, fs-ext compilation outputs, Koffi's Windows import library, and non-target node-pty prebuilds and debug symbols. The policy retains native executable dependencies, node-pty's ConPTY source distribution, licenses, and unrecognized assets; broad `src`, `test`, `.ts`, or `.map` exclusions could remove executable code or runtime data. Copy tests preserve sentinel assets and seal the filtered inventory; the Electron [payload smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs) verifies PTY output, native file seeking, FFI, image conversion, and HTML parsing. Runtime preparation still verifies every retained byte and boots the complete Host with an external plugin. +Package-specific exclusions remove Domino tests, fs-ext compilation outputs, Koffi's Windows import library, and non-target node-pty prebuilds and debug symbols. The policy retains native executable dependencies, licenses, and unrecognized assets; node-pty's ConPTY source distribution is retained only for Windows targets; broad `src`, `test`, `.ts`, or `.map` exclusions could remove executable code or runtime data. Copy tests preserve sentinel assets and seal the filtered inventory; the Electron [payload smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs) verifies PTY output, native file seeking, FFI, image conversion, and HTML parsing. Runtime preparation still verifies every retained byte and boots the complete Host with an external plugin. The shared profile runner supplies missing installation and selected-bundle dependencies through the runtime resolution. pnpm-installed packages take precedence. Runtime resolution delegates to the selected package paths, so Host and plugin imports reaching the same export share its module instance. Distinct ESM and CommonJS conditional exports remain distinct entry points; runtime resolution does not merge a package’s dual implementations. @@ -48,7 +48,7 @@ The [immediate-window reference](../../../../apps/desktop/README.md) owns direct ## Alternatives considered -Full runtime verification belongs to packaging. Startup reads the resource descriptor and checks shared package records and required Host entries. The [release-validation reference](../../../../apps/desktop/README.md) assigns release and target compatibility checks to packaging. Startup neither enumerates nor hashes installed runtime files. Reading every file before backend loading adds I/O proportional to distribution size; unusable modules instead fail when loaded. Build-time verification rejects changed, missing, extra, or linked files against the recorded inventory. +Full runtime verification belongs to packaging. Startup reads the resource descriptor and checks shared package records and required Host entries. The [release-validation reference](../../../../apps/desktop/packaging/README.md#desktop-runtime-preparation) assigns release and target compatibility checks to packaging. Startup neither enumerates nor hashes installed runtime files. Reading every file before backend loading adds I/O proportional to distribution size; unusable modules instead fail when loaded. Build-time verification rejects changed, missing, extra, or linked files against the recorded inventory. - **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility. - **Force host dependency versions into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. The runtime resolution supplies missing packages while pnpm-owned entries retain independent versions. diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md index 6d14494858..73728d1feb 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md @@ -26,7 +26,7 @@ Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成 [桌面文件规则](../../../../apps/desktop/scripts/runtime-file-policy.ts)在生产 npm 依赖安装之后、原生签名或描述文件生成之前执行。npm 发布列表服务于库的使用者,可以包含声明、map、测试和原生构建输入,不能直接表示桌面进程需要哪些文件。桌面副本排除声明和已识别的 source map,因为 Host 执行 JavaScript 和生成的 Typert 产物。Host 继承用户环境。已发布的 npm 包和外部插件目录保留各自的文件。源码调试导航由开发包提供。 -包专用排除项包括 Domino 测试、fs-ext 编译产物、Koffi 的 Windows 导入库,以及非目标平台的 node-pty 预构建文件和调试符号。规则保留原生可执行依赖、node-pty 的 ConPTY 源分发内容、许可证和未知资源;宽泛排除 `src`、`test`、`.ts` 或 `.map` 可能移除可执行代码或运行时数据。复制测试保留哨兵资源并封存过滤后的清单;Electron 的[产物 smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs)验证 PTY 输出、原生文件定位、FFI、图像转换和 HTML 解析。运行时准备仍会验证每个保留字节,并携带外部插件启动完整 Host。 +包专用排除项包括 Domino 测试、fs-ext 编译产物、Koffi 的 Windows 导入库,以及非目标平台的 node-pty 预构建文件和调试符号。规则保留原生可执行依赖、许可证和未知资源;node-pty 的 ConPTY 源分发内容仅在 Windows 目标中保留;宽泛排除 `src`、`test`、`.ts` 或 `.map` 可能移除可执行代码或运行时数据。复制测试保留哨兵资源并封存过滤后的清单;Electron 的[产物 smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs)验证 PTY 输出、原生文件定位、FFI、图像转换和 HTML 解析。运行时准备仍会验证每个保留字节,并携带外部插件启动完整 Host。 共享 profile runner 通过 runtime resolution 补全安装包与选中 bundle 缺失的依赖。pnpm 安装的包优先。运行时解析委托给所选包路径,因此 Host 与插件导入同一导出时共享其模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;运行时解析不会合并包的双重实现。 @@ -48,7 +48,7 @@ profile manifest 分别记录 pnpm 安装的依赖及已启用 bundle 列表。 ## 考虑过的替代方案 -完整运行时验证属于打包流程。启动读取资源描述文件,并检查共享包记录及必要的 Host 入口。[发布验证参考](../../../../apps/desktop/README.zh.md)将发布与目标兼容性检查交给打包流程。启动既不枚举已安装运行时文件,也不计算其哈希。后端加载前读取每个文件会增加与分发体积成正比的 I/O;不可用模块改由加载时失败暴露。构建时验证按记录清单拒绝内容变化、缺失、多余或链接文件。 +完整运行时验证属于打包流程。启动读取资源描述文件,并检查共享包记录及必要的 Host 入口。[发布验证参考](../../../../apps/desktop/packaging/README.zh.md#desktop-runtime-preparation)将发布与目标兼容性检查交给打包流程。启动既不枚举已安装运行时文件,也不计算其哈希。后端加载前读取每个文件会增加与分发体积成正比的 I/O;不可用模块改由加载时失败暴露。构建时验证按记录清单拒绝内容变化、缺失、多余或链接文件。 - **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。 - **强制插件使用 Host 依赖版本。** 这会让普通插件依赖不必要地耦合于 Host。runtime resolution 提供缺失的包,pnpm 拥有的条目保留独立版本。 diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml index d83f853c54..d7f60ead90 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml @@ -9,8 +9,8 @@ en: 66ac3d7ce7eb8e15 zh: abb9d41b71900bbf /agent-note-sandboxed-node-execution-for-ptc/decision: - en: cf072f53de09faca - zh: 9edd5c4a2bfaae5a + en: d6b9c22603a4f933 + zh: f324b91830078d9a /agent-note-sandboxed-node-execution-for-ptc/decision/resolved-inputs-and-policy: en: 3aef37bb4f8a8e53 zh: a7b477324d486ab3 @@ -18,8 +18,8 @@ en: 2c7a694b04951772 zh: 133b95b0477b127f /agent-note-sandboxed-node-execution-for-ptc/decision/resource-limits: - en: 84c73cf3ff33bc03 - zh: a228080683d6252c + en: 3f1d8a30f6d48243 + zh: faa7c01d7c959893 /agent-note-sandboxed-node-execution-for-ptc/alternatives-considered: en: 8ffcc2b32a2243d7 zh: 81d48d38947abc76 diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md index 2104e58e8e..1259a617ad 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md @@ -12,7 +12,7 @@ The [PTC foundation](../feature/2026-06-15-ptc.md) remains responsible for regis ## Decision -`dsh-ptc-runtime-node` runs each program in one fresh Node process. The host resolves execution choices, confines the launch through the same `ctx.sandbox` provider as Bash, and gives process lifetime to `ctx.subprocess`. The child evaluates erasable TypeScript with direct Node APIs, an empty model environment and host-provided asynchronous bindings. No worker or persistent kernel remains inside this provider. +`dsh-ptc-runtime-node` in its default `per-call` mode runs each program in one fresh Node process. The host resolves execution choices, confines the launch through the same `ctx.sandbox` provider as Bash, and gives process lifetime to `ctx.subprocess`. The child evaluates erasable TypeScript with direct Node APIs, an empty model environment and host-provided asynchronous bindings. No worker or persistent kernel remains after a per-call execution. The host preserves `ELECTRON_RUN_AS_NODE` for child startup; the bootstrap removes it from the native environment before evaluation, and model-visible `process.env` stays empty. Nested Electron launches require their own explicit Node-mode selection. Desktop uses Electron as its Node executable; removing this selector launches Electron's application path instead of the PTC bootstrap. Sandbox permission changes cannot repair that launch mismatch. The macOS Desktop regression uses real Electron to verify binding writes, direct workspace writes, and rejection of writes outside the workspace under restricted policy. It requires an installed Electron binary; ordinary runtime tests cover environment filtering without that dependency. @@ -34,6 +34,8 @@ Program completion, timeout, cancellation and protocol failure all close executi The default elapsed deadline is 120 seconds, capped at 600 seconds by default. Trusted service consumers may request `timeoutMs: null` to disable this timer; [workflow sandbox reuse](2026-09-13-workflow-ptc-sandbox-reuse.md) owns that caller-controlled lifetime. Omitted and numeric requests, including model-facing `run_code`, retain the numeric defaults and caps. An enabled deadline includes runtime setup and nested tool or approval waits. V8 old-generation memory, serialized outer output and control traffic have separate configured bounds. The heap limit excludes native allocations and descendant memory, and elapsed time is not a process-tree CPU budget. +The opt-in [in-memory execution mode](2026-09-23-best-effort-persistent-ptc.md) retains disposable memory under the same file policy; [retained-process permissions](2026-10-08-persistent-ptc-sandbox.md) govern policy changes and temporary escalation. + ## Alternatives considered **Keep the worker and add tool checks.** Tool checks cannot intercept direct Node imports or establish OS confinement. Keeping a worker inside a confined supervisor process preserves worker metering but adds another execution lifetime without supplying a process-tree CPU limit. diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md index 429351cca2..0074fdf578 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md @@ -12,7 +12,7 @@ Node worker 隔离 JavaScript 状态,但不应用调用 Session 的 OS 沙箱 ## 决策 -`dsh-ptc-runtime-node` 在一个全新 Node 进程中运行每个程序。Host 解析执行选择,通过与 Bash 相同的 `ctx.sandbox` 提供方约束启动,并将进程生命周期交给 `ctx.subprocess`。子进程以直接 Node API、空模型环境和 Host 提供的异步绑定求值可擦除 TypeScript。本提供方不保留 worker 或持久内核。 +`dsh-ptc-runtime-node` 的默认 `per-call` 模式在一个全新 Node 进程中运行每个程序。Host 解析执行选择,通过与 Bash 相同的 `ctx.sandbox` 提供方约束启动,并将进程生命周期交给 `ctx.subprocess`。子进程以直接 Node API、空模型环境和 Host 提供的异步绑定求值可擦除 TypeScript。逐次执行结束后不保留 worker 或持久内核。 Host 在子进程启动时保留 `ELECTRON_RUN_AS_NODE`;bootstrap 在求值前将其从原生环境中删除,模型可见的 `process.env` 仍为空。嵌套启动 Electron 需要自行显式选择 Node 模式。桌面端使用 Electron 作为 Node 可执行文件;删除此选择变量会启动 Electron 应用路径,而不是 PTC bootstrap。更改沙箱权限无法修复这一启动模式不匹配。macOS 桌面端回归测试使用真实 Electron 验证绑定写入、直接工作区写入以及受限策略对工作区外写入的拒绝。该测试需要已安装的 Electron 二进制文件;普通运行时测试无需此依赖即可覆盖环境过滤。 @@ -34,6 +34,8 @@ Host 在子进程启动时保留 `ELECTRON_RUN_AS_NODE`;bootstrap 在求值前 默认经过时间截止为 120 秒,默认上限为 600 秒。可信服务消费方可以请求 `timeoutMs: null` 来禁用该定时器;[工作流沙箱复用](2026-09-13-workflow-ptc-sandbox-reuse.zh.md)负责这种由调用方控制的生命周期。省略 timeout 或使用数值的请求(包括面向模型的 `run_code`)保留数值默认值与上限。启用的截止包括运行时准备以及嵌套工具或审批等待。V8 老生代内存、序列化外层输出与控制通信具有独立配置的上限。堆限制不包含原生分配和后代内存,经过时间也不是进程树 CPU 预算。 +显式启用的[内存保留执行模式](2026-09-23-best-effort-persistent-ptc.zh.md)在相同文件策略下保留可丢弃内存;[常驻进程权限](2026-10-08-persistent-ptc-sandbox.zh.md)负责策略切换和临时提权。 + ## 考虑过的替代方案 **保留 worker 并增加工具检查。** 工具检查不能拦截直接 Node 导入,也不能建立 OS 约束。在受限监督进程中保留 worker 可以保留 worker 计量,但会增加一层执行生命周期,仍不能提供进程树 CPU 限制。 diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml index 4bef95dc24..b348c71538 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml @@ -15,8 +15,8 @@ en: 3c2dd2c948ffb51b zh: d019197df0ee3dcf /agent-note-deepseek-account-browser-login/consequences: - en: a6eff4a0350e9018 - zh: 2e3200ca089023ab + en: 3c5681b492cd34cf + zh: d7707fed9241f9bc /agent-note-deepseek-account-browser-login/verification: en: 881edcb454b941a6 zh: 7dc5a9b7a066f623 diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md index 70ed73f265..2e08d36606 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md +++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md @@ -46,7 +46,7 @@ Profile and recharge-wallet queries use independent getProfile and getBalance op Deployment authentication uses explicit Host-only requestHeaders on the configured Platform origin. The provider rejects redirects and reserved-header overrides so deployment cookies cannot replace account authorization or follow a browser destination. Environment-specific authentication protocols remain outside the account provider. -The bundled account UI is Desktop-only: its preload bridge enables the account launcher, settings and sign-in onboarding. Plain Web retains API-key onboarding and the standard Settings launcher without account subscription or login. The Host protocol accepts login_source (desktop or web) in auth_init; the bundled UI sends desktop. The backend accepts localhost callbacks. DSH preserves the browser-supplied localhost hostname and port without DNS resolution or conversion to an IP literal. +The bundled account UI mounts the account launcher, settings and sign-in onboarding in both Desktop and plain Web. The Host sends login_source (desktop or web) in auth_init so Platform can choose the completion experience; the UI sends desktop when the Desktop preload bridge is present and web otherwise. Web failures close the authorization tab while the original tab receives Host state; no Web UI return URL is used. The backend accepts localhost callbacks. DSH preserves the browser-supplied localhost hostname and port without DNS resolution or conversion to an IP literal. The macOS development launcher registers an isolated, ad-hoc-signed application bundle for `dsh://open`. The bundle retains the workspace entry and development paths for Launch Services cold starts without copying credentials or changing the package-manager-owned Electron application. Protocol registration targets the latest launched development or packaged application. diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md index 5b0df92064..235fcba920 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md @@ -46,7 +46,7 @@ DSH 授权通过 x-dsh-auth-token 请求头鉴权 Platform、推理和 Files 请 开发环境认证通过配置的平台来源上的显式 Host 请求头 requestHeaders 完成。提供者拒绝重定向和保留请求头覆盖,防止开发环境 Cookie 替换账号授权或跟随浏览器跳转地址。特定环境的认证协议不属于账号提供者。 -内置账号界面仅用于 Desktop:preload 桥接启用账号入口、设置和登录引导。普通 Web 保留 API Key 引导及标准设置入口,不订阅账号状态或显示登录。Host 协议在 auth_init 中接受 login_source(desktop 或 web);内置界面发送 desktop。后端接受 localhost 回调。DSH 保留浏览器提供的 localhost 主机名和端口,不做 DNS 解析或 IP 字面量转换。 +内置账号界面在 Desktop 和普通 Web 中都挂载账号入口、设置和登录引导。Host 在 auth_init 中发送 login_source(desktop 或 web),以便 Platform 选择完成页体验;存在 Desktop preload 桥接时界面发送 desktop,否则发送 web。Web 登录失败时关闭授权标签页,原标签页通过 Host 状态获知结果;不使用 Web UI 返回 URL。后端接受 localhost 回调。DSH 保留浏览器提供的 localhost 主机名和端口,不做 DNS 解析或 IP 字面量转换。 macOS 开发启动器为 `dsh://open` 注册独立、经临时签名的应用包。该应用包保留工作区入口和开发路径,使 Launch Services 能够冷启动;凭证不会被复制,也不修改包管理器安装的 Electron 应用。协议注册指向最近启动的开发版或打包版应用。 diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml index ae4cd23658..5441a65985 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml @@ -9,14 +9,14 @@ en: 837ebb0071a5611b zh: 23d0f0713889ead6 /agent-note-writer-acquisition-for-blank-session-reuse/decision: - en: 7f2676feb224e7ea - zh: babfda5b31dfd0fe + en: a54db0d4a97aa9b8 + zh: a52d257b657fe99b /agent-note-writer-acquisition-for-blank-session-reuse/alternatives-considered: en: 2a67a53f7f396ba8 zh: 6df6ccc071fb0de2 /agent-note-writer-acquisition-for-blank-session-reuse/consequences: - en: 2e1f1894337689a2 - zh: d0cab07cc48083ee + en: 8307612b7476e8f3 + zh: 87fa39e58d33d4f1 /agent-note-writer-acquisition-for-blank-session-reuse/verification: - en: 4680e845e2ab294e - zh: ea5890c5ffb09831 + en: 4367d55ae9dce5c1 + zh: c610626f4e15eb77 diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md index c832604dfb..d6be46b3ed 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md @@ -10,7 +10,7 @@ Two Hosts sharing Session storage can select the same blank Session when opening ## Decision -The Session catalog includes persisted blanks. Startup restoration selects the saved blank; ordinary Workspace navigation selects the first eligible unarchived member in catalog order. Explicit-id `session.create` resumes that Session and acquires its writer before opening history. Only `session/writer-held` triggers creation of a new Session. Other errors propagate. Concurrent Workspace connects in one Client share the acquisition and fallback. Later navigation cancels a pending startup selection. +The Session catalog includes persisted blanks. Startup restoration selects the saved blank; ordinary Workspace navigation selects the first eligible unarchived member in catalog order. Explicit-id `session.create` names that Session; the Host resumes it and acquires its writer before opening history, and creates a new Session when another owner holds the writer ([reuse and preset rules](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.md)). Other errors propagate. A Workspace connect in one Client joins the latest in-flight connect to that Workspace only when both request the same preset. Later navigation cancels a pending startup selection. The Agent retains the acquired handle throughout its lifetime. Checking ownership and releasing a probe lock would leave a race before resume. No PID or process identity enters persisted data or the Remote schema. @@ -28,8 +28,8 @@ This decision extends the blank-reuse policy in the [Client Session scope refere ## Consequences -A Host can reuse its own live blank or reclaim an unowned persisted blank with its slash state. Separate Hosts create distinct blanks while existing writers remain held. A held selected blank causes fresh creation even when another blank is free; navigation does not search for another candidate. No cleanup is added. Opening the same established conversation from two Hosts can still encounter writer contention. Unknown projection hints remain visible without cold body scans; automatic reuse requires known blank metadata. A non-contention acquisition failure aborts New Session and is currently reported only to the console; hidden blanks have no recovery action in this flow. +A Host can reuse its own live blank or reclaim an unowned persisted blank with its slash state. Separate Hosts create distinct blanks while existing writers remain held. A held selected blank causes fresh creation even when another blank is free; navigation does not search for another candidate. No cleanup is added. Opening the same established conversation from two Hosts can still encounter writer contention. Unknown projection hints remain visible without cold body scans; automatic reuse requires known blank metadata. A non-contention acquisition failure aborts the navigation; a hero Workspace pick or explicit draft preparation reports it as a notice and startup restoration only to the console, and a hidden blank that fails this way has no recovery action in this flow. ## Verification -Navigation tests cover saved selection, single-candidate contention fallback, non-contention failures, overlapping acquisition, and superseding navigation. A shipped Web-composition test holds a real persistence writer, verifies fresh creation despite another free blank, and reclaims the released selected blank with its plan state. The fresh-round-trip recorded Session scenario reloads its blank before submitting the recorded prompt. +Navigation tests cover saved selection, opening the Session the Host returns for a held candidate, non-contention failures, overlapping acquisition, and superseding navigation. A shipped Web-composition test holds a real persistence writer, verifies fresh creation despite another free blank, and reclaims the released selected blank with its plan state. The fresh-round-trip recorded Session scenario reloads its blank before submitting the recorded prompt. diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md index f5d5d58bc6..d477e9770c 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -Session 列表包含持久化空白会话。启动恢复选择已保存的空白会话;普通 Workspace 导航按列表顺序选择第一个符合条件且未归档的成员。显式 ID 的 `session.create` 会恢复该 Session,并在打开历史前取得写锁。只有 `session/writer-held` 会触发新建 Session。其他错误返回调用方。同一 Client 的并发 Workspace 连接共享获取与回退过程。后续导航会取消尚未完成的启动选择。 +Session 列表包含持久化空白会话。启动恢复选择已保存的空白会话;普通 Workspace 导航按列表顺序选择第一个符合条件且未归档的成员。显式 ID 的 `session.create` 指明该 Session;Host 恢复它并在打开历史前取得写锁,写锁被其他写者持有时改为新建 Session([复用与 preset 规则](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md))。其他错误返回调用方。同一 Client 中的 Workspace 连接仅在与该 Workspace 最近一次进行中的连接请求相同 preset 时与之合并。后续导航会取消尚未完成的启动选择。 Agent 在整个生命周期内持有取得的句柄。检查占用后释放探测锁,会在恢复前留下竞态。持久化数据与 Remote schema 均不引入 PID 或进程身份。 @@ -28,8 +28,8 @@ Agent 在整个生命周期内持有取得的句柄。检查占用后释放探 ## Consequences -Host 可以复用自己的活动空白会话,也可以接管无人占用的持久化空白会话并保留其 slash 状态。现有写锁被持有时,不同 Host 创建不同的空白会话。所选空白会话被占用时,即使另有空闲空白会话也会新建;导航不再寻找其他候选。不增加清理逻辑。两个 Host 打开同一个已有对话时仍可能遇到写锁争用。未知投影提示仍可见且不扫描冷日志正文;自动复用要求已知的空白元数据。非锁争用的获取错误会中止新建会话,目前只报告到控制台;此流程没有针对隐藏空白会话的恢复操作。 +Host 可以复用自己的活动空白会话,也可以接管无人占用的持久化空白会话并保留其 slash 状态。现有写锁被持有时,不同 Host 创建不同的空白会话。所选空白会话被占用时,即使另有空闲空白会话也会新建;导航不再寻找其他候选。不增加清理逻辑。两个 Host 打开同一个已有对话时仍可能遇到写锁争用。未知投影提示仍可见且不扫描冷日志正文;自动复用要求已知的空白元数据。非锁争用的获取错误会中止该次导航;在 hero 中选择工作区或显式准备草稿时以提示展示该错误,启动恢复只报告到控制台,以此方式失败的隐藏空白会话在此流程中没有恢复操作。 ## Verification -导航测试覆盖已保存选择、单候选锁争用回退、其他错误、重叠获取以及后续导航替代。实际 Web 组合测试持有真实持久化写锁,验证另有空闲空白会话时仍新建,并接管已释放的所选空白会话及其 plan 状态。fresh-round-trip 的录制 Session 场景在提交已录制的提示词前刷新空白会话。 +导航测试覆盖已保存选择、为被占用候选打开 Host 返回的 Session、其他错误、重叠获取以及后续导航替代。实际 Web 组合测试持有真实持久化写锁,验证另有空闲空白会话时仍新建,并接管已释放的所选空白会话及其 plan 状态。fresh-round-trip 的录制 Session 场景在提交已录制的提示词前刷新空白会话。 diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml index 70d50d1b1a..455daa7fff 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml @@ -9,8 +9,8 @@ en: 725f773bb350218e zh: dd8fbc09ef48208d /agent-note-declarative-agent-presets-and-retained-revisions/decision: - en: 53fd72bf4bbe4eb3 - zh: 406eee6e4f818e7c + en: 88edb925b556300c + zh: c6f05b641513d768 /agent-note-declarative-agent-presets-and-retained-revisions/alternatives-considered: en: 09f40eab4659e9f6 zh: 7a70da0d0ae6845b diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md index fe7df55cde..38fa045ef1 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md @@ -16,7 +16,7 @@ The registry owns each revision's scope and Loader tree. Agents link their own s The Web editor and `agent_preset` tool accept only child plugin YAML. Saving writes the active profile's user patch, preserving display metadata and unrelated configuration. Revision checks and profile locking prevent overwriting concurrent saves; validation rejects a higher-priority override that would defeat the edit. Persistence and activation outcomes are reported separately. Tool writes require approval or full access because configuration executes in the Host. -Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition. Old executable revisions are process-local, not serialized. New sessions without an explicit preset resolve `selectedDefault`; Client choice filtering and default correction follow [the preset UI policy](../../../../packages/client/ui-agent-preset/README.md). +Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition; reusing a blank Session for a new conversation does not resolve it ([decision](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.md)). Old executable revisions are process-local, not serialized. New sessions without an explicit preset resolve `selectedDefault`; Client choice filtering and default correction follow [the preset UI policy](../../../../packages/client/ui-agent-preset/README.md). ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md index abe1ab27ae..4da53eb366 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md @@ -16,7 +16,7 @@ Preset 目录重复承担 Cordis 配置的所有权。独立的发现、元数 Web 编辑器与 `agent_preset` 工具只接受子插件 YAML。保存写入当前 profile 的用户补丁,保留显示元数据和无关配置。版本检查和 profile 锁防止覆盖并发保存;验证拒绝会使编辑失效的更高优先级覆盖。持久化与激活结果分别报告。配置在 Host 中执行,因此工具写入需要批准或完全访问权限。 -会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复。旧的可执行代际仅保留在进程内,不会序列化。未显式指定 preset 的新会话解析 `selectedDefault`;客户端选项过滤和默认值修正遵循[预设 UI 策略](../../../../packages/client/ui-agent-preset/README.zh.md)。 +会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复;为新对话复用空白会话时不解析该标识([决策](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md))。旧的可执行代际仅保留在进程内,不会序列化。未显式指定 preset 的新会话解析 `selectedDefault`;客户端选项过滤和默认值修正遵循[预设 UI 策略](../../../../packages/client/ui-agent-preset/README.zh.md)。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml index 599afcc6f0..0fb431067d 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml @@ -15,5 +15,5 @@ en: 4089464ad1fb60bd zh: 3168e9e4a205145d /agent-note-keep-live-configuration-in-the-owning-profile/consequences: - en: 6033aaa12b2a521b - zh: 43934413c97670de + en: 41bb170694c785d6 + zh: 17419fdd6d937dce diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md index 7acf109869..65cc8f60b1 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md @@ -24,6 +24,6 @@ The rejected alternative is retaining settings as a second store behind a config ## Consequences -This supersedes only the non-secret settings tier in [configuration source ownership](2026-08-04-configuration-source-ownership.md); its environment restrictions and credential ordering remain applicable. [Volatile references](2026-09-18-volatile-config-references.md) own reference lifetime and ordinary update behavior. +This supersedes only the non-secret settings tier in [configuration source ownership](2026-08-04-configuration-source-ownership.md); its environment restrictions remain applicable, and [stored credential precedence](2026-10-09-stored-credential-precedence.md) supersedes its credential ordering. [Volatile references](2026-09-18-volatile-config-references.md) own reference lifetime and ordinary update behavior. Real profile tests cover multiple instances, persisted restart, invalid edits, stale revisions, higher-layer rejection, secret redaction, and configuration expressions. Browser tests exercise form writes and reset through the assembled application. Runtime reference identity is checked across live edits. diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md index c9b88462a5..9ce0952aab 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md @@ -24,6 +24,6 @@ Cordis config patch 替换完整 entry config。编辑会保留普通字段、 ## 后果 -本决定只替代 [配置来源所有权](2026-08-04-configuration-source-ownership.zh.md) 中的非秘密 settings 层;其中的环境限制和凭据优先级仍适用。[Volatile 引用](2026-09-18-volatile-config-references.zh.md) 负责引用生命周期和普通更新行为。 +本决定只替代 [配置来源所有权](2026-08-04-configuration-source-ownership.zh.md) 中的非秘密 settings 层;其中的环境限制仍适用,其凭据优先级已由[存储凭据优先级](2026-10-09-stored-credential-precedence.zh.md)替代。[Volatile 引用](2026-09-18-volatile-config-references.zh.md) 负责引用生命周期和普通更新行为。 真实 profile 测试覆盖多实例、持久化重启、非法编辑、过期修订、高优先级层拒绝、秘密脱敏及配置表达式。浏览器测试通过完整应用执行表单写入和重置,并检查实时编辑前后的运行时引用身份。 diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml index 3bd20ef0bc..0a502fbafe 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml @@ -9,14 +9,14 @@ en: 70a969dce3283824 zh: 18c61bf840a6518a /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/problem/current-state: - en: 8fb06638898de59e - zh: 79ed0cfe79e40be1 -/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/problem/three-unrelated-uplink-mechanisms: - en: 386be7b691ab63e7 - zh: 935e0db22ae1cf5d -/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/problem/concrete-costs: - en: f374976b43645d15 - zh: 26b2c7aaafa41fba + en: 2de84c3fc0a3552e + zh: da9cd565588bf2dd +/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/problem/uplink-consumers: + en: 403103ef08958529 + zh: 2813d9d38c2c55b7 +/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/problem/consumer-responsibilities: + en: 28f8a3112d6ec5fe + zh: c70d68233e3f6b7f /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/problem/relation-to-background-jobs: en: 5e67475010d52ddb zh: ce23aad2d4aa0d67 @@ -129,14 +129,14 @@ en: 04e72789417d6e3a zh: b57c5cc68dff45a5 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/consequences: - en: 07524e0c2dc22ad5 - zh: 5bf4f27160d78b43 + en: 35326fed46e31d74 + zh: 6f47baa645a1e370 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/testing: en: 5c8188930b9d4fdb zh: 1d94e6563cedde13 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/deferred: - en: 3b99a327464edc3e - zh: dcffdc75369925dc + en: 8368c592f30af97f + zh: 2ff89acb7b3ed477 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/related: - en: 85d77fb246564024 - zh: 8b400818a91a3e3a + en: 475d212ae1f9c1c9 + zh: 21fe6c6cdb900a6b diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md index c1333ef62f..a8d89a9fe5 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md @@ -8,41 +8,41 @@ English | [中文](2026-09-19-remote-duplex-stream.zh.md) ### Current state -`dsh-api-gateway` multiplexes every Typert Remote stream over one WebSocket at the fixed path `/api/remote.mux` (`packages/api/gateway/src/stream-protocol.ts`). A Host method decorated with `@Remote({ mode: 'stream' })` that returns an `Iterable` or `AsyncIterable` becomes one Host-to-Client logical stream; the optional final `signal: AbortSignal` is the only reserved parameter, never enters the wire arguments, and is appended by the Gateway after the decoded business parameters (`packages/api/gateway/src/index.ts`). The generated method the Client receives returns a bare `AsyncIterable`. +`dsh-api-gateway` multiplexes Typert Remote streams over one WebSocket at `/api/remote.mux`. A Host method declares both directions with `@Remote({ mode: 'stream' })` and `RemoteStream`; the generated Client method returns `RemoteStreamHandle`. Existing `Iterable` and `AsyncIterable` return types declare downlink-only methods. The optional final `signal: AbortSignal` is supplied by the Gateway and does not enter the wire arguments. -There are only five wire frames: +Both directions share one `streamId`: | Direction | Frame | Definition | | --- | --- | --- | | Client to Host | `{ type: 'open', streamId, endpoint, payload }` | `stream-protocol.ts` | +| Client to Host | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` | +| Client to Host | `{ type: 'end', streamId }` | `stream-protocol.ts` | | Client to Host | `{ type: 'cancel', streamId }` | `stream-protocol.ts` | | Host to Client | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` | | Host to Client | `{ type: 'error', streamId, error: { code, message, details } }` | `stream-protocol.ts` | | Host to Client | `{ type: 'end', streamId }` | `stream-protocol.ts` | -The Client can send only open and cancel. The parser `parseRemoteStreamClientMessage` (`stream-protocol.ts`) throws on any other frame, and a Host connection that receives one closes the whole socket with 1008 (`stream-server.ts`). In short, the Gateway today supports only "a backend iterable sent to the frontend". Every feature that needs the frontend to send data to the backend continuously takes its own detour. +The Client handle's `send()` supplies uplink items, `end()` half-closes the uplink, and `dispose()` cancels the logical stream. The Host reads decoded items through `this.ctx.invocation.uplink()`; the invocation also carries the admitted Peer and cancellation signal. -A Host method also has no notion of who initiated this call: `InvokeRemoteRequest = { namespace, method, args, signal }` (`packages/api/gateway/src/types.ts`) has no caller slot, `RemoteStreamOpener` has the signature `(endpoint, payload, signal)` (`stream-server.ts`), and the WebSocket checks the cookie once, at the handshake. +The transport owns validation, cancellation, and its bounded inbox. Consumer protocols own input authority, acknowledgements, and recovery after disconnection. -### Three unrelated uplink mechanisms +### Uplink consumers | Feature | Uplink path | Location | Cost | | --- | --- | --- | --- | -| Web terminal keystrokes | One unary RPC `terminal.write(agent, id, attachmentId, data)` per xterm `onData`; the Client serializes them through a promise chain and caps its own byte budget | `packages/api/terminal-controller/src/client/model.ts`; Host `src/index.ts` | One HTTP round trip and one authentication per keystroke; two carriers correlated by hand through `attachmentId`; `inputFull` when the budget is exhausted | +| Web terminal input and resize | `terminal.attach` carries `TerminalInput` uplink items and `TerminalFrame` downlink items; the Client pipelines commands and releases its raw-input byte budget on `ack` | `packages/api/terminal-controller/src/client/model.ts`; Host `src/terminal.ts` | The current attachment owns input; `inputFull` when the Client budget is exhausted; input is never replayed after reconnection | | File upload | A `Blob` or `ReadableStream` goes to the native `POST /api/session/uploadFileBinary` outside the Remote layer, with `duplex: 'half'` | `packages/client/file-upload/src/client/runtime.ts`; route `src/index.ts` | Bypasses Typert descriptors; no type projection | | Answers to approvals and questions | The Host sends waterfall frames down the `$events` stream, the Client answers through a separate unary RPC `$events/result`, correlated by `clientId` plus `eventId` | `packages/api/gateway/src/client/remote-events.ts`; Host `index.ts` | A second correlation key; `index.ts` records the race this creates | -These three mechanisms solve the same problem: the Client wants to write into a logical stream that is already open. +These consumers need Client-to-Host data while a related operation remains active. Web terminals use the duplex stream; file upload and event answers retain their own transports. -### Concrete costs +### Consumer responsibilities -- **Aligning two carriers.** The terminal uses `attachmentId` to correlate the `follow` stream with the `write` RPC and to decide who owns input. Approvals use `clientId` to correlate the `$events` stream with the `$events/result` RPC. -- **No ordering guarantee.** A unary RPC travels over HTTP and is unordered relative to the WebSocket downlink; the terminal can preserve order only by serializing writes on the Client. -- **Separate authentication and lookup per uplink.** Every `write(agent, id, attachmentId, data)` resolves `agent`, looks up the terminal, and validates the attachment. -- **No EOF.** The Client cannot express "I am done writing"; a half-close needs yet another RPC. -- **Cancellation does not cover both halves.** Cancelling the downlink stream does not cancel queued uplink writes, and vice versa. -- **Backpressure implemented three times.** The downlink pulls the iterator at the pace of the socket write callback (`stream-server.ts`); the terminal uses a bounded follower that makes a slow consumer fail explicitly (`terminal-controller/src/stream.ts`); the uplink relies on the Client capping `maxInputBytes` itself. -- **The documentation is already stale.** `docs/api-gateway.md:160` still says Remote handles one request and one result, a sentence that lags the stream mode the README records. +- **Input authority.** A terminal retains its controlling attachment as a Host-local follower. Snapshot and state frames report each attachment's `writable` state; older attachments keep receiving output after control transfers. +- **Ordering and acknowledgement.** Terminal writes and resizes share a consecutive command sequence and the terminal's operation queue. An `ack` reports provider acceptance or a control refusal; it never authorizes replay after disconnection. +- **Independent lifetimes.** Cancelling `attach` stops its input and output delivery, drops unstarted commands, and joins an in-flight input operation. The PTY lifetime is owned separately by retention and explicit close. +- **Coordinated byte limits.** The Gateway exposes its complete-frame inbox capacity and this stream's JSON envelope cost through `invocation.uplinkWindow`. The terminal publishes that window in its snapshot and uses provider acknowledgements to release in-flight capacity. Writes and resizes wait in a bounded Client queue when the window is full. `maxInputBytes` limits raw UTF-8 input; `maxBufferedBytes` separately limits the complete encoded input retained by the Client and output retained by each follower. +- **Remaining separate carriers.** Approval answers still correlate `$events` with the `$events/result` RPC through `clientId` and `eventId`. Moving this protocol onto the duplex stream remains deferred. ### Relation to background jobs @@ -66,7 +66,7 @@ Every Remote stream accepts a Client uplink. The method's return type `RemoteStr Client Host ───────────────────────────────── ─────────────────────────────────────────── const s = remote.job.attach(req, signal) - │ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control) + │ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control, window) │ │ prepareInvocation │ s.send(v1) → item { streamId, v1 } ───────▶ inbox.push(v1) │ invocation = { request, service, peer, signal, uplink() } │ s.send(v2) → item { streamId, v2 } ───────▶ inbox.push(v2) │ receiverContext.extend({ invocation }).get(service) @@ -139,6 +139,7 @@ export interface RemoteInvocation { readonly peer: PeerScope /** Carrier cancellation: Client cancel, socket closure, uplink failure. */ readonly signal: AbortSignal + readonly uplinkWindow?: RemoteUplinkWindow | undefined /** * The uplink items of this call. Can be taken once; the second call throws. With an uplink codec * in the descriptor each item is decoded to In; without one, items are delivered as unknown with @@ -379,6 +380,7 @@ export type RemoteStreamOpener = ( uplink: AsyncIterable, peer: PeerScope, control: AbortController, + window: RemoteUplinkWindow, ) => Promise> // index.ts Config addition @@ -574,10 +576,11 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] - **Bought**: one stream with two directions, so interactive scenarios such as terminal keystrokes, approval answers, and background-job stdin no longer each build their own "one stream plus one unary"; a Host method knows its caller; the uplink and downlink types are declared in one place, the return type; uplink items come from the browser and are validated strictly per item at the Host, while downlink items are Host-produced typed values that pass straight through; the generated output and wire frames of existing `AsyncIterable` methods and unary methods are completely unchanged. - **`this.ctx.invocation` is `undefined` outside a Remote call**, so a method that reads it must handle the optional; a service method invoked only directly in process correctly reads `undefined`. +- **Web terminals use `attach`** for screen output and input commands, with provider acknowledgements and per-attachment writable state; their Client retains the current generation's handle and never replays input. - **The inbox limit applies to every stream**: a method that does not read its uplink fails as a whole when it receives many uplink frames. This is a deliberate explicit failure, recorded in the README. - **The operator is the only Peer**, and `admit` is the only admission point; the first consumer that needs a second Peer must add opening, association, and events in `dsh-client-connection`. - **No cross-direction ordering guarantee between uplink and downlink**; a protocol that needs request-response pairing carries its own sequence numbers. -- **Upper-layer protocols do not yet expose `send`**: consumers of `$stream`, snapshot, and journal use only the downlink today; the object they receive is already the handle. +- **Upper-layer supervisors expose only the downlink**: `$stream`, snapshot and journal do not expose `send`; a domain that writes uplink retains its generated physical handle for that generation. - **The worker tunnel behaves differently from the WebSocket carrier**: no inbox limit, and items after `end` are dropped, because page to worker is a same-origin trusted boundary. ## Testing @@ -595,10 +598,8 @@ Retention cases in `gateway-stream.host.spec.ts` and `gateway.client.spec.ts` co ## Deferred - `$stream` / snapshot / journal expose the handle's `send` to upper-layer protocols. -- Web terminal: one `attach` stream replaces `follow` plus `write` plus `resize`, and `attachmentId` retires. - Duplex `$events`: waterfall answers become uplink items on the same `streamId`, and `$events/result` and `clientId` retire. - Credit frames: wait for a consumer that needs sustained high-volume uplink; the inbox limit is the initial credit. -- The `streamInboxBytes` default of 262144 is far more than keyboard and stdin need; if the first consumer is terminal paste, it may be aligned with the `maxInputBytes` of `terminal-controller`. - A Host-initiated half-close frame (the Host stops reading the uplink but keeps writing the downlink): no consumer; not added. - `uplink()` on unary methods: it can read items that arrive while the method runs, semantically valid but of doubtful use; allowed, noted in the README. @@ -606,4 +607,4 @@ Retention cases in `gateway-stream.host.spec.ts` and `gateway.client.spec.ts` co - [Remote event delivery](../../../../packages/api/remotes/README.md): the unary `$events/result` answer path; the duplex `$events` item under Deferred partially supersedes it once it lands. - [Session history and event transport](2026-08-18-session-history-and-event-transport.md): the `$stream()` supervisor and the journal / snapshot protocols above it, which this note leaves unchanged. -- [Web sidebar terminal](../feature/2026-09-09-web-sidebar-terminal.md): the unary terminal `write` and `attachmentId`; the `attach` stream under Deferred replaces it once it lands. +- [Web sidebar terminal](../feature/2026-09-09-web-sidebar-terminal.md): the `attach` stream carries input commands, provider acknowledgements and per-attachment writable state; reconnect never replays input. diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md index 791b5e3c81..37613ef7e5 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md @@ -8,41 +8,41 @@ Status: implemented ### 现状 -`dsh-api-gateway` 在一条 WebSocket 上复用全部 Typert Remote 流,路径固定为 `/api/remote.mux`(`packages/api/gateway/src/stream-protocol.ts`)。一个 Host 方法用 `@Remote({ mode: 'stream' })` 修饰并返回 `Iterable` 或 `AsyncIterable`,就成为一条 Host 到 Client 的逻辑流;可选的末位 `signal: AbortSignal` 是唯一的保留参数,不进线路参数,由网关在解码后的业务参数之后追加(`packages/api/gateway/src/index.ts`)。客户端拿到的生成方法返回一个裸 `AsyncIterable`。 +`dsh-api-gateway` 在 `/api/remote.mux` 的一条 WebSocket 上复用 Typert Remote 流。Host 方法通过 `@Remote({ mode: 'stream' })` 和 `RemoteStream` 声明两个方向;生成的 Client 方法返回 `RemoteStreamHandle`。既有的 `Iterable` 和 `AsyncIterable` 返回类型声明仅下行的方法。可选的末位 `signal: AbortSignal` 由 Gateway 提供,不进入线路参数。 -线路帧只有五种: +两个方向共用一个 `streamId`: | 方向 | 帧 | 定义 | | --- | --- | --- | | Client 到 Host | `{ type: 'open', streamId, endpoint, payload }` | `stream-protocol.ts` | +| Client 到 Host | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` | +| Client 到 Host | `{ type: 'end', streamId }` | `stream-protocol.ts` | | Client 到 Host | `{ type: 'cancel', streamId }` | `stream-protocol.ts` | | Host 到 Client | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` | | Host 到 Client | `{ type: 'error', streamId, error: { code, message, details } }` | `stream-protocol.ts` | | Host 到 Client | `{ type: 'end', streamId }` | `stream-protocol.ts` | -客户端能发的只有打开与取消。解析器 `parseRemoteStreamClientMessage`(`stream-protocol.ts`)对其他任何帧抛错,Host 连接收到后以 1008 关闭整个 socket(`stream-server.ts`)。结论:网关今天只支持"后端 iterable 发到前端"。需要前端向后端持续发数据的功能,各自绕路。 +Client 句柄的 `send()` 提供上行项,`end()` 半关闭上行,`dispose()` 取消逻辑流。Host 通过 `this.ctx.invocation.uplink()` 读取解码后的项;调用上下文同时携带获准接入的 Peer 和取消信号。 -Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteRequest = { namespace, method, args, signal }`(`packages/api/gateway/src/types.ts`)没有调用者槽位,`RemoteStreamOpener` 的签名是 `(endpoint, payload, signal)`(`stream-server.ts`),WebSocket 只在握手时校一次 cookie。 +传输层负责校验、取消和有界 inbox。消费者协议负责输入权限、确认以及断线后的恢复。 -### 三套各自为政的上行机制 +### 上行消费者 | 功能 | 上行方式 | 位置 | 代价 | | --- | --- | --- | --- | -| Web 终端按键 | 每次 xterm `onData` 一个 unary RPC `terminal.write(agent, id, attachmentId, data)`,客户端用 promise 链串行化并自限字节预算 | `packages/api/terminal-controller/src/client/model.ts`;Host `src/index.ts` | 一次按键一次 HTTP 往返与鉴权;两条载体靠 `attachmentId` 手工关联;满了报 `inputFull` | +| Web 终端输入与尺寸调整 | `terminal.attach` 承载上行 `TerminalInput` 和下行 `TerminalFrame`;Client 连续发送命令,并在收到 `ack` 后释放原始输入字节预算 | `packages/api/terminal-controller/src/client/model.ts`;Host `src/terminal.ts` | 当前附件拥有输入权;Client 预算耗尽时报 `inputFull`;重连后绝不重放输入 | | 文件上传 | `Blob` 或 `ReadableStream` 走 Remote 层之外的原生 `POST /api/session/uploadFileBinary`,`duplex: 'half'` | `packages/client/file-upload/src/client/runtime.ts`;路由 `src/index.ts` | 不经 Typert 描述符,没有类型投影 | | 审批与提问的回答 | Host 在 `$events` 流上下发 waterfall 帧,Client 用一个独立的 unary RPC `$events/result` 回答,靠 `clientId` 加 `eventId` 关联 | `packages/api/gateway/src/client/remote-events.ts`;Host `index.ts` | 第二套关联键;`index.ts` 记录了由此产生的竞态 | -这三套机制解决的是同一个问题:一条已经打开的逻辑流,客户端想往里写。 +这些消费者都需要在相关操作仍进行时从 Client 向 Host 传送数据。Web 终端使用双工流;文件上传和事件回答保留各自的传输方式。 -### 具体代价 +### 消费者职责 -- **两条载体对齐**。终端要用 `attachmentId` 把 `follow` 流与 `write` RPC 关联起来,并用它决定谁拥有输入权。审批要用 `clientId` 把 `$events` 流与 `$events/result` RPC 关联起来。 -- **没有顺序保证**。unary RPC 走 HTTP,与 WebSocket 下行互不排序;终端只能在客户端串行化写入来自保顺序。 -- **每次上行单独鉴权与查找**。`write(agent, id, attachmentId, data)` 每次解析 `agent`、查终端、校验 attachment。 -- **没有 EOF**。客户端无法表达"我这边写完了",半关闭要再加一个 RPC。 -- **取消不覆盖两半**。取消下行流不会取消排队中的上行写;反之亦然。 -- **背压各写一套**。下行按 socket 写回调节拍拉迭代器(`stream-server.ts`);终端用有界 follower 让慢消费者显式失败(`terminal-controller/src/stream.ts`);上行靠客户端自限 `maxInputBytes`。 -- **文档已经过时**。`docs/api-gateway.md:160` 仍写着 Remote 只处理一请求一结果,这一句已经落后于 README 记录的 stream 模式。 +- **输入权限**。终端通过 Host 本地的 follower 持有控制附件。快照和状态帧报告各附件的 `writable` 状态;控制权转移后,旧附件继续接收输出。 +- **顺序与确认**。终端输入和尺寸调整共用连续命令序列及终端操作队列。`ack` 报告提供者已接受操作或输入权限拒绝,绝不授权断线后重放。 +- **独立生命周期**。取消 `attach` 会停止其输入和输出传递、丢弃尚未开始的命令,并等待在途输入操作收尾。PTY 的生命周期由保留机制和显式关闭独立管理。 +- **协调字节限额**。Gateway 通过 `invocation.uplinkWindow` 提供完整帧 inbox 容量及本流的 JSON 封装开销。终端在快照中公布窗口,并通过提供者确认释放在途额度。窗口满时,输入和尺寸调整命令在有界 Client 队列中等待。`maxInputBytes` 限制原始 UTF-8 输入;`maxBufferedBytes` 分别限制 Client 保留的完整编码输入及每个 follower 保留的输出。 +- **仍独立的载体**。审批回答仍通过 `clientId` 和 `eventId` 将 `$events` 与 `$events/result` RPC 关联。将该协议迁入双工流仍属后续工作。 ### 与后台 job 的关系 @@ -66,7 +66,7 @@ Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteReq Client Host ───────────────────────────────── ─────────────────────────────────────────── const s = remote.job.attach(req, signal) - │ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control) + │ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control, window) │ │ prepareInvocation │ s.send(v1) → item { streamId, v1 } ───────▶ inbox.push(v1) │ invocation = { request, service, peer, signal, uplink() } │ s.send(v2) → item { streamId, v2 } ───────▶ inbox.push(v2) │ receiverContext.extend({ invocation }).get(service) @@ -139,6 +139,7 @@ export interface RemoteInvocation { readonly peer: PeerScope /** Carrier cancellation: Client cancel, socket closure, uplink failure. */ readonly signal: AbortSignal + readonly uplinkWindow?: RemoteUplinkWindow | undefined /** * The uplink items of this call. Can be taken once; the second call throws. With an uplink codec * in the descriptor each item is decoded to In; without one, items are delivered as unknown with @@ -379,6 +380,7 @@ export type RemoteStreamOpener = ( uplink: AsyncIterable, peer: PeerScope, control: AbortController, + window: RemoteUplinkWindow, ) => Promise> // index.ts Config addition @@ -574,10 +576,11 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] - **买到的**:一条流两个方向,终端按键、审批回答、后台任务 stdin 这类交互式场景不再各造一套"一条流加一个 unary";Host 方法知道调用者;上行类型与下行类型在返回类型里一处声明,上行项来自浏览器,在 Host 逐项严格校验,下行项是 Host 自产的类型值,直接透传;既有 `AsyncIterable` 方法与 unary 方法的生成物与线路帧完全不变。 - **`this.ctx.invocation` 在非 Remote 调用下是 `undefined`**,方法读它要处理可选;一个只被进程内直接调用的服务方法读到 `undefined` 是正确行为。 +- **Web 终端使用 `attach`** 传输屏幕输出和输入命令,并提供提供者确认和附件专属可写状态;Client 保留当前代次的句柄,绝不重放输入。 - **inbox 上限对所有流生效**:一个不读上行的方法收到大量上行帧会整流失败。这是刻意的显式失败,README 记明。 - **Peer 只有操作者一个**,`admit` 是唯一的接纳点;第一个需要第二个 Peer 的消费者要在 `dsh-client-connection` 加上打开、关联与事件。 - **上行与下行之间没有跨方向顺序保证**;需要请求应答配对的协议自带序号。 -- **上层协议尚未透出 `send`**:`$stream`、snapshot、journal 的消费者今天只用下行;它们拿到的对象已经是句柄。 +- **上层监督器仅暴露下行**:`$stream`、snapshot 和 journal 不暴露 `send`;需要写入上行的领域保留该代次的生成物理句柄。 - **worker 隧道与 WebSocket 载体行为不同**:没有 inbox 上限,`end` 之后的项丢弃,因为页面到 worker 是同源可信边界。 ## 测试 @@ -595,10 +598,8 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] ## 后续 - `$stream` / snapshot / journal 把句柄的 `send` 透出给上层协议。 -- Web 终端:一条 `attach` 流取代 `follow` 加 `write` 加 `resize`,`attachmentId` 退场。 - 双工 `$events`:waterfall 的回答成为同一 `streamId` 上的上行项,`$events/result` 与 `clientId` 退场。 - credit 帧:等需要持续大流量上行的消费者出现;inbox 上限就是初始授信。 -- `streamInboxBytes` 默认值 262144 对键盘与 stdin 远超需要;若第一个消费者是终端粘贴,可能与 `terminal-controller` 的 `maxInputBytes` 对齐。 - Host 发起的半关闭帧(Host 不再读上行但继续写下行):没有消费者,不加。 - unary 方法的 `uplink()`:能读到方法运行期间到达的项,语义成立但用处存疑;允许,README 注明。 @@ -606,4 +607,4 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] - [Remote 事件投递](../../../../packages/api/remotes/README.zh.md):`$events/result` 的 unary 回答路径;「后续」里的双工 `$events` 落地后部分取代它。 - [会话历史与事件传输](2026-08-18-session-history-and-event-transport.zh.md):`$stream()` 监督器与其上的 journal / snapshot 协议,本 Note 不改它们。 -- [Web 侧边栏终端](../feature/2026-09-09-web-sidebar-terminal.zh.md):终端按键的 unary `write` 与 `attachmentId`;「后续」里的 `attach` 流落地后取代它。 +- [Web 侧边栏终端](../feature/2026-09-09-web-sidebar-terminal.zh.md):`attach` 流承载输入命令、提供者确认和附件专属可写状态;重连绝不重放输入。 diff --git a/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.i18n.yaml index a54db0a426..596b6ca28b 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.i18n.yaml @@ -15,8 +15,8 @@ en: adccc5dc758ad8f6 zh: 50304d68db42e8b1 /agent-note-timed-user-questions-as-two-settlements/decision/ownership: - en: 0543678ad9d26fc5 - zh: aa6ba207e661c567 + en: a28e37fda6e9eaff + zh: 9ca622e09ab24b4d /agent-note-timed-user-questions-as-two-settlements/decision/foreground-wait-and-claim-lifetime: en: 447713bbdf1ab4d8 zh: 2757678ae47822aa @@ -30,14 +30,14 @@ en: 3a33f2583e30e32a zh: 0ad7e8d1e47dc510 /agent-note-timed-user-questions-as-two-settlements/decision/reply-node-and-grouping: - en: 10323b0e0340a40b - zh: a17fc3a4d4d820a7 + en: d87d7432c37405a7 + zh: 49f1bbf53856851a /agent-note-timed-user-questions-as-two-settlements/alternatives-considered: - en: bf61d0ec80c63bb4 - zh: d4ce4d4438ffb013 + en: 1a070542bfa40e68 + zh: 82e32964da912dd8 /agent-note-timed-user-questions-as-two-settlements/verification: - en: d912a73a5ff409eb - zh: 7b626794773e2301 + en: 1d19d196c363a273 + zh: 7cb0475f0cb554d1 /agent-note-timed-user-questions-as-two-settlements/consequences: - en: 2bc99c99be8ca9da - zh: 8f262ec8b9b0945e + en: 0580e85cc12439c5 + zh: a4b151d2c9e5b747 diff --git a/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.md b/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.md index 4bdf47cb2c..014912c9a8 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.md +++ b/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.md @@ -29,7 +29,7 @@ Foreground answers use the existing Remote Event waterfall. A timeout ends that | `UserQuestionService` | Foreground waits and Client claims, unattended deadlines, late-answer validation and steering, and the durable question projection. | | Gateway and Remote runtime | Existing request delivery, settlement, cancellation, and Remote stream transport; no question-specific timer or event-name branch. | | `ui-user-questions` | One card per Session and call, answer-channel selection, local countdown and drafts, and the reply Definition and renderer. | -| `ui-chat` | Message-id-based presentation aggregation and ordinary process/Turn folding. | +| `ui-chat` | Ordinary process/Turn folding. | | Question-specific tool row | Pending/answered presentation and panel actions, composed in `QuestionToolRow` without adding `rowAction` to generic `ToolRow`. | The [question service](../../../../packages/interaction/user-questions/README.md), [tool](../../../../packages/interaction/tool-ask-user/README.md), and [questions UI](../../../../packages/client/ui-user-questions/README.md) own their package contracts. @@ -72,11 +72,11 @@ Autofocus applies only when the answer channel is ready, no countdown is active, The questions UI identifies a late reply from `source.kind`, then parses its recorded JSON for question-and-answer presentation. Pasting identical JSON in an ordinary user message does not identify it as a reply. Unreadable recorded payloads retain their raw-text presentation. -The ordinary message Definition and the question-reply Definition both match the appended message and retain separate Node Store projections with the same message id. Chat business grouping omits the `turn-trigger` presentation when a `question-reply` with that id is present. The duplicate trigger neither renders nor splits a group; the reply renders once. The same aggregation applies to unscoped historical input. +The question-reply Definition claims the appended reply message on the Chat target through an [exclusive match](2026-10-12-exclusive-conversation-matches.md), so the ordinary message Definition builds no `turn-trigger` or context projection for it and the reply renders once, including for unscoped historical input. The reply participates in ordinary process grouping and whole-Turn folding. It is not independently visible: reading it can require expanding both its Turn and process group. Turn disclosure stays before the group it controls, including a reply that opens the Turn. -This decision adds no process-role field, independent-layout flag, user-message structure change, or target-local fallback dispatch. The independent-kind rules stay in Chat's existing grouping logic. A node-data-owned declaration of grouping and overlap semantics is deferred infrastructure, not part of the question feature. +This decision adds no independent-layout flag or user-message structure change. The reply declares no group-tree position, so it follows the default placement of process content. ## Alternatives considered @@ -90,7 +90,7 @@ This decision adds no process-role field, independent-layout flag, user-message **Reclassify JSON inside generic `MessageItem`, borrow the relay form, or use the default context row.** Parsing text to identify the business message confuses its source with its content; the relay form describes another agent's message, and a generic context row exposes JSON. A dedicated source, Definition, and renderer own the question presentation. -**Independent reply layout through generic node metadata.** It broadens a question feature into a conversation-infrastructure change. Ordinary folding and Chat-owned aggregation are sufficient for this decision; independently declared node-data grouping semantics remain a separate design task. +**Independent reply layout through generic node metadata.** It broadens a question feature into a conversation-infrastructure change. Ordinary folding is sufficient for this decision; placing the reply as user input through its group-tree position is separate work. **A log-only close event or a manufactured dismissal message on panel close.** A log-only close hides the user's decision from the model and adds another durable mechanism. Manufacturing a reply treats hiding the panel as an answer. Panel close therefore leaves a timed question answerable. @@ -101,7 +101,7 @@ This decision adds no process-role field, independent-layout flag, user-message - [Service tests](../../../../packages/interaction/user-questions/tests/user-questions.spec.ts) and [wait tests](../../../../packages/interaction/user-questions/tests/timed-wait.spec.ts) cover claimed and unclaimed waits, last-claim disconnect, cancellation, timeout error mapping, continued-only answers, and exact answer-id validation. - [Projection tests](../../../../packages/interaction/user-questions/tests/projection.spec.ts) distinguish legacy and timed schemas and cover native and PTC pending calls, other failures, resume repair, queued reply cancellation, and admitted settlement. - [Client listener tests](../../../../packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts) and [composer tests](../../../../packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx) cover claim retention through Host acceptance, focus before handshake, indefinite waits, draft retention, RPC resubmission, and panel actions. -- [Grouping tests](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts) and [browser replay](../../../../apps/web/tests/question-composer.e2e.ts) cover one rendered reply with both projections retained, ordinary collapsed placement, disclosure ordering, and the original tool row's read-only late-answer panel. +- [Grouping tests](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts) and [browser replay](../../../../apps/web/tests/question-composer.e2e.ts) cover ordinary collapsed placement, disclosure ordering, and the original tool row's read-only late-answer panel. - Legacy round-trip, cancellation, plan-review intent, and Session-remount regressions remain covered by their owning tests. Live multi-Client delivery races and real Host restart remain integration coverage gaps beyond isolated lifecycle and projection tests. ## Consequences @@ -111,7 +111,6 @@ The model can continue independent work while the question remains answerable. B - Take time affects only one Client. Another Client can answer or expire the shared waterfall; local focus does not establish a global hold. - A terminal claim-stream failure can reject the foreground question rather than produce pending. No unattended-wait fallback is provided for that failure. - Host and Client clocks need not align, but Client system-clock changes can move expiry and transport latency can delay the local countdown's start. -- The duplicate projections remain stored. Aggregation adds linear scans during structural grouping rebuilds; ordinary text-only streaming updates retain their incremental path. This is an algorithmic bound, not a measured latency claim. - Unanswered continued questions can remain after reopening a Session. Panel close records nothing, and visibility follows normal folding rather than an always-visible reply guarantee. - A queued late reply can be canceled before the agent admits it. The question stays answerable until an admitted `user/message` records the answer. - A PTC sub-call interrupted before its `tool/ptc-dispatch` result is recorded cannot be reconstructed from the parent `run_code` failure, so it cannot take a late answer after recovery. diff --git a/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.zh.md b/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.zh.md index 6bce5e9960..91ddd5c6b4 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.zh.md @@ -29,7 +29,7 @@ Status: implemented | `UserQuestionService` | 前台等待与 Client 接手、无人接手时的 deadline、迟到回答校验与 steering(中途引导),以及持久问题投影。 | | Gateway 与 Remote 运行时 | 现有请求投递、结算、取消及 Remote stream 传输;不包含问题专属计时器或按事件名分支。 | | `ui-user-questions` | 每个 Session 和调用的一张卡片、回答通道选择、本地倒计时与草稿,以及回复 Definition 和渲染器。 | -| `ui-chat` | 基于消息 id 的呈现聚合,以及普通过程/轮次折叠。 | +| `ui-chat` | 普通过程/轮次折叠。 | | 问题专用工具行 | pending/已回答呈现与面板操作,在 `QuestionToolRow` 中组合,不向通用 `ToolRow` 添加 `rowAction`。 | [问题服务](../../../../packages/interaction/user-questions/README.zh.md)、[工具](../../../../packages/interaction/tool-ask-user/README.zh.md)和[问题 UI](../../../../packages/client/ui-user-questions/README.zh.md)分别拥有各包的约定。 @@ -72,11 +72,11 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会 问题 UI 通过 `source.kind` 识别迟到回复,再解析已记录的 JSON 来呈现问答。普通用户消息粘贴相同 JSON 不会被识别为回复。无法读取的已记录载荷保留原始文本呈现。 -普通消息 Definition 和问答回复 Definition 都匹配追加的消息,在 Node Store 中保留消息 id 相同的独立投影。存在同 id 的 `question-reply` 时,Chat 业务分组省略 `turn-trigger` 呈现。重复触发节点既不渲染,也不切开分组,回复只显示一次。相同聚合也适用于无轮次归属的历史输入。 +问答回复 Definition 通过[独占匹配](2026-10-12-exclusive-conversation-matches.zh.md)在 Chat target 上认领追加的回复消息,普通消息 Definition 因此不再为它建立 `turn-trigger` 或 context 投影,回复只显示一次;无轮次归属的历史输入同样如此。 回复参与普通过程分组和整轮折叠,不保证独立可见:阅读时可能需要展开所属轮次与过程分组。轮次折叠控件始终在所控制的分组之前,包括由回复开启轮次的情况。 -本决策不添加 process-role 字段、独立布局标记、用户消息结构变更或目标内 fallback 派发。独立 kind 规则保留在 Chat 现有分组逻辑中。由节点 data 声明分组与重叠语义的机制是留待拆分的基础设施,不属于问题功能。 +本决策不添加独立布局标记或用户消息结构变更。回复不声明分组树位置,因此按过程内容的默认位置放置。 ## Alternatives considered @@ -90,7 +90,7 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会 **在通用 `MessageItem` 内重新分类 JSON、借用 relay form,或使用默认 context 行。** 解析文本来识别业务消息混淆了来源与内容;relay form 表示其他 agent 的消息,通用 context 行则会暴露 JSON。专用来源、Definition 和渲染器拥有问题呈现。 -**通过通用节点元数据让回复独立布局。** 这会把问题功能扩大为对话基础设施变更。普通折叠与 Chat 内聚合足以满足本决策;由节点 data 独立声明分组语义仍是单独的设计任务。 +**通过通用节点元数据让回复独立布局。** 这会把问题功能扩大为对话基础设施变更。普通折叠足以满足本决策;通过分组树位置把回复放置为用户输入是单独的工作。 **面板关闭时写仅入日志的关闭事件,或伪造放弃消息。** 仅入日志会让模型看不到用户决定,还增加另一套持久机制。伪造回复则把隐藏面板当成回答。因此关闭面板仍让计时问题保持可回答。 @@ -101,7 +101,7 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会 - [服务测试](../../../../packages/interaction/user-questions/tests/user-questions.spec.ts)与[等待测试](../../../../packages/interaction/user-questions/tests/timed-wait.spec.ts)覆盖有人与无人接手、最后接手方断开、取消、超时错误映射、仅接受已继续问题的回答,以及答案 id 的精确校验。 - [投影测试](../../../../packages/interaction/user-questions/tests/projection.spec.ts)区分 legacy 与 timed schema,并覆盖原生及 PTC pending 调用、其他失败、恢复修复、排队回复取消和准入后结算。 - [Client 监听器测试](../../../../packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts)与[composer 测试](../../../../packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx)覆盖接手保留到 Host 接受结果、握手前聚焦、无限期等待、草稿保留、RPC 重提及面板操作。 -- [分组测试](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts)与[浏览器回放](../../../../apps/web/tests/question-composer.e2e.ts)覆盖保留两个投影但只渲染一次回复、普通折叠位置、折叠控件顺序,以及原工具行的迟到答案只读面板。 +- [分组测试](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts)与[浏览器回放](../../../../apps/web/tests/question-composer.e2e.ts)覆盖普通折叠位置、折叠控件顺序,以及原工具行的迟到答案只读面板。 - legacy 问答往返、取消、计划评审意图及 Session 重新挂载的回归仍由各自测试覆盖。真实多 Client 投递竞争与真实 Host 重启仍是独立生命周期和投影测试之外的集成覆盖缺口。 ## Consequences @@ -111,7 +111,6 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会 - 「慢慢回答」只影响一个 Client。其他 Client 可以回答或让共享 waterfall 到期,本地聚焦不构成全局暂停。 - 接手 stream 的终止性失败可能让前台问题失败,而不是产生 pending。不为该失败提供无人接手等待的 fallback。 - Host 与 Client 时钟不必对齐,但 Client 系统时钟变化可能移动到期时间,传输延迟可能推迟本地倒计时的开始。 -- 重复投影仍保留在存储中。聚合在结构性分组重建时增加线性扫描;普通纯文本 stream 更新保留增量路径。这是算法复杂度界限,不是实测延迟结论。 - 无人回答的已继续问题可以在重新打开 Session 后继续存在。关闭面板不写记录,可见性遵循普通折叠,而不是保证回复始终可见。 - 排队的迟到回复可能在 agent 准入前被取消。只有已准入的 `user/message` 记录答案,问题在此之前保持可回答。 - PTC 子调用若在记录 `tool/ptc-dispatch` 结果前中断,无法从父 `run_code` 的失败中重建,因此恢复后不能接受迟到回答。 diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml new file mode 100644 index 0000000000..615a8d43cf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-23-best-effort-persistent-ptc.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-09-23-best-effort-persistent-ptc.md +/agent-note-node-ptc-execution-modes-and-in-memory-state: + en: 078a89753b06cd6a + zh: 4004e5e4bb42f2cc +/agent-note-node-ptc-execution-modes-and-in-memory-state/problem: + en: e42db40b879fdbf4 + zh: 6017da9c4da17835 +/agent-note-node-ptc-execution-modes-and-in-memory-state/decision: + en: d2d08c54c6520f02 + zh: 520a9b3421ff5c65 +/agent-note-node-ptc-execution-modes-and-in-memory-state/alternatives-considered: + en: 396157a9fc293198 + zh: a061f05b4423e26f +/agent-note-node-ptc-execution-modes-and-in-memory-state/consequences: + en: 9b9172f4aafe907f + zh: 022a6bd52d7caf05 diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md new file mode 100644 index 0000000000..153a63b87e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md @@ -0,0 +1,37 @@ +# Agent Note: Node PTC execution modes and in-memory state + +Status: implemented + +English | [中文](2026-09-23-best-effort-persistent-ptc.zh.md) + +## Problem + +Interactive automation retains application objects, imported modules and helper functions between model calls. Recreating every object for every program prevents consumers from using ordinary JavaScript references across observations and actions. + +## Decision + +The existing `@deepseek-ai/dsh-ptc-runtime-node` plugin selects its execution mode through `executionMode`. The default `per-call` mode starts a fresh process under the configured file policy. Opt-in `persistent-in-memory` mode keeps a Node REPL process per explicit, object-identity state owner. Both modes provide the same `ctx.ptcRuntime` service. PTC uses the live Agent as owner and awaits release during its context disposal. Changing the configuration reloads the provider and releases its processes. The [sandboxed Node decision](2026-09-11-sandboxed-node-ptc-runtime.md) owns per-call confinement; the [PTC foundation](../feature/2026-06-15-ptc.md) owns tool dispatch and logged model requests. + +Mode names describe state retention rather than a process implementation or source syntax. `per-call` retains state for one invocation; `persistent-in-memory` retains it across invocations until the process or owner ends. `durable` names the deferred ability to save and restore execution state across restarts. It is not an accepted configuration value: a future implementation must define its recovery point, recoverable values and external-resource handling before exposing that mode. The [retained-process permission decision](2026-10-08-persistent-ptc-sandbox.md) owns confinement, policy changes and temporary escalation. + +One npm package and one plugin entry keep existing installation and loading paths stable. Communication, binding proxies, JSON encoding and output limits remain package-local modules. The Node provider delegates retained process ownership to an internal execution object, which registers no additional Cordis service. Per-call and in-memory execution keep their distinct lifecycle rules. `programFormat` describes source evaluation independently of retention: a REPL need not retain state. The runtime owns syntax, completion and retention instructions through `executionInstructions`; tools own call arguments, SDK declarations and image attachment. Workflow uses `programFormat` to wrap its program and refuses restricted file policies when the selected runtime cannot enforce them. + +The Session records submitted programs, nested tool calls and returned output. It does not serialize the REPL heap or rerun earlier programs after restart, resume or fork: replaying automation could repeat external side effects. A missing variable is a normal program error, and the model-visible instructions require recreating variables and reacquiring resources. No environment identifier or new durable Session event is required because the provider exposes no unlogged model input. + +Cancellation, deadline expiry, protocol or output-limit failure, owner disposal and process exit discard the environment. Ordinary exceptions retain earlier mutations. A retained function uses the active evaluation's host bindings; asynchronous work from an earlier evaluation cannot acquire a later evaluation's tool authority or output sink. + +## Alternatives considered + +Separate npm providers plus a shared library allow independent distribution, at the cost of another package, dependency declarations and cross-package exports for shared implementation details. The two Node modes have no current need for separate release schedules or independent dependencies. A second plugin subpath permits separate loading but asks deployments to replace plugin entries to select an execution policy. A configuration field expresses that selection while preserving the existing entry. Independent distribution or substantially different dependencies can justify revisiting this choice. + +A new Cordis service for common helpers would require another registration and injected dependency without adding an independently replaceable runtime capability. Internal functions and classes provide the required reuse. A common execution superclass would also couple fresh-process cleanup with owner-retained lifetimes; the provider shares mechanisms without merging those lifecycles. + +Serializing arbitrary JavaScript objects cannot preserve native resources and closures. Replaying prior programs can repeat writes and input actions. Both would imply recovery guarantees that in-memory execution does not offer. + +A realm inside the Harness process shares process lifetime with model code and cannot terminate a synchronous loop independently. A managed child process permits cancellation and resource cleanup while retaining imports and variables during healthy use. Process ownership alone does not provide a file sandbox or multi-tenant isolation. + +## Consequences + +Selecting `persistent-in-memory` retains execution state; the [permission decision](2026-10-08-persistent-ptc-sandbox.md) defines its file restrictions. Nested tools keep their normal authorization and logging. Memory and native handles may disappear without a Harness restart. The model must recover from reference or resource errors; the runtime never retries a program automatically. + +An idle owner retains a process with no expiration timer. Callers release owners explicitly; ownerless service calls use temporary processes. Programs use REPL statements and a final expression rather than top-level return, and calls for one retained owner cannot overlap. Tool schemas and program guidance follow the selected mode. diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md new file mode 100644 index 0000000000..a4c4a52828 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Node PTC 执行模式与内存状态 + +Status: implemented + +[English](2026-09-23-best-effort-persistent-ptc.md) | 中文 + +## 问题 + +交互式自动化需要在模型调用之间保留应用对象、导入模块和辅助函数。如果每次执行都重新创建对象,消费者就无法在观察与操作之间继续使用普通 JavaScript 引用。 + +## 决策 + +现有 `@deepseek-ai/dsh-ptc-runtime-node` 插件通过 `executionMode` 选择执行模式。默认的 `per-call` 模式按配置的文件策略启动新进程。显式启用的 `persistent-in-memory` 模式为每个以对象身份标识的状态所有者保留一个 Node REPL 进程。两种模式都提供同一个 `ctx.ptcRuntime` 服务。PTC 使用存活的 Agent 作为所有者,在其上下文销毁时等待释放。修改配置会重载提供方并释放其进程。[Node 沙箱决策](2026-09-11-sandboxed-node-ptc-runtime.zh.md)负责逐次执行的文件约束,[PTC 基础决策](../feature/2026-06-15-ptc.zh.md)负责工具分派与模型请求日志。 + +模式名称描述状态保留方式,不绑定进程实现或代码语法。`per-call` 只为一次调用保留状态;`persistent-in-memory` 跨调用保留状态,直到进程或所有者结束。`durable` 用于命名未来跨重启保存与恢复执行状态的能力。它不是可接受的配置值:实现必须先明确恢复点、可恢复的值以及外部资源的处理方式,才能公开这个模式。[常驻进程权限决策](2026-10-08-persistent-ptc-sandbox.zh.md)负责文件约束、权限切换与临时提权。 + +一个 npm 包和一个插件入口保持现有安装与加载路径稳定。通信、绑定代理、JSON 编码和输出限制继续作为包内模块复用。Node 提供方将保留进程的所有权交给内部执行对象,该对象不注册额外的 Cordis 服务。逐次执行和内存保留各自管理不同的生命周期。`programFormat` 描述代码求值形式,与状态保留独立:REPL 不必跨调用保留状态。运行时通过 `executionInstructions` 负责语法、完成值和状态保留指引;工具层负责调用参数、SDK 声明和图片附加。工作流根据 `programFormat` 包装程序;所选运行时无法执行受限文件策略时,工作流拒绝执行。 + +Session 记录提交的程序、嵌套工具调用和返回输出。它不序列化 REPL 堆,也不在重启、恢复或派生后重新执行旧程序,因为重放自动化可能重复外部操作。缺失变量产生普通程序错误,模型指引要求重新创建变量并获取资源。提供方没有引入日志之外的模型输入,因此不需要环境标识或新的持久 Session 事件。 + +取消、截止时间到期、协议失败、输出超限、所有者销毁和进程退出都会丢弃环境。普通异常保留此前发生的修改。保留的函数使用当前执行的宿主绑定;较早执行中的异步任务不能获取后续执行的工具权限或向其输出。 + +## 考虑过的替代方案 + +将提供方拆成独立 npm 包并增加共享库,可以分别分发,但需要维护额外的包、依赖声明和共享实现的跨包导出。两种 Node 模式目前没有不同发布节奏或独立依赖的需求。第二个插件子路径允许独立加载,但要求部署配置通过替换插件入口来选择执行策略。配置字段直接表达这个选择,并保留已有入口。需要独立分发或出现明显不同的依赖时,可以重新评估拆包。 + +为公共辅助代码新增 Cordis 服务,会增加注册和注入依赖,却没有提供独立替换的运行能力。内部函数和类已经能够完成复用。共同的执行基类还会把新进程清理与按所有者保留进程的生命周期耦合起来,因此提供方共用处理机制,各自管理生命周期。 + +序列化任意 JavaScript 对象无法保留原生资源和闭包。重放旧程序可能重复写入和输入操作。这两种做法都会暗示内存保留模式没有提供的恢复保证。 + +Harness 进程内的独立执行上下文与模型代码共享进程生命周期,无法单独终止同步死循环。托管子进程可以在正常使用时保留导入和变量,同时支持取消与资源清理。仅有进程所有权不能提供文件沙箱或多租户隔离。 + +## 影响 + +选择 `persistent-in-memory` 会保留执行状态;[权限决策](2026-10-08-persistent-ptc-sandbox.zh.md)定义其文件限制。嵌套工具仍遵循原有授权和日志规则。即使 Harness 没有重启,内存和原生句柄也可能丢失。模型需要从引用或资源错误中恢复,运行时不会自动重试程序。 + +空闲所有者保留一个没有过期计时器的进程。调用方显式释放所有者,没有所有者的服务调用使用临时进程。程序使用 REPL 语句和末尾表达式,不支持顶层 return;同一保留所有者的调用不能重叠。工具 schema 和程序指引跟随所选模式。 diff --git a/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.i18n.yaml new file mode 100644 index 0000000000..71c61113a6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-24-deepseek-account-remote-model-catalog.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.md +/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version: + en: e6d82714ac89a7ae + zh: 54c14278e749e7e1 +/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/problem: + en: 2eeb916eb65c66e7 + zh: 7bf5c0abb8bb61e4 +/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/decision: + en: 42e800c4cd3b6ebe + zh: 06fabe5a9681fa27 +/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/alternatives-considered: + en: 7c7880fdb0cc95f6 + zh: 2b8a539192626789 +/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/consequences: + en: 352ece6c2decaaf7 + zh: 1d0c3620fd26a35c diff --git a/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.md b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.md new file mode 100644 index 0000000000..75265db1ce --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.md @@ -0,0 +1,36 @@ +# Agent Note: DeepSeek account models come from the account listing and gate by client version + +Status: implemented + +English | [中文](2026-09-24-deepseek-account-remote-model-catalog.zh.md) + +## Problem + +The account route's catalog was deployment configuration: a build advertised the models it shipped with, a new model reached users only through a release, and nothing kept an older build from requesting a model it cannot drive. The account service publishes per-account model metadata at `GET /models`, and that listing can name models an older build does not understand. + +## Decision + +- [`llm-deepseek-account`](../../../../packages/llm/llm-deepseek-account/README.md) loads the listing lazily for each credential, waits for it once up to `modelsTimeoutMs`, refreshes it in the background after `modelsMaxAgeMs`, keeps the last good listing when a load fails or returns no model, and serves the bundled catalog before any listing loads. The cache lives in memory and is keyed by endpoint and token fingerprint, so per-account rollouts stay separate. +- A listed model is available when its `client_requirements["deepseek-harness"].min_version` is at most the current DeepSeek Harness version in semantic-version order. Without that declaration, only ids the built-in catalog knows are available; the others stay hidden, because a client cannot tell which future version supports them. A request for a refused listed model fails before network I/O with `CLIENT_UPGRADE_REQUIRED` or `MODEL_UNSUPPORTED`. +- Listed capabilities replace bundled ones field by field; the bundled entry keeps client-side knowledge such as tool-update mode and image budgets; `modelOverrides` applies last and reuses the `models` entry fields and validation. `max_output_tokens` bounds the request default instead of becoming it. +- The shared transport gains only two provider hooks, `connect` and `admit`; listing, gating, and merging stay in the account package. + +## Alternatives considered + +**Server-side filtering by client version only.** Codex sends `client_version` and lets its backend drop models. An older build then cannot show that a newer version supports a model, and a listing that omits a declaration would expose an unknown model. + +**Default parameters for unknown models.** Reasonix and Pi drive unknown ids with generic defaults, and MiniMax treats that as a defect. A model that needs client changes would run with the wrong behavior. + +**Whole-listing validation.** Codex, ZCode, and Kimi reject the whole listing on one bad field. One malformed row would then send every account back to the bundled catalog. + +**Persisting the last good listing.** A durable cache adds a persistence format and its migration duty; the bundled catalog already covers offline starts. + +**Keeping `models` with merge-by-id semantics.** The API-key route's `models` replaces its catalog, so the same field name with merge semantics would invert its meaning between sibling routes. + +**Generalizing the shared transport.** Per-model effort levels, an output-capability field, and catalog-change announcements would widen the Messages package for one consumer. The listed effort levels match the bundled defaults, and a model that needs different ones needs a release, which the gate enforces. + +## Consequences + +- An older build never offers a model it does not know unless the service declares a minimum version that build satisfies, and the service must declare `min_version` for a future build to show that an upgrade enables a model. +- A modified client can still request any id; the service enforces version floors only if it rejects such requests itself. +- The first account access after sign-in may wait up to `modelsTimeoutMs`; an offline start uses bundled capabilities until a listing loads; a listing change during a running process reaches the Web picker at its next catalog refresh. diff --git a/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.zh.md b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.zh.md new file mode 100644 index 0000000000..7199091c73 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.zh.md @@ -0,0 +1,36 @@ +# Agent Note: DeepSeek 账号模型来自账号列表,并按客户端版本门控 + +Status: implemented + +[English](2026-09-24-deepseek-account-remote-model-catalog.md) | 中文 + +## 问题 + +账号路由的目录原本是部署配置:每个构建只提供它发布时自带的模型,新模型只能随发版到达用户,也没有办法阻止旧构建请求它驱动不了的模型。账号服务在 `GET /models` 上按账号发布模型元数据,这份列表可能包含旧构建不认识的模型。 + +## 决策 + +- [`llm-deepseek-account`](../../../../packages/llm/llm-deepseek-account/README.zh.md) 按凭据懒加载列表,首次访问最多等待 `modelsTimeoutMs`,超过 `modelsMaxAgeMs` 后在后台刷新;获取失败或返回的列表中没有模型时保留上一份,还没有加载到任何列表时使用内置目录。缓存只在内存中,以端点和 token 指纹为键,因此按账号灰度互不干扰。 +- 列出的模型在其 `client_requirements["deepseek-harness"].min_version` 按 semver 顺序不高于当前 DeepSeek Harness 版本时可用。没有这个声明时,只有内置目录认识的 id 可用,其余保持隐藏,因为客户端无法判断将来哪个版本会支持它们。请求被拒绝的已列出模型时,在网络请求之前以 `CLIENT_UPGRADE_REQUIRED` 或 `MODEL_UNSUPPORTED` 失败。 +- 列表中的能力逐字段覆盖内置能力;内置条目保留工具更新模式、图片预算等客户端知识;`modelOverrides` 最后叠加,复用 `models` 条目的字段与校验。`max_output_tokens` 只限制请求默认值,不直接成为默认值。 +- 共享传输层只新增 `connect` 与 `admit` 两个提供方钩子;列表、门控与合并都留在账号包中。 + +## 考虑过的替代方案 + +**只在服务端按客户端版本过滤。** Codex 发送 `client_version`,由后端去掉模型。这样旧构建无法提示新版本支持某个模型,而且列表漏掉声明时会暴露未知模型。 + +**未知模型套用默认参数。** Reasonix 和 Pi 用通用默认值驱动未知 id,MiniMax 把这种做法当作缺陷。需要客户端改动的模型会以错误的方式运行。 + +**整份列表严格校验。** Codex、ZCode 和 Kimi 会因为一个坏字段拒绝整份列表。一条格式错误的记录就会让所有账号退回内置目录。 + +**持久化上一次成功的列表。** 持久缓存要新增持久化格式并承担迁移责任;内置目录已经覆盖离线启动。 + +**保留 `models` 并改为按 id 合并。** API key 路由的 `models` 会替换整个目录,同名字段在兄弟路由上改成合并语义会让含义相反。 + +**扩展共享传输层。** 按模型的推理档位、输出能力字段和目录变更通知,会为一个使用方扩大 Messages 包的范围。列表中的推理档位与内置默认值一致;需要不同档位的模型需要发版适配,门控正是用来保证这一点。 + +## 影响 + +- 旧构建永远不会提供它不认识的模型,除非服务端声明了该构建满足的最低版本;服务端必须声明 `min_version`,将来的构建才能提示升级后可用。 +- 改过的客户端仍然可以请求任意 id;只有服务端自己拒绝这类请求时,版本下限才会被强制执行。 +- 登录后的第一次账号访问最多等待 `modelsTimeoutMs`;离线启动在加载到列表之前使用内置能力;进程运行中途的列表变化要等 Web 选择器下一次刷新目录时才会显示。 diff --git a/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.i18n.yaml index 7c2ed58b01..01a5ad7732 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.i18n.yaml @@ -15,8 +15,8 @@ en: 5cfe619038339caa zh: c2d2651d304d867c /agent-note-preset-scoped-schedule-tools/consequences: - en: 546d83d28487d1c1 - zh: 438922cd4f3ff9ec + en: d7c7e6b8e6268559 + zh: 801e5be70e00cc90 /agent-note-preset-scoped-schedule-tools/testing: - en: 1c5f34cf30a24fd7 - zh: 6f7a372de52d0ecd + en: d42cb866907a96b5 + zh: 6e66f8babe744bcb diff --git a/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.md b/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.md index c0b1b34412..169f3bea52 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.md +++ b/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.md @@ -26,9 +26,9 @@ English | [中文](2026-09-24-preset-scoped-schedule-tools.zh.md) - Each preset that mounts the row pays the four schemas' fixed token cost, and tool availability is read from the composition instead of inferred from the Host service's presence. - A deployment can mount `dsh-schedule` for storage and delivery without granting its agents model-driven reminder management. - `dsh-schedule` injects no `ctx.tools` and registers no model-facing tool. -- A delegated child cannot use the four tools through either layer: the `tool-subagent` and `tool-subagent-fork` rows in `standard`, `cordis`, and `ptc` deny all four names through `toolFilter`, and the provider applies that filter in the child scope through `ctx.tools.restrict()`, so the tools leave the child's prompt; `subagentCallerRefusal` in `dsh-tool-schedule` independently returns `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }` from every one of the four tools when the caller's delegation depth is above zero, so a call that reaches a tool anyway is refused. +- A delegated child inherits the preset's four reminder schemas. `subagentCallerRefusal` in `dsh-tool-schedule` returns `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }` from each tool when the caller's delegation depth is above zero. The [preset composition decision](../simplification/2026-10-09-preset-owned-subagent-composition.md) removes the redundant child filter; visibility does not authorize execution. - A Session a delegated child owns can never receive a delivered reminder, so `ScheduleService.create` and `ScheduleService.update` refuse it with `subagent_session`. The refusal reads the delegation depth — the accounting the delegation cap itself enforces, carried by the persisted session header across a cold resume. The rule sits in the service, so other consumers reach it too — the Automation surface included. ## Testing -`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` pins the four definitions and their error mapping, including `refuses every tool for a delegated child caller`. `apps/cli/tests/web-agent-presets.e2e.ts` asserts the `minimal` preset's tool list, and `snapshots/web/minimal-preset/tool-schemas.expected.json` records its bash-only tool table. `packages/schedule/schedule/tests/subagent-ownership.spec.ts` pins the delegation-depth refusal, and `scripts/optional-bundles.spec.ts` asserts that both delegation rows in the three presets deny all four tool names. +`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` pins the four definitions and their error mapping, including `refuses every tool for a delegated child caller`. `apps/cli/tests/web-agent-presets.e2e.ts` asserts the `minimal` preset's tool list, and `snapshots/web/minimal-preset/tool-schemas.expected.json` records its tool table without reminder schemas. `packages/schedule/schedule/tests/subagent-ownership.spec.ts` pins the delegation-depth refusal, and `scripts/optional-bundles.spec.ts` asserts that both delegation rows in the three presets omit `toolFilter`. The Web preset e2e also checks equal parent/child tool schemas and refusal from all four reminder executors for fresh and seeded children. `snapshots/web/subagent-fork-schedule` records all four refusals in a seeded fork through the shipped Web composition and shares the standard preset's prompt and schema pins. diff --git a/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.zh.md b/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.zh.md index bb9a8c0894..75b41d8b9e 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.zh.md @@ -26,9 +26,9 @@ Status: implemented - 挂载该行的每个 preset 承担四个 schema 的固定 token 成本,工具可用性取自组合,而不是由宿主服务是否存在推断。 - 部署可以只为存储与投递挂载 `dsh-schedule`,而不授予其 Agent 由模型驱动的提醒管理能力。 - `dsh-schedule` 不注入 `ctx.tools`,也不注册任何面向模型的工具。 -- 被委派的子 agent 无法通过两道中的任何一道使用这四个工具:`standard`、`cordis` 与 `ptc` 中的 `tool-subagent` 与 `tool-subagent-fork` 两行通过 `toolFilter` 拒绝全部四个名称,provider 会在子作用域内通过 `ctx.tools.restrict()` 应用该过滤,因此这些工具会从子 agent 的 prompt 中消失;`dsh-tool-schedule` 中的 `subagentCallerRefusal` 会在调用方委派深度大于零时,从四个工具中的每一个独立返回 `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }`,因此即使某个调用仍能到达工具,也会被拒绝。 +- 被委派的子 agent 继承 preset 的四个提醒 schema。调用方委派深度大于零时,`dsh-tool-schedule` 中的 `subagentCallerRefusal` 从每个工具返回 `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }`。[preset 组装决策](../simplification/2026-10-09-preset-owned-subagent-composition.zh.md)移除了重复的子级过滤;可见性不授予执行权限。 - 被委派子 agent 拥有的 Session 永远收不到投递的提醒,因此 `ScheduleService.create` 与 `ScheduleService.update` 会以 `subagent_session` 拒绝它。该拒绝读取委派深度——委派封顶本身执行的记账,由持久化 session header 承载并跨冷恢复保留。这条规则在服务层,因此其它消费方(包括自动化任务页面)也会命中。 ## Testing -`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` 固定这四个定义及其错误映射,包括 `refuses every tool for a delegated child caller`。`apps/cli/tests/web-agent-presets.e2e.ts` 断言 `minimal` preset 的工具列表,`snapshots/web/minimal-preset/tool-schemas.expected.json` 记录其只含 `bash` 的工具表。`packages/schedule/schedule/tests/subagent-ownership.spec.ts` 固定委派深度拒绝,`scripts/optional-bundles.spec.ts` 断言三个 preset 的两行委派行都拒绝全部四个工具名。 +`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` 固定这四个定义及其错误映射,包括 `refuses every tool for a delegated child caller`。`apps/cli/tests/web-agent-presets.e2e.ts` 断言 `minimal` preset 的工具列表,`snapshots/web/minimal-preset/tool-schemas.expected.json` 记录其不含提醒工具 schema 的工具表。`packages/schedule/schedule/tests/subagent-ownership.spec.ts` 固定委派深度拒绝,`scripts/optional-bundles.spec.ts` 断言三个 preset 的两行委派行都不含 `toolFilter`。Web preset e2e 还检查父子工具 schema 相等,且四个提醒执行器对新建和带种子的子级均拒绝执行。`snapshots/web/subagent-fork-schedule` 通过出货 Web 组合记录带种子 fork 中的四次拒绝,并共享 standard preset 的提示和 schema 预期。 diff --git a/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.i18n.yaml new file mode 100644 index 0000000000..01cda717dd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-27-progressive-office-skills.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-09-27-progressive-office-skills.md +/agent-note-progressive-office-skills-with-ordinary-shell-execution: + en: 42e2f062c37e9ef1 + zh: 5cd3d421adec1cac +/agent-note-progressive-office-skills-with-ordinary-shell-execution/problem: + en: a9984a594cdac98d + zh: 38768e7699bc8d39 +/agent-note-progressive-office-skills-with-ordinary-shell-execution/decision: + en: d057c558d6b9803c + zh: acf3fdbc4cd7cee3 +/agent-note-progressive-office-skills-with-ordinary-shell-execution/alternatives-considered: + en: 8bca955255e6d982 + zh: bc313a4bcab3f6f9 +/agent-note-progressive-office-skills-with-ordinary-shell-execution/consequences: + en: cd4ff2423b2f5f9e + zh: 63135aeae9c46415 diff --git a/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.md b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.md new file mode 100644 index 0000000000..a89b896fe6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.md @@ -0,0 +1,32 @@ +# Agent Note: Progressive Office skills with ordinary shell execution + +Status: implemented + +English | [中文](2026-09-27-progressive-office-skills.zh.md) + +## Problem + +Combined authoring and rendering instructions spend context on operations unrelated to ordinary editing. Rendering tool activation adds a second discovery path and durable state even though shell tools already own execution permissions and process management. A missing renderer can prevent loading otherwise usable editing guidance. + +## Decision + + +The [Office provider](../../../../packages/skill/skill-office/README.md) lists a compact router and six format-specific authoring or operation skills. The model loads full instructions by name and selects rendering for appearance-focused changes unless the user explicitly skips it. Rendering uses a private, randomly named temporary directory inside the task workspace and removes it after inspection. Skills invoke the shared Python checker and configured LibreOffice Kit CLI through ordinary Bash or PowerShell. SDK and Desktop own their Office composition separately; ordinary Web and the shared base do not mount these plugins. + +Carriers own interpreter and resource locations. Operation skills append the configured Node and CLI paths only when their bodies are loaded; authoring skills use the selected Python and shared checker resource. Loading instructions does not prepare engines or authoring payloads. Ordinary shell execution retains its existing permissions and process management. + +Office dependency ranges allow patch updates; the root lock records the resolved adapter and engine versions. + +## Alternatives considered + +**Keep a dedicated renderer tool.** A small typed schema constrains rendering arguments, but requires its own execution configuration, scoped registration, projection, and activation lifecycle. The CLI operation skills accept shell quoting and model-selected arguments in exchange for reusing the existing approval, sandbox, cancellation, and output mechanisms. + +**Hide subskills from the catalog.** This requires a generic visibility policy independent of invocation permission. Listing seven short summaries preserves the existing skill API while full instruction bodies remain demand-loaded. + +**Automatically inject guides for file extensions.** Extensions do not distinguish text edits, recalculation, conversion, and requested visual checks. Model-selected routing keeps unrelated operations out of context and permits explicit direct invocation. + +## Consequences + +Instruction loading appends ordinary logged skill results; completion does not erase them. Historical tool records remain readable and existing declaration reconciliation handles resumed sessions without a Session format change or agent-loop modification. A dedicated renderer would need a new independently justified user or security requirement before reintroduction. + +Verification covers catalog entries, denied and explicit invocation, authoring without executable paths, native/PTC requests, resume and fork, ordinary and minimal profile composition, resource packaging, context budgets, and real-model operation selection. Rendering failures preserve usable sources and stop visual checks; successful commands alone cannot establish usable images. diff --git a/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.zh.md b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.zh.md new file mode 100644 index 0000000000..0aeb95eb1d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.zh.md @@ -0,0 +1,32 @@ +# Agent Note: Office 技能渐进加载与普通 shell 执行 + +Status: implemented + +[English](2026-09-27-progressive-office-skills.md) | 中文 + +## 问题 + +合并的创作和渲染说明会为普通编辑无关的操作消耗上下文。渲染工具激活增加第二条发现路径和持久状态,而 shell 工具已经负责执行权限及进程管理。缺少渲染器也可能阻止加载原本可用的编辑指南。 + +## 决策 + + +[Office 提供者](../../../../packages/skill/skill-office/README.zh.md) 列出精简路由和六个各格式创作或操作技能。模型按名称加载完整说明;以外观为目标的修改会选择渲染,除非用户明确要求跳过。渲染使用任务工作区内随机命名的私有临时目录,并在检查后删除。技能通过普通 Bash 或 PowerShell 调用共享 Python 检查器和配置的 LibreOffice Kit CLI。SDK 和 Desktop 分别拥有 Office 组合;普通 Web 和共享 base 不挂载这些插件。 + +载体负责解释器和资源位置。操作技能仅在加载正文时附加配置的 Node 与 CLI 路径;创作技能使用选定的 Python 和共享检查器资源。加载说明不准备引擎或创作 payload。普通 shell 执行保留现有权限和进程管理。 + +Office 依赖范围允许补丁版本更新;根锁文件记录实际解析的适配器与引擎版本。 + +## 考虑过的替代方案 + +**保留专属渲染工具。** 小型类型化 schema 能约束渲染参数,但需要专属执行配置、作用域注册、投影和激活生命周期。CLI 操作技能接受 shell 引用和模型选择参数的成本,以复用现有审批、沙箱、取消及输出机制。 + +**从目录隐藏子技能。** 这需要独立于调用权限的通用可见性策略。列出七条简短摘要可保留现有技能 API,完整说明仍按需加载。 + +**按文件扩展名自动注入指南。** 扩展名无法区分文本编辑、重计算、转换和用户要求的视觉检查。模型选路使无关操作不进入上下文,并允许显式直接调用。 + +## 影响 + +说明加载追加普通的已记录技能结果;操作完成不会擦除这些结果。历史工具记录仍可读取,恢复会话使用现有声明更新机制,无需修改 Session 格式或 agent loop。重新引入专属渲染器需要新的、独立成立的用户或安全要求。 + +验证覆盖目录条目、拒绝及显式调用、无可执行文件路径时的创作、native/PTC 请求、恢复和 fork、常规及 minimal 组合、资源打包、上下文预算和真实模型操作选择。渲染失败时保留可用源文件并停止视觉检查;命令成功本身不能证明图片可用。 diff --git a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.i18n.yaml new file mode 100644 index 0000000000..262e03bb22 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-08-persistent-ptc-sandbox.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-08-persistent-ptc-sandbox.md +/agent-note-retained-ptc-processes-follow-file-policy: + en: ef07a82c85e490e1 + zh: b1662d1970d2b83b +/agent-note-retained-ptc-processes-follow-file-policy/problem: + en: b8cb574032d4e6e4 + zh: 6a1a25dec5f96086 +/agent-note-retained-ptc-processes-follow-file-policy/decision: + en: 5109a4bb5be39867 + zh: 2dcc602aba9eae61 +/agent-note-retained-ptc-processes-follow-file-policy/alternatives-considered: + en: 10bebc7f982cf7f4 + zh: 9247a2ba57921a11 +/agent-note-retained-ptc-processes-follow-file-policy/consequences: + en: b3ddf273158d3754 + zh: 916356f4074b3622 diff --git a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md new file mode 100644 index 0000000000..2c4235fcc3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md @@ -0,0 +1,31 @@ +# Agent Note: Retained PTC processes follow file policy + +Status: implemented + +English | [中文](2026-10-08-persistent-ptc-sandbox.zh.md) + +## Problem + +Retaining JavaScript variables does not require broader file access. A process that outlives one execution can also outlive a one-time approval or a Session policy change. File-policy changes must invalidate retained environments and begin stopping their file handles and background work. + +## Decision + +`persistent-in-memory` starts its Node process through the same sandbox provider as `per-call`. A retained owner uses one resolved mode, workspace root and Session identity until release. The [memory decision](2026-09-23-best-effort-persistent-ptc.md) owns retained values, failure recovery and package composition; this note owns file authority. + +The [PTC-owned cleanup decision](2026-10-09-ptc-owned-policy-cleanup.md) supersedes this note’s permission-setter completion and cleanup-ownership decision. Permission changes commit synchronously; PTC owns immediate cancellation and waits internally before subsequent execution. The tools package keeps Agent ownership and omits the owner for temporary escalations. + +An approved one-time escalation omits the state owner and receives a fresh temporary environment. Its process is released before the call returns. It cannot access standing variables, and its declarations do not survive. The standing environment keeps its original policy and variables. Nested tools continue to apply their own permissions and approvals. + +## Alternatives considered + +**Unconfined retained execution.** Omitting confinement simplifies a temporary prototype but couples variable retention to full host file access. The existing process sandbox supports long-lived processes, so this grant is unnecessary. + +**Changing the running process policy.** Existing backends apply restrictions at launch. Arbitrary retained objects, open handles and background work cannot be transferred into another policy with the existing interface. Releasing the environment prevents later evaluations from reusing those resources. + +**Coordinating through permission presets.** File-policy changes also have direct callers. PTC observes committed Session mode events from both paths, independently of user-facing preset selection. + +**Keeping an elevated environment.** A one-time approval cannot authorize future programs or idle background work. Omitting the state owner makes the approval lifetime explicit while preserving the standing environment. + +## Consequences + +Normal calls retain variables without changing file permissions. Changing sandbox mode discards variables. Escalated programs must be self-contained. Runtime consumers must release a retained owner before supplying a different policy without a Session mode change; the provider rejects inconsistent reuse. The sandbox inherits the existing backends’ file-effect coverage and platform limitations. diff --git a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.zh.md b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.zh.md new file mode 100644 index 0000000000..d841702941 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 保留状态的 PTC 进程遵循文件策略 + +Status: implemented + +[English](2026-10-08-persistent-ptc-sandbox.md) | 中文 + +## 问题 + +保留 JavaScript 变量不需要更大的文件访问权限。跨调用存活的进程也可能在单次审批结束或 Session 策略变化后继续运行。文件策略变化必须使保留环境失效,并开始停止其文件句柄和后台工作。 + +## 决策 + +`persistent-in-memory` 与 `per-call` 通过同一个沙箱提供方启动 Node 进程。保留状态的所有者在释放前使用固定的已解析权限模式、工作区根目录和 Session 身份。[内存决策](2026-09-23-best-effort-persistent-ptc.zh.md)负责保留值、失败恢复和包组成;本文负责文件权限。 + +[PTC 自行等待清理的决策](2026-10-09-ptc-owned-policy-cleanup.zh.md)替代本文关于权限设置完成时点和清理归属的决策。权限变化同步提交;PTC 负责立即取消,并在后续执行前内部等待清理。工具包保留 Agent 所有权,在临时提权时省略所有者。 + +单次获准的提权省略状态所有者,使用全新的临时环境。该次调用返回前释放进程,无法访问原环境的变量,也不会保留新声明。原环境保持原权限和变量。嵌套工具继续应用各自的权限和审批规则。 + +## 考虑过的替代方案 + +**不限制保留状态的执行环境。** 省略沙箱能简化临时原型,却把变量保留与完整宿主文件访问权限绑定。现有进程沙箱支持长期运行的进程,因此这项授权没有必要。 + +**修改运行中进程的策略。** 现有后端在启动时应用限制。现有接口无法把任意保留对象、打开的句柄和后台工作转移到另一策略。释放环境可以防止后续执行复用这些资源。 + +**通过权限预设协调清理。** 文件策略也有直接调用方。PTC 观察两类调用方提交的 Session 模式事件,无需依赖面向用户的预设选择。 + +**保留提权环境。** 单次审批不能授权未来程序或空闲期间的后台工作。省略状态所有者明确了审批生效的范围,同时保留原环境。 + +## 影响 + +普通调用保留变量,同时维持原文件权限。切换沙箱模式会丢弃变量。提权程序必须包含执行所需的完整代码。未发生 Session 模式变化时,运行时消费者在传入不同策略前必须释放保留状态的所有者;提供方拒绝策略不一致的复用。沙箱继承现有后端的文件约束范围与平台限制。 diff --git a/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.i18n.yaml new file mode 100644 index 0000000000..ac38c709dc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-09-message-node-factories-and-delegates.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-09-message-node-factories-and-delegates.md +/agent-note-domain-image-slots-and-a-shared-gallery-factory: + en: bce6358fb4211261 + zh: bfebd3bbdb1affb1 +/agent-note-domain-image-slots-and-a-shared-gallery-factory/problem: + en: ec6ffd7f737a1f56 + zh: 5e368f87ce52e28c +/agent-note-domain-image-slots-and-a-shared-gallery-factory/decision: + en: b807777fd48560eb + zh: e7b1625bd4448ce1 +/agent-note-domain-image-slots-and-a-shared-gallery-factory/alternatives-considered: + en: db14fd6d0e2ce78b + zh: c29a19cde692a1dd +/agent-note-domain-image-slots-and-a-shared-gallery-factory/verification: + en: d82e036edb981e7c + zh: 5a05e9a2e6489d21 +/agent-note-domain-image-slots-and-a-shared-gallery-factory/consequences: + en: ac387ffb5a316920 + zh: ab584aaafe554278 diff --git a/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.md b/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.md new file mode 100644 index 0000000000..054dbe24ac --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.md @@ -0,0 +1,54 @@ +# Agent Note: Domain image Slots and a shared gallery Factory + +Status: implemented + +English | [中文](2026-10-09-message-node-factories-and-delegates.zh.md) + +## Problem + +User messages, assistant output, Trajectory records, and tool results need independently replaceable image presentation. Their official entries can share one gallery, but sharing that implementation must not merge their replacement positions or hide the caller's image-reading authority. + +## Decision + +Each domain declares its own image Slot and supplies the framework's `renderSlot` seat to the component that renders it. The official attachment entries use one reusable Content Factory, following the [Body Slot and Content Factory division](2026-10-09-reasoning-content-slot-factory.md). + +| Entry | Declaration owner and renderer | +| --- | --- | +| `conversation.chat.user.images` Slot | The `conversation.chat.user.content` Factory declares it; `UserContent` renders it for user messages, pending steering, and local echoes. | +| `conversation.chat.message.images` Slot | The Assistant node registration declares it; `AssistantMarkdown` renders consecutive output-image groups. | +| `conversation.trajectory.images` Slot | The Trajectory view registration declares it; record attachments and tool-output blocks render it through the ledger's Slot seat. | +| `tool.call.images` Slot | The `read_image` tool-view registration declares it; `ToolRow` renders the result image. | +| `conversation.images.content` Factory | The attachment plugin registers this root-scoped gallery, implemented by `MessageImagesContent`. | + +The attachment plugin fills all four image Slots with the same `MessageImages` React adapter at priority 100. A smaller priority replaces only the selected domain's entry; disposing that replacement restores the official entry. Each adapter invokes `conversation.images.content` with explicit image references, `loadImage`, and layout parameters. The shared Factory neither declares the domain Slots nor selects a domain from a scene parameter. + +The image callers use their declared Slot directly; no image-entry Factory exists solely to forward one Slot call. User text remains a separate shared `conversation.user.text` Factory with its own keyed node Slot, as specified by the [user-text decision](2026-10-10-user-text-json-nodes.md). + +`AssistantNodeView` passes its Slot seat and `loadImage` to `AssistantMarkdown`. User wrappers pass the loader through `UserStyleBubble` to `UserContent`. Trajectory passes its Slot seat and loader through the ledger. These paths preserve component responsibilities, image order, and keys without custom ReactNode-producing image callbacks. + +Conversation owns read authorization, per-Session URL caching, and cleanup. The shared gallery never searches for a global current Session: persistent reads use the loader supplied by the caller, while submission-echo previews retain their existing browser URLs. Gallery layout, retries, thumbnails, and lightbox behavior remain in the attachment package. + +`ChatView` keeps the existing `MarkdownDelegateProvider` with its wrapped file opener, external-link action, and file-image capabilities. Native-image composition does not replace Markdown image handling or its failure feedback. + +`MessageImagesContentInput` accepts optional complete `MessageImageLabels`. Supplied labels are used unchanged; when omitted, the Content Factory's locale seat supplies the default copy. + +## Alternatives considered + +**One shared image Slot for every caller.** A replacement intended for one domain would also affect the others; shared implementation does not require shared replacement identity. + +**Import the gallery component across feature packages.** This bypasses standard Slot replacement and the reusable Factory entry point. + +**Pass custom image-rendering functions or supply the loader through a private Context.** Framework Slot seats already express rendering authority, while explicit loader props preserve the caller's Session authority. + +## Verification + +- User and output images use separate Slots; replacing either leaves the other and its mounted gallery untouched. +- Each official image entry has priority 100; a priority-0 replacement wins and disposal restores the official gallery. +- All four defaults call the same Content Factory with their original image data, authorized loader, and layout choices. +- Source order, keys, cached previews, failed-load retries, and lightbox interactions retain their existing behavior. +- The Content Factory accepts complete custom labels and resolves default labels when the caller omits them. +- Unloading the attachment implementation removes its entries without removing Slots declared by the owning domains. + +## Consequences + +Independent Slots allow domain-specific presentation without copying the gallery. Domain callers must forward the framework Slot seat to their plain React descendants and must supply an authorized loader at each image call. A Factory and an ordinary Slot have different lifetimes and ownership; a reusable implementation does not own every position where it appears. diff --git a/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.zh.md b/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.zh.md new file mode 100644 index 0000000000..03869f2dbb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.zh.md @@ -0,0 +1,54 @@ +# Agent Note: 领域图片 Slot 与共享画廊 Factory + +Status: implemented + +[English](2026-10-09-message-node-factories-and-delegates.md) | 中文 + +## 问题 + +用户消息、助手输出、Trajectory 记录和工具结果需要可独立替换的图片展示。官方条目可以共享同一画廊,但共享实现不能合并各自的替换位置,也不能隐藏调用方的图片读取授权。 + +## 决策 + +各领域声明自己的图片 Slot,并把框架 `renderSlot` 席位交给实际渲染它的组件。官方附件条目复用一个 Content Factory,沿用 [Body Slot 与 Content Factory 的分工](2026-10-09-reasoning-content-slot-factory.zh.md)。 + +| 入口 | 声明所有者与渲染组件 | +| --- | --- | +| `conversation.chat.user.images` Slot | `conversation.chat.user.content` Factory 声明,由 `UserContent` 为用户消息、待处理 steering 和本地回显渲染。 | +| `conversation.chat.message.images` Slot | Assistant 节点注册声明,由 `AssistantMarkdown` 渲染连续的输出图片组。 | +| `conversation.trajectory.images` Slot | Trajectory 视图注册声明,记录附件及工具输出块通过账本的 Slot 席位渲染。 | +| `tool.call.images` Slot | `read_image` 工具视图注册声明,由 `ToolRow` 渲染结果图片。 | +| `conversation.images.content` Factory | 附件插件注册此 root 作用域画廊,由 `MessageImagesContent` 实现。 | + +附件插件以优先级 100 为四个图片 Slot 填入同一个 `MessageImages` React 适配组件。较小的优先级只替换选定领域的条目;替换条目释放后恢复官方条目。适配组件显式向 `conversation.images.content` 传入图片引用、`loadImage` 和布局参数。共享 Factory 不声明各领域的 Slot,也不通过场景参数选择领域。 + +图片调用方直接使用其声明的 Slot,不设置仅转发一次 Slot 调用的图片入口 Factory。用户文本仍使用独立共享的 `conversation.user.text` Factory 及其 keyed 节点 Slot,详见[用户文本决策](2026-10-10-user-text-json-nodes.zh.md)。 + +`AssistantNodeView` 将 Slot 席位与 `loadImage` 传给 `AssistantMarkdown`。用户包装层通过 `UserStyleBubble` 将 loader 传给 `UserContent`。Trajectory 通过账本传递自己的 Slot 席位与 loader。这些路径保持组件职责、图片顺序和 key,不使用自制的 ReactNode 图片渲染回调。 + +Conversation 拥有读取授权、逐 Session URL 缓存与回收。共享画廊不查找全局当前 Session:持久图片使用调用方提供的 loader 读取,本地提交回显预览保留已有浏览器 URL。画廊布局、重试、缩略图和灯箱行为仍由附件包负责。 + +`ChatView` 保留既有 `MarkdownDelegateProvider` 及其包装后的文件打开动作、外链动作和文件图片能力。原生图片组合不替代 Markdown 图片处理及失败反馈。 + +`MessageImagesContentInput` 接受可选的完整 `MessageImageLabels`。提供 labels 时原样使用,省略时由 Content Factory 的 locale 席位提供默认文案。 + +## 考虑过的替代方案 + +**所有调用方共用一个图片 Slot。** 面向某个领域的替换也会影响其他领域;共享实现不要求共享替换身份。 + +**跨功能包导入画廊组件。** 绕过标准 Slot 替换机制和可复用的 Factory 入口。 + +**传递自制图片渲染函数,或通过私有 Context 供应 loader。** 框架 Slot 席位已经表达渲染权限,显式 loader props 则保留调用方的 Session 授权。 + +## 验证 + +- 用户与输出图片使用独立 Slot;替换任意一方时,另一方及其已挂载画廊不受影响。 +- 各官方图片条目优先级为 100;优先级 0 的替换条目胜出,释放后恢复官方画廊。 +- 四个默认条目携带原有图片数据、授权 loader 和布局选项调用同一个 Content Factory。 +- 来源顺序、key、缓存预览、读取失败重试及灯箱交互保持既有行为。 +- Content Factory 接受完整自定义 labels,调用方省略时解析默认 labels。 +- 卸载附件实现会移除其条目,但不移除各领域声明的 Slot。 + +## 影响 + +独立 Slot 支持各领域定制展示而无需复制画廊。领域调用方需要将框架 Slot 席位传给普通 React 后代组件,并在每次图片调用中提供授权 loader。Factory 与普通 Slot 的生命周期和所有权不同;可复用实现不拥有它出现的所有位置。 diff --git a/.agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.i18n.yaml new file mode 100644 index 0000000000..da9d7f6c81 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-09-ptc-owned-policy-cleanup.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-09-ptc-owned-policy-cleanup.md +/agent-note-ptc-owns-policy-change-cleanup-waits: + en: dfe28bee0ab79ff6 + zh: dd68c51b4cbfbc3f +/agent-note-ptc-owns-policy-change-cleanup-waits/problem: + en: 5620ff525d762114 + zh: ddef3a3624cab79b +/agent-note-ptc-owns-policy-change-cleanup-waits/decision: + en: 4389c330d95f1588 + zh: 97e4b9cf8b69f48e +/agent-note-ptc-owns-policy-change-cleanup-waits/alternatives-considered: + en: a51d7f5c5b8fd7dc + zh: 4480107a4d8cff70 +/agent-note-ptc-owns-policy-change-cleanup-waits/consequences: + en: f83009b6406bafa1 + zh: c774eb1895bf37e1 diff --git a/.agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.md b/.agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.md new file mode 100644 index 0000000000..470568cc3f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.md @@ -0,0 +1,27 @@ +# Agent Note: PTC owns policy-change cleanup waits + +Status: implemented + +English | [中文](2026-10-09-ptc-owned-policy-cleanup.zh.md) + +## Problem + +Stopping a retained Node process and its nested tools can take longer than recording a new file policy. Making permission setters await that cleanup spreads PTC lifecycle requirements into unrelated callers, including Webhook Session creation, and adds cancellation windows to those callers. + +## Decision + +Permission setters commit synchronously. Success means the selected policy is recorded; it does not guarantee that operations admitted under the old policy have completely stopped. This replaces the completion requirement and cleanup ownership in the [retained-process sandbox decision](2026-10-08-persistent-ptc-sandbox.md), whose confinement and one-time escalation rules still apply. + +PTC observes committed Session mode changes. The tool bridge immediately cancels affected complete calls, including pending approvals and nested tools. A later PTC call waits for canceled predecessors to settle before resolving the latest standing policy. The Node provider immediately invalidates affected processes, including idle environments, starts cleanup, and retains the cleanup Promise internally. A later program waits for that cleanup; failed cleanup remains retryable and prevents process reuse. Unchanged modes preserve environments. Tool-registry and provider unload remove their observers and await owned work. + +## Alternatives considered + +**Wait in permission setters.** This provides a definite completion point for old-authority work, but no current permission consumer requires that guarantee. It introduces a general resource registry and asynchronous API migration for PTC-specific cleanup. The completion guarantee belongs to PTC execution and disposal instead. + +**Clean up only on the next PTC call.** An idle environment can retain background work indefinitely when no further call occurs. Policy changes begin cancellation immediately; only waiting is deferred. + +**Reconfigure an existing process.** The sandbox applies restrictions at launch. Open handles, retained objects and background work require releasing the old environment before another policy can execute there. + +## Consequences + +Permission changes return without process-cleanup latency. Old operations may remain active while cancellation finishes, and cleanup failure does not roll back the recorded policy. Subsequent PTC execution waits for cleanup and uses the latest policy. Webhook creation keeps its synchronous permission step, so that step adds no cancellation window before prompt admission. Regression coverage holds cleanup with explicit barriers, changes policy again while a call waits, checks idle cancellation without another call, and verifies cleanup retry and owner unload. diff --git a/.agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.zh.md b/.agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.zh.md new file mode 100644 index 0000000000..9d852a0eb6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-ptc-owned-policy-cleanup.zh.md @@ -0,0 +1,27 @@ +# Agent Note: PTC 自行等待策略变化后的清理 + +Status: implemented + +[English](2026-10-09-ptc-owned-policy-cleanup.md) | 中文 + +## 问题 + +停止保留的 Node 进程及其嵌套工具,可能比记录新文件策略耗时更长。让权限设置等待这些清理,会把 PTC 生命周期要求传给 Webhook Session 创建等无关调用方,并为这些调用方增加取消窗口。 + +## 决策 + +权限设置同步提交。成功表示所选策略已经记录,不保证按旧策略接纳的操作已完全停止。这替代[保留进程沙箱决策](2026-10-08-persistent-ptc-sandbox.zh.md)中的完成要求和清理归属;其中的沙箱限制与单次提权规则继续适用。 + +PTC 观察已提交的 Session 模式变化。工具桥接立即取消受影响的完整调用,包括等待中的审批和嵌套工具。后续 PTC 调用先等待已取消的调用结束,再解析最新常设策略。Node 提供方立即使受影响的进程失效,包括空闲环境,开始清理并在内部保留清理 Promise。后续程序等待清理完成;清理失败可重试,且阻止复用进程。模式不变时保留环境。工具注册表和提供方卸载时移除各自监听器,并等待所拥有的工作结束。 + +## 考虑过的替代方案 + +**在权限设置中等待。** 这可以明确旧权限下的工作完成时点,但当前权限调用方不需要该保证。为此增加通用资源登记和异步 API 迁移,会扩大 PTC 清理的影响范围。完成保证由 PTC 执行和销毁负责。 + +**等下次 PTC 调用再清理。** 没有后续调用时,空闲环境的后台工作可能一直存在。策略变化立即开始取消,只有等待被推迟。 + +**重新配置已有进程。** 沙箱在启动时应用限制。打开的句柄、保留对象和后台工作要求先释放旧环境,才能在其中按另一策略执行。 + +## 影响 + +权限变化返回时无需等待进程清理。取消完成前,旧操作可能仍在运行;清理失败不会回滚已记录的策略。后续 PTC 执行等待清理并使用最新策略。Webhook 创建保留同步权限步骤,该步骤不会在提交任务前增加取消窗口。回归测试通过显式信号阻塞清理,覆盖等待期间再次改变策略、没有后续调用时取消空闲环境、清理重试及所有者卸载。 diff --git a/.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.i18n.yaml new file mode 100644 index 0000000000..544d0c2c04 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-09-stored-credential-precedence.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-09-stored-credential-precedence.md +/agent-note-stored-credentials-take-precedence-over-the-launch-environment: + en: 5253af6605ba0fac + zh: 160ac751c664a502 +/agent-note-stored-credentials-take-precedence-over-the-launch-environment/problem: + en: 878c68dcfe5b3771 + zh: 8a24d16118155245 +/agent-note-stored-credentials-take-precedence-over-the-launch-environment/decision: + en: 7cd89bda9f793431 + zh: 93d26a948eea63f3 +/agent-note-stored-credentials-take-precedence-over-the-launch-environment/per-run-overrides: + en: 7479ffc82280a1fb + zh: ce3d42489191ab2b +/agent-note-stored-credentials-take-precedence-over-the-launch-environment/alternatives-considered: + en: 268add7fd8415280 + zh: 0765cbfd3fc7b57c +/agent-note-stored-credentials-take-precedence-over-the-launch-environment/consequences: + en: 2d71fd527b3060c4 + zh: fafed884315b9782 diff --git a/.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.md b/.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.md new file mode 100644 index 0000000000..75997d9db0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.md @@ -0,0 +1,45 @@ +# Agent Note: Stored credentials take precedence over the launch environment + +Status: implemented + +English | [中文](2026-10-09-stored-credential-precedence.zh.md) + +The [headless environment authentication](2026-10-12-headless-environment-auth.md) decision partially supersedes this note's per-run override policy: the shipped headless profile omits the store. The ordering below remains the store's contract when mounted. + +## Problem + +[Configuration source ownership](2026-08-04-configuration-source-ownership.md) ranked the inherited process environment above `$DSH_HOME/.credentials.yaml`, and `credentials-local` refused `set` and `unset` for a name the launching shell exported. Two kinds of stored credential then had opposite precedence. pi-ai's resolvers read the credential record stored under `llm-pi-ai/` before any environment variable, so a stored OAuth grant or `api-key` record won over an exported key. A key that the Models page stored under a reference lost to the same exported name. Which credential a request used therefore depended on the provider family, and the Models page disabled its key field whenever the name was exported (#5527). + +## Decision + +**A stored credential wins for every provider.** `LocalCredentialProvider` resolves a reference in this order: + +```text +$DSH_HOME/.credentials.yaml (provider-managed, written by set/unset) +> inherited process environment (fallback; the provider never writes it) +> /.env (fallback; the provider never writes it) +> $DSH_HOME/.env (fallback; the provider never writes it) +``` + +A save always writes the winning layer, so `describe` reports `writable: true` for every reference, and `set` and `unset` never refuse a name because it is exported. `resolve` and `describe` still report the supplying layer as `source` (`file`, `env`, `project-env`, or `user-env`). `authContextFrom` in `llm-pi-ai` answers pi-ai's `env()` from the credential seam before the launch environment, so a pi-ai resolver reads its stored record first, then a stored reference, then the fallbacks in the order above. + +The non-secret ordering and the environment restrictions in configuration source ownership are unchanged. This note supersedes only its credential ordering. + +## Per-run overrides + +The previous order existed so that CI, containers, and a one-shot `DEEPSEEK_API_KEY=… dsh` could override a credential without editing machine state. A fresh CI or container harness home has no `.credentials.yaml`, so its exported secret is still the effective value. A harness home holds a stored key only because someone saved it there, through the Models page or by editing the file. To use an exported value for that name, the user deletes the `refs` entry, or launches with a separate `DSH_HOME`. + +## Alternatives considered + +**Keep the launch environment on top.** It keeps per-run overrides without touching the store, but it keeps the opposite precedence of stored records and stored references, and the Models page cannot save or replace a key while the launching shell exports the name. + +**Refuse a write while only the launch environment supplies the name.** Under the new order such a write takes effect, because the stored value outranks the environment. Refusing it would block the first save of a key for every exported name, and it would keep the read-only field state that the new order removes. + +**Warn when a stored value hides an exported variable.** The provider would compare layers on every resolution and report the result for users who keep both values on purpose, such as a shell profile that exports a key for other tools. The Models page help text states the order, and `describe` reports the supplying layer. + +## Consequences + +- A key left in `.credentials.yaml` overrides an exported variable with the same name, including a CI secret on a machine whose harness home stores a value for that name. The [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.md) tells affected users how to keep the exported account. +- The Models page can always save, replace, or remove a key, and it does not disable the key field for an exported name. +- There is no per-run flag above the store. A one-run override takes a separate `DSH_HOME`. +- `packages/credentials/credentials-local/tests/local.spec.ts` covers a store over a launch environment value and its return after `unset`; `packages/llm/llm-pi-ai/tests/auth.spec.ts` covers a stored credential that wins over every launch layer. diff --git a/.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.zh.md b/.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.zh.md new file mode 100644 index 0000000000..3846944c8d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 存储的凭据优先于启动环境 + +Status: implemented + +[English](2026-10-09-stored-credential-precedence.md) | 中文 + +[Headless 环境认证](2026-10-12-headless-environment-auth.zh.md)决策部分取代本文的单次覆盖策略:随附 headless profile 不挂载存储。存储挂载时仍遵循下述顺序。 + +## 问题 + +[配置来源所有权](2026-08-04-configuration-source-ownership.zh.md) 把继承的进程环境排在 `$DSH_HOME/.credentials.yaml` 之前,并且 `credentials-local` 会拒绝对启动 shell 已导出的名字执行 `set` 和 `unset`。于是两类存储的凭据具有相反的优先级。pi-ai 的解析器先读取存储在 `llm-pi-ai/` 下的凭据记录,再读取任何环境变量,因此存储的 OAuth 授权或 `api-key` 记录会压过导出的密钥;而 Models 页面以引用形式存储的密钥会输给同名的导出变量。一次请求使用哪个凭据因此取决于提供方类别,而且只要该名字被导出,Models 页面就会禁用其密钥输入框(#5527)。 + +## 决策 + +**存储的凭据对每个提供方都优先。** `LocalCredentialProvider` 按以下顺序解析引用: + +```text +$DSH_HOME/.credentials.yaml (provider-managed, written by set/unset) +> inherited process environment (fallback; the provider never writes it) +> /.env (fallback; the provider never writes it) +> $DSH_HOME/.env (fallback; the provider never writes it) +``` + +保存总是写入胜出的那一层,因此 `describe` 对每个引用都报告 `writable: true`,`set` 与 `unset` 也不会因为名字已被导出而拒绝。`resolve` 与 `describe` 仍以 `source`(`file`、`env`、`project-env` 或 `user-env`)报告提供值的层。`llm-pi-ai` 中的 `authContextFrom` 先从凭据 seam、再从启动环境回答 pi-ai 的 `env()`,因此 pi-ai 解析器依次读取其存储的记录、存储的引用,再按上面的顺序读取后备来源。 + +配置来源所有权中的非秘密顺序与环境限制保持不变。本 Note 只替代其中的凭据优先级。 + +## 按次覆盖 + +之前的顺序让 CI、容器和一次性的 `DEEPSEEK_API_KEY=… dsh` 无需修改机器状态就能覆盖凭据。全新的 CI 或容器 harness home 中没有 `.credentials.yaml`,因此其导出的机密仍是生效值。harness home 中存有密钥,只可能是有人通过 Models 页面或直接编辑该文件存入的。要对该名字使用导出的值,用户应删除对应的 `refs` 条目,或使用单独的 `DSH_HOME` 启动。 + +## 考虑过的替代方案 + +**保持启动环境在最上层。** 它无需改动存储就能保留按次覆盖,但会保留存储记录与存储引用之间相反的优先级,而且只要启动 shell 导出了该名字,Models 页面就无法保存或替换密钥。 + +**只有启动环境提供该名字时拒绝写入。** 在新顺序下这样的写入会生效,因为存储值优先于环境。拒绝它会阻止为每个已导出的名字首次保存密钥,也会保留新顺序已经去掉的只读输入框状态。 + +**存储值遮蔽导出变量时发出警告。** 提供方需要在每次解析时比较各层,并向有意同时保留两个值的用户报告结果,例如在 shell 配置文件中为其他工具导出密钥的用户。Models 页面的帮助文本说明了该顺序,`describe` 也会报告提供值的层。 + +## 后果 + +- 留在 `.credentials.yaml` 中的密钥会覆盖同名的导出变量,包括在 harness home 存有该名字的机器上运行的 CI 机密。[升级指南](../../../../docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.zh.md)说明受影响的用户如何继续使用导出的账户。 +- Models 页面总能保存、替换或删除密钥,也不会因名字被导出而禁用密钥输入框。 +- 存储之上没有按次生效的标志。一次运行的覆盖需要使用单独的 `DSH_HOME`。 +- `packages/credentials/credentials-local/tests/local.spec.ts` 覆盖在启动环境值之上存储、以及 `unset` 后恢复该值;`packages/llm/llm-pi-ai/tests/auth.spec.ts` 覆盖存储的凭据压过每个启动层。 diff --git a/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.i18n.yaml new file mode 100644 index 0000000000..41d9dfe377 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.i18n.yaml @@ -0,0 +1,40 @@ +# Bilingual-pair consistency record for 2026-10-10-user-text-json-nodes.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-10-user-text-json-nodes.md +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh: + en: 6185ac67a459beba + zh: 994bf90f05c0fd5a +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/problem: + en: 42e3752efebedde5 + zh: 75e23c5762efbb1f +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision: + en: 71f5b33f58cd2066 + zh: 8365506795c0b158 +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision/rule-roles-and-original-coordinates: + en: ed0fa9306f0605cb + zh: 7240e135f6d1b46b +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision/one-reconciliation-loop: + en: acaeb2dccfac3f07 + zh: 2170ec2b858627fc +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision/query-cache-and-traversal: + en: 55dee64de32bef9a + zh: 69eb625451f55268 +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision/actual-facts-dependencies: + en: 11bc718324d22c03 + zh: 371576c1b2a90d62 +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision/facts-text-and-registration-refresh: + en: 47bc3f32b9aa22de + zh: 59ffd06c800c00af +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/decision/output-ownership-and-publication: + en: e076cb57a1fdcfc1 + zh: 988c02c6cf751f16 +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/verification-requirements: + en: 78127c1f18eacf8a + zh: fcc2ad5db1279356 +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/alternatives-considered: + en: 439e5ecff6701938 + zh: 9861805f2a19439c +/agent-note-user-text-candidate-scheduling-and-facts-driven-refresh/consequences: + en: 13bd26532d0353fa + zh: d6a4997b51af09d5 diff --git a/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.md b/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.md new file mode 100644 index 0000000000..3b01c313f0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.md @@ -0,0 +1,91 @@ +# Agent Note: User-text candidate scheduling and Facts-driven refresh + +Status: implemented + +English | [中文](<2026-10-10-user-text-json-nodes.zh.md>) + +## Problem + +User text contains references owned by independent businesses. Recognition must follow original-text positions, resolve collisions deterministically, and reuse unaffected matching work when Facts change. The evaluator must expose its scheduling behavior to pure logic tests without depending on React, browser timing, or elapsed-time benchmarks. + +## Decision + +The composition is business registration, lazy candidate matching, a single reconciliation loop, readonly JSON nodes, and business-owned keyed renderers. Initialization and Facts refresh use the same algorithm; they differ only in which query caches remain valid. + +### Rule roles and original coordinates + +Every rule provides an ID, finite order, and synchronous `match(text, from, context)`. Each call returns its nearest eligible node with `start >= from`, or no candidate anywhere in the remaining original text. It returns one node, not all matches or a replacement document. The original string and UTF-16 offsets remain intact: a search cursor does not create a new word, line, or quoted-token boundary. Matching depends only on original text and Facts, not previously selected node payloads. + +Each matcher resolves its own alternative spellings by earliest start and then its documented same-start choice; Session labels choose the longer match. A matcher cannot replace a consumed prefix with the remainder of an overlapping candidate. Each business owns its grammar, payload, and renderer; the generic evaluator has no Session, file, skill, or command branches. + +There is one rule type and one candidate protocol. A candidate contains either a business node using the rule ID or a Text node preserving the original substring with an optional `data.display`. Text-producing rules compete by the same start, order, and stable ID as business rules. Order only breaks same-start ties; no rule gets precedence over an earlier position. + +### One reconciliation loop + +The evaluator keeps original text, the previous successful segmentation, registered definitions, per-rule Facts reads, and query results. Nonempty text initially has one Text covering `[0, text.length)`; empty text has no nodes. Initialization makes every rule invalid, including rules that read no Facts. + +Each reconciliation starts its result cursor at zero. Every matcher obtains one candidate through the same cache-aware query operation. The evaluator selects the smallest candidate start, breaks same-start ties by ascending rule order and stable ID, emits the preceding raw Text gap, emits the winner, and advances the cursor to the winner's end. No candidate means the remaining raw text is emitted and recognition ends. + +Only a consumed candidate or a candidate whose start is before the new cursor needs another query. A cached candidate beginning at or after the cursor remains eligible. A no-candidate result remains eligible as the cursor advances. All ranges are nonempty half-open original-text ranges; candidates cannot move the cursor backward or exceed the original string. + +Previously accepted nodes are reusable output, not a complete list of possible matches: a losing candidate can become visible when an earlier winner disappears. The evaluator retains queries for this purpose instead of treating an old Text node or the absence of a business node as unconditional evidence of no match. + +### Query cache and traversal + +A query record contains its original `from` offset and either the next candidate, no candidate, or a failure. If a query from `q` returned a candidate starting at `s`, its result is valid for any requested cursor `c` with `q <= c <= s`. A no-candidate result is valid for every `c >= q`. Neither result proves anything before `q`. A failed query is reusable only at the same requested offset while its dependencies remain valid. + +During a reconciliation, each matcher has one active candidate. The evaluator also retains the sorted query records already produced for that matcher across reconciliations; it does not enumerate all possible matches eagerly. Forward cursors walk these records monotonically. Queries generated in newly exposed gaps are merged with retained records in source order, with one record per query offset. Unsearched intervals require matching rather than an invented negative result. + +The result cursor and matcher search cursors are distinct. Starting result reconciliation at zero does not require unchanged matchers to scan from zero again: their recorded queries answer valid requests. Each matcher must implement a forward search rather than repeatedly materializing every match in the remaining text. Internal scans, candidate selection, and result comparison still have costs; the scheduling guarantees concern actual matcher calls, not a claim that arbitrary regular expressions are linear. + +### Actual Facts dependencies + +`context.read(key)` returns the borrowed readonly value from the current Facts snapshot and records the read, including `undefined`. The reader is valid only during its owning synchronous call and cannot be retained. Businesses receive neither a whole Facts object, a manual `dependsOn` list, nor previous-output/cache objects. + +A matcher's dependency set is the union of reads for the queries retained in its current cache generation, including losing candidates, no-match queries, and failed calls. Changed fields are detected with `Object.is` at field granularity. A new outer Facts object alone does not invalidate matching. When a rule is invalidated, its old queries and read set are discarded; subsequent calls establish the replacement read set, so obsolete conditional dependencies do not remain permanently attached. + +Text-producing rules use the same query cache and dependency generation as every other rule. Updating a whitespace switch invalidates only its readers; it does not trigger an unconditional pass over the completed nodes. + +### Facts, text, and registration refresh + +A Facts update invalidates only rules that read changed fields. Their first query starts at original offset zero; all other rules retain their query records. The same reconciliation loop then compares fresh candidates with reusable ones. Refreshing a rule does not give it priority, and a new first candidate does not hide the removal of an earlier old winner. + +A fresh candidate confined to previously plain text can split that Text without invoking unrelated matchers whose cached queries still cover the new cursors. A fresh candidate that loses to an earlier unchanged winner is discarded without invalidating that winner. When a fresh winner covers another candidate, only expired heads advance. When a winner disappears or shrinks, cached losing candidates can compete again; any previously unsearched interval causes a query at the newly exposed cursor. All changes use the same cursor loop and query-coverage rules. + +Several changed Facts fields form one snapshot and one invalidation set. All affected matchers participate in the same reconciliation; one update is not committed before processing the next. Equivalent refreshed results reuse previous nodes and, when all elements remain identical, the entire result array. Reconciliation settles the complete document before publishing its result. + +Original-text changes discard all query caches and supply one full-range Text as the previous segmentation. New or replaced rule definitions start with empty caches; unchanged definitions retain their independent queries. Removed rules lose their dependencies and candidates. Reconciliation after additions, removals, or order changes uses the same selection logic and can expose formerly blocked candidates. + +### Output, ownership, and publication + +Nodes retain `{ id, start, end, data }`. Business nodes use their matcher's ID; Text retains the exact source substring in `data.text` and optional display text in `data.display`. Output is ordered, nonoverlapping, and completely covers the original text. JSON compatibility and readonly ownership are TypeScript obligations: the evaluator neither freezes borrowed objects nor deep-copies Facts or payloads for isolation. + +The document evaluator owns all caches; the registry retains neither evaluators nor document subscriptions. Separate text instances do not share state through a global cache. Only fully successful evaluation publishes a new result. A matcher failure carries the rule ID and cause and publishes no partial segmentation; its actual reads remain available for dependency-driven retry. Reentrant evaluation and escaped synchronous read contexts are rejected. + +`conversation.user.text` remains a session-scoped Factory owning `conversation.user.text.node`, a keyed inline Slot. React consumes final nodes and forwards `raw` and `interactive` to the business renderer. Session, skill, file/folder, and command matching remain owned by their respective plugins, and actions remain in each renderer's Injection. No matching work or cache subscription moves into React. + +`collapseWhitespace` and `trimWhitespace` each control one ordinary matcher and default to off. Collapse offers whitespace runs as Text candidates with display `" "`; trim offers original leading/trailing whitespace runs with display `""`. Trim has lower order than collapse, so it wins their same-start edge collisions. An earlier reference candidate consumes its whole range and naturally excludes internal whitespace candidates. Every Text candidate retains its exact raw substring; emoji and spaces between references remain intact. `interactive` and CSS `ellipsis` remain React Props, not matching Facts. Queue uses full original text with CSS ellipsis; native attachments remain in their existing cards and image Slots, outside text recognition. + +## Verification requirements + +Pure logic tests assert both the final nodes and each matcher's ordered calls with their `from` offsets. They use deterministic input and spies rather than clocks or performance thresholds. + +- Initialization calls every matcher, including Facts-free and no-match rules; empty input has explicit terminal-query behavior. +- Earlier starts beat stronger order values; same-start ties use order and stable ID, and each matcher preserves its own longest-match choice. +- A future candidate and a no-match answer are not requested again during forward traversal; only the winner and overlapping heads advance. +- Unread Facts changes and new outer Facts objects with unchanged field values cause no matcher calls. +- One or several changed fields rerun exactly their readers; missing fields, losing candidates, no-match results, failures, and conditional read-set replacement remain covered. +- Equivalent refreshes preserve node/array identity and do not invoke unrelated matchers; payload-only changes preserve unaffected nodes. +- Insertions in plain text, losing new candidates, same-start winner changes, extending/shrinking matches, and removal of an early winner assert the exact affected query offsets and counts. +- Removing or shrinking a winner exposes cached losers and unsearched gaps; an old tail no-match result cannot answer a query before its recorded origin. +- Batched Facts updates equal a fresh evaluation under the same snapshot and do not give the first refreshed matcher precedence. +- Text and rule-definition changes, registration/disposal, range validation, synchronous failure containment, Context lifetime, and independent evaluator state have deterministic tests. +- Real Session/file/skill/command combinations preserve full quoted references, original word boundaries, and same-start precedence; whitespace candidates use exactly the same scheduling and cache rules. + +## Alternatives considered + +**Ordered whole-document business transforms.** A business can consume later text before an earlier reference from another business is considered, so fixed type order does not express leftmost recognition. + +## Consequences + +Field-level invalidation can require a matcher to traverse the document again; it does not promise per-element Facts invalidation. Query history consumes memory proportional to the distinct recorded search positions for the current text and definitions. Reconciliation and JSON identity alignment can still traverse output without invoking matchers. The performance evidence is the exact scheduling count and query offsets, while semantic evidence compares refreshed output with a fresh evaluator. This mechanism requires no browser end-to-end or elapsed-time benchmark tests. diff --git a/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.zh.md b/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.zh.md new file mode 100644 index 0000000000..c689d56cc9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.zh.md @@ -0,0 +1,91 @@ +# Agent Note: 用户文本候选调度与 Facts 驱动刷新 + +Status: implemented + +[English](<2026-10-10-user-text-json-nodes.md>) | 中文 + +## 问题 + +用户文本包含独立业务拥有的引用。识别必须按原文位置推进,确定性地处理冲突,并在 Facts 变化时复用无关匹配工作。计算器必须能通过纯逻辑测试展示调度行为,不依赖 React、浏览器时序或耗时基准。 + +## 决策 + +采用业务注册、按需候选匹配、单一协调循环、只读 JSON 节点和业务拥有的 keyed renderer。初始化与 Facts 刷新使用同一个算法,只在查询缓存的有效性上存在差别。 + +### 规则职责与原始坐标 + +所有规则都提供 ID、有限的 order 和同步 `match(text, from, context)`。每次调用返回 `start >= from` 的最近有效节点,或确认剩余原文中没有候选。它返回一个节点,不返回全部匹配或整份替换文档。原始字符串及 UTF-16 坐标保持不变:搜索游标不创造新的词首、行首或带引号词元边界。匹配只依赖原文和 Facts,不依赖先前选中节点的 payload。 + +每个匹配器先按最早起点处理自身不同写法,再按该语法规定处理同起点选择;Session 标签选择较长匹配。匹配器不能把被消费前缀之后的半截重叠候选变成新匹配。语法、payload 和 renderer 均归对应业务所有,通用计算器不包含 Session、文件、skill 或命令分支。 + +只有一种规则类型和一个候选协议。候选既可以包含使用规则 ID 的业务节点,也可以包含保留原文子串、携带可选 `data.display` 的 Text 节点。产生 Text 的规则与业务规则按相同的起点、order 和稳定 ID 竞争。Order 仅裁定同起点冲突,任何规则都不优先于更早的位置。 + +### 单一协调循环 + +计算器保留原文、上一次成功分段、注册定义、逐规则 Facts 读取记录和查询结果。非空原文最初对应一个覆盖 `[0, text.length)` 的 Text,空原文没有节点。初始化使所有规则失效,包括不读取 Facts 的规则。 + +每次协调都从结果游标零开始,各匹配器通过同一个带缓存的查询操作获取一个候选。计算器选择起点最小的候选,同起点按规则 order 升序和稳定 ID 裁定,输出前面的原文 Text 空隙,输出赢家,再将游标推进到赢家末尾。全部没有候选时,输出剩余原文并结束识别。 + +只有被消费的候选或起点早于新游标的候选需要再次查询。缓存候选从新游标或其后开始时仍然有效;无候选结论在游标前进时保持有效。所有范围均是原文中的非空半开区间;候选不能使游标倒退或超出原文。 + +先前采纳的节点是可复用输出,不是所有可能匹配的完整清单;更早的赢家消失后,落败候选可能重新可见。计算器为此保留查询,而不能将旧 Text 节点或缺少某类业务节点无条件解释为没有匹配。 + +### 查询缓存与遍历 + +查询记录包含原始 `from` 偏移,以及下一个候选、无候选或失败。若从 `q` 查询得到起点为 `s` 的候选,则在请求游标 `c` 满足 `q <= c <= s` 时可以复用;无候选结论可用于所有 `c >= q`。两者都不能证明 `q` 之前的情况。失败查询只在请求偏移相同且依赖仍有效时复用。 + +一次协调过程中,每个匹配器只有一个活动候选。计算器同时保留该匹配器跨次协调已经产生的有序查询记录,不预先枚举所有可能匹配。前进游标单调遍历这些记录;新暴露空隙中的查询按原文顺序与旧记录合并,每个查询偏移只保留一条记录。未搜索区间必须执行匹配,不能虚构无匹配结论。 + +结果游标与匹配器搜索游标不同。结果协调从零开始,不代表无变化的匹配器需要从零重新扫描,它们的查询记录可以回答有效请求。各匹配器必须实现向前搜索,不能每次调用都重新收集剩余文本中的全部匹配。内部扫描、候选选择及结果比较仍有成本;调度保证针对实际匹配器调用,不宣称任意正则表达式都是线性的。 + +### 实际 Facts 依赖 + +`context.read(key)` 返回本次 Facts 快照中借用的只读值,并记录读取,包括 `undefined`。读取器仅在所属同步调用期间有效,不得保留。业务既不获得完整 Facts 对象,也不维护手工 `dependsOn` 列表或接收上一轮输出/缓存对象。 + +匹配器依赖集合是当前缓存代次所保留查询读取字段的并集,包括落败候选、无匹配查询和失败调用。字段变化按 `Object.is` 比较,外层 Facts 对象换引用不单独使匹配失效。规则失效时丢弃旧查询及读取集合,后续调用建立新的集合,因此已经不再读取的条件依赖不会永久残留。 + +产生 Text 的规则与其他规则共用查询缓存和依赖代次。空白开关更新只使它的读取者失效,不触发对已完成节点的无条件遍历。 + +### Facts、原文与注册刷新 + +Facts 更新只使实际读取变化字段的规则失效。它们的首次查询从原文零开始,其他规则保留查询记录;随后由同一个协调循环比较新候选和可复用候选。规则刷新不赋予额外优先权,新首个候选也不能掩盖更早旧赢家的消失。 + +新候选完全位于原有普通文字内时,可以拆分该 Text;无关匹配器的缓存仍覆盖新游标时,不调用它们。新候选输给更早的有效旧赢家时,丢弃新候选,不使旧赢家失效。新赢家覆盖其他候选时,只推进过期的候选头。赢家消失或缩短时,缓存的落败候选可以重新竞争;此前未搜索的区间在新暴露游标处触发查询。所有变化都使用同一个游标循环和查询覆盖规则。 + +多个 Facts 字段变化形成同一份快照和一个失效集合,所有受影响匹配器共同参与一次协调,不先提交某个更新再处理另一个。刷新结果等价时复用旧节点,所有元素相同则复用整个结果数组。协调在整份文档确定后发布结果。 + +原文变化会丢弃全部查询缓存,并以覆盖全文的 Text 作为上一版分段。新增或替换的规则定义从空缓存开始,无变化定义保留各自独立查询。规则移除时删除其依赖和候选。新增、移除或顺序变化后的协调仍使用相同选择逻辑,并可暴露此前被遮挡的候选。 + +### 输出、所有权与发布 + +节点保留 `{ id, start, end, data }`。业务节点使用所属匹配器 ID;Text 的 `data.text` 保留准确原文子串,`data.display` 可保存显示文字。输出有序、非重叠并完整覆盖原文。JSON 兼容性与只读所有权由 TypeScript 协议约束;计算器既不冻结借用对象,也不为隔离而深拷贝 Facts 或 payload。 + +文档计算器拥有全部缓存;注册表不持有计算器或文档订阅,不同文本实例不通过全局缓存共享状态。只有完整成功的求值才能发布新结果。匹配器失败时保留规则 ID 与 cause,不发布部分分段;实际读取仍参与依赖驱动的重试。禁止重入求值以及逃逸的同步读取 Context。 + +`conversation.user.text` 保持 session 作用域 Factory,拥有 keyed inline Slot `conversation.user.text.node`。React 消费最终节点,将 `raw` 和 `interactive` 交给业务 renderer。Session、skill、文件/目录和命令匹配仍归各插件所有,动作仍由各 renderer 的 Injection 提供,匹配与缓存订阅不进入 React。 + +`collapseWhitespace` 与 `trimWhitespace` 分别控制一个普通匹配器,缺省均关闭。Collapse 将空白串作为显示值为 `" "` 的 Text 候选;trim 将原文开头/末尾的空白串作为显示值为 `""` 的候选。Trim 的 order 小于 collapse,因此在同起点的边缘空白冲突中胜出。更早的引用候选消费自身完整范围,自然排除内部空白候选。每个 Text 候选保留准确的原文子串,emoji 及引用之间的空格保持不变。`interactive` 和 CSS `ellipsis` 仍为 React Props,不是匹配 Facts。Queue 使用完整原文和 CSS 省略,原生附件保留既有卡片及图片 Slot,不进入文本识别。 + +## 验证要求 + +纯逻辑测试同时断言最终节点,以及每个匹配器有序的调用和 `from` 偏移。使用确定性输入与 spy,不依赖时钟或性能阈值。 + +- 初始化调用所有匹配器,包括不读 Facts 和始终无匹配的规则;空输入具有明确的终止查询行为。 +- 较早起点胜过更强 order;同起点按 order 和稳定 ID 裁定,各匹配器保留自身最长匹配选择。 +- 向前遍历不重复查询未来候选及无匹配结论,只推进赢家和重叠候选头。 +- 未读取 Facts 字段变化,或字段值不变而外层 Facts 对象换引用,不调用匹配器。 +- 一个或多个字段变化只重跑实际读取者;覆盖缺失字段、落败候选、无匹配结果、失败和条件依赖集合替换。 +- 等价刷新保留节点/数组引用且不调用无关匹配器;仅 payload 变化保留无关节点。 +- 普通文字中插入、落败的新候选、同起点换赢家、匹配扩张/缩短、较早赢家移除,均断言准确的受影响查询偏移与次数。 +- 赢家移除或缩短后暴露缓存落败者及未搜索空隙;旧尾部的无匹配结论不能回答其查询起点之前的问题。 +- 批量 Facts 更新等于相同快照下的新建计算器结果,不赋予先刷新匹配器额外优先权。 +- 原文及规则定义变化、注册/注销、范围校验、同步失败隔离、Context 生命周期和计算器状态独立均有确定性测试。 +- 真实 Session/文件/skill/命令组合保留完整带引号引用、原始词边界和同起点优先关系;空白候选使用完全相同的调度和缓存规则。 + +## 备选方案 + +**按顺序处理整篇文档的业务变换。** 某业务可能在另一业务更早的引用获得考虑前消费后面的文本,因此固定类型顺序不能表达最左优先识别。 + +## 影响 + +字段级失效仍可能要求某个匹配器重新遍历文档,不承诺 Facts 字段内部逐元素失效。查询历史的内存量与当前原文、定义下记录的不同搜索位置成正比。协调及 JSON 引用对齐可能遍历输出而不调用匹配器。性能证据是准确的调度次数和查询偏移,语义证据是刷新结果与全新计算器的等价性。本机制不需要浏览器端到端测试或耗时基准测试。 diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml new file mode 100644 index 0000000000..97834be19a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for 2026-10-11-chat-group-tree-exchanges.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-11-chat-group-tree-exchanges.md +/agent-note-chat-group-tree-positions-and-exchanges: + en: b26212eda152d6c6 + zh: a801a75ee0c07911 +/agent-note-chat-group-tree-positions-and-exchanges/problem: + en: 84858bc0e1e268ab + zh: 39ebb2979968d6cc +/agent-note-chat-group-tree-positions-and-exchanges/decision: + en: ca3153ae96994baa + zh: de6c7d925559efbb +/agent-note-chat-group-tree-positions-and-exchanges/why-mark-the-opening-rather-than-the-notice: + en: db8d77cd773ad646 + zh: ab2f03b8fc58e952 +/agent-note-chat-group-tree-positions-and-exchanges/alternatives-considered: + en: a2b48738666a7d5f + zh: 53377f4f1109af26 +/agent-note-chat-group-tree-positions-and-exchanges/consequences: + en: f51678f9f30bad59 + zh: b525e9ed10b9928c +/agent-note-chat-group-tree-positions-and-exchanges/testing: + en: e2e3982718e16ef4 + zh: 4f3802946e51c55c diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md new file mode 100644 index 0000000000..c30483a404 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md @@ -0,0 +1,56 @@ +# Agent Note: Chat group-tree positions and exchanges + +Status: implemented + +English | [中文](2026-10-11-chat-group-tree-exchanges.zh.md) + +## Problem + +On next message folding kept only the last Turn of the loaded timeline expanded. A Turn woken by a notice (a settled subagent, goal round, schedule, or background job) is not submitted by this Client, yet its arrival made the preceding Turn historical and folded it. No own-input driver computed the exact remaining room for that fold, so the scroll layer reserved generic bottom space and a reader following the tail saw blank space. In the recorded Session the notice woke the next Turn 13–15 ms after the previous Turn ended; the minimal reproduction left 596 px blank that had not recovered after five seconds. + +## Decision + +A notice is neither body text nor a user message: folding and bottom advancement ignore it. The bottom changes from the last Turn to the latest exchange. + +- **Group-tree positions.** `ChatConversationViewNode.groupTree` declares a Node's position in the group tree, typed `ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process'` and ordered outermost first; a Node at a position also has the effect of every inner position. `exchange` opens an exchange. `input` makes the Node an input of its Turn: before the Turn's process it is the opening input, after it an interleaved input that keeps the whole Turn open. `turn` keeps the Node outside the Turn's process disclosure, `process` keeps it inside the disclosure but outside step runs, and a Node without a position joins the surrounding step run. `isAtOrOutside(node, position)` is the single reader of that order: process grouping reads `process`, whole-Turn folding reads `turn`, Turn-input detection reads `input`, and exchanges read `exchange`. +- **Exchanges.** A Turn holding an `exchange` Node opens an exchange, which lasts until the next opening Turn. The business message Definition marks only human input claimed in Step 1 (`user` or `steering`) as `exchange`; later human input and trigger notices stand at `input` and never open an exchange. +- **Built-in positions.** `turn-error`, `turn-max-tokens`, `turn-tail`, and the `turn-process` control stand at `turn`; `model-retry` stands at `process`. Two placement rules stay with ui-chat's own `assistant-step` Node: its reasoning row joins the step run while its reply row stands outside it, and the Turn's process specification keeps the finalized answer's reply outside the disclosure because which step answers changes as later steps arrive. +- **Mechanism.** `GroupTreeSegments(position)` splits the loaded Turns at the Turns holding a Node at that position. The latest segment starts at the last opening Turn, or at the first loaded Turn when the window holds none. It counts per Turn incrementally without scanning loaded Nodes, and a start move returns only the Turns that entered or left the latest segment. The Chat Builder's `bottomSource` is "the Node is visible and belongs to the latest exchange". + +As a result, a notice-woken Turn no longer folds earlier Turns, so no room is reserved; the human's next ordinary message folds the whole exchange through the own-input path. + +## Why mark the opening rather than the notice + +The host claims the waking input right after `turn/start`, assembles the system prompt, and only then logs `step/start` and `user/message`; the recorded Session spent 187 ms in that gap. Marking only the notice leaves the new Turn untagged during the gap, so the bottom still advances and the previous Turn folds and then reopens. Marking the human opening and letting unmarked Turns continue the exchange keeps the gap inert. + +## Alternatives considered + +**Tag the notice as a continuation.** The 187 ms gap above makes it fold and reopen, and a claim that mixes a notice with human input needs extra classification. + +**Name opening kinds in a list (`user`, `steering`).** A kind cannot separate Step 1 steering from mid-Turn steering, so a human steering into a notice-woken Turn would wrongly fold the previous exchange. + +**Publish Turn-level Location data.** Only one Context may publish a key for a Turn, while opening input comes from several message Contexts; aggregating per Turn would repeat the inbox claim classification in a new Definition and read every Turn on each update. + +**Declare a business role (`input`, `notice`) on Nodes.** That places business semantics in the grouping mechanism; group-tree positions describe structure only, and each business Definition chooses its position. + +**Correct the scroll room for folds not caused by own input.** Notices should not fold at all, and correcting the room would not remove that unnecessary fold; it remains a residual issue for human input from another tab. + +**Read Turn inputs at `turn`.** Terminal errors, max-token notices, and the completed-turn footer also stand outside the process disclosure, so reading `turn` would treat them as opening input; Turn inputs need their own position. + +**Derive the exchange from an `input` in Step 1.** A trigger notice is its Turn's opening input but must not open an exchange. Deriving the exchange would drop trigger notices from `input` and change how notice-woken Turns order their process rows. + +## Consequences + +- Notice-woken Turns no longer cause a fold, reserved room, or blank space. +- External Definitions place their Chat rows through `groupTree`. Apart from the two `assistant-step` rules, ui-chat names no kind when it groups, folds, or detects Turn inputs, and built-in kinds keep the placement their former kind lists gave them. +- Under On next message, a long run of notice-woken Turns (up to 31 goal-mode Turns in sampled Sessions) stays expanded until the human types again. +- When the loaded window holds no opening Turn, every loaded Turn belongs to the latest exchange. +- Human input from another tab folds when its `user/message` arrives, about 200 ms later than the former `turn/start` fold; that fold still uses generic room and is unchanged here. + +## Testing + +- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`: position order, segmentation, count fallback, and the empty window. +- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`: latest-exchange membership and notification scope. +- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`: a real assembler across an opening Turn, mid-Turn steering, the pre-Step gap, a notice wake, and an idle steer, with non-opening inputs at `input`. +- `packages/client/ui-chat/tests/process-groups.client.spec.ts`: a Node at any position splits the step run regardless of its kind, and a position change regroups. +- `apps/web/tests/notice-wake-tail.e2e.ts`: after a long Turn a notice wakes the next Turn; the long Turn stays expanded through the pre-Step gap and after the notice reply, and a following reader sees the notice and the reply. diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md new file mode 100644 index 0000000000..bd3375f5dc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md @@ -0,0 +1,56 @@ +# Agent Note: Chat 分组树位置与交互段 + +Status: implemented + +[English](2026-10-11-chat-group-tree-exchanges.md) | 中文 + +## Problem + +「下次发送时收起」原本只让已加载时间线的最后一个 Turn 保持展开。由通知唤醒的 Turn(子 agent 完成、Goal Round、定时任务、后台作业)不是本客户端提交的,它一出现,上一轮就被当作历史折叠。这次折叠没有自己输入的驱动来计算精确的剩余补白,滚动层只能按通用规则预留底部空间,正在跟尾的读者因此看到一片空白。录制的真实 Session 中,通知在上一轮结束后 13–15 ms 唤醒新轮次;最小复现里空白高 596 px,五秒后仍未恢复。 + +## Decision + +通知不算正文,也不算用户消息:折叠和推进底部时都忽略它。底部从"最后一个 Turn"改为"最新交互段"。 + +- **分组树位置。** `ChatConversationViewNode.groupTree` 声明节点在分组树中的位置,类型为 `ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process'`,由外到内有序;站在某个位置的节点同时具有所有内层位置的效果。`exchange` 开启一个交互段。`input` 表示节点是所在 Turn 的输入:出现在该轮过程之前即为开场输入,之后出现即为中途输入,使整个 Turn 保持展开。`turn` 让节点留在 Turn 的整轮折叠之外,`process` 让节点留在整轮折叠之内、步骤组之外,没有位置的节点并入相邻的步骤组。`isAtOrOutside(node, position)` 是读取这一顺序的唯一入口:过程分组读 `process`,整轮折叠读 `turn`,轮次输入判断读 `input`,交互段读 `exchange`。 +- **交互段。** 含 `exchange` 节点的 Turn 开启一个交互段,交互段持续到下一个开启 Turn。业务层的 message Definition 只给 Step 1 中被认领的真人输入(`user` 或 `steering`)标 `exchange`;之后的真人输入和触发通知位于 `input`,不开启交互段。 +- **内置位置。** `turn-error`、`turn-max-tokens`、`turn-tail` 与 `turn-process` 控件位于 `turn`;`model-retry` 位于 `process`。ui-chat 自己的 `assistant-step` 节点保留两条放置规则:推理行并入步骤组、回复行在步骤组之外;该轮的过程规格让最终答案的回复留在整轮折叠之外,因为哪一步是最终答案会随后续步骤到来而变化。 +- **机制。** `GroupTreeSegments(position)` 按含该位置节点的 Turn 切分已加载的 Turn,最新一段从最后一个开启 Turn 开始,窗口中没有开启 Turn 时从第一个已加载 Turn 开始。它按 Turn 增量计数,不扫描已加载节点;起点移动时只返回进入或离开最新一段的 Turn。Chat Builder 的 `bottomSource` 为"节点可见且属于最新交互段"。 + +结果是:通知唤醒的 Turn 不再折叠前面的 Turn,没有折叠就没有补白;真人下一次普通输入时,整个交互段经由自己输入的路径一起折叠。 + +## 为什么标开头而不是标通知 + +Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示,再写 `step/start` 和 `user/message`。录制的 Session 中这段间隔为 187 ms。只给通知打标的话,这段时间新 Turn 还没有标签,底部照样推进,上一轮会先折叠再展开。把标签放在真人开头上、未标的 Turn 默认续接,空白间隔里什么都不会发生。 + +## Alternatives considered + +**给通知打"续接"标签。** 上面那 187 ms 的间隔会让它先折后展;同一次认领里通知和真人输入并存时还要额外判断。 + +**按 kind 写一张开启名单(`user`、`steering`)。** kind 区分不了 Step 1 的 steering 与 Turn 中途的 steering,真人往通知唤醒的 Turn 里插话时会错误地折叠前一个交互段。 + +**发布到 Turn 级 Location data。** 同一 Turn 的同一个 key 只允许一个 Context 发布,而开启输入来自多个消息 Context;改成按 Turn 汇总就得在新的 Definition 里重复 inbox 认领分类,并且每次都要读取所有 Turn。 + +**节点声明业务角色(`input`、`notice`)。** 这会把业务语义放进分组机制;分组树位置只描述结构,业务 Definition 自行决定位置。 + +**修正非自身输入折叠时的滚动补白。** 通知本来就不应触发折叠,修补白不能消除这次不必要的折叠;它仍是另一个标签页里真人输入的残留问题。 + +**在 `turn` 位置上判断轮次输入。** 终止错误、最大 token 提示和已完成轮次页脚同样在整轮折叠之外,读 `turn` 会把它们误当作开场输入;轮次输入需要自己的位置。 + +**由 Step 1 中的 `input` 推导交互段。** 触发通知是所在 Turn 的开场输入,却不能开启交互段。推导交互段就得把触发通知移出 `input`,从而改变通知唤醒的 Turn 中过程行的排列。 + +## Consequences + +- 通知唤醒的 Turn 不再产生折叠、补白和空白。 +- 外部 Definition 通过 `groupTree` 放置自己的 Chat 行。除 `assistant-step` 的两条规则外,ui-chat 在分组、折叠和判断轮次输入时不点名任何 kind,内置 kind 保持原 kind 名单给出的放置。 +- 「下次发送时收起」下,一长串通知唤醒的 Turn(抽样会话中 Goal 模式最长 31 轮)会一直展开,直到真人再次输入。 +- 已加载窗口里没有开启 Turn 时,所有已加载 Turn 都属于最新交互段。 +- 另一个标签页里的真人输入在它的 `user/message` 到达时才折叠,比原来在 `turn/start` 时晚约 200 ms;这类折叠仍使用通用补白,本次没有修改。 + +## Testing + +- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`:位置顺序、切分、计数回退与空窗口。 +- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`:最新交互段的成员与通知范围。 +- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`:真实 assembler 下的开轮、中途 steering、Step 1 前的间隔、通知唤醒与空闲 steer,非开启输入位于 `input`。 +- `packages/client/ui-chat/tests/process-groups.client.spec.ts`:任意位置上的节点不论 kind 都会切开步骤组,位置变化会触发重新分组。 +- `apps/web/tests/notice-wake-tail.e2e.ts`:长 Turn 之后由通知唤醒,在 Step 1 前的间隔和通知回复之后,上一轮都保持展开,跟尾读者看到通知与回复。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.i18n.yaml new file mode 100644 index 0000000000..e1f4c7c1e9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-12-exclusive-conversation-matches.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-12-exclusive-conversation-matches.md +/agent-note-exclusive-conversation-matches: + en: 6f78605cc85cf7f2 + zh: f73e26bb3f9d1896 +/agent-note-exclusive-conversation-matches/problem: + en: 38ed7541d4cb7019 + zh: 98ed08f8c0c7c5ba +/agent-note-exclusive-conversation-matches/decision: + en: f2bfa3d0cc6b429a + zh: c1dd501c79086d71 +/agent-note-exclusive-conversation-matches/alternatives-considered: + en: efd209a2c0e97381 + zh: 8b6f10ff4de49449 +/agent-note-exclusive-conversation-matches/consequences: + en: 414ae7349bd4cc03 + zh: 9861b3e72bdfc548 +/agent-note-exclusive-conversation-matches/testing: + en: a88ed18065da4d28 + zh: 5bf4448a8b1f4763 diff --git a/.agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.md b/.agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.md new file mode 100644 index 0000000000..5dc353f598 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.md @@ -0,0 +1,52 @@ +# Agent Note: Exclusive Conversation matches + +Status: implemented + +English | [中文](2026-10-12-exclusive-conversation-matches.zh.md) + +## Problem + +Every event Definition matches each event independently, and a target may hold a generic Definition that presents a whole event family. When a business Definition presents one kind of that family itself, both Definitions match the same event and each builds a Context and a Node. Nothing let the business Definition say that the event belongs to it on that target. + +The late question reply is the shipped case. Chat's generic `input-message` Definition matches every appended `user/message`, and the questions UI's `question-reply` Definition matches the reply message it presents. Chat process grouping therefore dropped the duplicate `turn-trigger` presentation by message id, in both the Turn-owned and unscoped paths, and anchored the Turn control to the first group member so it stayed above the reply. The duplicate Node remained in the Node Store, and the generic Definition kept treating the reply as context or a trigger notice. + +## Decision + +A match result may claim its event on the Definition's target: + +```ts +interface ConversationMatchResult { + readonly id: string + readonly role: 'start' | 'update' + readonly exclusive?: true +} +``` + +- **Dispatch.** `ConversationNodeAssembler.dispatchInput()` evaluates every match of one event into two reused arrays before accepting any, then settles claims. Other Definitions of a claimed target build no Context for the event, and that target's unmatched-event fallback does not run. Definitions of other targets and Definitions without a target still receive it. The outcome is independent of registration order. Live append and window collection share this dispatch. +- **Failures.** Two claims on one target, or a claim from a Definition without a target, throw during dispatch and name the event, its `seq`, and the claiming Definition kinds. +- **Scope.** A claim belongs to one event and comes from `match()`, so a Definition claims only the events it recognizes. The generic Definition declares nothing. +- **Current use.** `question-reply` returns `exclusive: true` for a recorded `user-question-reply` message. Chat no longer aggregates by message id, and the reply's placement is unchanged. + +## Alternatives considered + +**Definition precedence (`specific` / `default`).** A generic Definition would declare itself overridable, and dispatch would become a priority order rather than a statement of which Definition presents an event. A claim needs no change to the generic Definition, and two claims fail instead of silently ranking. + +**Declare human sources in a registry and render the reply through user-text rules.** The reply would stay a generic message Node with customized text. Chat's message and Inbox Definitions would need a service-backed predicate, registry changes would have to reassemble every bound Conversation, and the text rule would need the message source as a fact. A claim keeps the reply in its owning Definition. + +**Mark human-authored sources in durable data.** A generic field on the message source would let the generic Definition classify the reply, but it changes a persisted type, and replies recorded by released builds do not carry it. + +**Skip the reply kind in `input-message`.** The generic Chat Definition would name a plugin's source kind. + +**Deduplicate in the grouping output.** This is the replaced mechanism: the duplicate Node stays stored and visible to every other Chat reader, and each grouping path repeats the aggregation. + +## Consequences + +- A claimed event has exactly one Context per claimed target, so no hidden duplicate remains in the Node Store. +- A conflicting claim fails at dispatch rather than at registration, because claims are decided per event from event data. +- Dispatch makes one extra pass over an event's matches, usually one to three, and allocates nothing for events without a claim. +- A plugin that presents part of a generic Definition's event family on one target uses a claim; it does not need the generic Definition's cooperation. + +## Testing + +- `packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts` ("exclusive matches"): a claim withholds the event from other Definitions of its target in either registration order and on live append; other targets, target-less Definitions, and no fallback; two claims and a target-less claim throw. +- `packages/client/ui-user-questions/tests/question-reply.client.spec.ts`: the reply match claims its event, and an assembler holding both message Definitions produces one Chat Node for the reply. diff --git a/.agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.zh.md b/.agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.zh.md new file mode 100644 index 0000000000..021b1cc918 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.zh.md @@ -0,0 +1,52 @@ +# Agent Note: Conversation 独占匹配 + +Status: implemented + +[English](2026-10-12-exclusive-conversation-matches.md) | 中文 + +## Problem + +每个事件 Definition 各自独立地匹配每个事件,同一个 target 上可能有一个呈现整类事件的通用 Definition。业务 Definition 自己呈现这类事件中的一种时,两个 Definition 会命中同一个事件,各自建立 Context 和 Node。业务 Definition 没有办法声明这个事件在该 target 上归它呈现。 + +迟到的问题回答是已上线的实例。Chat 的通用 `input-message` Definition 匹配每条追加的 `user/message`,问题 UI 的 `question-reply` Definition 也匹配它要呈现的回答消息。Chat 过程分组因此在有轮次归属和无轮次归属两条路径上都按消息 id 丢掉重复的 `turn-trigger` 呈现,并把整轮控件锚到组首成员,让它留在回答之上。重复的 Node 仍留在 Node Store 中,通用 Definition 也仍把回答当作 context 或触发通知。 + +## Decision + +match 结果可以在 Definition 的 target 上认领它的事件: + +```ts +interface ConversationMatchResult { + readonly id: string + readonly role: 'start' | 'update' + readonly exclusive?: true +} +``` + +- **派发。** `ConversationNodeAssembler.dispatchInput()` 先把一个事件的全部 match 求入两个复用数组,再结算认领,之后才逐个接收。被认领 target 上的其他 Definition 不为该事件建立 Context,该 target 的未匹配事件 fallback 也不运行;其他 target 的 Definition 与没有 target 的 Definition 照常接收。结果与注册顺序无关。实时追加和窗口收集共用这一派发。 +- **失败。** 同一 target 被认领两次,或没有 target 的 Definition 发起认领,都会在派发时抛错,错误信息包含事件、其 `seq` 与认领方 Definition 的 kind。 +- **范围。** 认领只对一个事件生效,由 `match()` 返回,因此 Definition 只认领它识别出的事件;通用 Definition 不需要声明任何东西。 +- **当前用法。** `question-reply` 对已记录的 `user-question-reply` 消息返回 `exclusive: true`。Chat 不再按消息 id 聚合,回答的放置不变。 + +## Alternatives considered + +**Definition 优先级(`specific` / `default`)。** 通用 Definition 得声明自己可被覆盖,派发也变成优先级排序,而不是声明由哪个 Definition 呈现事件。认领不要求改动通用 Definition,两个认领冲突时直接失败,而不是静默排序。 + +**用注册表声明真人来源,并通过用户文本规则渲染回答。** 回答会保持为通用消息 Node,只定制文本。Chat 的 message 与 Inbox Definition 需要一个依赖 service 的判断函数,注册表变化必须重新组装所有已绑定的 Conversation,文本规则还要把消息来源当作 fact 读取。认领让回答留在它自己的 Definition 中。 + +**在持久数据中标记真人撰写的来源。** 消息来源上的通用字段可以让通用 Definition 自行分类回答,但这会改动持久化类型,已发布版本记录的回答也不带这个字段。 + +**在 `input-message` 中跳过回答的 kind。** 通用的 Chat Definition 会点名插件的来源 kind。 + +**在分组输出层去重。** 这是被替换的机制:重复 Node 仍留在存储里,对其他所有 Chat 读取方可见,每条分组路径都要重复这一聚合。 + +## Consequences + +- 被认领的事件在每个被认领 target 上恰好只有一个 Context,Node Store 中不再残留隐藏的重复节点。 +- 认领冲突在派发时失败,而不是在注册时失败,因为认领由事件数据逐个决定。 +- 派发对每个事件的 match 多遍历一次,通常一到三个;没有认领的事件不产生额外分配。 +- 插件在一个 target 上呈现通用 Definition 事件族中的一部分时,使用认领即可,不需要通用 Definition 配合。 + +## Testing + +- `packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts`("exclusive matches"):两种注册顺序与实时追加下,认领都会对同 target 的其他 Definition 屏蔽该事件;其他 target 与无 target 的 Definition 照常接收,fallback 不运行;两次认领与无 target 认领会抛错。 +- `packages/client/ui-user-questions/tests/question-reply.client.spec.ts`:回答的 match 认领其事件;同时持有两个消息 Definition 的 assembler 只为回答产生一个 Chat Node。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.i18n.yaml new file mode 100644 index 0000000000..df5212392d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-12-headless-environment-auth.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-12-headless-environment-auth.md +/agent-note-headless-jobs-omit-the-interactive-credential-store: + en: c768db5a7e821bf0 + zh: 423b3e423d071ddc +/agent-note-headless-jobs-omit-the-interactive-credential-store/problem: + en: 829df36d87975148 + zh: 171d29ef2f2e5079 +/agent-note-headless-jobs-omit-the-interactive-credential-store/decision: + en: af275ffea713f76b + zh: 12a3bacfcb147df7 +/agent-note-headless-jobs-omit-the-interactive-credential-store/alternatives-considered: + en: f6f8dfde4789adc8 + zh: ced62f3d1ec402e6 +/agent-note-headless-jobs-omit-the-interactive-credential-store/consequences: + en: 1c1a699dc9f5681f + zh: 4a72b09d24f9a005 diff --git a/.agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.md b/.agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.md new file mode 100644 index 0000000000..0a497c44e7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.md @@ -0,0 +1,30 @@ +# Agent Note: Headless jobs omit the interactive credential store + +Status: implemented + +English | [中文](2026-10-12-headless-environment-auth.zh.md) + +## Problem + +The [stored credential precedence](2026-10-09-stored-credential-precedence.md) decision makes GUI saves effective even when the shell exports a key for another application. A headless job sharing that Harness home can then use the GUI account instead of the key explicitly supplied to the job. Changing reference precedence alone would leave pi-ai provider records and saved grants ahead of the environment. + +## Decision + +The shipped headless bundle disables the base `credentials`, `authorization` and `deepseek-account` rows. API-key adapters already support compositions without the credentials service and resolve through the launch snapshot: process environment, invocation-directory `.env`, then Harness-home `.env`. The profile does not read, migrate, write or delete `.credentials.yaml`. Dependent authorization and account rows are disabled explicitly so ordinary jobs do not emit pending-service warnings. + +This partially supersedes the per-run override policy in stored credential precedence for the shipped headless profile. Other profiles and an explicitly restored headless store retain stored-first resolution. The headless [README](../../../../packages/bundle/headless/README.md#credentials) owns the restoration patch. + +## Alternatives considered + +**Put the environment first in every profile.** It prevents a GUI save from replacing a key left in the launching environment and restores inconsistent reference and provider-record precedence. + +**Disable only the credentials row.** It achieves environment selection but leaves authorization and the account provider pending, producing warnings on every launch. + +**Add a second precedence mode to the store.** It introduces configuration and record-handling policy into the shared provider when existing adapter fallbacks and profile overrides already supply the required behavior. + +## Consequences + +- Headless ignores saved API keys, provider records and grants. A provider requiring stored sign-in or the DeepSeek account route fails until the user restores stored authentication; restoring it also restores saved-first precedence. +- Environment and external provider credential chains remain available. Sessions, settings and tools stay composed; omission of the store does not deny filesystem reads by tools. +- Web, SDK and ACP retain the credential store. Their authentication, login and refresh implementations are unchanged. +- Profile-level process tests cover all three environment layers, named and native pi-ai authentication, missing keys, ignored malformed storage, restoration and preservation of the document. Existing headless transcripts and real-model workspace tests cover the remaining composition. diff --git a/.agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.zh.md b/.agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.zh.md new file mode 100644 index 0000000000..e6e0b8b870 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.zh.md @@ -0,0 +1,30 @@ +# Agent Note: Headless 任务不挂载交互式凭据存储 + +Status: implemented + +[English](2026-10-12-headless-environment-auth.md) | 中文 + +## 问题 + +[已保存凭据优先级](2026-10-09-stored-credential-precedence.zh.md)的决策保证 GUI 保存的密钥生效,即使 shell 为其他应用导出了密钥。共享同一个 Harness home 的 headless 任务可能因此使用 GUI 的账户,而不是显式传给任务的密钥。只调整引用优先级仍会让 pi-ai 提供方记录和已保存 grant 排在环境变量之前。 + +## 决策 + +随附 headless 组合包禁用 base 中的 `credentials`、`authorization` 和 `deepseek-account` 行。API 密钥适配器已经支持不挂载凭据服务的组合,按启动快照解析:进程环境、运行命令的目录 `.env`、Harness home `.env`。该 profile 不读取、迁移、写入或删除 `.credentials.yaml`。显式禁用依赖它的 authorization 和 account 行,避免普通任务输出等待服务的警告。 + +这部分取代了已保存凭据优先级中针对随附 headless profile 的单次覆盖策略。其他 profile,以及显式恢复存储的 headless,仍按已保存凭据优先解析。headless [README](../../../../packages/bundle/headless/README.zh.md#credentials)负责恢复 patch 的用法。 + +## 考虑过的替代方案 + +**在所有 profile 中让环境变量优先。** 这会使 GUI 保存的密钥无法替换启动环境中遗留的密钥,并恢复引用与提供方记录之间不一致的优先级。 + +**只禁用 credentials 行。** 这能选用环境变量,但 authorization 与 account 提供方会保持 pending,每次启动都产生警告。 + +**给存储增加第二种优先级模式。** 现有适配器后备与 profile 覆盖已经满足需求;这种做法却会把额外配置和记录处理策略引入共享提供方。 + +## 后果 + +- Headless 忽略已保存 API 密钥、提供方记录和 grant。依赖已保存登录凭据的提供方或 DeepSeek account 路由会失败,直到用户恢复存储认证;恢复后也恢复已保存凭据优先。 +- 环境变量与提供方外部凭据链仍然可用。会话、settings 与工具继续组合;不挂载存储不会禁止工具读取文件系统。 +- Web、SDK 与 ACP 保留凭据存储;其认证、登录和刷新实现不变。 +- Profile 进程测试覆盖三个环境层、具名与原生 pi-ai 认证、缺失密钥、忽略畸形存储、恢复存储,以及文件保持原样。现有 headless 录制与真实模型工作区测试覆盖组合中的其余行为。 diff --git a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml new file mode 100644 index 0000000000..65205260c3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-13-team-identity-runtime-context.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-13-team-identity-runtime-context.md +/agent-note-team-identity-in-required-runtime-context: + en: 8c88759320ae8481 + zh: 4068b18723907eb9 +/agent-note-team-identity-in-required-runtime-context/problem: + en: c9a81cb731ddf7b2 + zh: 428c0e6f8937d7c5 +/agent-note-team-identity-in-required-runtime-context/decision: + en: 6c73479973c1e378 + zh: 98677c77f77f8d81 +/agent-note-team-identity-in-required-runtime-context/alternatives-considered: + en: 774566ffbf9e9db8 + zh: ea761d876af462f2 +/agent-note-team-identity-in-required-runtime-context/consequences: + en: 18d1aa67936f1227 + zh: 7e06c1f51553ecdd diff --git a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md new file mode 100644 index 0000000000..39794f02d3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md @@ -0,0 +1,31 @@ +# Agent Note: Team identity in required runtime context + +Status: implemented + +English | [中文](2026-10-13-team-identity-runtime-context.zh.md) + +## Problem + +A teammate identity embedded in its initial task depends on ordinary history retention. Compaction can remove it, and an ordinary fork from that teammate can retain the old role even though the fork is an independent Lead. Identity must reflect current membership while keeping shared system instructions uniform. + +## Decision + +`tool-agent-team` contributes `team:identity` through the Agent-scoped `systemPrompt.context()` registry with `required: true`. Its provider reads the existing Team membership and renders the member's role, name, and addressing instructions. Optional-context suppression cannot remove it. Fixed collaboration policy stays in the system prompt; roster and task state remain tool queries. Initial tasks contain only their supplied content. + +The loop owns logging, retained-snapshot comparison, and re-admission after compaction. A changed role appends the current full runtime-context snapshot after inherited history; unchanged retained context adds no message. No Team-specific retention state, event type, or log migration is introduced. Existing recorded reminders remain historical messages. + +This supersedes only the initial-task identity decision in [Agent Teams](../feature/2026-08-05-agent-teams.md#team-identity); its roster, mailbox, and task decisions remain active. + +## Alternatives considered + +**Keep identity in the initial task.** Preserves a uniform system prompt but cannot restore removed identity or correct a fork's inherited role without another mechanism. + +**Put identity in the system prompt.** Makes system text differ across members and can rewrite the inherited request prefix on routes without in-history system updates. + +**Add custom reminder retention and reinjection.** Duplicates the loop's existing runtime-context projection and recovery responsibilities. + +## Consequences + +Members pay for identity within the full runtime-context snapshot. Other context changes repeat it, while stable retained context adds no new message. Forks preserve inherited history and append current identity; actual provider cache hits remain best-effort. Existing Team authorization remains in tool execution. + +Verification covers fresh and fork teammates, an independent fork from a teammate, optional-context suppression, compaction restoration, cold resume, and registration disposal. The recorded `team-targets` scenario pins Lead context; the keyless headless Team workflow pins teammate context through the shipped profile. diff --git a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md new file mode 100644 index 0000000000..d0e333a71e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 必需 runtime context 中的 Team 身份 + +Status: implemented + +[English](2026-10-13-team-identity-runtime-context.md) | 中文 + +## Problem + +嵌入初始任务的 teammate 身份依赖普通历史保留。压缩可能移除它;从 teammate 创建的普通 fork 即使已成为独立 Lead,也可能保留旧角色。身份必须反映当前 membership,同时保持共享系统指令一致。 + +## Decision + +`tool-agent-team` 通过 Agent 作用域的 `systemPrompt.context()` 注册 `team:identity`,并设置 `required: true`。提供方读取现有 Team membership,渲染成员角色、名字与寻址说明。关闭可选 context 不能移除它。固定协作策略保留在 system prompt;roster 和 task 状态仍通过工具查询。初始任务只包含传入的正文。 + +Loop 负责日志记录、保留快照比较和压缩后的重新接纳。角色变化会在继承历史之后追加当前完整 runtime-context 快照;保留的 context 未变化时不追加消息。不引入 Team 专属的保留状态、事件类型或日志迁移。已有身份提醒仍作为历史消息保留。 + +本决策仅取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md#team-identity) 中的初始任务身份决策;其 roster、mailbox 和 task 决策仍然有效。 + +## Alternatives considered + +**身份留在初始任务中。** 保持 system prompt 一致,但恢复被移除的身份或纠正 fork 继承的角色需要额外机制。 + +**身份放进 system prompt。** 成员之间的 system 文本会不同;不支持历史内 system 更新的路由可能重写继承的请求前缀。 + +**添加自定义提醒保留与重新注入。** 重复了 loop 已有的 runtime-context 投影和恢复职责。 + +## Consequences + +成员承担完整 runtime-context 快照中的身份 token 成本。其他 context 变化会重复身份,保留的 context 稳定时则不追加消息。Fork 保留继承历史并追加当前身份;提供方实际缓存命中仍为尽力而为。现有 Team 授权仍在工具执行时落实。 + +验证覆盖 fresh 和 fork teammate、从 teammate 创建的独立 fork、关闭可选 context、压缩后恢复、冷恢复和注册释放。录制的 `team-targets` 场景固定 Lead context;无密钥 headless Team 工作流通过内置 profile 固定 teammate context。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.i18n.yaml new file mode 100644 index 0000000000..1fe1553059 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-09-15-authenticated-browser-host-preferences.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-09-15-authenticated-browser-host-preferences.md +/agent-note-share-host-preferences-with-every-authenticated-browser: + en: f4b8b4500f8dca9c + zh: bf27ded54fba703b +/agent-note-share-host-preferences-with-every-authenticated-browser/problem: + en: 22bf57b616581253 + zh: d1108a4042646e21 +/agent-note-share-host-preferences-with-every-authenticated-browser/decision: + en: 2d54784b8b3558cf + zh: 31190a9b284867b7 +/agent-note-share-host-preferences-with-every-authenticated-browser/alternatives-considered: + en: 299105745b084fc0 + zh: 8c2d82de44ac2aad +/agent-note-share-host-preferences-with-every-authenticated-browser/consequences: + en: 520028b8c4031009 + zh: 8c130624261538cc +/agent-note-share-host-preferences-with-every-authenticated-browser/testing: + en: e433f139fe72e69f + zh: 279cc8b1cd3d5377 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.md b/.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.md new file mode 100644 index 0000000000..120903b88d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.md @@ -0,0 +1,29 @@ +# Agent Note: Share Host preferences with every authenticated browser + +Status: implemented + +English | [中文](2026-09-15-authenticated-browser-host-preferences.zh.md) + +## Problem + +[Persist Web user preferences through Host settings](../../archived/bug-fix/2026-08-06-host-backed-web-preferences.md) chose the preference store by page locality, so a non-loopback page kept Appearance, Language, busy-Enter, and the welcome acknowledgement in a process-local copy. A trusted remote host, and a deployment published behind the configured public URL, therefore lost every choice on reload even though Connection authenticates the complete settings API. The second mode also split the shared runtime: theme, locale, the settings shell, the Models page, and the welcome notice each rendered a durable row that could never persist for a remote page. + +## Decision + +Locality selects nothing. One `settings.describe` mirror, one `ctx.configForms.get(entryId)` per namespace derived from it, and the `settings.mutate` write path serve every authenticated browser. Browser token authentication of the Connection API is the identity boundary; the fixed `remote.$host` locality facts are request-routing evidence that no store selector consults. The mirror's state space is `idle`, `loading`, and `ready`, a form snapshot carries no mode, and a form reports `unavailable` only for a namespace the Host did not register. + +The settings shell keeps one locality-dependent UI element: it builds its document controller from `ctx.remote.$host.isLoopback`, so only a loopback page renders **Open configuration file** and issues the `hasDocument` read behind it. The `settings/openSettingsDocument` Remote itself stays browser-authenticated like every other Host method; the omission is a presentation choice for pages that cannot see the Host's native editor, not a persistence rule. + +## Alternatives considered + +**Keep the memory-branch locality gate.** Rejected: browser token authentication already covers every Host method for remote pages, so the gate protected nothing, and locality is request-routing evidence rather than identity. It also made every Host-backed row inert on trusted remote and public deployments. + +## Consequences + +Every authenticated browser reaching one Host shares one preference document. Two browsers on the same deployment observe each other's Appearance, Language, busy-Enter, and welcome-acknowledgement choices, and a value written remotely is the value a loopback browser loads next. Preference access is governed by API authentication and `settings.describe.writable`. + +A remote page omits the **Open configuration file** action, the one locality-dependent UI element that remains. Concurrent writers on one document resolve through the namespace revision each form sends as `expectedRevision`. + +## Testing + +Focused unit coverage pins the contract: the ui-settings mirror and configuration-form suites construct both objects with no persistence selector, the theme apply spec persists from a non-loopback bench and asserts the Host write, the ui-settings-general apply spec keeps the shared describe read and the Host write path off-loopback while asserting the native document action stays unregistered, and the welcome store retains its acknowledgement across a settings-plugin recreation. The keyless Web settings scenario `apps/web/tests/remote-settings.e2e.ts` acknowledges the welcome notice, configures a model provider, and selects the dark Appearance from a trusted remote page, reloads after each, and re-asserts the welcome, model, and Appearance state from the Host settings document. diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.zh.md new file mode 100644 index 0000000000..10aa7af847 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 每个已认证浏览器共用 Host 偏好 + +Status: implemented + +[English](2026-09-15-authenticated-browser-host-preferences.md) | 中文 + +## 问题 + +[通过 Host settings 持久化 Web 用户偏好](../../archived/bug-fix/2026-08-06-host-backed-web-preferences.md)按页面本地性选择偏好存储,因此非回环页面把 Appearance、Language、繁忙态 Enter 与欢迎提示的确认留在一份进程内副本中。可信远程主机,以及配置了公共 URL 的部署,因而在重新加载时丢失全部选择,尽管 Connection 认证完整的 settings API。这套第二种模式也割裂了共享运行时:主题、语言、设置外壳、Models 页面与欢迎提示各自都在渲染一行对远程页面永远无法持久化的界面。 + +## 决策 + +本地性不选择任何东西。一个 `settings.describe` 镜像、由它派生的逐 namespace `ctx.configForms.get(entryId)`,以及 `settings.mutate` 写入路径服务每个已认证浏览器。Connection API 的浏览器令牌认证是身份边界;固定的 `remote.$host` 本地性事实只是请求路由的依据,没有任何存储选择器会读取它。镜像的状态空间只有 `idle`、`loading` 与 `ready`,表单快照不携带 mode,表单也只有在 Host 未注册该 namespace 时才报告 `unavailable`。 + +settings 外壳保留唯一一个依赖本地性的 UI 元素:它依据 `ctx.remote.$host.isLoopback` 构造文档控制器,因此只有回环页面会渲染**打开配置文件**并发出其背后的 `hasDocument` 读取。`settings/openSettingsDocument` Remote 本身与其他 Host 方法一样保持浏览器认证;省略该操作是面向看不到 Host 原生编辑器的页面的展示选择,而非持久化规则。 + +## 曾考虑的替代方案 + +**保留内存分支的本地性门禁。** 已否决:浏览器令牌认证已覆盖远程页面的每个 Host 方法,该门禁保护不了任何东西,而本地性只是请求路由的依据而非身份。它还会让受信远程与公共部署上每一行依赖 Host 的界面失效。 + +## 后果 + +每个访问同一 Host 的已认证浏览器共用一份偏好文档。同一部署上的两个浏览器会看到彼此选择的 Appearance、Language、繁忙态 Enter 与欢迎提示确认,远程写入的值就是回环浏览器下一次加载的值。偏好可访问性由 API 认证与 `settings.describe.writable` 决定。 + +远程页面省略**打开配置文件**操作,这是仍依赖本地性的唯一 UI 元素。同一文档上的并发写入由各表单作为 `expectedRevision` 发送的 namespace revision 裁决。 + +## 测试 + +聚焦的单元测试固定当前契约:ui-settings 的镜像与配置表单测试在构造这两个对象时不传持久化选择器,主题 apply 测试从非回环 bench 持久化并断言 Host 写入,ui-settings-general 的 apply 测试在非回环下保留共享 describe 读取与 Host 写入路径、同时断言原生文档操作保持未注册,欢迎 store 则在 settings 插件重建后保留其确认。无密钥 Web settings 场景 `apps/web/tests/remote-settings.e2e.ts` 从可信远程页面确认欢迎通知、配置一个模型提供商并选择深色 Appearance,每步之后重新加载,并从 Host settings 文档重新确认欢迎提示、模型与 Appearance 状态。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.i18n.yaml new file mode 100644 index 0000000000..56ecd753a6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-09-29-reused-blank-runs-requested-preset.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-09-29-reused-blank-runs-requested-preset.md +/agent-note-a-reused-blank-session-runs-the-requested-preset: + en: 06173bc82e1d9f59 + zh: 637352fd92142310 +/agent-note-a-reused-blank-session-runs-the-requested-preset/problem: + en: 7514025504dc2ddd + zh: 96249d3472005023 +/agent-note-a-reused-blank-session-runs-the-requested-preset/decision: + en: e396c291f742a5f3 + zh: c67be0fd305215fe +/agent-note-a-reused-blank-session-runs-the-requested-preset/alternatives-considered: + en: ae99345e8df800e4 + zh: a4d0c257c4216337 +/agent-note-a-reused-blank-session-runs-the-requested-preset/consequences: + en: bc7dda06670b4d96 + zh: 24f381b8cb6ed3f8 +/agent-note-a-reused-blank-session-runs-the-requested-preset/testing: + en: 1aefca569f5dce81 + zh: 527c9aacf5a2f6b8 diff --git a/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md new file mode 100644 index 0000000000..0ebcb569a2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md @@ -0,0 +1,37 @@ +# Agent Note: A reused blank Session runs the requested preset + +Status: implemented + +English | [中文](2026-09-29-reused-blank-runs-requested-preset.zh.md) + +## Problem + +Web New Session, Workspace navigation, and startup restoration reused a blank Session by naming it in `session.create`. The Host resumed that Session under the preset its log recorded, so a new conversation inherited the preset the blank had been created or switched under. When that preset was no longer declared, such as a removed or renamed definition or a legacy `$DSH_HOME/.agent-presets//` directory that nothing reads any more, every New Session in that Workspace and every startup restoration of that draft failed with `agent-preset/not-found` although the default preset was valid (issue #2566). A blank created under an earlier default silently kept that default. The client retried only `session/writer-held`, so the stale blank stayed first in line. + +## Decision + +`session.create` returns a Session for a new conversation. A named `sessionId` is a reuse candidate that the Host verifies: it must not have started a turn, must belong to the requested directory, must be an unarchived member of the requested Workspace, and must not be written by another owner. For a persisted candidate the Host repeats the started check under the writer it acquires. Requests naming one identity run in arrival order, including their preset resolution, and the directory and ownership checks precede that resolution. The reused Session runs the requested `agentPreset`, or the configured default when the request names none; the Host does not resolve the recorded preset for reuse. When the target differs, a live Session switches through the preset registry's `select`, and a persisted Session mounts the target in its unpublished resume setup and appends `agent-preset/selected` there, so the log names the composition before the Agent is published. A candidate that has started, fails placement, or is written by another owner stays unchanged, and the Host creates a new Session; the response names the Session used. An explicit request for an undeclared preset still fails with `agent-preset/not-found`, and resuming a started Session still resolves its recorded identity. + +The Web client names the first eligible blank when it connects a Workspace or prepares an explicit draft, and passes the preset a flow knows before it starts, such as the creator entry's `cordis`; a plain New Session creates a fresh Session instead. Startup restoration names the saved blank with the preset its log recorded, reloading the Session's projections first because listed values are cached hints that can lag the log after an unclean Host exit, so a draft keeps its selection; after `agent-preset/not-found` or `agent-preset/invalid` it asks again without a preset, and the draft continues under the default. When the Host declines a named blank, the client reloads that Session's projections, because stale cached list metadata can still mark a started conversation blank and hide it from the Session tree. + +## Alternatives considered + +**Fall back to the default only when the recorded preset is missing.** This removes the failure but keeps an inherited preset whenever it still resolves, so a New Session after a default change runs the old default. + +**Retry `agent-preset/not-found` in the client.** The stale blank stays the first reuse candidate, so later New Sessions fail once before creating another Session, and startup restoration and other callers still fail. + +**Skip blanks whose preset is missing from the roster.** Navigation would need the roster, stale Sessions would never be repaired, and other callers would still fail. + +**Let the Host choose the blank.** The Host would scan the catalog on every request, while the client already holds the ordered catalog; verification under the writer covers a stale client view. + +**Keep a restored draft's preset on the Host when it resolves.** A request that names a blank without a preset would then mean "keep" for restoration and "default" for New Session. Passing the recorded preset gives each request one meaning. + +## Consequences + +A reused blank runs the default or the preset its flow names, including after the default changes or a definition disappears. A preset picked on a draft survives a page reload but not a later Workspace connection or draft preparation that reuses the draft, and reusing a draft that another tab shows changes that tab's preset too. Writer contention no longer reaches the client: the Host creates the replacement Session. A blank whose first prompt was accepted but whose turn has not started still counts as blank, as it does for `select`. A declined stale blank is corrected only in the client that named it, until the Session is next activated. The preset registry's `select` leaves a blank Session that records the requested preset and runs its current revision unchanged, so a client that also selects that preset after the creation request adds no second record. + +This note replaces the client-side contention fallback of [Writer acquisition for blank Session reuse](../architecture/2026-09-17-process-local-blank-sessions.md); writer acquisition and candidate order remain as decided there. + +## Testing + +Session Controller tests cover live and persisted reuse, a recorded preset that is no longer declared, named presets, started and overtaken Sessions, writer contention, Workspace placement, storage failures, arrival order when an earlier preset resolves later, and directory checks before preset resolution. Workspace navigation tests cover restoration with and without a usable recorded preset, restoration when the listed preset lags the log, connection coalescing that a different preset request supersedes, and the projection reload for a declined blank; the preset plugin tests cover the creator entry, and the registry tests cover a repeated selection with and without a replaced definition. The shipped Web composition test reuses a blank recorded under an undeclared preset under the default when startup connects its Workspace and when startup restores it, keeps a restored draft's declared preset, and checks the durable `agent-preset/selected` record; the stale blank cache scenario checks that startup opens a new Session and still lists the declined conversation. diff --git a/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md new file mode 100644 index 0000000000..28c717f958 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 被复用的空白会话运行请求的 preset + +Status: implemented + +[English](2026-09-29-reused-blank-runs-requested-preset.md) | 中文 + +## Problem + +Web 的新建会话、工作区导航与启动恢复过去通过在 `session.create` 中指明空白会话来复用它。Host 过去按日志记录的 preset 恢复该会话,因此新对话继承了空白会话创建时或切换后的 preset。当该 preset 已不再声明时,例如定义被删除或改名,或是如今已无人读取的旧版 `$DSH_HOME/.agent-presets//` 目录,即使默认 preset 有效,该工作区中的每次新建会话以及该草稿的每次启动恢复都会以 `agent-preset/not-found` 失败(issue #2566)。在更早默认值下创建的空白会话则会静默保留那个旧默认值。客户端只对 `session/writer-held` 重试,因此过期的空白会话始终排在复用候选首位。 + +## Decision + +`session.create` 返回一个用于新对话的会话。请求指明的 `sessionId` 是由 Host 核实的复用候选:它必须尚未开始任何轮次、属于请求的目录、是请求的工作区中未归档的成员,且未被其他写者占用。对于持久化候选,Host 会在取得写锁后再次检查它是否已开始。指明同一标识的请求按到达顺序执行,preset 解析也包含在内,且目录与归属检查先于该解析。被复用的会话运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset;Host 复用时不解析记录的 preset。目标与当前不同时,活动会话通过 preset 注册表的 `select` 切换;持久化会话在尚未发布的恢复 setup 中挂载目标 preset,并在同一处追加 `agent-preset/selected`,使日志在 Agent 发布前就写明其组成。已开始、不满足归属或被其他写者占用的候选保持不变,Host 改为新建会话;响应指明实际使用的会话。显式请求未声明的 preset 仍以 `agent-preset/not-found` 失败,恢复已开始的会话仍解析其记录的标识。 + +Web 客户端在连接工作区或显式准备草稿时指明第一个符合条件的空白会话,并传入流程在开始前已确定的 preset,例如创造模式入口的 `cordis`;普通新建会话则直接新建 Session。启动恢复指明已保存的空白会话及其日志记录的 preset,并先重新加载该会话的投影,因为列表中的值是缓存提示,Host 非正常退出后可能落后于日志,从而使草稿保留其选择;遇到 `agent-preset/not-found` 或 `agent-preset/invalid` 后改为不带 preset 再请求一次,草稿在默认 preset 下继续。Host 拒绝复用某个指明的空白会话时,客户端会重新读取该会话的投影,因为过期的列表缓存元数据可能仍把已开始的对话标为空白,并把它从会话树中隐藏。 + +## Alternatives considered + +**只在记录的 preset 缺失时回退到默认值。** 这能消除失败,但只要继承的 preset 仍可解析就会保留它,因此默认值变更后的新建会话仍运行旧默认值。 + +**在客户端重试 `agent-preset/not-found`。** 过期的空白会话仍是第一个复用候选,之后每次新建会话都会先失败一次再新建,启动恢复和其他调用方仍会失败。 + +**跳过 preset 不在列表中的空白会话。** 导航需要读取 preset 列表,过期会话永远得不到修复,其他调用方仍会失败。 + +**由 Host 选择空白会话。** Host 每次请求都要扫描会话列表,而客户端已持有排好序的列表;在写锁下的核实足以覆盖客户端视图过期的情况。 + +**恢复草稿时由 Host 在记录的 preset 可解析时保留它。** 这样一个指明空白会话但未带 preset 的请求,在恢复时表示「保留」,在新建会话时表示「默认」。传入记录的 preset 让每个请求只有一种含义。 + +## Consequences + +被复用的空白会话运行默认 preset 或其流程指明的 preset,默认值变更或定义消失之后也是如此。在草稿上选择的 preset 在刷新页面后仍保留,但之后复用该草稿的工作区连接或草稿准备不会保留它;复用另一个标签页正在显示的草稿也会改变那个标签页的 preset。写锁争用不再到达客户端:由 Host 新建替代会话。首条提示词已被接受但轮次尚未开始的空白会话仍算空白,与 `select` 一致。被拒绝复用的过期空白会话只在指明它的客户端中得到纠正,直到该会话下次被激活。若空白会话已记录所请求的 preset 且正运行其当前版本,preset 注册表的 `select` 不做改动,因此客户端在创建请求之后再选择同一 preset 也不会追加第二条记录。 + +本注记取代 [复用空白 Session 时获取写锁](../architecture/2026-09-17-process-local-blank-sessions.zh.md) 中的客户端锁争用回退;写锁获取与候选顺序仍按该决策执行。 + +## Testing + +Session Controller 测试覆盖活动会话与持久化会话的复用、记录的 preset 已不再声明、指明的 preset、已开始与被抢先开始的会话、写锁争用、工作区归属、存储失败、较早请求的 preset 解析较慢时仍按到达顺序执行,以及目录检查先于 preset 解析。工作区导航测试覆盖记录的 preset 可用与不可用时的恢复、列表中的 preset 落后于日志时的恢复、被不同 preset 请求取代的连接不再合并,以及被拒绝的空白会话的投影重读;preset 插件测试覆盖创造模式入口,注册表测试覆盖定义未替换与已替换时的重复选择。实际 Web 组合测试在启动连接工作区与启动恢复时,把记录了未声明 preset 的空白会话复用到默认 preset 下,验证恢复的草稿保留已声明的 preset,并检查持久化的 `agent-preset/selected` 记录;过期空白缓存场景验证启动时打开新会话,并仍列出被拒绝复用的对话。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.i18n.yaml new file mode 100644 index 0000000000..a46ef9740b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-30-job-session-lifecycle.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-09-30-job-session-lifecycle.md +/agent-note-persist-owned-job-lifecycle-facts: + en: 79d83b765acf6e24 + zh: afe6ea9af6d96d10 +/agent-note-persist-owned-job-lifecycle-facts/problem: + en: b3d0591cc559e466 + zh: aa4c385e84f7eaa1 +/agent-note-persist-owned-job-lifecycle-facts/decision: + en: c982a59cbe5202d3 + zh: 9315962d2e4b8f0a +/agent-note-persist-owned-job-lifecycle-facts/alternatives-considered: + en: 140cf7152a35bb27 + zh: 001aa96687836914 +/agent-note-persist-owned-job-lifecycle-facts/consequences: + en: 3761af53744aa0c7 + zh: 876cb056c248c43c diff --git a/.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.md b/.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.md new file mode 100644 index 0000000000..0edf7deeef --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.md @@ -0,0 +1,37 @@ +# Agent Note: Persist owned job lifecycle facts + +Status: implemented + +English | [中文](2026-09-30-job-session-lifecycle.zh.md) + +## Problem + +SDK consumers receive Session events, while job registration and settlement previously existed only in the live registry. A background tool can return before work finishes, and output collection can happen much later or never. Tool invocation times therefore cannot reconstruct a job's execution interval. Runtime-local ids also repeat after a registry restart. + +## Decision + +The local provider captures `job/registered` and `job/settled` facts for owned jobs, retaining the registry's epoch-millisecond timestamps, original owner, producer kind, label, and optional creating tool call. A UUID-backed `JobId` identifies both live controls and historical records across registry restarts and copied history. Unowned work has no Session journal. + +The terminal record distinguishes a producer report from forced bookkeeping: `producerReported` is true when `JobHooks.done` fulfills, and false when that promise rejects or teardown cancellation throws. It reports fulfillment, not independently confirmed resource release. Missing terminal records remain incomplete and are not reconstructed from later reads or process restart. + +Facts are appended synchronously at the registry commit before live hub reactions and completion waiters resume. Recording errors warn independently: already-started work still has its control id, and a failed journal append cannot prevent cleanup or release of waiters. The Session's ordinary persistence and SDK notification paths own delivery after append. SDK `run()` results cover bounded observation windows and can receive a settlement whose registration belongs to an earlier run; settlement records retain the registration facts for that consumer. + +The [registry contract](../../../../packages/jobs/jobs/README.md) and [seam consolidation note](../architecture/2026-09-03-jobs-seam-consolidation.md) own live execution, resource ownership, and event routing. This journal adds durable observations without replacing those responsibilities. + +## Alternatives considered + +**Add timestamps only to `job_output`.** A model may never collect a job or may collect it after a long delay. Logging lifecycle commits records both cases without additional model calls or consuming output. + +**Keep a reusable control id alongside an execution UUID.** Both values would identify one job. A unique `JobId` serves live controls and historical pairing. + +**Add a launcher cleanup registry.** A separate pre-exit disposer path duplicates Cordis resource ownership and adds a launcher requirement to headless. Keeping persistence available through the final job teardown requires a separate cross-fiber lifecycle change; this journal retains the existing disposal mechanism. + +**Defer publication or propagate recording errors.** Deferral lets a synchronous hub subscriber journal a completion notice before the settlement that caused it. Throwing after the starter succeeds can hide an already-running job from its caller. Synchronous, contained recording preserves causal order without abandoning execution. + +## Consequences + +Application teardown can stop Session routing or close its writer before the final job settlement reaches storage. Normal one-shot completion and signal shutdown can therefore leave incomplete history, as can hard process loss. This change does not enforce cross-fiber disposal order or synthesize a missing terminal record. + +The log and both SDKs expose execution intervals without changing tool schemas, output cursors, or job scheduling. Model-facing controls and notices carry the unique JobId; a bounded notice points to job_list when the full id cannot fit. These intervals run from registration commit through registry settlement, including the producer work and cleanup between those points; they are neither CPU time nor a directly additive rollout-latency metric. An append failure can also leave incomplete history. Persisted facts do not resume an external process or its output ring. + +Focused tests cover original timestamps, nested call identity, output preservation, delayed cancellation, producer rejection, forced teardown, late completion, unique control ids, causal publication order, and recording-failure containment. SDK snapshots cover the durable and protocol projections. diff --git a/.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.zh.md b/.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.zh.md new file mode 100644 index 0000000000..25cd83d922 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 持久化有所有者 job 的生命周期事实 + +Status: implemented + +[English](2026-09-30-job-session-lifecycle.md) | 中文 + +## Problem + +SDK 消费方接收 Session 事件,而 job 注册和结算此前只存在于运行期注册表中。后台工具可能在工作结束前返回,输出收集则可能晚得多,甚至永远不发生。因此工具调用时间无法重建 job 的执行区间。运行期 id 也会在注册表重启后重复。 + +## Decision + +本地提供方为有所有者的 job 捕获 `job/registered` 与 `job/settled` 事实,保留注册表的 Unix 毫秒时间戳、原始所有者、生产方 kind、label 和可选的创建工具调用。由 UUID 构成的 `JobId` 在注册表重启和历史复制后同时标识运行中的控制对象和历史记录。无主工作没有 Session 日志。 + +终态记录区分生产方报告与强制记账:`producerReported` 在 `JobHooks.done` 兑现时为 true,在该 promise 拒绝或销毁取消抛错时为 false。它记录兑现情况,不独立确认资源已释放。缺少终态记录时保持不完整,不根据较晚的读取或进程重启重建。 + +事实在注册表提交时同步追加,先于运行期 hub 的反应和完成等待方恢复。记录错误单独发出警告:已启动的工作仍有可控制的 id,日志追加失败不能阻止清理或释放等待方。追加后的投递由 Session 既有的持久化和 SDK 通知路径负责。SDK `run()` 结果只覆盖有界观察窗口,可能收到注册属于更早运行的结算;结算记录为这个消费方保留注册事实。 + +[注册表约定](../../../../packages/jobs/jobs/README.zh.md)和[seam 收敛说明](../architecture/2026-09-03-jobs-seam-consolidation.zh.md)负责运行期执行、资源归属和事件路由。本日志增加持久化观测,不替代这些职责。 + +## Alternatives considered + +**仅向 `job_output` 添加时间戳。** 模型可能永远不收集某个 job,也可能很晚才收集。记录生命周期提交能覆盖这两种情况,无需额外模型调用或消费输出。 + +**保留可重用的控制 id,另设执行 UUID。** 两个值会标识同一个 job。唯一的 `JobId` 同时服务运行期控制和历史配对。 + +**增加启动器清理注册表。** 独立的退出前 disposer 路径会重复 Cordis 的资源归属机制,并增加 headless 对启动器的要求。让持久化持续可用直至最后一个 job 销毁,需要单独修改跨 fiber 生命周期;本日志保留既有销毁机制。 + +**延后发布或传播记录错误。** 延后发布让同步 hub 订阅者可能先记录完成通知、后记录产生通知的结算事实。starter 成功后抛错可能使调用方拿不到已运行 job 的 id。同步并隔离记录错误既保留因果顺序,也不会放弃执行。 + +## Consequences + +应用销毁可能在最后一条 job 结算写入存储前停止 Session 路由或关闭写句柄。因此正常的一次性运行结束、信号关停和进程硬退出都可能留下不完整历史。本次变更不强制跨 fiber 的销毁顺序,也不虚构缺失的终态记录。 + +日志与两种 SDK 暴露执行区间,不改变工具 schema、输出游标或 job 调度。模型侧控制和通知使用唯一的 JobId;有界通知无法容纳完整 id 时指向 job_list。区间从注册提交延续到注册表结算,包含这两个时刻之间的生产方工作与清理;它既不是 CPU 时间,也不是可直接相加的 rollout 延迟指标。追加失败也可能留下不完整历史。持久化事实不会恢复外部进程或其输出环。 + +定向测试覆盖原始时间戳、嵌套调用标识、输出保留、延后取消、生产方拒绝、强制销毁、较晚完成、唯一控制 id、因果发布顺序,以及记录错误隔离。SDK 快照覆盖持久化与协议投影。 diff --git a/.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.i18n.yaml new file mode 100644 index 0000000000..edb4886429 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-10-background-shell-command-display.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-10-background-shell-command-display.md +/agent-note-separate-background-shell-command-display-from-job-state: + en: c531a4b2eb444a12 + zh: 7c216f829d2e5168 +/agent-note-separate-background-shell-command-display-from-job-state/problem: + en: 116454bc385c0cc3 + zh: 241905c37c22cb45 +/agent-note-separate-background-shell-command-display-from-job-state/decision: + en: ca05afff8b542979 + zh: ccb19c8709718ca0 +/agent-note-separate-background-shell-command-display-from-job-state/alternatives-considered: + en: 573a8d2e99de14e6 + zh: 1e7767c951672b42 +/agent-note-separate-background-shell-command-display-from-job-state/consequences: + en: 15b6456f87cfeaa2 + zh: 5fec862a0180dc55 diff --git a/.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.md b/.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.md new file mode 100644 index 0000000000..156aae0ae8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.md @@ -0,0 +1,33 @@ +# Agent Note: Separate background Shell command display from job state + +Status: implemented + +English | [中文](2026-10-10-background-shell-command-display.zh.md) + +## Problem + +An explicit background Shell call already carries its complete command and working directory at Start, but the Tool card shows generic argument JSON rather than the ordinary Shell command area. The original call returns a launch acknowledgement; the background result arrives through a separate completion notice or `job_output` call. Those messages do not directly carry the original Bash callId, so the acknowledgement cannot establish execution completion, and the original call's state cannot represent the job's live state. + +## Decision + +Valid explicit background `bash`/`pwsh` calls reuse the existing terminal command presentation. Before Start returns, the card indicates only that the launch call is in progress. After a normal launch acknowledgement, the same Tool card retains its command, working directory, wrapping, and disclosure state, and becomes a static command-only display without a run-state dot, exit code, empty-output placeholder, or continuing running animation. Launch failures retain the existing raw-argument and error-result presentation; Inspect still exposes the complete arguments and launch acknowledgement. + +The terminal component receives the original call's `background` flag from Start and combines it with the call's `running` value: `background=true` and `running=true` mean that the launch call is still in progress. When the acknowledgement arrives, `background` stays unchanged and `running=false` leaves a static command; it does not describe the background job's running state. This introduces no derived presentation parameter or job state, and does not keep `running=true` indefinitely to pretend that the command is still executing. Ordinary foreground calls, persistent Shell calls, and `terminal_send` retain their default behavior; the existing Copy button's output-copy semantics do not change. + +This decision covers explicit background launches only, not timeout promotion. It adds no job subscription, polling, output assembly, or callId/jobId index. Later completion notices and `job_output` keep their independent presentation, while the header's job panel owns live job information. The Tool card does not receive the final output, and Host interfaces, execution, and the Session log remain unchanged. + +The [Client tool-presentation decision](../architecture/2026-08-23-client-derived-tool-presentation.md#terminal-card) still governs raw-event derivation and business ownership. This note supersedes only its generic-body rule for explicit background Shell calls, not background `terminal_send`. + +## Alternatives considered + +**Parse the launch acknowledgement as an ordinary completed-command result.** The acknowledgement contains no command exit result and can therefore produce a false success indicator. + +**Retain Start's running state indefinitely.** The original Tool call has returned, and without a job subscription the card cannot establish that the background job is still running. + +**Match subsequent job output back to the original Tool.** This requires a reliable jobId-to-callId association and rules for incremental reads, missing history, and duplicate presentation. None is a prerequisite for displaying the existing command, so they remain outside this decision. + +**Keep generic argument JSON as the only presentation.** The data remains available, but the card does not reuse the familiar Shell command presentation and retains an unnecessary difference between foreground and background calls. + +## Consequences + +Users can inspect the launch command in its original Tool card but cannot infer whether the background job is currently running, successful, or failed. Final state and output remain in the job panel, completion notices, and independent read calls. Component and assembly regressions cover disclosure preservation from Start to acknowledgement, absence of false state and empty output, launch failures, root/child calls, and unchanged foreground and `terminal_send` behavior; assembly-regression event cases use explicitly identified constructed data. diff --git a/.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.zh.md b/.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.zh.md new file mode 100644 index 0000000000..19090614e3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 主动后台 Shell 的命令展示与任务状态分离 + +Status: implemented + +[English](2026-10-10-background-shell-command-display.md) | 中文 + +## Problem + +主动后台 Shell 在 Start 时已经具有完整 command 和工作目录,但 Tool 卡片使用通用参数 JSON,而不是普通 Shell 的命令区域。原调用返回的是启动回执,后台最终结果通过另一条完成通知或 `job_output` 调用出现;这些消息没有直接携带原 Bash callId,不能把启动回执当成执行完成,也不能用原调用状态代表任务实时状态。 + +## Decision + +合法的主动后台 `bash`/`pwsh` 复用现有终端命令展示。Start 尚未返回时只表达启动调用正在进行;正常启动回执到达后,同一 Tool 卡片保留 command、工作目录、软换行及展开状态,切换为静态的仅命令展示,不显示执行状态点、退出码、空输出提示或持续运行动画。启动错误继续使用现有原始参数和错误结果展示;完整参数与启动回执仍可通过 Inspect 查看。 + +终端组件从 Start 起接收原调用的 `background` 标志,并结合调用的 `running` 决定展示:`background=true`、`running=true` 表示启动调用尚在进行;回执到达后 `background` 不变、`running=false`,只保留静态命令,不代表后台任务的运行状态。不新增派生展示参数或任务状态,也不以永久 `running=true` 伪造命令仍在执行。普通前台、持久 Shell 和 `terminal_send` 的默认行为不变;不改变原有 Copy 按钮的输出复制语义。 + +本次只处理主动后台启动,不修改超时转后台。没有 job 订阅、轮询、结果拼接或 callId/jobId 索引;后续完成通知和 `job_output` 保持原来的独立展示,标题栏任务面板继续拥有实时任务信息。Tool 卡片不回填最终输出,不修改 Host 接口、执行器或 Session 日志。 + +[Client 工具展示决策](../architecture/2026-08-23-client-derived-tool-presentation.zh.md#terminal-card)的原始事件派生与业务归属保持有效;本文仅替代其主动后台 Shell 使用通用正文的条目,不改变后台 `terminal_send`。 + +## Alternatives considered + +**直接按普通已结束命令解析启动回执。** 回执没有命令退出结果,容易显示虚假的成功状态。 + +**永久保留 Start 的 running 状态。** 原 Tool 调用已返回,且不订阅任务状态,无法保证后台任务仍在运行。 + +**将后续 job 输出匹配回原 Tool。** 需要可靠的 jobId 与原 callId 关联,还要定义分次读取、缺失历史和重复展示的处理;这些不是展示已有 command 的前提,本次不做。 + +**继续只展示通用参数 JSON。** 数据虽然存在,但没有复用用户熟悉的 Shell 命令展示,保留了后台与前台的不必要差异。 + +## Consequences + +用户能在原 Tool 卡片查看启动命令,但不能据此判断后台任务当前成功、失败或运行中。最终状态和输出仍在任务面板、完成通知及独立读取调用中查看。组件与装配回归覆盖 Start→回执的展开保持、无虚假状态与空输出、启动失败、root/child、普通前台及 `terminal_send` 不变;装配回归的事件用例使用明确标识的构造数据。 diff --git a/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.i18n.yaml new file mode 100644 index 0000000000..f9ff8a3881 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.i18n.yaml @@ -0,0 +1,31 @@ +# Bilingual-pair consistency record for 2026-10-11-process-specific-prompt-values-in-runtime-context.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-11-process-specific-prompt-values-in-runtime-context.md +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context: + en: 711ce3e3afb1f905 + zh: 2d4e4aea44043c4a +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/problem: + en: f4e050c7dbf645da + zh: 722bcdc409d09bec +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision: + en: 5222f00fd05d3801 + zh: fcfa430a0fa5588c +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision/ownership: + en: c23d5c27f1f160f0 + zh: 5c2464666f1e6c29 +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision/new-sessions: + en: 1ee144c39905bb89 + zh: a6669d7ad394e41c +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision/earlier-format-sessions: + en: 27c11e7ced286898 + zh: 944d051494b1498d +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/alternatives-considered: + en: c324412be4034c40 + zh: 923ad612fc884be1 +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/consequences: + en: 8aa41ff08b848916 + zh: bc1c1647ca0eb86a +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/testing: + en: 9667d1749bdf1547 + zh: 598dc39120c91228 diff --git a/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.md b/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.md new file mode 100644 index 0000000000..86bda9b2a6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.md @@ -0,0 +1,95 @@ +# Agent Note: Process-specific checkout path and Web URL move to runtime context + +Status: implemented + +English | [中文](2026-10-11-process-specific-prompt-values-in-runtime-context.zh.md) + +## Problem + +`dsh web` wrote two process-specific values directly into the system prompt: the implementation checkout path in the `harness:source` section (four directories above the web-app module) and the Web GUI URL in the `app:web-surface` section (the bound port or `publicUrl`). These operations change them without the user perceiving a system-prompt change: + +| Operation | Changed value | +|---|---| +| Every Desktop launch (`--port 0` picks a random port) | Web URL | +| A different `--port`, or alternating A/B checkouts | Web URL, checkout path | +| npm and Desktop installations sharing one `DSH_HOME` | Checkout path | + +On resume the loop renders the prompt again. On a route without `systemPromptUpdate: 'in-history'`, such as a hand-declared pi-ai `anthropic-messages` route, the changed prompt rewrites the surface head node. Claude binds historical thinking-block signatures to the conversation prefix, system prompt included, so the upstream answers 400 (`Invalid signature … bound to a different conversation`) and the Session cannot continue until the environment is restored. In-history routes do not fail, but every change appends a complete prompt. + +## Decision + +Both values become runtime context and the system prompt keeps only value-free guidance; a Session whose log already holds the earlier format keeps its logged paragraphs. + +### Ownership + +| Package | Responsibility | +|---|---| +| `@deepseek-ai/dsh-harness-dev-context` (`packages/context/harness-dev-context`) | Both sections, both contexts, and the earlier-format projection; all module-private, exporting only the plugin protocol (`name`, `inject`, `apply`) | +| `@deepseek-ai/dsh-host-webserver` | `WebServer.getOrigin()` (the bind address's URL form with protocol and bound port) and `bindUrlHost(host)` | +| `@deepseek-ai/dsh-web-app` | The `web-runtime` row provides `webApp`, whose `getAdvertisedUrl()` returns the configured `publicUrl`, otherwise the listener origin, the same value as `DSH_WEB_URL` and the startup URL; the bundle patch mounts the `harness-dev-context` row | +| `@deepseek-ai/dsh-system-prompt` | `CONTEXT_ORDERS.HARNESS_SOURCE = 200` and `WEB_SURFACE = 210` | + +The plugin injects `systemPrompt`, `sessionProjections`, and `webApp` and reads those services in code; its patch row carries no configuration expression. The checkout path is four directories above this package's source or built entry, the same depth as the former web-app location, so source, npm, and Desktop installations resolve the same root. The URL is `webApp.getAdvertisedUrl()`, so a profile that overrides the `web-runtime` row's `publicUrl` reaches the model as well. The plugin reaches the `webApp` type through `import type {} from '@deepseek-ai/dsh-web-app'`; the import is erased at runtime, so the web-app bundle's dependency on this package remains the only runtime edge. + +### New Sessions + +| Registration | Content | +|---|---| +| Section `harness:source` (the `HARNESS_SOURCE` slot) | `The runtime context names the DeepSeek Harness implementation checkout. …`, the remaining guidance unchanged | +| Context `harness:source` (`CONTEXT_ORDERS.HARNESS_SOURCE = 200`) | `Current DeepSeek Harness implementation checkout: "".` | +| Section `app:web-surface` (the `WEB_SURFACE` slot) | `You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. …`, the remaining guidance unchanged | +| Context `app:web-surface` (`CONTEXT_ORDERS.WEB_SURFACE = 210`) | `Current DeepSeek Harness Web GUI URL: "".` | + +Both contexts are optional (no `required`). The only complete persona, web-app's minimal preset, also disables optional runtime context, so it still sees neither value. Runtime context follows the existing mechanism: an unchanged value emits no snapshot, a change appends a complete snapshot before the step's user message, and resume compares against the latest logged snapshot, never rewriting a committed message. + +### Earlier-format Sessions + +The plugin registers the host-only session projection `harnessDevLegacyPrompt`, folding every nonempty `system/message` in event order into `{ source, web }`: the paragraphs of that prompt that match an earlier template verbatim, or `null`. + +| Earlier template | Matching rule | +|---|---| +| `harness:source` | Starts with `The DeepSeek Harness implementation checkout is at `, ends with the earlier fixed guidance, no line break between | +| `app:web-surface` | Starts with `You are interacting with the user through the DeepSeek Harness Web GUI at `, ends with `. ` plus the guidance as earlier releases rendered it, frozen as a literal, no line break between | + +Both section text providers read the projection state of `AssembleContext.agent.session`: with an earlier paragraph they return it verbatim (`interpolate: false`), otherwise the current guidance. Diagnostic assemblies without an agent use the current guidance. `sessionProjections` is a required injection and the plugin registers its own unit, so the state is readable whenever an agent exists. + +An earlier-format Session therefore satisfies both requirements: + +| Scenario | System prompt | Runtime context | +|---|---|---| +| First resume after upgrading, same port | Byte-identical to the log; the loop emits no `system/message` | Appends a snapshot with both current values | +| Port, slot, or installation change after upgrading | Still the logged earlier paragraphs | Appends a snapshot with the new values | + +The projection computes only on each committed `system/message`, adding no synchronous read of historical events; its state persists with projection checkpoints. When compaction or a new request series rewrites the head, the rendering still contains the same earlier paragraphs, so the projection state does not change. + +## Alternatives considered + +**Move both values to runtime context without keeping earlier paragraphs.** The upgrade itself changes the prompt text, so every existing Claude Session rewrites its head and fails with 400 on its first turn after the upgrade. + +**Offer `supportsMidConvoSystemMessages` on hand-declared Anthropic routes.** The upstream accepts mid-conversation system messages in a live probe, but the pi-ai catalog already supplies that capability for named Anthropic routes and hand-declared routes withhold catalog fields by convention; in-history routes would still append a complete prompt on every value change. + +**Ask the upstream to drop mismatched thinking blocks (`prefix_mismatch_behavior: "drop_block"`).** It silently loses historical reasoning and covers one vendor. + +**Freeze the whole system prompt per Session.** Legitimate prompt changes such as plan mode, skills, and tool guidance would also be frozen and never reach the model. + +**Keep the implementation in web-app and app-boot.** Development-facing context and earlier-format compatibility would enlarge the public API of two core packages; app-boot's `addHarnessSourceSection` had web-app as its only caller. + +**Read `webStartup.publicUrl`.** It carries only the `--public-url` flag, so a profile override of the `web-runtime` row's `publicUrl` would reach `DSH_WEB_URL` but not the model; the effective value exists only inside web-app, which now provides it as `webApp`. + +**Read the head node text from Session history directly.** The synchronous arbitrary-history read APIs are deprecated and new calls are prohibited; a projection is the established way to obtain that state after resume. + +## Consequences + +- A new Session's system prompt no longer changes with the port, slot, or installation; both current values arrive as runtime-context snapshots, and a change appends a few lines. +- An earlier-format Session keeps the old values of its earlier paragraphs for its whole lifetime; the later `Current …` runtime context in the same request gives the current values. +- Only these two paragraphs are protected. When other prompt text changes between releases, routes without in-history updates still rewrite the head. +- A release without this fix rewrites the head of a new-format Session with the earlier template on resume; both A/B checkouts need this fix before they alternate. +- app-boot no longer exports `addHarnessSourceSection` or `HARNESS_SOURCE_SECTION`; web-app's `surfaceContext` controls only `DSH_WEB_URL`, and disabling the `harness-dev-context` row turns both contexts off. +- Runtime context in recorded Web e2e Sessions gains two lines; the test scaffold normalizes the URL and checkout path to placeholders. + +## Testing + +- `packages/context/harness-dev-context/tests/harness-dev-context.spec.ts`: on a production AgentLoop, an earlier-format Session resumed on another port emits no `system/message`, the request's system prompt is byte-identical to the log, and runtime context carries the current URL; a second port change keeps this; replace-only and in-history routes each run. When a series consolidation only empties a later version, the next turn keeps the earlier paragraphs. Similar text without the fixed ending or broken across lines receives the current guidance. A new Session keeps its system prompt across a port change and only appends a runtime-context snapshot. Sections carry no values, the context carries the advertised URL, and disposing the plugin fiber removes every registration. +- `packages/host/webserver/tests/webserver.spec.ts`: `bindUrlHost` across address forms; `getOrigin()` on HTTP and HTTPS listeners, and its errors before binding and for a zoned non-loopback address. +- `packages/bundle/web-app/tests/web-app.spec.ts`: `webApp.getAdvertisedUrl()` returns the listener origin, or the row's configured `publicUrl`. +- `packages/core/system-prompt/tests/system-prompt.spec.ts`: the added context positions are unique integers. diff --git a/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.zh.md b/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.zh.md new file mode 100644 index 0000000000..b37e09e6bb --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.zh.md @@ -0,0 +1,95 @@ +# Agent Note: 进程相关的 checkout 路径与 Web URL 改走运行时上下文 + +Status: implemented + +[English](2026-10-11-process-specific-prompt-values-in-runtime-context.md) | 中文 + +## 问题 + +`dsh web` 曾把两项随进程变化的值直接写进 system prompt:`harness:source` 段里的实现 checkout 路径(web-app 模块位置上溯四层),以及 `app:web-surface` 段里的 Web GUI URL(实际监听端口或 `publicUrl`)。下列操作都会改变它们,而用户感知不到 system prompt 变了: + +| 操作 | 变化的值 | +|---|---| +| Desktop 每次启动(`--port 0` 取随机端口) | Web URL | +| 换 `--port`、A/B 两个 checkout 交替运行 | Web URL、checkout 路径 | +| npm 安装与 Desktop 安装共用同一个 `DSH_HOME` | checkout 路径 | + +会话恢复时 loop 重新渲染 prompt。对不声明 `systemPromptUpdate: 'in-history'` 的路由(例如手写的 pi-ai `anthropic-messages` 路由),变化的 prompt 会改写 surface 头节点。Claude 的历史 thinking 块签名绑定包括 system prompt 在内的对话前缀,前缀一变上游返回 400(`Invalid signature … bound to a different conversation`),会话在环境恢复原样之前无法继续。声明 in-history 的路由不报错,但每次变化都追加一份完整 prompt。 + +## 决定 + +两项值改为运行时上下文,system prompt 只保留不含值的说明;已经按旧格式写入日志的会话继续沿用日志里的旧段落。 + +### 归属 + +| 包 | 职责 | +|---|---| +| `@deepseek-ai/dsh-harness-dev-context`(`packages/context/harness-dev-context`) | 两个 section、两个 context、旧格式投影;全部模块私有,只导出插件协议(`name`、`inject`、`apply`) | +| `@deepseek-ai/dsh-host-webserver` | `WebServer.getOrigin()`(绑定地址的 URL 形式加协议与实际端口)与 `bindUrlHost(host)` | +| `@deepseek-ai/dsh-web-app` | `web-runtime` 行提供 `webApp`,其 `getAdvertisedUrl()` 返回配置的 `publicUrl`,否则返回监听器 origin,与 `DSH_WEB_URL` 和启动 URL 同值;bundle patch 挂载 `harness-dev-context` 行 | +| `@deepseek-ai/dsh-system-prompt` | `CONTEXT_ORDERS.HARNESS_SOURCE = 200`、`WEB_SURFACE = 210` | + +插件 `inject` 为 `systemPrompt`、`sessionProjections`、`webApp`,在代码里直接读取这些服务;patch 行不写任何配置表达式。checkout 路径取本包源码或构建入口上溯四层,与原 web-app 位置同深度,源码、npm 与 Desktop 安装下结果不变。URL 取 `webApp.getAdvertisedUrl()`,因此 profile 覆盖 `web-runtime` 行的 `publicUrl` 时模型也能看到。插件通过 `import type {} from '@deepseek-ai/dsh-web-app'` 取得 `webApp` 类型;该导入在运行时被擦除,因此 web-app bundle 对本包的依赖仍是唯一的运行时依赖。 + +### 新会话 + +| 注册 | 内容 | +|---|---| +| section `harness:source`(`HARNESS_SOURCE` 位) | `The runtime context names the DeepSeek Harness implementation checkout. …`,其余说明不变 | +| context `harness:source`(`CONTEXT_ORDERS.HARNESS_SOURCE = 200`) | `Current DeepSeek Harness implementation checkout: "".` | +| section `app:web-surface`(`WEB_SURFACE` 位) | `You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. …`,其余说明不变 | +| context `app:web-surface`(`CONTEXT_ORDERS.WEB_SURFACE = 210`) | `Current DeepSeek Harness Web GUI URL: "".` | + +两个 context 都是可选项(不设 `required`)。唯一的 complete persona(web-app 的 minimal 预设)同时关闭了可选运行时上下文,因此它仍然看不到这两项。运行时上下文按既有机制工作:值不变不发新快照,变化时在本轮 user 消息前追加完整快照,恢复时从会话日志取上一份快照比较,不改写任何已提交消息。 + +### 旧格式会话 + +插件注册仅限宿主的会话投影 `harnessDevLegacyPrompt`,按事件顺序折叠每个非空 `system/message`,状态为 `{ source, web }`:该 prompt 中按旧模板逐字匹配到的段落,未匹配为 `null`。 + +| 旧模板 | 匹配规则 | +|---|---| +| `harness:source` | `The DeepSeek Harness implementation checkout is at ` 开头、旧版固定说明结尾、中间不含换行 | +| `app:web-surface` | `You are interacting with the user through the DeepSeek Harness Web GUI at ` 开头、`. ` 加早期版本渲染的说明结尾(冻结为字面量)、中间不含换行 | + +两个 section 的文本提供函数读取 `AssembleContext.agent.session` 的投影状态:有旧段落时原样返回该段落(`interpolate: false`),否则返回新说明。没有 agent 的诊断性组装使用新说明。`sessionProjections` 是插件的必需注入,投影单元由插件自己注册,因此存在 agent 时状态必然可读。 + +旧会话因此满足两点: + +| 场景 | system prompt | 运行时上下文 | +|---|---|---| +| 升级后首次恢复,端口不变 | 与日志逐字相同,loop 不产生 `system/message` | 追加含两项当前值的快照 | +| 升级后换端口、slot 或安装位置 | 仍是日志里的旧段落 | 追加新值快照 | + +投影只在每次提交的 `system/message` 上计算,不新增同步读取历史事件的调用;状态随投影检查点持久化。压缩或新请求序列重写头节点时,渲染结果仍含同一旧段落,投影状态不变。 + +## 备选方案 + +**只把两项值移到运行时上下文,不保留旧段落。** 升级本身会改变 prompt 文本,所有既有 Claude 会话在升级后第一轮改写头节点并报 400。 + +**为手写 Anthropic 路由开放 `supportsMidConvoSystemMessages`。** 上游实测接受对话中途的 system 消息,但 pi-ai 目录已为具名 Anthropic 路由提供该能力,手写路由按既有约定不开放目录字段;而且 in-history 路由每次值变化仍追加一份完整 prompt。 + +**请求上游丢弃不匹配的 thinking 块(`prefix_mismatch_behavior: "drop_block"`)。** 会静默丢失历史推理,且只覆盖一个供应商。 + +**会话内冻结整份 system prompt。** plan 模式、技能和工具说明等合法的 prompt 变化也会被冻结,无法到达模型。 + +**留在 web-app 与 app-boot 中实现。** 面向开发的上下文与旧格式兼容逻辑会扩大两个核心包的公开 API;app-boot 的 `addHarnessSourceSection` 当时只有 web-app 一个调用方。 + +**读取 `webStartup.publicUrl`。** 它只携带 `--public-url` 参数,profile 覆盖 `web-runtime` 行的 `publicUrl` 时只会影响 `DSH_WEB_URL`,到不了模型;生效值只存在于 web-app 内部,现在由它以 `webApp` 提供。 + +**直接读取会话历史中的头节点文本。** 同步读取任意历史事件的 API 已弃用,新调用被禁止;投影是恢复后取得该状态的既定方式。 + +## 后果 + +- 新会话的 system prompt 不再随端口、slot 或安装位置变化;两项当前值以运行时上下文快照送达,变化时只追加几行。 +- 旧会话在整个生命周期内保留旧段落中的旧值;同一请求里更晚的 `Current …` 运行时上下文给出当前值。 +- 只保护这两段。其他提示文本在版本之间变化时,不支持 in-history 的路由仍会改写头节点。 +- 不含本修复的旧版本恢复新格式会话时会按旧模板改写头节点;A/B 两个 checkout 都需包含本修复后再交替运行。 +- app-boot 不再导出 `addHarnessSourceSection` 与 `HARNESS_SOURCE_SECTION`;web-app 的 `surfaceContext` 只控制 `DSH_WEB_URL`,关闭这两项上下文改为禁用 `harness-dev-context` 行。 +- Web e2e 录制会话里的运行时上下文多出两行;测试脚手架把 URL 与 checkout 路径规范化为占位符。 + +## 测试 + +- `packages/context/harness-dev-context/tests/harness-dev-context.spec.ts`:真实 AgentLoop 上,旧格式会话在另一端口恢复时不产生 `system/message`、请求中的 system prompt 与日志逐字相同、运行时上下文携带当前 URL;再换一次端口仍成立;不支持与支持 in-history 的路由各跑一遍。series 合并只清空后续版本时,下一轮仍沿用旧段落。缺少固定结尾或跨行的相似文本得到新说明。新会话换端口后 system prompt 不变、只追加运行时上下文快照。section 不含值、context 携带公告 URL,销毁插件 fiber 后全部注册消失。 +- `packages/host/webserver/tests/webserver.spec.ts`:`bindUrlHost` 各地址形式;`getOrigin()` 在 HTTP 与 HTTPS 监听上的值,绑定前与带 zone 的非回环地址抛错。 +- `packages/bundle/web-app/tests/web-app.spec.ts`:`webApp.getAdvertisedUrl()` 返回监听器 origin,或该行配置的 `publicUrl`。 +- `packages/core/system-prompt/tests/system-prompt.spec.ts`:新增的 context 位置唯一且为整数。 diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml index 38cc4a8bf5..e3bd9b93f5 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml @@ -39,8 +39,8 @@ en: 761eb6c1aeb7af18 zh: 5434456a3d0a1b65 /agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/testing: - en: f5d287f610962a48 - zh: 88368e1df517b087 + en: 6e637b0970682265 + zh: 7fed6e98f87b5f1a /agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/alternatives-considered: en: e78530994a88d14f zh: 49f64c7f1421f507 diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.md b/.agents/notes/implemented/feature/2026-06-15-ptc.md index c49b21ecee..97fc5b04db 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.md @@ -101,6 +101,8 @@ Deployments switching to `'ptc'` must update any native-only `toolOrder`. Assemb - **With-key e2e:** A real model composes two bash calls in one program; another discovers nested workspace instructions through a PTC mode fs dispatch. The tests verify collapsed request headers, correlated dispatch events, resulting files, deferred context, and model behavior. - **Snapshot:** The `ptc-turn`, `both-mode-turn`, and `ptc-workspace-context` fixtures pin SDK text, header tool lists, dispatch events, deferred context, and result cards. The TypeScript SDK PTC scenario mounts its Node runtime through an explicit test-owned profile patch and pins Session events and JSON-RPC notifications; its expected response and completed-turn checks run before refresh writes. +The optional [Node in-memory execution mode](../architecture/2026-09-23-best-effort-persistent-ptc.md) permits disposable cross-call memory without restoring objects or replaying programs after restart; the default execution mode stays stateless. + ## Alternatives considered **An add-on consumer plugin with zero core changes.** Rejected because `agent/request` is call-config-only under [reconstructable requests](../architecture/2026-07-05-reconstructable-requests.md), while transforming an assembled tool list would have to undo `toolOrder` canonicalization without owning its config and would depend on listener order. Which tools the model is offered, and in which representation, is the registry's single concern: native schemas and the SDK are two projections of one visible store. diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md index 669a63e991..381448d354 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md @@ -101,6 +101,8 @@ SDK 指示模型编写一个所加载运行时语言的异步函数体(默认 - **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;另一个模型通过 PTC mode fs 分发发现嵌套的工作区指令。测试验证折叠的请求头、关联的分发事件、结果文件、延后上下文和模型行为。 - **快照:** `ptc-turn`、`both-mode-turn` 和 `ptc-workspace-context` fixture(测试前置数据)固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。TypeScript SDK PTC 场景通过测试拥有的显式 profile patch 挂载 Node 运行时,并固定 Session 事件与 JSON-RPC 通知;预期回复和完成轮次检查在 refresh 写入前执行。 +可选的[Node 内存保留执行模式](../architecture/2026-09-23-best-effort-persistent-ptc.zh.md)允许跨调用保留可丢失的内存,不在重启后恢复对象或回放程序;默认执行模式保持无状态。 + ## 曾考虑的替代方案 **一个零核心改动的附加消费方插件。** 否决,因为 `agent/request` 在[可重建请求](../architecture/2026-07-05-reconstructable-requests.zh.md)下仅限 call-config,而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化,并依赖监听器顺序。向模型提供哪些工具、以何种表示形式提供,是注册表的单一关注点:原生 schema 和 SDK 是同一个可见存储的两种投影。 diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml index 431d5f60ec..529c16087a 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml @@ -9,29 +9,29 @@ en: 311bc04b9c36ab53 zh: 0c52e6493e9350a5 /agent-note-configure-subagent-persona-tool-visibility-and-depth/decision: - en: 79b3fcfaaaaccf9e - zh: 0287bfb41c7b9707 + en: 45111f900708aaf9 + zh: e75bed4951516963 /agent-note-configure-subagent-persona-tool-visibility-and-depth/decision/persona-is-a-scoped-shadow: - en: 8b299c99d26aa5c3 - zh: 6956efbd236cef8e + en: f9ee31a1fdcdd38f + zh: c9d05721cef80659 /agent-note-configure-subagent-persona-tool-visibility-and-depth/decision/tool-filtering-is-one-live-global-view-rule: en: a9fe46283ea28722 zh: 7b7d1be7c070b893 /agent-note-configure-subagent-persona-tool-visibility-and-depth/decision/depth-is-an-absolute-tree-cap: - en: 9145bec4e3f7eb5b - zh: 986276925cb064d9 + en: c1d92ec60342c8b7 + zh: 063c9eaba331312b /agent-note-configure-subagent-persona-tool-visibility-and-depth/decision/capability-gating-keeps-providers-honest: - en: 5334afafefdb0f59 - zh: 7e96c9a08bbc3d1a + en: 1669c3372b728fc8 + zh: 349ee461c9307811 /agent-note-configure-subagent-persona-tool-visibility-and-depth/decision/unpublished-setup-makes-the-first-request-correct: - en: 3c988dfff07c8da3 - zh: 8ce2fbcc438e7dd8 + en: 32b0648b901c49ad + zh: b72f29a8be987479 /agent-note-configure-subagent-persona-tool-visibility-and-depth/visibility-is-not-authority: en: d0db5310a87ff8e4 zh: 4b9f502238cd4bae /agent-note-configure-subagent-persona-tool-visibility-and-depth/alternatives-considered: - en: 78ae41331d8fcc3e - zh: a93ee10b3e30c267 + en: cf5006aa2b345a37 + zh: f26a9a1ab9a83a46 /agent-note-configure-subagent-persona-tool-visibility-and-depth/consequences: - en: d5f05d63b7a01dd8 - zh: f0be1e88b06d8cc9 + en: 58a53e46a6ce8307 + zh: 6e552d8b6ff4b186 diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md index f848b92a7a..fabcb81393 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md @@ -12,7 +12,7 @@ These controls affect the child's first model request and therefore cannot be in ## Decision -Subagent starts have three independent composition controls: `persona`, `toolFilter`, and `maxDepth`. A provider advertises support for each control, the service rejects unsupported requests before starting a run, and an in-process provider installs the requested composition while the child is still unpublished. +This note retains the depth policy and the persona/tool-filter semantics needed to restore historical child descriptors. New children use [preset-owned composition](../simplification/2026-10-09-preset-owned-subagent-composition.md); new requests do not accept persona or tool filters. Historical overrides are applied while a resumed child is unpublished. The controls answer different questions: @@ -22,16 +22,18 @@ The controls answer different questions: | `toolFilter` | Which deployment-global tools enter this child's visible tool view? | A scoped restriction filters globals before child-local tools are added | | `maxDepth` | How deep may this delegation tree grow? | A start whose child depth exceeds the absolute cap is rejected | -`dsh-tool-subagent` exposes the controls as plugin configuration and copies them into each request it creates. Direct `SubagentRuntime` callers may choose them per request. The provider capability descriptor remains the source of truth for whether a backend can honor each field. +`dsh-tool-subagent` and direct `SubagentRuntime` callers expose `maxDepth`. Its provider capability determines whether a backend can enforce the cap. Persona and tool-filter values come only from historical descriptors during cold resume. ### Persona is a scoped shadow -The persona control changes one child without changing deployment-wide prompt assembly. During unpublished setup, an in-process provider registers a child-scoped section named `deployment:persona-prefix`; ordinary most-specific-wins resolution replaces the global section only in that child's assemblies. +A historical persona changes one resumed child without changing deployment-wide prompt assembly. During unpublished setup, the manager registers a child-scoped section named `deployment:persona-prefix`; ordinary most-specific-wins resolution replaces the inherited section only in that child’s assemblies. -The value has the same strict template semantics as the deployment persona. Omitting it inherits the deployment section through the global layer; an explicit empty string shadows the global persona with an empty section. Parent and sibling personas never enter the child's flat scope. +The value has the same strict template semantics as the deployment persona. Omitting it preserves the composed persona; an explicit empty string shadows that persona with an empty section. This uses the normal system-prompt registration mechanism rather than a second persona channel. The first prompt therefore sees the same named contribution that later prompts and prompt-inspection tools see. + + ### Tool filtering is one live global-view rule The tool filter controls capability visibility and executable lookup together. An in-process provider installs `ToolRuntime.restrict()` in the child's scope before publication, and the registry's single resolver applies the same result to wire tool schemas, lookup, execution, and PTC mode SDK generation. Independently registered system-prompt sections remain owned by their plugins. The filesystem, search, and web tool plugins use the existing `PromptSection.text({ scope })` callback and `ctx.tools.get(name, scope)` to omit guidance for unavailable tools and select applicable cross-tool text. This keeps the original wording and ordering for a supported tool set and works for any agent scope, including underlying PTC capabilities whose wire presentation is `run_code`. It adds no section-ownership metadata or assembly pass; unrelated static prose is not automatically rewritten by `restrict()`. @@ -53,19 +55,19 @@ The depth limit bounds recursive delegation independently of tool visibility. A The effective parent depth is the greater of durable `SessionHeader.delegationDepth` and runtime `AgentOptions.subagentDepth`. An in-process child records its derived depth in the session header, and resume restores that header, so a restart cannot lower the recursion count. -Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; loader-resolved `dsh-tool-subagent` configuration instead defaults to `3`, accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. Three is a small finite default that still permits a root plus three descendant generations, and the [base profile](../../../../packages/bundle/base/cordis.patch.yml) uses that general policy. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`. +Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; `dsh-tool-subagent` instead reads the live Host depth setting (initially `1`), accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. The [base profile](../../../../packages/bundle/base/cordis.patch.yml) uses the Host setting. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`. -A deployment can combine depth and filtering, but the numeric cap does not synthesize a filter. The delegation tool stays visible at the cap because authorization may depend on runtime state; every attempted start checks the calling agent's current durable and runtime depth, and a rejected start returns an errored tool result without publishing a child. A deployment may separately deny delegation tools in children when its visibility policy is static. Neither choice changes the provider's conversation-history behavior. +The numeric cap does not synthesize a filter. The delegation tool stays visible at the cap because authorization may depend on runtime state; every attempted start checks the calling agent’s current durable and runtime depth, and a rejected start returns an errored tool result without publishing a child. A preset can omit delegation tool plugins independently of the depth policy. At the cap, the tool contributes an optional runtime-context notice telling the model to work directly. `includeRuntimeContext: false` suppresses this advisory notice; the runtime depth guard remains active. When enabled, the notice reads the live cap and durable depth at request time to keep resumed and reconfigured agents informed without changing their system prompt or tool schemas; unchanged context is not repeatedly appended. ### Capability gating keeps providers honest -Capabilities separate a requested feature from a provider implementation. `SubagentCapabilities` advertises `persona`, `toolFilter`, and `depthLimit`; `SubagentRuntime.start()` checks every present request field against those flags before calling the provider. +Capabilities separate a requested feature from a provider implementation. `SubagentCapabilities.depthLimit` advertises depth enforcement; `SubagentRuntime.startActivation()` checks a requested cap before calling the provider. This lets spawn and fork providers share the in-process implementation while external providers advertise only what they can enforce. A request never degrades silently: selecting an unsupported control produces `UNSUPPORTED_CAPABILITY`, and no run or lifecycle event exists. ### Unpublished setup makes the first request correct -All child-local composition is complete before the child becomes observable. The in-process provider supplies one setup callback to agent creation; that callback installs persona, tool restriction, and structured-output contributions in the child's scope. Only after setup succeeds does creation publish the session and agent and allow the driver to start. +All child-local composition is complete before the child becomes observable. The manager supplies a setup callback to agent creation that composes the preset, restores historical persona and tool restrictions on cold resume, and installs structured output. Only after setup succeeds does creation publish the session and agent and allow the driver to start. A setup failure rolls back the private child. No observer can acquire a child whose first prompt used the deployment persona or unfiltered tool set and whose later prompts use the requested configuration. @@ -79,7 +81,7 @@ A security design would need a separate authority representation, propagation ru ## Alternatives considered -**Create one provider per persona or tool set.** This multiplies providers that share the same transport and lifecycle implementation, makes dynamic deployment configuration awkward, and still needs a recursion mechanism. Providers remain about execution transport; requests carry per-child composition. +**Create one provider per persona or tool set.** This multiplies providers that share the same transport and lifecycle implementation and still needs a recursion mechanism. Providers remain about execution transport; presets own new child composition, while descriptors retain historical overrides. **Copy the parent's complete tool view.** Registration scope is flat by design, and lifetime ownership does not imply visibility inheritance. Copying a resolved view would also freeze dynamic global registrations and conflate composition with authority without defining either contract fully. @@ -91,6 +93,6 @@ A security design would need a separate authority representation, propagation ru ## Consequences -Contributors can configure child role, visible global tools, and recursion without defining new providers. Capability checks fail before ownership starts, unpublished setup makes the first request consistent, and one tool resolver prevents presentation/execution drift. +Historical child composition survives cold resume without defining new providers. Capability checks fail before ownership starts, unpublished setup makes the first request consistent, and one tool resolver prevents presentation/execution drift. The cost is that deployments must understand live allow/deny behavior and the distinction between visibility and authority. A model may call a visible delegation tool after the current depth policy forbids another child and receive an error. Provider authors must advertise each supported control accurately, and in-process providers must install every requested contribution before publication. The controls deliberately do not solve security confinement or parent-to-child non-escalation. diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md index 3360b27c9f..8993d0dc73 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `maxDepth`。提供方声明对每个控制的支持情况,服务在启动运行之前拒绝不受支持的请求,进程内提供方在子 agent 尚未发布时安装所请求的组合。 +本记录保留深度策略,以及恢复历史子级 descriptor 所需的 persona/工具过滤语义。新子级使用 [preset 拥有的组装](../simplification/2026-10-09-preset-owned-subagent-composition.zh.md);新请求不接受 persona 或工具过滤。历史覆盖在恢复的子级尚未发布时应用。 这些控制回答不同的问题: @@ -22,13 +22,13 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma | `toolFilter` | 部署全局工具中哪些进入该子 agent 的可见工具视图? | 一个有作用域的限制在添加子 agent 局部工具之前过滤全局工具 | | `maxDepth` | 这棵委派树最深可以长到多少层? | 子 agent 深度超过绝对上限时,启动请求被拒绝 | -`dsh-tool-subagent` 将这些控制作为插件配置暴露,并复制到它创建的每个请求中。直接调用 `SubagentRuntime` 的调用方可以按请求选择这些控制。提供方的能力描述符仍然是后端能否兑现各字段的真源。 +`dsh-tool-subagent` 和直接调用 `SubagentRuntime` 的调用方公开 `maxDepth`。对应的提供方能力决定后端是否能强制执行上限。persona 和工具过滤值仅在冷恢复时来自历史 descriptor。 ### 人设是有作用域的遮蔽 -人设控制改变一个子 agent 的行为,而不改变部署级的提示词组装。在未发布的设置阶段,进程内提供方在子 agent 作用域中注册一个名为 `deployment:persona-prefix` 的段落;普通的最具体者优先解析规则仅在该子 agent 的组装中替换全局段落。 +历史 persona 改变一个恢复子级的行为,而不改变部署级提示词组装。在未发布的设置阶段,管理器注册子级作用域中名为 `deployment:persona-prefix` 的段落;普通的最具体者优先解析规则只在该子级的组装中替换继承段落。 -其值与部署人设具有相同的严格模板语义。省略时通过全局层继承部署段落;显式空字符串则以空段落遮蔽全局人设。父级和兄弟级的人设永远不会进入子 agent 的扁平作用域。 +该值与部署 persona 具有相同的严格模板语义。省略时保留组装的 persona;显式空字符串则以空段落遮蔽该 persona。 这使用的是常规的系统提示词注册机制,而非第二条人设通道。因此第一次提示词看到的命名贡献与后续提示词和提示词检查工具看到的一致。 @@ -55,19 +55,19 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma 有效父级深度取持久 `SessionHeader.delegationDepth` 与运行时 `AgentOptions.subagentDepth` 中的较大值。进程内子 agent 把推导出的深度记录在会话 header 中,恢复时会重新载入该 header,因此重启无法降低递归计数。 -每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。直接的 `SubagentStartRequest` 可以省略上限,让此机制不约束深度;经 loader 解析的 `dsh-tool-subagent` 配置则默认值为 `3`、接受数值覆盖,并使用显式的 `'provider-managed'` 来省略由进程外提供方部署拥有递归预算时的上限。三是一个较小的有限默认值,仍允许 root 加三代后代,[base profile](../../../../packages/bundle/base/cordis.patch.yml)采用这项通用策略。提供方缺少 `depthLimit` 时,数值工具上限会在提供方挂载阶段失败。 +每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。直接的 `SubagentStartRequest` 可以省略上限,让此机制不约束深度;`dsh-tool-subagent` 则读取实时 Host 深度设置(初始值为 `1`)、接受数值覆盖,并使用显式的 `'provider-managed'` 来省略由进程外提供方部署拥有递归预算时的上限。[base profile](../../../../packages/bundle/base/cordis.patch.yml)采用 Host 设置。提供方缺少 `depthLimit` 时,数值工具上限会在提供方挂载阶段失败。 -部署可以组合深度与过滤,但数值上限不会合成过滤器。委派工具在上限处仍然可见,因为授权可能依赖运行时状态;每次尝试启动都会检查调用方 agent 当前的持久与运行时深度,被拒绝的启动返回错误工具结果,且不发布子 agent。可见性策略固定的部署可以另外在子 agent 中 deny 委派工具。两种选择都不改变提供方的对话历史行为。 +数值上限不会合成过滤器。委派工具在上限处仍然可见,因为授权可能依赖运行时状态;每次尝试启动都会检查调用方 agent 当前的持久与运行时深度,被拒绝的启动返回错误工具结果,且不发布子 agent。preset 可独立于深度策略省略委派工具插件。达到上限时,工具贡献一条可选的运行时上下文提示,让模型直接完成工作。`includeRuntimeContext: false` 会抑制这条建议性提示;运行时的深度拦截仍然生效。提示启用时,每次请求读取实时上限与持久深度,使恢复会话或调整配置后的 agent 获知当前限制,同时不改变其系统提示词或工具 schema;未变化的上下文不会重复追加。 ### 能力门控保持提供方诚实 -能力将请求的功能与提供方实现分离。`SubagentCapabilities` 声明 `persona`、`toolFilter` 和 `depthLimit`;`SubagentRuntime.start()` 在调用提供方之前,对照这些标志检查请求中每个存在的字段。 +能力将请求的功能与提供方实现分离。`SubagentCapabilities.depthLimit` 声明深度强制执行能力;`SubagentRuntime.startActivation()` 在调用提供方前检查请求的上限。 这使 spawn 和 fork 提供方可以共享进程内实现,而外部提供方只声明自己能强制执行的部分。请求永远不会静默降级:选择不受支持的控制会产生 `UNSUPPORTED_CAPABILITY`,不会有运行或生命周期事件存在。 ### 未发布设置使第一次请求正确 -所有子 agent 局部的组合在子 agent 变得可观察之前完成。进程内提供方向 agent 创建提供一个设置回调;该回调在子 agent 作用域中安装人设、工具限制和结构化输出贡献。只有设置成功后,创建才发布会话和 agent 并允许驱动器启动。 +所有子级局部组装均在子级可观察前完成。管理器向 agent 创建提供设置回调,组装 preset,在冷恢复时恢复历史 persona 和工具限制,并安装结构化输出。只有设置成功后才发布会话和 agent,并允许驱动器启动。 设置失败会回滚私有子 agent。没有观察者能获取到一个「第一次提示词使用了部署人设或未过滤工具集、后续提示词才使用所请求配置」的子 agent。 @@ -81,7 +81,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma ## 曾考虑的替代方案 -**为每种人设或工具集创建一个提供方。** 这会使共享相同传输和生命周期实现的提供方成倍增加,使动态部署配置变得笨拙,且仍需要递归机制。提供方的职责是执行传输;请求承载每个子 agent 的组合。 +**为每种 persona 或工具集创建一个提供方。** 这会使共享同一传输和生命周期实现的提供方成倍增加,且仍需要递归机制。提供方负责执行传输;preset 拥有新子级组装,descriptor 保留历史覆盖。 **复制父级的完整工具视图。** 注册作用域设计上是扁平的,生命周期所有权不意味着可见性继承。复制已解析视图还会冻结动态全局注册,并在未完整定义任一约定的情况下混淆组合与授权。 @@ -93,6 +93,6 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma ## 后果 -贡献者可以配置子 agent 的角色、可见全局工具和递归深度,而无需定义新的提供方。能力检查在所有权开始之前失败,未发布设置使第一次请求一致,单一工具解析器防止呈现/执行漂移。 +历史子级组装在冷恢复后保留,无需定义新的提供方。能力检查在所有权开始前失败,未发布设置使第一次请求一致,单一工具解析器防止呈现与执行不一致。 代价是部署方必须理解活跃的 allow/deny 行为以及可见性与授权的区别。当前深度策略禁止再创建子 agent 后,模型仍可能调用可见的委派工具并收到错误。提供方作者必须准确声明每个受支持的控制,进程内提供方必须在发布前安装所有请求的贡献。这些控制有意不解决安全隔离或父到子的非升权问题。 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index aab0051c22..3ef2bd6bfb 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -12,11 +12,11 @@ en: 95e7d723d6b00930 zh: d18fdf9b2bf69bdb /agent-note-durable-agent-teams-over-continuable-children/profile-delegation: - en: 3a66308576f74247 - zh: 3453f0bcce88033d + en: 4835fb8c0c0d8a0d + zh: 5f9438a0d58104c4 /agent-note-durable-agent-teams-over-continuable-children/team-identity: - en: 83e446c928a20c47 - zh: b0a2bd24419dd815 + en: f82812d6ac0ac78d + zh: 7dd80a6ad13674ca /agent-note-durable-agent-teams-over-continuable-children/provisioning-and-recovery: en: 4345ac9a6ac7ad04 zh: 1d19ecbb128a75a3 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index 2643101a91..82864e2552 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -22,11 +22,11 @@ The Lead must wait for required work before its final answer. Process teardown r ## Profile delegation -The [Team profile](../../../../packages/experimental/agent-team-profile/README.md) disables `subagent` and `subagent_fork` together with the overlapping global controls. Direct model delegation uses `spawn_teammate` with fresh or fork context, keeping those children in the durable roster. Workflow remains available through the base profile’s fresh `spawn` provider for scripted orchestration; it cannot inherit a teammate’s conversation identity through the model tool. The Subagent services and providers remain shared infrastructure. Ordinary Session forks retain their history without identity correction. Provider-owned child tool visibility remains a [documented limitation](../../../../packages/experimental/tool-agent-team/README.md#known-limitations-and-deferred-work). +The [Team profile](../../../../packages/experimental/agent-team-profile/README.md) disables `subagent` and `subagent_fork` together with the overlapping global controls. Direct model delegation uses `spawn_teammate` with fresh or fork context, keeping those children in the durable roster. Workflow remains available through the base profile’s fresh `spawn` provider for scripted orchestration; it cannot inherit a teammate’s conversation identity through the model tool. The Subagent services and providers remain shared infrastructure. Ordinary Session forks retain their history; [current identity handling](../architecture/2026-10-13-team-identity-runtime-context.md) supplies the fork’s own role. ## Team identity -The `spawn_teammate` tool prefixes the initial task with a user-role `` stating `You are teammate "".`. Identity and task enter the same durable inbox message. Shared system policy and all tool schemas stay uniform across members; execution owns role restrictions. Team tools resolve the caller’s Team and accept member names, so the model needs no Team id. Identity follows ordinary history through cold recovery and compaction; the plugin does not inspect reminder retention or add replacement messages. Forks inherit the recorded text without a Lead identity correction. Putting identity in the system prompt changes the prefix before inherited history; keeping it in the initial task preserves that prefix without per-step identity bookkeeping. Existing system-embedded identities may require a one-time prompt reconciliation; retained event generations are unchanged. +Shared system policy and all tool schemas stay uniform across members; execution owns role restrictions. Team tools resolve the caller’s Team and accept member names, so the model needs no Team id. [Required runtime-context identity](../architecture/2026-10-13-team-identity-runtime-context.md) owns identity injection, retention, and fork handling. ## Provisioning and recovery diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index 92af6e1afb..7a292f2d7e 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -22,11 +22,11 @@ Lead 必须等待所需工作后才能给出最终答案。进程 teardown 仍 ## Profile delegation -[Team profile](../../../../packages/experimental/agent-team-profile/README.zh.md) 禁用 `subagent`、`subagent_fork` 以及名称重叠的全局控件。模型直接委派使用支持 fresh 或 fork 上下文的 `spawn_teammate`,使这些子代理进入持久 roster。Workflow 仍通过 base profile 的 fresh `spawn` 提供方执行脚本编排;经模型工具创建的 workflow 子代理不会继承 teammate 的对话身份。Subagent 服务和提供方仍是共享基础设施。普通 Session fork 保留历史,不纠正身份。提供方所拥有的子代理工具可见性仍是[已记录的限制](../../../../packages/experimental/tool-agent-team/README.zh.md#known-limitations-and-deferred-work)。 +[Team profile](../../../../packages/experimental/agent-team-profile/README.zh.md) 禁用 `subagent`、`subagent_fork` 以及名称重叠的全局控件。模型直接委派使用支持 fresh 或 fork 上下文的 `spawn_teammate`,使这些子代理进入持久 roster。Workflow 仍通过 base profile 的 fresh `spawn` 提供方执行脚本编排;经模型工具创建的 workflow 子代理不会继承 teammate 的对话身份。Subagent 服务和提供方仍是共享基础设施。普通 Session fork 保留历史;[当前身份处理](../architecture/2026-10-13-team-identity-runtime-context.zh.md)提供 fork 自己的角色。 ## Team identity -工具 `spawn_teammate` 在初始任务前加上 user-role ``,声明 `You are teammate "".`。身份和任务进入同一条持久化收件箱消息。共享 system 策略和全部工具 schema 在成员间保持一致;执行时检查角色权限。Team 工具根据调用者确定 Team,并接受成员名字,因此模型不需要 Team id。身份随普通历史经历冷恢复和压缩;插件不检查提醒是否保留,也不添加替代消息。fork 继承已记录文本,不补发 Lead 身份修正。把身份放进 system prompt 会在继承历史之前改变前缀;把它留在初始任务中既保留此前缀,也无需每步维护身份提醒。已有的 system 内嵌身份可能需要一次提示词协调;保留的事件格式代际保持不变。 +共享 system 策略和全部工具 schema 在成员间保持一致;执行时检查角色权限。Team 工具根据调用者确定 Team,并接受成员名字,因此模型不需要 Team id。[必需 runtime-context 身份](../architecture/2026-10-13-team-identity-runtime-context.zh.md)负责身份注入、保留与 fork 处理。 ## Provisioning and recovery diff --git a/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml b/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml index f985dfc4a3..787ac9d839 100644 --- a/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml @@ -21,8 +21,8 @@ en: 237018634b5f9dcf zh: cbc49d9c1b794065 /agent-note-experimental-auto-review-before-each-tool-call/decision/process-catalog-and-children: - en: 6ebe6f90241fd322 - zh: eafdebc5277e83a1 + en: 0042c645360c8361 + zh: 5dba39466d376559 /agent-note-experimental-auto-review-before-each-tool-call/alternatives-considered: en: fe97128ed05b9e43 zh: 4aaa344933fa89b4 diff --git a/.agents/notes/implemented/feature/2026-08-28-auto-review.md b/.agents/notes/implemented/feature/2026-08-28-auto-review.md index c19e780d01..ff54f9d927 100644 --- a/.agents/notes/implemented/feature/2026-08-28-auto-review.md +++ b/.agents/notes/implemented/feature/2026-08-28-auto-review.md @@ -58,7 +58,7 @@ A persisted Auto Session cannot publish when the complete integration is absent The permission owner publishes one complete process catalog through generated `permissionPresets` Remote methods; the BFF explicitly mounts it and forwards a payload-free invalidation event. One browser directory subscribes before reading and serves both pickers. Epoch and connection-generation checks publish only the winning complete result. A winning failure or connection reset clears the previous snapshot; only a later existing read, notification, or reset retries. Disposed or stale settlements cannot publish. Each catalog invalidation dismisses the slash picker and its pending confirmation through the command owner while preserving the draft, so a picker waiting on its own read keeps its failure and retry state; reopening loads the current catalog. Session projection carries current selection only, so catalog installation or removal writes no Session event or sequence. -Auto appears with a superscript `EXP` badge. Both visible current-session pickers require the experimental confirmation; explicit `/permission auto` is already consent. The composer uses the generic Menu's existing portal placement to stay within the viewport while keeping its 218–360px bounds. The slash popup keeps `min(220px, 100%)` with `max-width: 100%`, including when a narrow composer collapses its trigger. +Auto appears with an `EXP` badge in picker options; the selected composer control omits it. Both visible current-session pickers require the experimental confirmation; explicit `/permission auto` is already consent. The composer uses the generic Menu's existing portal placement to stay within the viewport while keeping its 218–360px bounds. The slash popup keeps `min(220px, 100%)` with `max-width: 100%`, including when a narrow composer collapses its trigger. The [delegation-time policy capture](../../../../packages/subagent/subagent/README.md) records Auto or Full access before the first await and appends that existing preset event after fork seeding and sandbox/approval overrides. One-shot and continuable creation share the rule; cold resume reads only the child log. Later parent switches do not alter that child, while a later child switch can win. Read Only and Workspace Write retain sandbox inheritance plus `approval: never` and may therefore remain `custom`. Auto children classify each call independently using the existing lineage and messages, without parent call metadata, delegation records, receipts, Header/descriptor additions, or a Session-format change. Out-of-process children retain their own permission systems after the parent delegation is allowed. diff --git a/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md b/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md index bd99839132..a77aec0259 100644 --- a/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md +++ b/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md @@ -58,7 +58,7 @@ Reviewer 可以输出 reasoning blocks,随后恰好一个 JSON text block 和 Permission owner 通过生成的 `permissionPresets` Remote 方法发布一份完整进程目录;BFF 显式挂载它,并转发无 payload 的失效事件。一个浏览器目录在读取前订阅,为两个选择器提供数据。Epoch 与 connection-generation 检查只发布胜出的完整结果。胜出读取失败或 connection reset 会清空旧快照;只有后续既有读取、通知或 reset 才重试。已 dispose 或陈旧的结算不能发布。每次目录失效都通过命令 owner 关闭 slash 选择器与待确认对话框,同时保留草稿,因此等待自身读取的选择器仍保留失败与重试状态;重新打开时加载当前目录。Session 投影只携带当前选择,因此目录安装或移除不写 Session 事件或序号。 -Auto 带右上标 `EXP`。两个可见当前会话选择器都要求实验确认;显式 `/permission auto` 已构成同意。Composer 使用通用 Menu 既有 portal 定位保持在视口内,同时保留 218–360px 边界。Slash popup 保留 `min(220px, 100%)` 与 `max-width: 100%`,窄 composer 将 trigger 折叠时也一样。 +Auto 在选择器选项中带 `EXP` 标记;选中后的 composer 控件不显示该标记。两个可见当前会话选择器都要求实验确认;显式 `/permission auto` 已构成同意。Composer 使用通用 Menu 既有 portal 定位保持在视口内,同时保留 218–360px 边界。Slash popup 保留 `min(220px, 100%)` 与 `max-width: 100%`,窄 composer 将 trigger 折叠时也一样。 [委派时权限捕获](../../../../packages/subagent/subagent/README.zh.md)在首次 await 前记录 Auto 或 Full access,并在 fork seed 与 sandbox/approval override 之后追加该既有 preset event。单次与可继续创建共用此规则;cold resume 只读取 child 日志。后续 parent 切换不改变该 child,后续 child 自己的切换仍可胜出。Read Only 与 Workspace Write 保留 sandbox 继承加 `approval: never`,因此可能保持 `custom`。Auto child 使用既有 lineage 与消息独立分类每次调用,不增加父 call metadata、委派记录、receipt、Header/descriptor 字段或 Session format。进程外 child 在父委派获准后保留自己的权限系统。 diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml index d74aa0ba75..75bfe1d654 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml @@ -9,8 +9,8 @@ en: 71813b1cb1f750ba zh: 64d2b999933ccd98 /agent-note-web-sidebar-terminals/decision: - en: 8d28949572d990ef - zh: f6e26790415134d2 + en: ef54b8ca31bb71de + zh: 0c05685c25010e8c /agent-note-web-sidebar-terminals/alternatives-considered: en: b0ffb94cd5033e9c zh: 9294bb76503c858a @@ -18,5 +18,5 @@ en: d710787595b5dc89 zh: 4df9518c32698f85 /agent-note-web-sidebar-terminals/related: - en: 94540c743da01fc4 - zh: 53e3d9d2268153d1 + en: 2ae8d413f928803f + zh: 3075efb83112b130 diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md index 448bcf84c1..39a0a5fff8 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md @@ -22,7 +22,7 @@ Close and replacement remove the tab synchronously and run process cleanup in th The caller retains a terminal ID before creating it. Repeating create with the same Session and open ID does not allocate a second process. Closing an uncertain create uses that ID even when no creation response arrived. The Host records closed IDs before awaiting allocation, preventing a delayed create from reviving a closed terminal. Each attachment begins with a consistent, bounded serialized xterm screen; ordered output follows through the Gateway's existing multiplexed Remote stream. Output and screen snapshots share one operation queue. Followers retain final output on normal closure and fail explicitly on buffer overflow. Browser render acknowledgement prevents React batching from dropping increments. -The latest attachment controls input and dimensions; other attachments remain read-only. Every physical stream opening gets a fresh input attachment identity, including automatic transport recovery. Input RPCs are serialized, and results from a superseded attachment cannot downgrade a replacement connection. Terminal output creates no model input, Agent tool result or Session event. The existing [persistent Agent terminal decision](2026-07-16-persistent-pty-sessions.md) continues to govern model-owned sessions; this feature extends the [portable subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.md) with terminal environment facts and resize. Control-transfer and process-exit refusals only disable input, preserving the healthy output view. Client-owned error identifiers are translated by the terminal UI, including guidance to close retained exited terminals when the quota is full. +The latest attachment controls input and dimensions; other attachments remain read-only. Each physical `attach` stream owns its input and resize commands, including after automatic transport recovery. The Host orders commands and acknowledges provider acceptance; the Client bounds raw input and complete encoded frames, queues commands when the advertised carrier window is full, and resumes on acknowledgements. Superseded streams cannot write through their replacements, and unacknowledged input is never replayed. Terminal output creates no model input, Agent tool result or Session event. The existing [persistent Agent terminal decision](2026-07-16-persistent-pty-sessions.md) continues to govern model-owned sessions; this feature extends the [portable subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.md) with terminal environment facts and resize. Control-transfer and process-exit refusals only disable input, preserving the healthy output view. Client-owned error identifiers are translated by the terminal UI, including guidance to close retained exited terminals when the quota is full. ## Alternatives considered @@ -52,4 +52,4 @@ The [two-hour unattended-terminal reclamation](2026-09-14-unattended-browser-ter ## Related -- [Remote duplex stream](../architecture/2026-09-19-remote-duplex-stream.md): the transport-layer uplink under which one `attach` stream replaces the unary `write` and `resize` calls and the `attachmentId` correlation; that terminal change is listed under its Deferred section. +- [Remote duplex stream](../architecture/2026-09-19-remote-duplex-stream.md): the transport-layer uplink carrying terminal input and output under one physical attachment lifetime. diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md index 81bdf81ed9..6f1249e181 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md @@ -22,7 +22,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命 调用者在创建之前保留终端 ID。相同 Session 和未关闭 ID 的重复 create 不再分配进程。创建结果不确定时,关闭仍使用该 ID,即使没有收到创建响应。Host 在等待分配完成前记录已关闭 ID,防止迟到的 create 复活已关闭终端。每次连接先接收一致、有界的 xterm 序列化屏幕,后续有序输出使用 Gateway 已有的复用 Remote stream。输出和屏幕快照共享操作队列。订阅者正常关闭时保留末尾输出,缓存超限时明确失败。浏览器在完成渲染后确认帧,避免 React 批处理丢失增量。 -最新连接控制输入和尺寸,其他连接保持只读。每次物理流建立都创建新的输入连接标识,包括传输自动恢复。输入 RPC 按序发送,旧连接的结果不能把新连接降级为失败。终端输出不产生模型输入、Agent 工具结果或 Session 事件。[Agent 持久终端决策](2026-07-16-persistent-pty-sessions.zh.md)仍约束模型拥有的终端;此功能为[可移植 subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)增加执行环境事实和 resize。 控制权转移和进程退出导致的拒绝只禁用输入,保留健康的输出视图。Client 自产错误标识由终端 UI 翻译,包括名额用满时关闭已保留的退出终端的提示。 +最新连接控制输入和尺寸,其他连接保持只读。每条物理 `attach` 流拥有自己的输入和尺寸命令,传输自动恢复后亦如此。Host 按序执行命令并确认提供者已接受操作;Client 限制原始输入和完整编码帧,在公布的载体窗口满时排队,并在收到确认后继续发送。旧流不能借用新流写入,未确认输入绝不重放。终端输出不产生模型输入、Agent 工具结果或 Session 事件。[Agent 持久终端决策](2026-07-16-persistent-pty-sessions.zh.md)仍约束模型拥有的终端;此功能为[可移植 subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)增加执行环境事实和 resize。 控制权转移和进程退出导致的拒绝只禁用输入,保留健康的输出视图。Client 自产错误标识由终端 UI 翻译,包括名额用满时关闭已保留的退出终端的提示。 ## 考虑过的替代方案 @@ -52,4 +52,4 @@ Agent 终端和可移植执行环境两篇记录仍保留,其所有权与 prov ## 相关 -- [Remote 双工流](../architecture/2026-09-19-remote-duplex-stream.zh.md):传输层上行,让一条 `attach` 流取代 unary `write`、`resize` 与 `attachmentId` 关联;该终端改动列在其「后续」中。 +- [Remote 双工流](../architecture/2026-09-19-remote-duplex-stream.zh.md):传输层上行在同一物理附件生命周期内承载终端输入和输出。 diff --git a/.agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.i18n.yaml new file mode 100644 index 0000000000..be693bf4cb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-20-spawn-agent-preset-selection.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.md +/agent-note-independent-spawn-preset-selection-and-restoration: + en: 2c57fd9b420af1cc + zh: 8d0d7a53fc85f746 +/agent-note-independent-spawn-preset-selection-and-restoration/problem: + en: 62fa53e4befdf947 + zh: 99956ba40cbda1ab +/agent-note-independent-spawn-preset-selection-and-restoration/decision: + en: a321ee6871ef14c0 + zh: 599093af0657a675 +/agent-note-independent-spawn-preset-selection-and-restoration/alternatives-considered: + en: 82fa00b86c8d375b + zh: eb00a537a854dfe4 +/agent-note-independent-spawn-preset-selection-and-restoration/consequences: + en: 57531f2f2eb7da5b + zh: 206564a73e8d7193 diff --git a/.agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.md b/.agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.md new file mode 100644 index 0000000000..e59975ca16 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.md @@ -0,0 +1,33 @@ +# Agent Note: Independent spawn preset selection and restoration + +Status: implemented + +English | [中文](2026-09-20-spawn-agent-preset-selection.zh.md) + +## Problem + +Spawned agents need reusable combinations of persona, tools, and skills without replacing the [implicit parent-preset join](../../archived/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md). Restoration must distinguish an independent selection from historical child logs without changing the Session format or fork behavior. + +## Decision + +An explicit spawn preset id resolves the currently registered definition, even when it equals the parent's id. Omission joins the parent's exact live revision. Selecting a named configuration accepts its current declaration and resolution failures; inheriting an active composition preserves its plugin instances even after that declaration changes or is removed. + +An independently selected child records the existing `agent-preset/selected` event before its first own `subagent/descriptor`. Cold restoration excludes the inherited fork prefix and uses that ordering to identify independent selection, then reads the selected id from the existing `agentPreset` projection. A missing projection rejects restoration rather than silently switching to the parent's composition. + +Historical children without this ordering retain the implicit join. A creation header can contain an inherited preset id, while a fork's seed can contain an ancestor's selection and an old child can record a selection after its descriptor. None of these facts establishes independent selection at child creation. The ordering marker preserves those histories without adding a descriptor field or another event type. + +## Alternatives considered + +**Resolve every child by preset id.** This would make an omitted selection observe an updated definition or fail after its removal, changing the implicit join and historical restoration behavior. + +**Infer independent selection from the header or projection alone.** Both can carry inherited values. A last-value projection also loses the ordering needed to distinguish a creation-time selection from later selections in historical logs. + +**Store a preset id in a new descriptor version.** The existing selection event and projection already persist the id. Another field would duplicate it and require a descriptor migration for a distinction the existing event order can express. + +**Support a nested preset object.** A second configuration spelling adds precedence and validation rules without serving an existing caller. A flat preset id selects composition; fork does not accept independent preset selection. + +## Consequences + +Independent restoration resolves the selected id against the registered definition; it does not preserve a historical revision across restarts. Missing or invalid presets fail explicitly. Implicit children and fork retain their existing composition and cold-restoration behavior. + +Focused continuation tests cover event ordering, inherited prefixes, historical logs, and missing projections. The recorded-session preset scenario covers independent persona and tool selection through the shipped headless composition. diff --git a/.agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.zh.md b/.agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.zh.md new file mode 100644 index 0000000000..8b5e8dbff4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 独立 spawn preset 选择与恢复 + +Status: implemented + +[English](2026-09-20-spawn-agent-preset-selection.md) | 中文 + +## 问题 + +spawn 子 Agent 需要可复用的 persona、工具和技能组合,同时保留[隐式加入父方 preset](../../archived/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md) 的行为。恢复时必须区分独立选择与历史子会话日志,不改变 Session 格式或 fork 行为。 + +## 决策 + +显式指定 spawn preset id 时解析当前注册的定义,即使该 id 与父方相同;省略时加入父方正在运行的确切代际。选择具名配置意味着接受当前声明及其解析失败;继承活跃组装则保留它的插件实例,即使声明已经修改或移除。 + +独立选择的子 Agent 在首条自有 `subagent/descriptor` 之前记录已有的 `agent-preset/selected` 事件。冷恢复排除继承的 fork 前缀,用此顺序识别独立选择,再从已有 `agentPreset` 投影读取所选 id。投影缺失时拒绝恢复,避免静默切换到父方组装。 + +不具备此顺序的历史子会话继续隐式加入父方。创建 header 可能包含继承的 preset id,fork 种子可能包含祖先的选择,旧子会话也可能在 descriptor 之后记录选择;这些事实都不能证明子会话创建时作过独立选择。事件顺序标记保留这些历史语义,无须增加 descriptor 字段或新的事件类型。 + +## 考虑过的替代方案 + +**所有子 Agent 都按 preset id 解析。** 这会让省略选择的子 Agent 读到更新后的定义,或因定义移除而失败,改变隐式加入及历史恢复行为。 + +**仅从 header 或投影推断独立选择。** 两者都可能携带继承值。末值投影还会丢失事件顺序,无法区分创建时选择与历史日志中的后续选择。 + +**在新版本 descriptor 中存储 preset id。** 已有选择事件和投影已经持久化该 id。增加字段会重复存储,并为现有事件顺序已能表达的区别引入 descriptor 迁移。 + +**支持嵌套 preset 对象。** 第二种配置写法会增加优先级与校验规则,却不服务于现有调用方。扁平的 preset id 选择组装;fork 不接受独立 preset 选择。 + +## 后果 + +独立恢复按所选 id 解析已注册定义,不跨重启保存历史代际。preset 缺失或无效时明确失败。隐式子 Agent 与 fork 保留原有组装和冷恢复行为。 + +针对性 continuation 测试覆盖事件顺序、继承前缀、历史日志和缺失投影。会话录制快照通过 shipped headless 组装覆盖独立 persona 与工具选择。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index 67d1d90e9b..83ef4ebea0 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml @@ -9,14 +9,14 @@ en: e6929494e60041a2 zh: 15bec8e8baabcab2 /agent-note-generated-third-party-notices/decision: - en: 11f3362b0f45debb - zh: 5953b0848b940b67 + en: e0a339aa86f13898 + zh: 9ab0c7de7bb185b5 /agent-note-generated-third-party-notices/testing: en: 194f87f780991068 zh: 58096d2339576d20 /agent-note-generated-third-party-notices/alternatives-considered: - en: 093ddc027e296bf0 - zh: 6b9e64d99dcefa11 + en: 7e9d8d7864503e46 + zh: 5b93a1130cfab260 /agent-note-generated-third-party-notices/consequences: - en: 860b806c5d393c98 - zh: 51b4ca11e2d3802a + en: ed2210ac6c133e6c + zh: 7957cb33da842642 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index 0ebd540939..d233fd1eb4 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -14,9 +14,7 @@ A hand-written inventory answers none of those durably. Roughly a hundred rows o [`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, `pnpm-workspace.yaml`, and the [shared runtime lock](../../../../scripts/primary-runtime/lock.json). The root README pair links the file from its License section. -**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root or shared runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check. - -One trigger gap is accepted rather than worked around: lefthook inspects only files present on disk, so **deleting** a manifest runs no job, and removing a package reaches the assertion in the test lane instead. Reconstructing the staged file list to include deletions does not work — lefthook filters the list against the working tree either way. The assertion is the backstop for exactly this case. +**The test lane enforces freshness.** The committed bytes are asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs, including the required `node 24 / coverage` pull-request lane — the check adds no gate process, no scheduler slot, and no separate CI step. A contributor who changes a generator input — any manifest, a workspace declaration, the root or shared runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — runs `pnpm run gen-third-party-notices`; `pnpm run verify-third-party-notices` remains available for a standalone check. No Git hook regenerates the file: browser inputs make every package source file a generator input, so a hook would run the 16-second generator on nearly every commit, while few commits change the output. The file discloses **direct** dependencies by default; complete npm and Python SDK closures remain in their lock files. Two separately distributed payload sets receive explicit entries: the official Claude executable packages declared by `@anthropic-ai/claude-agent-sdk`, and every distribution in the shared runtime lock's `pythonPackages`. Bundled entries include normalized names, exact versions, and recorded license metadata; missing metadata, duplicate normalized names and conflicting normalized versions fail generation. The shared wheel set is independent of the `python/` manifests, so scanning only those manifests would omit the packaged Office libraries. Bundled distributions also pass the runtime license check, including the permissive `MIT-CMU` and `PSF-2.0` identifiers declared by Pillow and typing-extensions. @@ -38,7 +36,7 @@ The Claude distribution tests prove that only the exact direct SDK identity bypa **Keep the hand-written file and review it at release time.** Reviewing a hundred derived rows by eye is exactly the work a generator does correctly, and the file's own claim — that it lists every direct dependency — would be unverified between releases. -**Verify through a dedicated `doc-sync` gate.** That is how every other generated artifact here is checked, and it was the first shape of this change. It costs a gate process and a scheduler slot in a matrix that is already long, and — worse — its only failure mode is telling a contributor, minutes after they pushed an unrelated dependency bump, to go rerun a generator. Regenerating at commit time removes the interruption, and the assertion inside a spec the test lane already runs keeps the guarantee at no additional CI cost. +**Verify through a dedicated `doc-sync` gate.** That is how every other generated artifact here is checked, and it was the first shape of this change. It costs a gate process and a scheduler slot in a matrix that is already long, while the assertion inside a spec the test lane already runs reports the same staleness at no additional CI cost. **Enumerate the full transitive closure.** The closure is thousands of packages, already recorded in the lock files with exact versions, and would bury the direct dependencies that a reader actually evaluates. The file points at the lock files and the `pnpm licenses list` renderer instead. @@ -52,7 +50,7 @@ The Claude distribution tests prove that only the exact direct SDK identity bypa ## Consequences -A dependency edit now carries a regenerated notices file into the same commit. Contributors pay one generator run — about a second — on commits that touch a manifest, and nothing on any other commit. Committing with hooks disabled defers the cost to a test-lane failure that names the command. +Commits pay no generator run. A dependency or browser-import edit that leaves the notices stale fails the test lane with an error that names the regeneration command. The generator needs an installed tree, which makes it heavier than a pure-source generator, and a new package with unusable published metadata needs an `OVERRIDES` entry rather than silently rendering a blank license. Both failures are loud and name the remedy. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index 323ad8ea15..b229c16396 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -14,9 +14,7 @@ Status: implemented [`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区 manifest、`vendor/README.md`、`pyproject.toml`、`pnpm-workspace.yaml` 与 [共享运行时锁文件](../../../../scripts/primary-runtime/lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。 -**新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 manifest、工作区声明、根锁文件或共享运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成该文件并将其暂存,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。 - -有一处触发缺口是接受而非绕过的:lefthook 只检视磁盘上存在的文件,因此**删除** manifest 不会触发任何任务,移除一个包会落到测试 lane 的断言上。重构暂存文件列表以纳入删除的做法不成立——无论怎么给列表,lefthook 都会拿工作树过滤一遍。这个场景正由断言兜底。 +**新鲜度由测试 lane 保证。** 已提交的字节由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件,其中包括 PR 必过的 `node 24 / coverage` lane——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。改动生成器任一输入——任何 manifest、工作区声明、根锁文件或共享运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——的贡献者运行 `pnpm run gen-third-party-notices`;需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。没有 Git 钩子重新生成该文件:浏览器输入使每个包源码文件都成为生成器输入,钩子会让几乎每个提交都运行约 16 秒的生成器,而改变输出的提交很少。 文件默认只披露**直接**依赖;完整的 npm 与 Python SDK 闭包保留在各自锁文件中。两组独立分发的载荷会明确列出:`@anthropic-ai/claude-agent-sdk` 声明的官方 Claude 可执行包,以及共享运行时锁文件 `pythonPackages` 中的每个分发包。内置条目包含归一化名称、精确版本和已记录的许可证元数据;元数据缺失、归一化后名称重复或版本冲突都会使生成失败。共享 wheel 集合独立于 `python/` 下的 manifest,仅扫描后者会遗漏已打包的 Office 库。内置分发包同样通过运行时许可证检查,其中包括 Pillow 与 typing-extensions 声明的宽松许可证标识 `MIT-CMU` 和 `PSF-2.0`。 @@ -38,7 +36,7 @@ Claude 分发测试证明:只有精确匹配的直接 SDK 身份会绕过通 **保留手写文件,发版时人工过一遍。** 用肉眼审阅上百行推导数据,恰恰是生成器能做对的活;而且在两次发版之间,文件自称「列出全部直接依赖」这句话无人验证。 -**用专门的 `doc-sync`(文档同步门禁)校验。** 仓库里其他生成产物都是这么把关的,本次改动最初也是这个形态。但它要在本已冗长的矩阵里再占一个门禁进程和一个调度位;更糟的是,它唯一的失败方式,就是在别人推完一个无关的依赖升级几分钟后,通知对方回去重跑一次生成器。改为提交时重新生成消除了这次打断,而把断言放进测试 lane 本就会跑的 spec 里,则以零额外 CI 成本保住了这项保证。 +**用专门的 `doc-sync`(文档同步门禁)校验。** 仓库里其他生成产物都是这么把关的,本次改动最初也是这个形态。但它要在本已冗长的矩阵里再占一个门禁进程和一个调度位,而测试 lane 本就会跑的 spec 里的断言能以零额外 CI 成本报告同样的过期。 **列出完整传递闭包。** 闭包有数千个包,锁文件里已带精确版本,铺开只会淹没读者真正要评估的直接依赖。文件转而指向锁文件与 `pnpm licenses list`。 @@ -52,7 +50,7 @@ Claude 分发测试证明:只有精确匹配的直接 SDK 身份会绕过通 ## 后果 -此后改动依赖时,重新生成的披露文件会随同一个提交入库。触及 manifest 的提交多付一次生成器运行——约一秒;其余提交不受影响。若禁用钩子提交,代价推迟为一次测试 lane 失败,其报错会指明补救命令。 +提交不运行生成器。改动依赖或浏览器侧 import 却未更新披露文件时,测试 lane 会失败,其报错会指明重新生成的命令。 生成器需要已安装的依赖树,因此比纯源码生成器更重;发布元数据不可用的新包需要补一条 `OVERRIDES`,而不是默默渲染出空白许可证。这两类失败都会明确报错并指出补救方式。 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 79f3339e71..192e40c2d2 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: 6e84bceaba4573ca - zh: 25a37c147a675643 + en: af93d8849bb8831a + zh: 53e15e801ab844da /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 7577a7c06e..6c422a612d 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 @@ -12,13 +12,13 @@ PR jobs need the same compiled packages for benchmarks, compatibility, packaging Required Linux consumer and native Windows build jobs retain normal builds, including both complete aggregate programs. Public `build` and `typecheck` keep the same checks. [Node version ownership](2026-07-06-node-engine-floor.md) and [native Windows ownership](2026-08-08-native-windows-pull-request-ci.md) remain in force. -Checked PR artifact builds compile every existing project reference from each compiler face with one serial `tsc -b` process, then run the ordinary Typert, bundling, Desktop, and Web stages. The helper reads the existing Host and Client aggregates rather than maintaining another package list. It omits only each root's additional test and script program; package diagnostics, emitted outputs, and Host-before-Client ordering remain required. Non-PR release and real-API workflow builds retain their normal checks. +Checked PR artifact builds compile every existing project reference from each compiler face with one serial `tsc -b` process, then run the ordinary Typert, bundling, Desktop, and Web stages. The helper reads the existing Host and Client aggregates rather than maintaining another package list. It omits only each root's additional test and script program; package diagnostics, emitted outputs, and Host-before-Client ordering remain required. Non-PR release and real-API workflow builds retain their normal checks. That artifact build is `pnpm run build:artifacts`; [the build split](2026-10-11-build-artifacts-check-split.md) owns its steps and scheduling. Only the CI benchmark helper selects `tsc -b --noCheck` for package emission. Benchmarks consume JavaScript and Typert runtime metadata; their declaration and build-information files are disposable and never published. Typert analyzes source types independently, while required complete builds own semantic diagnostics. Syntax and configuration failures still reject benchmark preparation. TypeScript records pending checks so a later ordinary incremental build diagnoses the same source without deleting caches or forcing a rebuild. Public benchmark scripts and every release, Python, preview, compatibility, and normal artifact builder keep their checked defaults. -The Python executable builder selects artifact compilation with `--artifacts-only` after verifying its runtime closure. Invalid closure inputs fail before compilation, deployment, or packing; the default complete build and prebuilt `--skip-build` mode keep their existing stages. +The Python executable builder selects `build:artifacts` artifact compilation with `--build-artifacts` after verifying its runtime closure. Invalid closure inputs fail before compilation, deployment, or packing; the default complete build and prebuilt `--skip-build` mode keep their existing stages. -CI invokes the internal build helpers directly and keeps the root package manifest unchanged. Root manifest edits activate the separate native-addon platform matrix even when only build aliases change; avoiding that trigger preserves the existing workflow set and concurrent-job ceiling without weakening native validation. +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 three compatibility cases run one job at a time; the two-target Python runtime matrix starts after the keyless Python SDK job succeeds. Four Linux lanes, the Python SDK, three Windows lanes, and one compatibility job permit at most nine worker jobs initially. Once the SDK job finishes, the remaining seven independent lanes, one compatibility job, and two runtime targets permit at most ten. The runtime plan and wheel stages precede those targets and each use one job. This permits runtime validation to overlap the Node 22 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. 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 c0b3c1bcf4..9f25366401 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 @@ -12,13 +12,13 @@ PR job 需要相同的编译包来运行 benchmark、兼容性、打包和运行 必需的 Linux consumer 与原生 Windows build job 保留普通构建,包括两个完整 aggregate program。公开的 `build` 和 `typecheck` 保留相同检查。[Node 版本职责](2026-07-06-node-engine-floor.zh.md)与[原生 Windows 职责](2026-08-08-native-windows-pull-request-ci.zh.md)仍然有效。 -带检查的 PR 产物构建通过一个串行 `tsc -b` 进程编译各 compiler face 的全部现有 project reference,然后运行普通 Typert、bundling、Desktop 和 Web 阶段。helper 读取现有 Host 与 Client aggregate,而不维护另一份包清单。它只省略各 root 附加的测试和脚本 program;包诊断、emit 输出及 Host 先于 Client 的顺序仍然必需。非 PR 的 release 和真实 API 工作流构建保留普通检查。 +带检查的 PR 产物构建通过一个串行 `tsc -b` 进程编译各 compiler face 的全部现有 project reference,然后运行普通 Typert、bundling、Desktop 和 Web 阶段。helper 读取现有 Host 与 Client aggregate,而不维护另一份包清单。它只省略各 root 附加的测试和脚本 program;包诊断、emit 输出及 Host 先于 Client 的顺序仍然必需。非 PR 的 release 和真实 API 工作流构建保留普通检查。该产物构建即 `pnpm run build:artifacts`,其步骤与调度见[构建拆分](2026-10-11-build-artifacts-check-split.zh.md)。 只有 CI benchmark helper 选择 `tsc -b --noCheck` 来 emit 包。benchmark 消费 JavaScript 和 Typert 运行时元数据;其声明与 build-information 文件是临时产物,不会发布。Typert 独立分析源码类型,语义诊断由必需的完整构建负责。语法与配置错误仍会拒绝 benchmark 准备。TypeScript 记录待检查状态,使后续普通增量构建无需删除缓存或强制重建,就能诊断相同源码。公开 benchmark script,以及所有 release、Python、preview、compatibility 和普通产物 builder,均保留带检查的默认行为。 -Python executable builder 在验证 runtime closure 后,通过 `--artifacts-only` 选择产物编译。无效 closure 在编译、部署或打包前失败;默认完整构建和复用预构建产物的 `--skip-build` 模式保留原有阶段。 +Python executable builder 在验证 runtime closure 后,通过 `--build-artifacts` 选择 `build:artifacts` 产物编译。无效 closure 在编译、部署或打包前失败;默认完整构建和复用预构建产物的 `--skip-build` 模式保留原有阶段。 -CI 直接调用内部 build helper,并保持 root package manifest 不变。即使只修改 build alias,root manifest 改动也会激活独立的 native-addon 平台矩阵;避免这一触发条件,可以在不削弱 native 验证的情况下保留现有 workflow 集合与并发 job 上限。 +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;双目标 Python runtime 矩阵在 keyless Python SDK job 成功后启动。四个 Linux lane、Python SDK、三个 Windows lane 和一个 compatibility job,初始最多占用九个 worker job。SDK job 完成后,剩余七个独立 lane、一个 compatibility job 和两个 runtime target,最多占用十个 job。runtime plan 与 wheel 阶段先于这些 target,各占一个 job。这允许 runtime 验证与 Node 22 构建重叠,同时保持原有十个 job 的上限。覆盖率分区在各自的平台和 runner pool 内复用精简的文件成本元数据。并行 release 打包使用 pnpm 现有的有界调度器;串行发布保持 family 顺序。 diff --git a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml new file mode 100644 index 0000000000..84099cc7a6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml @@ -0,0 +1,40 @@ +# Bilingual-pair consistency record for 2026-10-11-build-artifacts-check-split.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/process/2026-10-11-build-artifacts-check-split.md +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently: + en: b1a98fdd8008dce1 + zh: 15bdc2f2c2584226 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/problem: + en: bc382ad1f1e71352 + zh: 44189bf0f69a0bf7 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision: + en: a15c7c13f167e5ac + zh: 63bcc2ef60c2f344 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/commands: + en: 174241330463841d + zh: 7fe361c07e634053 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/steps-and-dependencies: + en: 43e78e255306cd43 + zh: a741d5ef1c7379f3 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/scheduling-failure-and-output: + en: 4b324f92307d3691 + zh: e0cf4411a747f7e5 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/callers: + en: 55c55eea07fdb459 + zh: ab78cdfa4d04a5e7 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/measurements: + en: b3a753ac2aab6073 + zh: 19cbd0e32751896f +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/completeness-and-lossless-evidence: + en: 3cd40e0db9a2c89a + zh: 3f708be239779d0a +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/intentional-trade-offs-not-lossless: + en: 14370bc5b54e1278 + zh: 7ac933092d3e9954 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/alternatives-considered: + en: 54905b912d17c74e + zh: 86a298004c8024e3 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/consequences: + en: 083467f68d0f03ed + zh: 93400e9994b8c645 diff --git a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md new file mode 100644 index 0000000000..4476424af5 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md @@ -0,0 +1,157 @@ +# Agent Note: Split build:artifacts and build:check, with build running both concurrently + +Status: implemented + +English | [中文](2026-10-11-build-artifacts-check-split.zh.md) + +## Problem + +`pnpm run build` runs the native addon, the package-library build (Host `tsc -b tsconfig.host.json` → Host tsdown → Desktop bundle → Client `tsc -b tsconfig.client.json` → Client tsdown), and the Web build serially. Besides referencing package projects, the Host and Client aggregates each carry a `noEmit` root program covering tests, scripts, benchmarks, and website. These two root programs emit no files and feed no later artifact step, yet sit on the serial critical path: timed alone, the Host root program takes 68.5 s and the Client root program 36.0 s, while the serial package-library build takes about 249 s (median of 2). + +Development iterations need only artifacts; test and script type checks are needed before work is declared complete. The only way to skip the two root programs is the CI-oriented `pnpm run build --artifacts-only`, which has no local development command name and no counterpart that runs only those two checks. Meanwhile, `pnpm run build` must stay complete and detect every type error. + +## Decision + +### Commands + +Each command does only its own job. + +| Command | Responsibility | Steps | Scheduling | Client build record | +|---|---|---|---|---| +| `pnpm run build:artifacts` | Produces every artifact of `build`; package projects are fully type-checked as usual, the two aggregate root programs are not checked | native → host-src → host-bundle → desktop-bundle → client-src → client-bundle → web | One linear chain | Deleted at start, written after every step passes | +| `pnpm run build:check` | Type-checks only the two aggregate root programs | host-check, client-check | Both steps concurrent, no prerequisite steps | Neither read nor written | +| `pnpm run build` | Every step of `build:artifacts` and `build:check` | The dependency graph below | Concurrent through the run-gates scheduler | Deleted at start, written after every step passes | + +`build:check` neither detects, rebuilds, nor validates `build:artifacts` outputs; when prerequisite outputs are missing or stale, the compiler's own diagnostics (for example TS6305 and TS2307) report it, and no command adds prerequisite checks, guards, or fallbacks. `build:artifacts` produces the same artifacts and record as `build` and replaces `--artifacts-only`; that flag and the `libraries` mode of `compile-referenced-projects.ts` are removed, and the Python executable builder uses `--build-artifacts`. `build:official` remains `tsx scripts/build.ts --profile official` and runs the complete `build`. + +### Steps and dependencies + +| Step | Command | `needs` in `build` | +|---|---|---| +| native | `pnpm run build:native-system` | — | +| host-src | `node --max-old-space-size=4096 -b ` | native | +| host-bundle | `tsdown --config-loader native --env.DSH_BUILD_FACE host` (runs Typert) | host-src | +| desktop-bundle | `pnpm --filter @deepseek-ai/dsh-desktop run bundle` | host-bundle | +| client-src | ` -b ` | desktop-bundle | +| client-bundle | `tsdown --config-loader native --env.DSH_BUILD_FACE client` | client-src | +| web | `pnpm run build:web` | client-bundle | +| host-check | `node --max-old-space-size=4096 -p tsconfig.host.json` | host-src | +| client-check | ` -p tsconfig.client.json` | client-src | + +```text +native → host-src ─┬─ host-check ───────────────────────────────────────────────────────┐ + └─ host-bundle → desktop-bundle → client-src ─┬─ client-check ───────┤ → record + └─ client-bundle → web ┘ +``` + +- client-src must follow host-bundle: Client packages import `@deepseek-ai/dsh-*/remote`, whose types `lib/typert.remote-client.d.ts` Typert writes, inside Host tsdown in these scopes and through `scripts/generate-typert.ts` in the typecheck scope. host-check reads no Typert output; clean serial builds have always finished the Host aggregate check before Host tsdown. +- The artifact chain keeps the two non-data edges native → host-src and desktop-bundle → client-src; only the two check steps attach to the chain in parallel. +- [`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts) owns the package-library step list `libraryBuildSteps`, the references argument construction `referencedProjectsCompilerArgs` (relative paths keep the command below Windows' command-line limit), and the aggregate-check arguments `aggregateCheckArgs`. The vendor release rehearsal (`host-libraries`) and CI benchmarks (`--noCheck`) run the same step list serially. +- The check steps use project mode (`tsc -p`): it reads the declarations that references already emitted and compiles no reference, writing incremental state to the aggregates' existing `tsconfig.{host,client}.tsbuildinfo`. `build` and `build:check` run the same command, so their diagnostics are identical. + +### Scheduling, failure, and output + +- [`scripts/build.ts`](../../../../scripts/build.ts) owns the step table and the three scopes and executes them through `runAggregate` from [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts): each step is a gate with `needs`, and shell-free spawning, per-step buffered output, fail-fast process-tree termination, Windows `taskkill`, and host signal forwarding come from the run-gates implementation. `build:artifacts` is `tsx scripts/build.ts --scope artifacts`, `build:check` is `--scope check`, and `build` omits `--scope` and runs both as one graph; `--profile` applies only to artifact scopes, and combining it with `--scope check` fails argument parsing. +- The concurrency limit is `DSH_GATE_CONCURRENCY`, defaulting to the available CPU count; the dependency graph is at most 3 wide (client-check and client-bundle can start while host-check is still running). Master serial reference lanes set `DSH_GATE_CONCURRENCY=1`, so `build` runs serially in topological order there, consistent with the [serial reference decision](2026-07-21-serial-cross-platform-ci-reference.md) and without workflow edits. +- By default (locally), every dependent of a failed step is recorded as skipped, while running steps and steps unrelated to the failure run to completion; the executed set depends only on step outcomes, so one run reports every independent type error. With `DSH_GATE_FAIL_FAST=1` (set in every building PR lane), the first failure terminates the process trees of the other running steps and records every unfinished step as skipped. +- A failed step prints `== FAILED ==`, its command, the exit reason, and the step's complete buffered output, and the final summary lists failed and skipped steps; a passing step prints only its PASS line, and `DSH_GATE_VERBOSE=1` prints all output. The exit code is 1 when any step failed or was skipped, and the client build record is not written. Every step inherits the public Client environment selected by `clientBuildProcessEnvironment`. +- In CI, `build` itself is one gate of an outer run-gates aggregate; outer fail-fast termination propagates to the inner scheduler. `check:node-compat` and `check:ci:lint:contracts-ready` already nest the same way. + +### Callers + +| Caller | Command | Aggregate root-program checks | +|---|---|---| +| `build`, `build:official`, `Makefile` `build`, `scripts/publish-npm-baseline.ts`, `apps/desktop/scripts/package-target.ts`, `scripts/dev-bundle-registry.ts`, `release-publish.yml`, `sandbox.yml`, and the non-PR branches of `e2e.yml` and `release.yml` | `build` / `build:official` | Yes | +| `test:web`, `test:web:refresh`, `test:web:perf`, `test:web:stress`, `apps/desktop` `test:updates:local`, `test:bench` / `build:bench` | `build`; `build:bench` runs `build:native-system`, `build:lib`, and the benchmark tsdown | Yes | +| `typecheck` (run by the pre-push hook) | `build.ts --scope typecheck`: host-src beside host-typert (the bundle-free, source-only Typert pass `generate-typert.ts`) → client-src, with `tsc -b tsconfig.{host,client}.json` after each face's package compilation; no bundle | Yes | +| `dev:web` ([`scripts/dev-web.ts`](../../../../scripts/dev-web.ts)), `dev:desktop` (`apps/desktop/scripts/dev.ts`) | `build:artifacts` | None before launch; after `dev:web` launches, its `tsc -b tsconfig.client.json --watch` keeps checking the Client aggregate root program | +| Prerequisite step of `lint`, `lint:fix`, `doc-typecheck`, and the CI Typert contracts gate | Host package compilation beside the bundle-free Typert pass (`compile-referenced-projects.ts host-contracts`), without any bundle | None | +| CI node 24 / snapshots and artifacts (`check:ci:consumers`), windows node 24 / build & observational (`check:ci:windows-blocking`) | `build` | Yes, the two required lanes | +| CI node-compat 22.19 | `build:artifacts` (through run-gates) | None | +| CI python runtime | `build-exe-for-python-sdk.ts --build-artifacts` | None | +| PR rehearsals in `e2e.yml` and `release.yml` | `pnpm run build:artifacts --profile official` | None | +| `build-preview-cloudflare.yml` | `pnpm run build:artifacts` | None | +| CI node 24 / benchmarks | `run-ci-bench.ts` (`tsc -b --noCheck`) | None | +| PR rehearsal in `release-vendor.yml` | `compile-referenced-projects.ts host-libraries` | None | +| Non-PR branch of `release-vendor.yml`, `release-vendor-publish.yml` | `build:lib:host` | Host aggregate root program only | + +`lint`, `lint:fix`, `doc-typecheck`, and the Typert contracts gate read only Typert outputs and package declarations: both aggregates are `noEmit`, oxlint builds its own programs, `doc-typecheck` references only package projects, and none of them reads a bundle. Their prerequisite step therefore skips the Host aggregate check and every bundle; skipping the Host aggregate check and the Desktop bundle alone took it from 159.8 s to 90.9 s (see the measurements below). + +The consumers lane's HMR Web test starts `dev:web` and therefore uses `build:artifacts` inside that lane; the same lane's `build` gate still runs both aggregate checks. CI does not call `build:check` separately: the two required lanes already get the same checks through `build`. The [PR artifact build decision](2026-10-05-pr-artifact-typechecks.md) keeps its required complete builds, benchmark `--noCheck`, job and worker ceilings, serial node-compat Vitest, coverage weights, and pnpm packing checks; its PR artifact build is `build:artifacts`. + +### Measurements + +Prototype conditions: a shared 256 vCPU Linux host (load average 23–70), Node v24.18.0, TypeScript 6.0.3, React type packages hoisted to the root, without the Typert changes of [the lossless speed-ups note](2026-10-11-lossless-build-speedups.md); scope is the package-library build (excluding native and web). Every round starts from `pnpm run clean`, running once each in the order serial, concurrent, `build:lib`, `build:lib`, concurrent, serial; `/usr/bin/time -v` times each step, and summed process-tree RSS is sampled every 250 ms. The prototype runs the aggregate checks as `tsc -b tsconfig.{host,client}.json` once references are up to date, when build mode builds only the aggregate root program. + +| Mode (medians of 2) | Wall (s) | CPU user+sys (s) | Process-tree RSS peak (GiB) | +|---|---|---|---| +| Serial `pnpm run build:lib` | 249.4 | 378.0 | 5.95 | +| Each step run separately in sequence | 255.1 | 373.8 | 5.82 | +| Concurrent by dependency graph | 172.1 | 392.1 | 8.00 | + +Median wall falls from 249.4 s to 172.1 s (−77.3 s, −31%). The two runs of each step separately differ by 28 s, so the overhead of running the steps as separate processes is not resolvable. CPU grows by about 14 s (+3.7%), mostly Host aggregate check user time under contention; the RSS peak grows by about 2.1 GiB where the Host aggregate check (about 4 GiB) overlaps Host tsdown (about 5.7 GiB). The concurrent critical path is host-src → host-bundle → desktop-bundle → client-src → client-check; host-check finishes about 33 s before the end and client-bundle about 21 s before. + +Final measurements: master (the merge base) and this branch run interleaved, each from clean, in a 64-core cgroup; values are medians of 2 samples unless marked n=1. + +| Scenario | master | This branch | CPU user+sys (master → branch) | Process-tree RSS peak (master → branch) | +|---|---|---|---|---| +| Complete build `pnpm run build` | 317.1 s | 169.1 s (−46.7%) | 423.1 → 374.4 s (−11.5%) | 6.11 → 7.53 GiB (+1.42) | +| Serial branch build `DSH_GATE_CONCURRENCY=1 pnpm run build` (n=1) | — | 266.7 s | 389.5 s | 6.27 GiB | +| Artifact build: master `build --artifacts-only`, branch `build:artifacts` | 185.8 s | 153.6 s (−17.3%) | 256.2 → 231.2 s | 6.58 → 6.18 GiB | +| Development iteration: master `build`, branch `build:artifacts` | 317.1 s | 153.6 s (−51.6%) | 423.1 → 231.2 s (−45.4%) | 6.11 → 6.18 GiB | +| `pnpm run build:check` after `build:artifacts` | — | 67.7 s | 144.6 s | 5.33 GiB | +| `pnpm run build:check` no-op rerun | — | 19.4 s | 33.1 s | Not measured | +| Prerequisite of `lint` and `doc-typecheck`: master `build:lib:host`, branch `host-libraries` (since replaced by `host-contracts`, see [local gate speed-ups](2026-10-12-local-gate-speedups.md)) | 159.8 s | 90.9 s (−43.2%) | 234.7 → 136.0 s | Not measured | + +The serial branch sample overlapped other work on the host (742 s of foreign CPU), so its wall likely overstates the serial cost. In the serial build segments (master → branch, one sample each), Host tsdown falls from 22.5 to 11.7 s, Client package compilation from 55.6 to 48.7 s, the Client aggregate check from 68.2 to 35.9 s, and Client tsdown from 13.9 to 10.5 s; the segments sum to 308.5 s and 254.9 s. Those reductions come from [the lossless speed-ups note](2026-10-11-lossless-build-speedups.md); the further reduction from 254.9 s of serial segments to the 169.1 s concurrent build comes from this note's scheduling, which takes the Host aggregate check (about 66 s) off the critical path. In the concurrent build, the critical path is native → host-src → host-bundle → desktop-bundle → client-src → client-check, and host-check ends about 30 s before the last step (one earlier sample). `build:check` runs the Host and Client aggregate checks concurrently, so its wall is the Host check's (about 65 s). + +### Completeness and lossless evidence + +`build` runs the same set of compiler programs as the serial build: host-src and client-src compile every package project of both faces, and host-check and client-check check both aggregate root programs with the same commands as `build:check`. Across the prototype's six rounds, the 11,580 files of `{packages,apps,vendor,native}/**/lib/**` (excluding `*.tsbuildinfo`) are byte-identical between serial and concurrent runs, and after every round `tsc -b --dry tsconfig.host.json tsconfig.client.json` reports no out-of-date project. tsbuildinfo writers are host-src (292), client-src (76, exactly the client-only projects), and one per aggregate check; client-src rewrites no tsbuildinfo or declaration that host-check reads, so the graph has no write race. The fixture tests `checks only the aggregate program in project mode` and `reports missing package declarations without compiling references` show that `tsc -b --dry` treats the tsbuildinfo written by `tsc -p` as up to date and that project mode compiles no reference. In this implementation, the concurrent `build` and `DSH_GATE_CONCURRENCY=1 pnpm run build` produce byte-identical `lib/` (11,584 files), `apps/web/dist` (156 files), and client build record; afterwards `tsc -b --dry` reports all 372 projects of both aggregates up to date, with none to build. + +| Error | `build:artifacts` | `build:check` | `build` | `typecheck` | Required CI lanes | +|---|---|---|---|---|---| +| Package source type error | host-src / client-src | Not reported (reads existing declarations) | host-src / client-src | Reported | Reported | +| Type error only in an aggregate root program's tests, scripts, benchmarks, or website | Not reported | host-check / client-check | host-check / client-check | Reported | consumers, windows-build | +| Package tsconfig configuration error | host-src / client-src | Not reported (`tsc -p` does not validate referenced projects' tsconfig options or syntax) | host-src / client-src | Reported | Reported | +| Typert analysis error | host-bundle | Not reported | host-bundle | Reported (through host-typert) | Reported | +| Missing prerequisite output | — | Compiler diagnostic (TS6305, TS2307) | — | — | — | + +Negative controls: after a type error (TS2322) is added to `scripts/pnpm-invocation.spec.ts`, which only the Host aggregate root program contains, `build:artifacts` exits 0; `build:check` exits 1 with the Host aggregate check failing and the Client aggregate check passing; `build` exits 1 with 8 of 9 steps passing and 1 failing, and writes no client build record. In a scratch fixture whose referenced project's tsconfig has an invalid `target`, a JSON syntax error, or an unknown compiler option, `tsc -p` exits 0, while `tsc -b` reports TS6046, TS5092 with TS1012, and TS5023 respectively and exits 1; when the referenced project lacks `composite`, both report TS6306. + +CI evidence: on PR #6005 both required lanes that run the concurrent `pnpm run build` passed, `node 24 / snapshots and artifacts` (`check:ci:consumers` on `blacksmith-16vcpu-ubuntu-2404`) and `windows node 24 / build & observational` (`check:ci:windows-blocking` on `blacksmith-16vcpu-windows-2025`); the GitHub-hosted 4 vCPU benchmarks lane does not run `build`. The inner concurrency follows `DSH_GATE_CONCURRENCY`: master serial lanes set 1, the consumers lane sets 10 under self-hosted failover, and an unset value selects the available CPU count, capped at the 9 steps. + +### Intentional trade-offs (not lossless) + +- **Some commands skip the aggregate checks:** `build:artifacts`, the launch builds of `dev:web` and `dev:desktop`, and the prerequisite steps of `lint`, `lint:fix`, `doc-typecheck`, and the Typert contracts gate do not type-check the two aggregate root programs, and these prerequisite steps produce declarations and Typert outputs but no bundle. `build`, `build:check`, `typecheck` (pre-push hook), and the two required CI lanes own completeness; `dev:web` and `dev:desktop` do not refuse to launch because of test type errors. +- **`build:check` does not check freshness:** When sources change after `build:artifacts`, `build:check` checks against stale declarations, including Typert Remote declarations; rerun `build:artifacts` first. The complete `build` is unaffected. +- **Concurrent `build` raises the memory peak:** This branch's complete `build` peaks at 7.53 GiB of process-tree RSS against 6.11 GiB for the master serial build (+1.42 GiB, medians of 2) and 6.27 GiB for the branch's serial build (n=1); CPU meanwhile falls from 423.1 s to 374.4 s because [the lossless speed-ups note](2026-10-11-lossless-build-speedups.md) removes more work than concurrent contention adds. In the prototype (without the Typert changes), concurrent scheduling alone raises the RSS peak by about 2.1 GiB (5.9 → 8.0 GiB) and CPU by about 3.7%. `DSH_GATE_CONCURRENCY` bounds it; under memory pressure use `DSH_GATE_CONCURRENCY=1 pnpm run build`, or run `build:artifacts` and `build:check` separately. In the windows-build lane, `build` runs concurrently with `docs:build`. +- **Passing steps' output is hidden by default:** Only failed steps print their complete output. +- **`tsc -p` does not skip an up-to-date root program:** An immediate rerun of `build:check` without changes still takes 19.4 s and 33.1 s of CPU because both programs are parsed again; `tsc -b` skips an up-to-date root. +- **On many-core cold builds `build:artifacts` is only 15.5 s faster than `build`:** This branch's cold `build` takes 169.1 s and `build:artifacts` 153.6 s (medians of 2), because host-check is off the critical path and client-check is only about 16 s longer than client-bundle plus web. The rest of `build:artifacts`'s saving is CPU and memory: 374.4 → 231.2 s of CPU and 7.53 → 6.18 GiB of peak RSS; on few-core machines and in incremental rebuilds this also turns into wall time. +- **CI invokes public commands in the root manifest:** PR artifact builds invoke `build:artifacts` rather than an internal helper flag; a PR that edits these root manifest scripts activates the separate Node Addon System platform matrix. + +## Alternatives considered + +- **Keep the aggregate checks only in `typecheck`:** `pnpm run build` must stay complete and detect every error. +- **Keep `--artifacts-only` and add a `build:artifacts` alias:** One operation would have two names. +- **Name the commands `build:src` and `build:test`:** `build:artifacts` produces every artifact, including the tsdown bundles, the Desktop bundle, and Web, not only compiled sources; `build:check` checks scripts, benchmarks, and website code as well as tests. +- **Name the commands after compiler terms (`build:b`/`build:p`, `build:project`, `build:emit`) or `build:dist`:** tsc runs in only two artifact steps, and `-p` is an implementation choice for the check steps; TypeScript calls the `tsc -p` side the project; `emit` reads as the emit-only `--noCheck` mode although package projects stay fully checked; package outputs live under `lib/`, and `dist` suggests release packaging. +- **`build:check` runs `tsc -b `:** Build mode compiles out-of-date references, doing `build:artifacts`'s work. +- **`build:check` first completes or validates prerequisite outputs:** It violates each command doing only its own job and adds prerequisite logic; compiler diagnostics already name missing outputs. +- **A new scheduler (shell `&`/`wait`, `concurrently`, turbo/nx):** It duplicates run-gates' failure summary, process-tree termination, and Windows handling. +- **Expand `build` into several gates in CI's run-gates:** `pnpm run build` must be a complete command usable directly locally and by release workflows, and it writes the record. +- **Relax the non-data edges in the artifact chain (native ∥ host-src, desktop-bundle ∥ client-src):** Not measured; the Desktop bundle itself takes only 4.0–4.5 s. +- **Move tests into a new tsconfig:** The existing aggregates already separate package references from the root program. +- **Split the aggregate root programs into sub-programs:** Each sub-program must inject the 242 files carrying module augmentations, CPU grows by 9–57%, and only wall falls (see [the lossless speed-ups note](2026-10-11-lossless-build-speedups.md)). +- **Run `build:check` separately in CI:** The required lanes already get the same checks through `build`; a separate run repeats the work. + +## Consequences + +Development iterations can skip the two aggregate root programs with `build:artifacts` (51.6% faster cold than master's `build`) and complete the checks with `build:check` or `build` before declaring work complete. `build` stays complete: the Host aggregate check is off the critical path, the Client aggregate check runs alongside the Client bundle and web, and a cold build is 46.7% faster than master (including the lossless speed-ups note's changes; medians of 2). The costs are a higher memory peak, a little extra CPU from concurrent contention, less passing output by default, and several development and prerequisite commands that do not check test types (see the trade-offs above). + +Removing or changing `build` in the two required lanes, consumers and windows-build, requires first transferring both aggregate checks to another blocking owner. The typecheck scope and the lint prerequisites generate Typert contracts without bundling ([local gate speed-ups](2026-10-12-local-gate-speedups.md)); the artifact scopes keep generating them inside Host tsdown. + +client-src's dependency on host-bundle guarantees the "Host and Remote artifacts complete before Client compilation" ordering that [Typert Remote method calls](../architecture/2026-08-02-typert-remote-method-calls.md) describes. + +Verification: `scripts/build.spec.ts` covers the type-stripping check, rejection of the removed flag and invalid scopes, the conflict between `--profile` and `--scope check`, scope selection, step dependencies, and the environment; `compile-referenced-projects.spec.ts` covers project mode checking only the aggregate program, the diagnostic for missing declarations, and `tsc -b --dry` accepting the tsbuildinfo that `tsc -p` writes; failure propagation, output buffering, and process-tree termination come from the run-gates scheduler and its tests; `run-gates.spec.ts`, `ci-workflow.spec.ts`, `preview-workflow.spec.ts`, `build-exe-for-python-sdk.spec.ts`, `dev-web.spec.ts`, and `tests/ci-release-selfhosted.spec.ts` pin each caller's command. diff --git a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md new file mode 100644 index 0000000000..4929e28574 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md @@ -0,0 +1,157 @@ +# Agent Note: 拆分 build:artifacts 与 build:check,build 并发执行两者 + +Status: implemented + +[English](2026-10-11-build-artifacts-check-split.md) | 中文 + +## 问题 + +`pnpm run build` 串行执行 native addon、包库构建(Host `tsc -b tsconfig.host.json` → Host tsdown → Desktop bundle → Client `tsc -b tsconfig.client.json` → Client tsdown)和 Web 构建。Host 与 Client 聚合配置除了引用各包 project,还各自带有一个 `noEmit` 根程序,覆盖 tests、scripts、benchmarks 与 website。这两个根程序不产出任何文件,也不是后续产物步骤的输入,却位于串行关键路径上:单独计时时 Host 根程序为 68.5 s、Client 根程序为 36.0 s,串行包库构建约为 249 s(2 个样本的中位数)。 + +开发迭代只需要产物,测试与脚本的类型检查在声明完成前才需要。能跳过两个根程序的只有面向 CI 的 `pnpm run build --artifacts-only`;它没有本地开发命令名,也没有只做这两项检查的对应命令。同时,`pnpm run build` 必须保持完整,能检测出所有类型错误。 + +## 决策 + +### 命令 + +每个命令只做自己的工作。 + +| 命令 | 职责 | 步骤 | 调度 | client build record | +|---|---|---|---|---| +| `pnpm run build:artifacts` | 产出 `build` 的全部产物;包 project 照常完整类型检查,不检查两个聚合根程序 | native → host-src → host-bundle → desktop-bundle → client-src → client-bundle → web | 一条线性链 | 开始时删除,全部通过后写入 | +| `pnpm run build:check` | 只类型检查两个聚合根程序 | host-check、client-check | 两步并发,无前置步骤 | 不读写 | +| `pnpm run build` | `build:artifacts` 与 `build:check` 的全部步骤 | 下文依赖图 | run-gates 调度器并发执行 | 开始时删除,全部通过后写入 | + +`build:check` 不检测、不重建、也不校验 `build:artifacts` 的产物;前置产物缺失或过期时,由编译器自身的诊断(例如 TS6305、TS2307)报告,任何命令都不增加前置检查、守卫或回退逻辑。`build:artifacts` 产出的产物与 record 和 `build` 相同,并取代 `--artifacts-only`;该参数与 `compile-referenced-projects.ts` 的 `libraries` 模式已删除,Python executable builder 使用 `--build-artifacts`。`build:official` 仍是 `tsx scripts/build.ts --profile official`,执行完整的 `build`。 + +### 步骤与依赖 + +| 步骤 | 命令 | `build` 中的 `needs` | +|---|---|---| +| native | `pnpm run build:native-system` | — | +| host-src | `node --max-old-space-size=4096 -b ` | native | +| host-bundle | `tsdown --config-loader native --env.DSH_BUILD_FACE host`(运行 Typert) | host-src | +| desktop-bundle | `pnpm --filter @deepseek-ai/dsh-desktop run bundle` | host-bundle | +| client-src | ` -b ` | desktop-bundle | +| client-bundle | `tsdown --config-loader native --env.DSH_BUILD_FACE client` | client-src | +| web | `pnpm run build:web` | client-bundle | +| host-check | `node --max-old-space-size=4096 -p tsconfig.host.json` | host-src | +| client-check | ` -p tsconfig.client.json` | client-src | + +```text +native → host-src ─┬─ host-check ───────────────────────────────────────────────────────┐ + └─ host-bundle → desktop-bundle → client-src ─┬─ client-check ───────┤ → record + └─ client-bundle → web ┘ +``` + +- client-src 必须在 host-bundle 之后:Client 包导入 `@deepseek-ai/dsh-*/remote`,其类型 `lib/typert.remote-client.d.ts` 由 Typert 写出:这些 scope 在 Host tsdown 中生成,typecheck scope 经 `scripts/generate-typert.ts` 生成。host-check 不读取 Typert 输出,干净的串行构建一直在 Host tsdown 之前完成 Host 聚合检查。 +- 产物链保留 native → host-src 与 desktop-bundle → client-src 这两条非数据依赖;只有两个 check 步骤并行挂接到链上。 +- [`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts) 拥有包库步骤列表 `libraryBuildSteps`、references 参数构造 `referencedProjectsCompilerArgs`(相对路径传参,避开 Windows 命令行长度上限)与聚合检查参数 `aggregateCheckArgs`。vendor 发布演练(`host-libraries`)与 CI benchmark(`--noCheck`)串行执行同一步骤列表。 +- check 步骤使用 project mode(`tsc -p`):它读取 references 已有的声明而不编译任何 reference,incremental 状态写入聚合配置已有的 `tsconfig.{host,client}.tsbuildinfo`。`build` 与 `build:check` 使用同一条命令,因此诊断相同。 + +### 调度、失败与输出 + +- [`scripts/build.ts`](../../../../scripts/build.ts) 拥有步骤表与三个范围,执行复用 [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 的 `runAggregate`:每个步骤是一个带 `needs` 的 gate,shell-free 启动、按步骤缓冲输出、fail-fast 进程树终止、Windows `taskkill` 与宿主信号转发都沿用 run-gates 的实现。`build:artifacts` 为 `tsx scripts/build.ts --scope artifacts`,`build:check` 为 `--scope check`,`build` 不带 `--scope`,把两者作为一张图执行;`--profile` 只适用于产生产物的范围,与 `--scope check` 同用时参数解析失败。 +- 并发上限取 `DSH_GATE_CONCURRENCY`,未设置时为可用 CPU 数;依赖图宽度最多为 3(host-check 尚未结束时,client-check 与 client-bundle 可能已开始)。master 串行参考 lane 设置 `DSH_GATE_CONCURRENCY=1`,`build` 在那里按拓扑顺序串行执行,符合[串行参考决策](2026-07-21-serial-cross-platform-ci-reference.zh.md),无需修改 workflow。 +- 缺省(本地)时,失败步骤的所有依赖方记为 skipped,正在运行的步骤和与失败无关的步骤继续运行到结束;执行集合只取决于各步骤的结果,因此一次运行报告所有相互独立的类型错误。`DSH_GATE_FAIL_FAST=1`(所有构建型 PR lane 已设置)时,第一个失败终止其余正在运行步骤的进程树,所有未完成步骤记为 skipped。 +- 失败步骤打印 `== FAILED <步骤> ==`、命令、退出原因与该步骤完整的缓冲输出,结尾汇总列出 failed 与 skipped 步骤;成功步骤只打印 PASS 行,`DSH_GATE_VERBOSE=1` 打印全部输出。任一步骤 failed 或 skipped 时退出码为 1,且不写 client build record。所有步骤继承 `clientBuildProcessEnvironment` 选定的公开 Client 环境。 +- CI 中 `build` 本身是外层 run-gates 的一个 gate;外层 fail-fast 终止会转发到内层调度。`check:node-compat` 与 `check:ci:lint:contracts-ready` 已使用同样的嵌套。 + +### 调用方 + +| 调用方 | 命令 | 聚合根程序检查 | +|---|---|---| +| `build`、`build:official`、`Makefile` `build`、`scripts/publish-npm-baseline.ts`、`apps/desktop/scripts/package-target.ts`、`scripts/dev-bundle-registry.ts`、`release-publish.yml`、`sandbox.yml`、`e2e.yml` 与 `release.yml` 的非 PR 分支 | `build` / `build:official` | 有 | +| `test:web`、`test:web:refresh`、`test:web:perf`、`test:web:stress`、`apps/desktop` `test:updates:local`、`test:bench` / `build:bench` | `build`;`build:bench` 运行 `build:native-system`、`build:lib` 与 benchmark tsdown | 有 | +| `typecheck`(pre-push hook 运行它) | `build.ts --scope typecheck`:host-src 与 host-typert(不打包、只读源码的 Typert 生成 `generate-typert.ts`)并行 → client-src,各 face 包编译后运行 `tsc -b tsconfig.{host,client}.json`;不含任何 bundle | 有 | +| `dev:web`([`scripts/dev-web.ts`](../../../../scripts/dev-web.ts))、`dev:desktop`(`apps/desktop/scripts/dev.ts`) | `build:artifacts` | 启动前无;`dev:web` 启动后的 `tsc -b tsconfig.client.json --watch` 持续检查 Client 聚合根程序 | +| `lint`、`lint:fix`、`doc-typecheck` 与 CI 的 Typert contracts gate 的前置步骤 | Host 包编译与不打包的 Typert 生成并行(`compile-referenced-projects.ts host-contracts`),不含任何 bundle | 无 | +| CI node 24 / snapshots and artifacts(`check:ci:consumers`)、windows node 24 / build & observational(`check:ci:windows-blocking`) | `build` | 有,两个必需 lane | +| CI node-compat 22.19 | `build:artifacts`(经 run-gates) | 无 | +| CI python runtime | `build-exe-for-python-sdk.ts --build-artifacts` | 无 | +| `e2e.yml`、`release.yml` 的 PR 演练 | `pnpm run build:artifacts --profile official` | 无 | +| `build-preview-cloudflare.yml` | `pnpm run build:artifacts` | 无 | +| CI node 24 / benchmarks | `run-ci-bench.ts`(`tsc -b --noCheck`) | 无 | +| `release-vendor.yml` 的 PR 演练 | `compile-referenced-projects.ts host-libraries` | 无 | +| `release-vendor.yml` 的非 PR 分支、`release-vendor-publish.yml` | `build:lib:host` | 仅 Host 聚合根程序 | + +`lint`、`lint:fix`、`doc-typecheck` 与 Typert contracts gate 只读取 Typert 输出与包声明:两个聚合配置都是 `noEmit`,oxlint 自行构建程序,`doc-typecheck` 只引用包 project,它们都不读取 bundle。它们的前置步骤因此跳过 Host 聚合检查与所有 bundle;仅跳过 Host 聚合检查与 Desktop bundle 就使耗时从 159.8 s 降到 90.9 s(见下文测量)。 + +consumers lane 的 HMR Web 测试启动 `dev:web`,因此在该 lane 内使用 `build:artifacts`;同一 lane 的 `build` gate 仍运行两个聚合检查。CI 不单独调用 `build:check`:两个必需 lane 已通过 `build` 获得同一组检查。[PR 产物构建决策](2026-10-05-pr-artifact-typechecks.zh.md)的必需完整构建、benchmark `--noCheck`、job 与 worker 上限、node-compat 串行 Vitest、覆盖率权重与 pnpm 打包检查仍然有效;其中的 PR 产物构建由 `build:artifacts` 执行。 + +### 测量 + +原型测量的条件:256 vCPU 共享 Linux 主机(load average 23–70),Node v24.18.0、TypeScript 6.0.3,React 类型包已提升到根,不含[无损提速 Note](2026-10-11-lossless-build-speedups.zh.md) 的 Typert 改动;范围是包库构建(不含 native 与 web)。每轮从 `pnpm run clean` 开始,按 串行、并发、`build:lib`、`build:lib`、并发、串行 的顺序各运行一次;每个步骤由 `/usr/bin/time -v` 计时,进程树 RSS 每 250 ms 采样求和。原型中的聚合检查以 `tsc -b tsconfig.{host,client}.json` 在 references 已是最新时运行,此时 build mode 只构建聚合根程序。 + +| 方式(2 个样本的中位数) | wall(s) | CPU user+sys(s) | 进程树 RSS 峰值(GiB) | +|---|---|---|---| +| 串行 `pnpm run build:lib` | 249.4 | 378.0 | 5.95 | +| 各步骤依次单独运行 | 255.1 | 373.8 | 5.82 | +| 按依赖图并发 | 172.1 | 392.1 | 8.00 | + +wall 中位数从 249.4 s 降到 172.1 s(−77.3 s,−31%)。各步骤单独运行的两次样本相差 28 s,因此把步骤拆成独立进程的开销无法分辨。CPU 增加约 14 s(+3.7%),主要是争用下 Host 聚合检查增加的 user 时间;RSS 峰值增加约 2.1 GiB,出现在 Host 聚合检查(约 4 GiB)与 Host tsdown(约 5.7 GiB)重叠时。并发时的关键路径是 host-src → host-bundle → desktop-bundle → client-src → client-check;host-check 在结束前约 33 s 完成,client-bundle 在结束前约 21 s 完成。 + +最终测量:master(合并基点)与本分支交替运行,每次均从 clean 开始,在 64 核 cgroup 中运行;除标注 n=1 者外,数值均为 2 个样本的中位数。 + +| 场景 | master | 本分支 | CPU user+sys(master → 本分支) | 进程树 RSS 峰值(master → 本分支) | +|---|---|---|---|---| +| 完整构建 `pnpm run build` | 317.1 s | 169.1 s(−46.7%) | 423.1 → 374.4 s(−11.5%) | 6.11 → 7.53 GiB(+1.42) | +| 本分支串行构建 `DSH_GATE_CONCURRENCY=1 pnpm run build`(n=1) | — | 266.7 s | 389.5 s | 6.27 GiB | +| 产物构建:master `build --artifacts-only`,本分支 `build:artifacts` | 185.8 s | 153.6 s(−17.3%) | 256.2 → 231.2 s | 6.58 → 6.18 GiB | +| 开发迭代:master `build`,本分支 `build:artifacts` | 317.1 s | 153.6 s(−51.6%) | 423.1 → 231.2 s(−45.4%) | 6.11 → 6.18 GiB | +| `pnpm run build:check`(`build:artifacts` 之后) | — | 67.7 s | 144.6 s | 5.33 GiB | +| `pnpm run build:check` 无改动重跑 | — | 19.4 s | 33.1 s | 未测量 | +| `lint` 与 `doc-typecheck` 的前置步骤:master `build:lib:host`,本分支 `host-libraries`(后由 `host-contracts` 取代,见[本地门禁提速](2026-10-12-local-gate-speedups.zh.md)) | 159.8 s | 90.9 s(−43.2%) | 234.7 → 136.0 s | 未测量 | + +本分支串行样本运行时主机上有其他工作(外来 CPU 742 s),因此它的 wall 可能高估串行成本。串行构建的各段中(master → 本分支,各一个样本),Host tsdown 从 22.5 s 降到 11.7 s,Client 包编译从 55.6 s 降到 48.7 s,Client 聚合检查从 68.2 s 降到 35.9 s,Client tsdown 从 13.9 s 降到 10.5 s;各段合计分别为 308.5 s 与 254.9 s。这些缩短来自[无损提速 Note](2026-10-11-lossless-build-speedups.zh.md);从 254.9 s 的串行各段合计到 169.1 s 的并发构建,其余缩短来自本 Note 的调度,它把 Host 聚合检查(约 66 s)移出关键路径。并发构建的关键路径是 native → host-src → host-bundle → desktop-bundle → client-src → client-check,host-check 在最后一步结束前约 30 s 完成(取自更早的一个样本)。`build:check` 并发运行 Host 与 Client 聚合检查,因此其 wall 等于 Host 聚合检查的 wall(约 65 s)。 + +### 完整性与无损依据 + +`build` 运行的编译器程序集合与串行构建相同:host-src 与 client-src 编译两个 face 的全部包 project,host-check 与 client-check 检查两个聚合根程序,且使用与 `build:check` 相同的命令。原型的 6 轮运行中,`{packages,apps,vendor,native}/**/lib/**`(排除 `*.tsbuildinfo`)的 11,580 个文件在串行与并发之间逐字节一致,每轮之后 `tsc -b --dry tsconfig.host.json tsconfig.client.json` 报告没有过期 project。tsbuildinfo 写入方归属为:host-src 292 个,client-src 76 个(恰好是 client-only project),两个聚合检查各 1 个;client-src 不改写 host-check 读取的任何 tsbuildinfo 或声明,因此该依赖图中不存在写入竞争。fixture 测试 `checks only the aggregate program in project mode` 表明 `tsc -p` 写出的 tsbuildinfo 被 `tsc -b --dry` 视为最新,`reports missing package declarations without compiling references` 表明 project mode 不编译 references。本实现中,并发 `build` 与 `DSH_GATE_CONCURRENCY=1 pnpm run build` 产出逐字节一致的 `lib/`(11,584 个文件)、`apps/web/dist`(156 个文件)与 client build record;之后 `tsc -b --dry` 对两个聚合配置报告 372 个 project 全部为最新,没有需要构建的 project。 + +| 错误 | `build:artifacts` | `build:check` | `build` | `typecheck` | 必需 CI lane | +|---|---|---|---|---|---| +| 包源码类型错误 | host-src / client-src | 不报告(读取已有声明) | host-src / client-src | 报告 | 报告 | +| 只属于聚合根程序的测试、脚本、benchmark、website 类型错误 | 不报告 | host-check / client-check | host-check / client-check | 报告 | consumers、windows-build | +| 包 tsconfig 配置错误 | host-src / client-src | 不报告(`tsc -p` 不校验被引用 project 的 tsconfig 选项与语法) | host-src / client-src | 报告 | 报告 | +| Typert 分析错误 | host-bundle | 不报告 | host-bundle | 报告(经 host-typert) | 报告 | +| 前置产物缺失 | — | 编译器诊断(TS6305、TS2307) | — | — | — | + +负对照:在只属于 Host 聚合根程序的 `scripts/pnpm-invocation.spec.ts` 中加入类型错误(TS2322)后,`build:artifacts` 以 0 退出;`build:check` 以 1 退出,其中 Host 聚合检查失败、Client 聚合检查通过;`build` 以 1 退出,9 个步骤中 8 个通过、1 个失败,且不写 client build record。在临时 fixture 中,被引用 project 的 tsconfig 含无效 `target`、JSON 语法错误或未知编译选项时,`tsc -p` 以 0 退出,`tsc -b` 分别报告 TS6046、TS5092 与 TS1012、TS5023 并以 1 退出;被引用 project 缺少 `composite` 时,两者都报告 TS6306。 + +CI 证据:PR #6005 上,两个运行并发 `pnpm run build` 的必需 lane 均通过:`node 24 / snapshots and artifacts`(`check:ci:consumers`,运行于 `blacksmith-16vcpu-ubuntu-2404`)与 `windows node 24 / build & observational`(`check:ci:windows-blocking`,运行于 `blacksmith-16vcpu-windows-2025`);GitHub 托管的 4 vCPU benchmarks lane 不运行 `build`。内层并发取 `DSH_GATE_CONCURRENCY`:master 串行 lane 设为 1,consumers lane 在 self-hosted failover 时设为 10,未设置时取可用 CPU 数,且不超过 9 个步骤。 + +### 有意的取舍(非无损) + +- **部分命令跳过聚合检查:** `build:artifacts`、`dev:web` 与 `dev:desktop` 的启动构建,以及 `lint`、`lint:fix`、`doc-typecheck` 与 Typert contracts gate 的前置步骤,都不类型检查两个聚合根程序,这些前置步骤只产出声明与 Typert 输出,不打包。完整性由 `build`、`build:check`、`typecheck`(pre-push hook)与两个必需 CI lane 负责;`dev:web` 与 `dev:desktop` 不会因测试类型错误而拒绝启动。 +- **`build:check` 不检查新鲜度:** 在 `build:artifacts` 之后修改源码时,`build:check` 基于旧声明(包括 Typert Remote 声明)检查;需要先重跑 `build:artifacts`。完整 `build` 不受影响。 +- **并发 `build` 提高内存峰值:** 本分支完整 `build` 的进程树 RSS 峰值为 7.53 GiB,master 串行构建为 6.11 GiB(+1.42 GiB,2 个样本的中位数),本分支串行构建为 6.27 GiB(n=1);同时 CPU 从 423.1 s 降到 374.4 s,因为[无损提速 Note](2026-10-11-lossless-build-speedups.zh.md) 省去的工作多于并发争用增加的工作。原型(不含 Typert 改动)中,单独的并发调度使 RSS 峰值增加约 2.1 GiB(5.9 → 8.0 GiB)、CPU 增加约 3.7%。上限由 `DSH_GATE_CONCURRENCY` 控制;内存紧张时使用 `DSH_GATE_CONCURRENCY=1 pnpm run build`,或分别运行 `build:artifacts` 与 `build:check`。windows-build lane 中 `build` 与 `docs:build` 同时运行。 +- **成功步骤的输出默认不显示:** 只有失败步骤打印完整输出。 +- **`tsc -p` 不跳过已是最新的根程序:** 无改动时立即重跑 `build:check` 仍需 19.4 s 与 33.1 s CPU,因为两个程序都要重新解析;`tsc -b` 会跳过已是最新的根。 +- **多核冷启动时 `build:artifacts` 只比 `build` 快 15.5 s:** 本分支冷启动 `build` 为 169.1 s,`build:artifacts` 为 153.6 s(2 个样本的中位数),因为 host-check 不在关键路径上,client-check 只比 client-bundle 与 web 之和长约 16 s。`build:artifacts` 的其余收益是 CPU 与内存:CPU 从 374.4 s 降到 231.2 s,RSS 峰值从 7.53 GiB 降到 6.18 GiB;在少核机器与增量重建中,这部分也转化为 wall。 +- **CI 调用根 manifest 中的公开命令:** PR 产物构建调用 `build:artifacts`,而不是内部 helper 参数;修改根 manifest 中这些 script 的 PR 会激活独立的 Node Addon System 平台矩阵。 + +## 考虑过的替代方案 + +- **只在 `typecheck` 中保留聚合检查:** `pnpm run build` 必须完整,能检测出所有错误。 +- **保留 `--artifacts-only` 并新增 `build:artifacts` 别名:** 同一件事有两个名字。 +- **命名为 `build:src` 与 `build:test`:** `build:artifacts` 产出全部产物,包括 tsdown bundle、Desktop bundle 与 Web,而不只是编译后的源码;`build:check` 除测试外还检查脚本、benchmarks 与 website 代码。 +- **按编译器术语命名(`build:b`/`build:p`、`build:project`、`build:emit`)或命名为 `build:dist`:** tsc 只出现在两个产物步骤中,`-p` 只是 check 步骤的实现选择;TypeScript 把 `tsc -p` 一侧称为 project;`emit` 容易被理解为只 emit 的 `--noCheck` 模式,而包 project 仍完整检查;包产物位于 `lib/`,`dist` 则让人联想到发布打包。 +- **`build:check` 使用 `tsc -b `:** build mode 会编译过期的 references,即执行 `build:artifacts` 的工作。 +- **`build:check` 先补齐或校验前置产物:** 违背每个命令只做自己的工作,并增加前置逻辑;编译器诊断已经指明缺失的输出。 +- **新的调度实现(shell `&`/`wait`、`concurrently`、turbo/nx):** 重复 run-gates 已有的失败汇总、进程树终止与 Windows 处理。 +- **在 CI 的 run-gates 中把 `build` 展开为多个 gate:** `pnpm run build` 必须是本地与 release workflow 可直接使用的完整命令,record 也由它写入。 +- **放宽产物链内的非数据依赖(native ∥ host-src、desktop-bundle ∥ client-src):** 未测量;Desktop bundle 本身只占 4.0–4.5 s。 +- **把测试移入新的 tsconfig:** 现有聚合配置已经把包 references 与根程序分开。 +- **把聚合根程序拆成多个子程序:** 每个子程序都要注入 242 个含模块增强的文件,CPU 增加 9–57%,只降低 wall(见[无损提速 Note](2026-10-11-lossless-build-speedups.zh.md))。 +- **CI 单独运行 `build:check`:** 必需 lane 已通过 `build` 获得同一组检查,再单独运行是重复工作。 + +## 后果 + +开发迭代可以用 `build:artifacts` 跳过两个聚合根程序(冷启动比 master 的 `build` 快 51.6%),在声明完成前用 `build:check` 或 `build` 补齐检查。`build` 保持完整:Host 聚合检查不在关键路径上,Client 聚合检查与 Client bundle、web 并行,冷启动比 master 快 46.7%(含无损提速 Note 的改动,2 个样本的中位数)。代价是更高的内存峰值、并发争用带来的少量额外 CPU、默认更少的成功输出,以及多个开发与前置命令不检查测试类型(见上文取舍)。 + +删除或改变 consumers 与 windows-build 两个必需 lane 的 `build`,必须先把两个聚合检查转交另一处阻塞型 owner。typecheck scope 与 lint 类前置步骤不打包即生成 Typert 约定([本地门禁提速](2026-10-12-local-gate-speedups.zh.md));产物 scope 仍在 Host tsdown 中生成。 + +[Typert Remote 方法调用](../architecture/2026-08-02-typert-remote-method-calls.zh.md)描述的“Client 编译前先完成 Host 与 Remote 产物”由 client-src 对 host-bundle 的依赖保证。 + +验证:`scripts/build.spec.ts` 覆盖类型剥离检查、已删除参数与非法范围的拒绝、`--profile` 与 `--scope check` 的冲突、范围选择、步骤依赖与环境;`compile-referenced-projects.spec.ts` 覆盖 project mode 只检查聚合程序、缺失声明的诊断,以及 `tsc -b --dry` 接受 `tsc -p` 写出的 tsbuildinfo;失败传播、输出缓冲与进程树终止沿用 run-gates 的调度实现及其测试;`run-gates.spec.ts`、`ci-workflow.spec.ts`、`preview-workflow.spec.ts`、`build-exe-for-python-sdk.spec.ts`、`dev-web.spec.ts` 与 `tests/ci-release-selfhosted.spec.ts` 固定各调用方的命令。 diff --git a/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.i18n.yaml b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.i18n.yaml new file mode 100644 index 0000000000..41ef49d183 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.i18n.yaml @@ -0,0 +1,46 @@ +# Bilingual-pair consistency record for 2026-10-11-lossless-build-speedups.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/process/2026-10-11-lossless-build-speedups.md +/agent-note-build-speed-ups-that-keep-outputs-unchanged: + en: 48682b646b8f993a + zh: 4c26cb4aab5cd08c +/agent-note-build-speed-ups-that-keep-outputs-unchanged/problem: + en: 46551eb7e33c40ca + zh: f8c1fc063fa77062 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision: + en: 3948d6fc3fabf32e + zh: c3b8dcdfe8168275 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/measurement-conditions: + en: 2ab844073ec7c336 + zh: b25e39f2f80b0d7e +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/1-only-the-root-manifest-declares-react-type-packages: + en: 1101c94d04f9c7e9 + zh: 413b7b15f98a5f62 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/2-typert-selects-packages-by-manifest-first: + en: 1ae4b65792db0975 + zh: a11c0d90842a3b55 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/3-typert-does-not-expand-aggregate-include: + en: 0c8ab2d412113914 + zh: 01ba4f5042422d77 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/4-typert-parses-package-tsconfigs-lazily: + en: cd6e05c61a7cc495 + zh: 480d2d85d78d2d7f +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/5-the-typert-workspace-root-also-requires-packages: + en: 8b89436df201141c + zh: 2fab82419c8ba704 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/6-typert-generates-in-a-worker-thread: + en: 0362483a79c07881 + zh: d84a2390b49c4012 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/7-the-typert-transform-hook-filters-by-module-id: + en: b5ad6b5506c150d5 + zh: 25f09e4ab74696ad +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/8-client-tsdown-presets-read-the-manifest-from-the-package-directory: + en: 80c2ddc5fdff0b5f + zh: f5ce309a2e88ff4f +/agent-note-build-speed-ups-that-keep-outputs-unchanged/alternatives-considered: + en: 4372c82ef0c944dd + zh: 5fb70d499baadea7 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/consequences: + en: 93a96d3ed398f348 + zh: 93b7c7405be981a6 diff --git a/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md new file mode 100644 index 0000000000..021fe5b241 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md @@ -0,0 +1,136 @@ +# Agent Note: Build speed-ups that keep outputs unchanged + +Status: implemented + +English | [中文](2026-10-11-lossless-build-speedups.zh.md) + +## Problem + +A cold build carries three fixed costs that do not affect output contents: + +- **JSX signatures in the Client test aggregate program.** The Check phase of `tsc -p tsconfig.client.json` takes 59.1 s, more than half of it computing the `@types/react` module specifier for every JSX file that imports React. TypeScript's `computeModuleSpecifiers` visits every symlink location of that declaration file and scans all 491 tsconfig `paths` entries at each location. pnpm creates one `node_modules/@types/react` link for each workspace package that declares `@types/react` itself; 66 manifests declare it, so the Client test aggregate program sees 65 locations. Counters measured 88 JSX files × 2 modules (`index.d.ts`, `jsx-runtime.d.ts`) × 65 locations: 11,457 `tryGetModuleNameFromPaths` calls and 5,623,782 entry scans, 32.6 s in total. A dose-response run at five truncation levels of the visible location count N (N = 1 to 65; Check 26.92 s to 58.40 s) fits Check ≈ 26.9 + 0.494·N s. +- **Typert inside the Host tsdown pass.** The Host tsdown pass takes about 21.6–22.6 s. Typert discovers and generates artifacts synchronously inside the first `writeBundle`; the CPU profile shows it blocking the tsdown main thread for about 12.4 s while every package build waits. Discovery takes 8.06 s: it parses all 362 package tsconfigs referenced by the two face aggregates (about 3.8 s), expands the Host aggregate's 2,064 literal `include` entries on every parse (1.6 s; the analyzer never reads those file names), and walks the public-export surface of 322 registrations (2.7–3.2 s). Only the 28 packages that declare a Typert export produce Host artifacts. +- **Manifest lookup in the Client tsdown pass.** The Client tsdown pass takes about 11.7 s with a saturated main thread (7.1% idle); 34.5% of it (3.95 s) is `workspaceManifest`: every package config runs `globSync('packages/*/*/package.json')` for its package name and parses manifests until the name matches. The module-level cache has no effect because tsdown's native config loader imports every package config through `import-without-cache`, creating a fresh module instance per config. Each glob takes 40–125 ms and runs inside the `neverBundle`/`alwaysBundle` callbacks of `tsdown:deps`, blocking every build. + +#5849 merged Host fixture type narrowing and Typert memoization; this note's baseline is the master that follows it. + +## Decision + +The following eight speed-ups change neither the bytes of `lib/` and `apps/web/dist` outputs nor the set of type diagnostics that repository builds report. The only change in diagnostic ownership comes from item 4: Typert does not validate tsconfigs of packages it does not analyze, and only `tsc -b` reports those errors. [The build split note](2026-10-11-build-artifacts-check-split.md) records the command split and concurrent scheduling. + +| # | Decision | Affected stage | Measured median | Outputs and diagnostics | Intended behavior difference | +|---|---|---|---|---|---| +| 1 | Only the root manifest declares React type packages | Client type checking | Test aggregate Check 59.1 → 26.3 s | Program file count and diagnostics unchanged | Package manifests do not list React type packages | +| 2 | Typert selects packages by manifest first | Host tsdown | 22.62 → 19.94 s | Byte-identical | None | +| 3 | Typert does not expand aggregate `include` | Host tsdown | 19.94 → 16.99 s | Byte-identical; registration inventory identical | None | +| 4 | Typert parses package tsconfigs lazily | Host tsdown | 16.99 → 16.25 s (within noise); Typert discovery 5.9–7.1 → 0.36–0.40 s | Byte-identical; registration inventory identical | Only `tsc -b` reports tsconfig errors in unanalyzed packages | +| 5 | The Typert workspace root also requires `packages/` | Host tsdown | Within noise | Byte-identical | Dual-face packages get artifacts in `package` mode | +| 6 | Typert generates in a Worker Thread | Host tsdown | 15.39 → 10.25 s | Byte-identical; failures still fail tsdown | Host tsdown max RSS about +0.6 GiB | +| 7 | The Typert `transform` hook filters by module id | Host tsdown | Within noise; hook calls 1,566 → 195 | Byte-identical | None | +| 8 | Client tsdown presets read the manifest from the package directory | Client tsdown | 12.78 → 8.73 s | Byte-identical; external decisions identical | The presets' first argument is the package directory | + +The published `./tsdown` export `typertPlugin()` changes with items 5–7 (`transform` as `{ filter, handler }`, an asynchronous `writeBundle`, and a workspace root that must contain `packages/`); rolldown and tsdown consumers accept both hook forms, and the analyzer already required `/packages`, so no external workspace that worked before stops working. + +### Measurement conditions + +All numbers come from one shared Linux host (256 vCPU, 4 TB memory, load average 23–111 during runs) with Node v24.18.0, TypeScript 6.0.3, and tsdown 0.22.2 (rolldown 1.1.1). + +- **Host tsdown:** `node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE host`, deleting every `lib/typert.*` before each run; variants run interleaved and sequentially, 3–4 samples per group. Items 2–4 come from the first round (starting from the baseline) and items 5–7 from the second round (starting from the code with items 2–4). Output comparison covers SHA-256 of the 743 top-level files of `packages/*/*/lib`, `apps/*/lib`, and `vendor/*/lib` (140 of them Typert artifacts). Medians of two groups running identical code differ by 0–0.11 s; samples within a group spread by up to 2.5 s. +- **Client test aggregate:** `tsc -p tsconfig.client.json --extendedDiagnostics` with prebuilt references and a fresh tsBuildInfo file; six samples of identical code spread by 3.8 s in Check (about 6%). +- **Client tsdown:** `node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE client` on a built tree. The item 8 prototype ran three rounds in rotated order with a baseline spread of 1.56 s; the final implementation and the original ran interleaved, three samples each. Output comparison covers every file of `{packages,apps,vendor,native}/**/lib/**`, excluding `*.tsbuildinfo`. + +### 1. Only the root manifest declares React type packages + +- **Purpose:** Reduce the number N of `@types/react` locations that the Client test aggregate program sees from 65 to 1, removing module-specifier work that grows linearly with N. +- **Change:** The root `package.json` declares `@types/react` `~18.3.1` and `@types/react-dom` `~18.3.0` in `devDependencies`, the ranges every package used before; manifests under `apps/*`, `packages/*/*`, and `vendor/*` declare neither in any dependency section. TypeScript resolves the root `node_modules/@types/react` from every package. Packages still declare runtime `react` and `react-dom` under the [Client dependency declaration rules](../../../../packages/client/AGENTS.md#dependency-declaration); `packages/client/ui-slots`, which references React only through `import type`, declares `react` in `devDependencies`. pnpm `dedupeDirectDeps` stays off. Lockfile changes are confined to the importers section, plus one reordered `papaparse` entry with the same integrity. +- **Regression guard:** [`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) runs in `hygiene` and CI. It requires the root manifest's `devDependencies` to declare both packages and rejects either one in the `dependencies`, `devDependencies`, `optionalDependencies`, or `peerDependencies` of any manifest under `apps/*`, `packages/*/*`, or `vendor/*`; the diagnostic names the entry to delete and links Client dependency declaration rule 8. The rule matches only these two exact names; other `@types/*` packages remain DefinitelyTyped type providers. Dependency-section inference does not treat these two packages as type providers for `react`/`react-dom`: when a package lacks a `react` declaration, the verifier reports `react` as missing and `--fix` refuses to repair it until a version range is declared; `--fix` never writes back or deletes the two packages. [`verify-package-dependencies.spec.ts`](../../../../scripts/verify-package-dependencies.spec.ts) covers rejection in each dependency section, a root manifest missing the declarations, the accepted layout, and the type-provider exclusion. +- **Performance:** + + | Measurement | Per-package declarations (N = 65) | Root manifest declaration (N = 1) | + |---|---|---| + | Client test aggregate Check | 59.08 s (median of 3) | 26.28 s (median of 5), −56% | + | Client test aggregate wall | 68–71 s | 33.6–34.9 s | + | Client test aggregate user CPU | 79–83 s | 44.5–45.3 s | + | Check sum of the 76 client-only package projects | 34.6 s (median of 2) | 26.5 s (median of 2), −23% | + | Wall of the 76 client-only package projects | 55.7 s (median of 2) | 44.5 s (median of 2) | + +- **Why it is lossless:** The 66 package-level links and the root link share one realpath (`.pnpm/@types+react@18.3.31`), so the program reads the same declarations; the program keeps 2,613 files, and `tsc -b --force tsconfig.client.json` exits 0 without diagnostics. `devDependencies` are not installed with published packages, so the [published dependency faces](2026-08-26-published-dependency-faces.md) conclusion that independent TypeScript consumers install referenced type packages themselves is unchanged; runtime resolution never reads `@types/*`. +- **Trade-off:** Package manifests do not list their own React type dependency, and every workspace package shares one React types version; a package that needs a different version requires a new Agent Note overturning this item. The check covers only `apps/*`, `packages/*/*`, and `vendor/*`; other pnpm members such as `website`, `benchmarks`, `native/system`, and `python/sdk-runtime` are out of scope, and none of them currently declares React type packages. + +### 2. Typert selects packages by manifest first + +- **Purpose:** Workspace mode generates artifacts only for packages that declare a Typert export, so it skips the surface walk for every other candidate. +- **Change:** `WorkspaceAnalyzer.discoverPackages(includeManifest?)` and `WorkspaceTypertGenerator.discover(faces?, includeManifest?)` accept an optional `package.json` predicate applied before the surface walk; a rejected package has neither its sources read nor its project config parsed. `WorkspaceTypertGenerator.generateContributors(faces?)` passes `hasTypertExport` (`exports` contains `./typert`, `./client/typert`, or `./remote`). Callers that pass no predicate (the Cordis catalog, `generate()` without packages) behave as before. +- **Performance:** Host tsdown 22.62 s → 19.94 s (medians of 3). +- **Why it is lossless:** The predicate is the filter previously applied after discovery, and a package it rejects never produced artifacts; the 743 output files are byte-identical except the Typert generator's own `lib/index.js`, whose source is this change. + +### 3. Typert does not expand aggregate `include` + +- **Purpose:** The analyzer reads only an aggregate's compiler options and project references; expanding `include` only fills `fileNames`, which it never uses. +- **Change:** `WorkspaceCaches.aggregate(path)` returns and caches `AggregateConfig { path, options, projectReferences }`. An aggregate that declares its own `references` is parsed with a `ParseConfigHost` whose `readDirectory` returns an empty list; an aggregate without `references` is expanded as usual. +- **Performance:** Host tsdown 19.94 s → 16.99 s (medians of 3). +- **Why it is lossless:** TypeScript reports TS18003 (no inputs) only for a config with neither `files` nor `references`, so an aggregate that declares `references` yields the same diagnostics, options, and references without expansion. The registration inventory dumped after forcing every lazy config (408 registrations, including `fileNames` and options, plus the aggregates' options and references) is byte-identical to the baseline, and the outputs are byte-identical. The tests `reads aggregate options and references without expanding include patterns` and `reports an aggregate without references or inputs` pin both kinds of config. + +### 4. Typert parses package tsconfigs lazily + +- **Purpose:** Parse only the tsconfigs of packages selected for analysis, not every package that an aggregate references. +- **Change:** `PackageRegistration.config` is a getter over `caches.config(configPath)` that parses on first read. The registration inventory keeps its order, faces, names, roots, and export subpaths. +- **Performance:** Host tsdown 16.99 s → 16.25 s (medians of 3), within the 2.5 s within-group spread. Together, items 2–4 reduce Typert discovery in an isolated benchmark from 5.9–7.1 s to 0.36–0.40 s, tsconfig parses from 362 to 28, surface walks from 322 to 28, and total Typert time from 9.7–11.8 s to 4.4–4.5 s. +- **Why it is lossless:** The inventory comparison is the one in item 3 (byte-identical), and the outputs are byte-identical. The test `reads only the project configs of manifests selected for discovery` pins that only selected configs are parsed. +- **Trade-off (not lossless):** Typert does not parse tsconfigs of packages it does not analyze, so a syntax or configuration error in such a tsconfig does not fail Typert. In the same build, the Host package compilation `tsc -b` that runs before the Host bundle and the Client package compilation `tsc -b` parse every referenced tsconfig and reject those errors; the `PackageRegistration.config` JSDoc and the [Typert generator README](../../../../packages/typert/generator/README.md) state this ownership. A consumer that runs Typert without `tsc -b` does not receive those diagnostics. + +### 5. The Typert workspace root also requires `packages/` + +- **Purpose:** Correct workspace-root resolution. 24 dual-face packages carry their own `tsconfig.host.json` leaf; searching upward for `tsconfig.host.json` alone took each of them as a root, so workspace mode ran one empty discovery per such package per build, and `package` mode silently produced no artifacts for dual-face packages. +- **Change:** The workspace root is the nearest ancestor of the bundle output that contains both `tsconfig.host.json` and `packages/`. +- **Performance:** Host tsdown 14.82 s → 15.39 s (medians of 3), within noise. +- **Why it is lossless:** The repository root [`tsdown.config.ts`](../../../../tsdown.config.ts) uses only workspace mode, and the 743 output files are byte-identical. The test `resolves the workspace root above a dual-face package leaf` pins the rule. +- **Behavior difference:** A `package`-mode consumer building a dual-face package receives that package's Typert artifacts; repository builds do not use `package` mode. + +### 6. Typert generates in a Worker Thread + +- **Purpose:** Overlap Typert analysis with bundling on the tsdown main thread instead of blocking every package build inside the first `writeBundle`. +- **Change:** [`src/worker.ts`](../../../../packages/typert/generator/src/worker.ts) is the Worker Thread entry; it runs `generateContributors(faces)` and posts one `artifacts` or `failure` message. In workspace mode, the build's first `buildStart` locates the workspace root from `options.cwd` and starts generation; `writeBundle` is asynchronous, the first finishing bundle claims emission and awaits the result, and later bundles return immediately. An analysis error, a worker crash or early exit, a malformed or undeserializable response, or a worker that cannot be terminated fails the claiming bundle with a `TypertWorkerError` naming the workspace and cause, which fails the whole tsdown pass; the worker is terminated before the outcome settles, and an unclaimed outcome never becomes an unhandled rejection. Built output starts the worker from `./worker.js`; source runs (tests) load `worker.ts` through `tsx/esm/api`. `package` mode still generates on the main thread. +- **Performance:** Host tsdown 15.39 s → 10.25 s (medians of 3). Profile of the final configuration (items 2–7): main thread 10.1 s, first `buildStart` at 4.18 s (tsdown config resolution), `writeBundle` on the main thread only 0.37 s; worker 5.0 s (module load about 0.4 s, discovery 0.28 s, generation 3.88 s including `createProgram` 2.21 s), ending at about 9.3 s. Host tsdown wall is about max(bundling, 4.2 s + Typert). +- **Why it is lossless:** All 54 second-round runs exit 0, and each run's 743 output files match the baseline except the generator's own `lib/index.js`. The test `writes the artifacts in-process generation produces` compares worker and in-process artifacts; in a scratch workspace with one package's `files` broken, tsdown exits 1 within 2.4 s, reporting `TypertWorkerError` with the worker's `TypertAnalysisError`, and writes no artifacts. The `typertPlugin workspace mode` test group covers each failure path. +- **Trade-off:** The worker heap is about 300–420 MiB; the final configuration's median Host tsdown max RSS is 6,296–6,408 MiB against 5,676 MiB at baseline. Under heavy shared-host load the worker can land on the critical path: one sample took 21.91 s with the artifact-owning package build waiting about 14 s for the worker, not reproduced in 10 later samples; at load average about 100, two runs took 17.86 s and 14.3 s. The worker also completes under a 1,024 MiB heap limit, so GC pressure is not the cause; attribution to host contention is unproven. + +### 7. The Typert `transform` hook filters by module id + +- **Purpose:** Stop every module from crossing from rolldown into the JavaScript `transform` hook only to be rejected by an extension check. +- **Change:** `transform` declares `filter: { id: /^[^?]*\.[cm]?tsx?(?:\?|$)/ }`, and its handler still checks the id with the same regular expression. +- **Performance:** Hook calls per Host build fall from 1,566 to 195 (195 TypeScript modules, 4 of them with decorators). Wall medians are 11.07 s (3 samples) and 9.98 s (4 samples) against 10.25 s for item 6, while the identical-code control groups give 11.07 s and 9.87 s; the difference is within noise. +- **Why it is lossless:** The regular expression uses only ASCII constructs and matches the same id set in rolldown's Rust regex engine and in JavaScript; the handler applies the same check, so hosts that ignore filters behave the same. Outputs are byte-identical; the test `filters transform calls to TypeScript module ids` pins the filter. + +### 8. Client tsdown presets read the manifest from the package directory + +- **Purpose:** Each package config reads only its own `package.json`, removing the glob over every manifest by package name. +- **Change:** The first argument of `clientBundle`, `staticLinked`, and `clientLibrary` in [`packages/client/tsdown.client.ts`](../../../../packages/client/tsdown.client.ts) is the package directory, normally the config file's `import.meta.dirname`; the preset reads `/package.json` once and uses its `name` as the plugin id and diagnostic name; when that manifest is missing, unreadable, or has no `name`, config loading fails with an error naming the path and cause. 82 package configs call these presets. The presets keep no module-level manifest cache; the Node half's production-dependency external list is computed from that manifest on first resolve and cached in that config's closure. +- **Performance:** Client tsdown falls from 12.78 s to 8.73 s (medians of 3), −4.05 s (−31.7%), with disjoint sample ranges; user CPU falls from 26.1 s to 25.2 s and sys from 5.9 s to 4.9 s, so the saving comes mainly from unblocking the main thread rather than from less total CPU. The prototype (one glob building a name index) measured 11.72 s → 7.46 s (−36%). In Host tsdown only 5 `hostPhase` packages reach the lookup; one sample pair measured 11.92 s → 10.71 s, with low confidence. +- **Why it is lossless:** The 11,566 lib files are byte-identical across 11 runs. After the 82 call sites switched to package directories, no resolved package name differs from the name previously written at the call site. An in-process comparison of the external decisions for 82 configs × 3 faces × 1,600 specifiers matches the original implementation exactly; a negative control with one extra external changes 75 decisions. `client-bundle-purity.spec.ts` covers these manifest errors. +- **Behavior difference:** The presets' calling convention changes from a package name to a package directory. + +## Alternatives considered + +- **Enable pnpm `dedupeDirectDeps` (item 1):** It applies to every direct dependency and changes the link layout of every workspace package, a global side effect. +- **Patch TypeScript (item 1):** An upstream-style patch (precomputed candidate endings and cached normalized `paths` patterns) lowers Check by 31.9 s, comparable to the hoist; on the hoisted tree it adds nothing (references Check 25.76 s against 26.47 s) and must be maintained across every TypeScript upgrade. +- **Alias `react` in tsconfig `paths` (item 1):** tsx, vitest, and vite also read `paths`, so the alias changes their resolution of runtime `react`. +- **Narrow `paths` to each face's reference closure (item 1):** The 328-entry version that keeps wildcard keys checks in 55.73 s against 59.08 s (one sample), within noise; the version without wildcard keys produces 18 diagnostics. +- **Split the Client test aggregate program (item 1):** Every sub-program must inject the 242 files carrying module augmentations to pass, and the visible location count stays at 33–49; splitting into 2, 4, or 8 groups leaves the Check sum nearly flat while user CPU rises by 9%, 30%, and 57%, lowering only wall, and the 4- and 8-group splits each produce 1–2 diagnostics. +- **Cache resolution results and make the discovery queue O(1) (Typert):** After items 2–4 only 28 surface walks remain, about 0.18 s, below noise. +- **Move Typert into a separate build step in artifact builds (Typert):** After item 6 the worker already overlaps bundling, leaving at most about 1 s. Type checks, which need no bundle, run a source-only generation step ([local gate speed-ups](2026-10-12-local-gate-speedups.md)). +- **Filter `transform` by code content (item 7):** rolldown interprets `^` and `\s` with Rust regex semantics, which differ from JavaScript on lone CR, U+2028, and BOM, so a decorated module could be missed. +- **Build a process-wide manifest index on `globalThis` (item 8):** The prototype saves about as much time as item 8, but it is process-global state without an owner, violating the shared-state ownership rule in the [package rules](../../../../packages/AGENTS.md). +- **Disable tsdown's gzip report (item 8):** `--no-report` lowers Client tsdown by 0.95 s, within the 1.56 s baseline spread. +- **Add rolldown filters to DSH `resolveId`/`load` hooks (item 8):** Lowers it by 1.16 s, within noise. +- **Memoize the input-isolation check per module id (item 8):** Lowers it by 0.93 s, within noise; on top of the manifest index it saves only another 0.18 s. + +## Consequences + +Client type checking spends substantially less CPU and wall time, and the Host and Client tsdown passes take substantially less wall time, without changing runtime resolution, published installation semantics, or any output byte. In the serial build segments (master → branch, one sample each), Host tsdown falls from 22.5 to 11.7 s, Client package compilation from 55.6 to 48.7 s, the Client aggregate check from 68.2 to 35.9 s, and Client tsdown from 13.9 to 10.5 s; the serial segments sum to 308.5 s on master and 254.9 s on the branch (see [the build split note](2026-10-11-build-artifacts-check-split.md)). Host tsdown lies on the critical path of both `build` and `build:artifacts`, Client tsdown on that of `build:artifacts`, and the Client aggregate check is the last step of the `build` critical path. + +Costs: every workspace package shares one React types version; Typert does not validate tsconfigs of packages it does not analyze, and `tsc -b` owns those errors; Host tsdown max RSS grows by about 0.6 GiB, and under heavy shared-host load the worker can become Host tsdown's critical path. + +Reevaluation conditions: remeasure item 1 when migrating to TypeScript 7, and reconsider per-package declarations if the location count no longer affects Check time. tsdown's own config loading still takes 1.8–2.3 s per Client build (one uncached module instance per config) and needs an upstream change. The first `buildStart` arrives about 4.2 s into the Host build; starting the worker at plugin construction with an explicit root would start Typert earlier. + +Verification: the Typert generator's `tsdown-plugin.spec.ts`, `tsdown-plugin-worker.spec.ts`, and `type-model.spec.ts` pin the behavior of items 2–7; the generator tests report 209 passed and 28 skipped, and `tsc -b tsconfig.host.json` exits 0. The repository coverage gate excludes `packages/typert/*/src/**`; under a scratch coverage config, `tsdown-plugin.ts` reaches 100% statements, branches, functions, and lines. Five negative controls (the old root rule, eager package configs, an expanded aggregate, no emission claim, a skipped worker termination) each fail the test that guards them; the real tsdown failure path exits 1 within 2.4 s and reports `TypertWorkerError`. Item 1: `verify-package-dependencies` exits 0 (74 packages), its spec passes 47/47, `verify-client-packages` exits 0, `tsc -b tsconfig.client.json` reports no diagnostics, and the Client aggregate Check takes 26.48 s. Item 8: all 72 focused specs pass. diff --git a/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.zh.md b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.zh.md new file mode 100644 index 0000000000..7ccfe2d3c9 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.zh.md @@ -0,0 +1,136 @@ +# Agent Note: 不改变产物的构建提速 + +Status: implemented + +[English](2026-10-11-lossless-build-speedups.md) | 中文 + +## 问题 + +冷启动构建中有三处与产物内容无关的固定开销: + +- **Client 测试聚合程序的 JSX 签名。** `tsc -p tsconfig.client.json` 的 Check 阶段为 59.1 s,其中一半以上用于为每个导入 React 的 JSX 文件计算 `@types/react` 的 module specifier。TypeScript 的 `computeModuleSpecifiers` 遍历该声明文件的每个符号链接位置,并在每个位置扫描全部 491 条 tsconfig `paths`。pnpm 为每个自行声明 `@types/react` 的 workspace 包创建一个 `node_modules/@types/react` 链接;66 个 manifest 声明了它,Client 测试聚合程序因此看到 65 个位置。计数器测得 88 个 JSX 文件 × 2 个模块(`index.d.ts`、`jsx-runtime.d.ts`)× 65 个位置,共 11,457 次 `tryGetModuleNameFromPaths` 调用、5,623,782 次条目扫描,合计 32.6 s。按可见位置数 N 截断的剂量实验在五个截断级别(N 从 1 到 65,Check 从 26.92 s 到 58.40 s)上拟合出 Check ≈ 26.9 + 0.494·N s。 +- **Host tsdown 阶段中的 Typert。** Host tsdown 阶段约 21.6–22.6 s。Typert 在第一个 `writeBundle` 中同步发现并生成产物,CPU profile 显示它阻塞 tsdown 主线程约 12.4 s,期间所有包构建都在等待。发现阶段占 8.06 s:它解析两个 face 聚合配置所引用的 362 个包 tsconfig(约 3.8 s),每次解析 Host 聚合配置都展开 2,064 条字面 `include`(1.6 s,分析器从不读取这些文件名),并对 322 个注册项做公开导出表面遍历(2.7–3.2 s);最终只有 28 个声明 Typert 导出的包产生 Host 产物。 +- **Client tsdown 阶段的 manifest 查找。** Client tsdown 阶段约 11.7 s,主线程饱和(空闲仅 7.1%),其中 34.5%(3.95 s)在 `workspaceManifest`:每个包配置按包名 `globSync('packages/*/*/package.json')` 并逐个解析 manifest,直到名字匹配。模块级缓存无效,因为 tsdown 的 native 配置加载器通过 `import-without-cache` 为每个包配置创建新的模块实例;每次 glob 耗时 40–125 ms,且发生在 `tsdown:deps` 的 `neverBundle`/`alwaysBundle` 回调中,阻塞全部构建。 + +#5849 已合并 Host fixture 类型收窄与 Typert memoization;本 Note 的基线是其后的 master。 + +## 决策 + +以下八项提速不改变 `lib/`、`apps/web/dist` 的产物字节,也不改变仓库构建报告的类型诊断集合。唯一的诊断归属变化来自第 4 项:Typert 不校验它不分析的包 tsconfig,这些错误只由 `tsc -b` 报告。命令拆分与并发调度由[构建拆分 Note](2026-10-11-build-artifacts-check-split.zh.md)记录。 + +| # | 决策 | 受影响阶段 | 实测中位数 | 产物与诊断 | 有意的行为差异 | +|---|---|---|---|---|---| +| 1 | React 类型包只由根 manifest 声明 | Client 类型检查 | 测试聚合 Check 59.1 → 26.3 s | 程序文件数与诊断不变 | 包 manifest 不列出 React 类型包 | +| 2 | Typert 先按 manifest 选择包 | Host tsdown | 22.62 → 19.94 s | 逐字节一致 | 无 | +| 3 | Typert 不展开聚合配置的 `include` | Host tsdown | 19.94 → 16.99 s | 逐字节一致;注册清单一致 | 无 | +| 4 | Typert 惰性解析包 tsconfig | Host tsdown | 16.99 → 16.25 s(噪声内);Typert 发现 5.9–7.1 → 0.36–0.40 s | 逐字节一致;注册清单一致 | 未分析包的 tsconfig 错误只由 `tsc -b` 报告 | +| 5 | Typert 工作区根目录同时要求 `packages/` | Host tsdown | 噪声内 | 逐字节一致 | `package` 模式下双 face 包获得产物 | +| 6 | Typert 在 Worker Thread 中生成 | Host tsdown | 15.39 → 10.25 s | 逐字节一致;失败仍使 tsdown 失败 | Host tsdown 最大 RSS 约 +0.6 GiB | +| 7 | Typert `transform` 钩子按模块 id 过滤 | Host tsdown | 噪声内;钩子调用 1,566 → 195 | 逐字节一致 | 无 | +| 8 | Client tsdown 预设按包目录读取 manifest | Client tsdown | 12.78 → 8.73 s | 逐字节一致;external 判定一致 | 预设第一个参数是包目录 | + +第 5–7 项改变了发布的 `./tsdown` 导出 `typertPlugin()`(`transform` 为 `{ filter, handler }`、`writeBundle` 为异步、工作区根目录必须包含 `packages/`);rolldown 与 tsdown 的使用方接受两种钩子形式,分析器本来就要求 `/packages`,因此此前可用的外部工作区都不会失效。 + +### 测量条件 + +所有数字取自同一台共享 Linux 主机(256 vCPU、4 TB 内存,运行期间 load average 23–111),Node v24.18.0、TypeScript 6.0.3、tsdown 0.22.2(rolldown 1.1.1)。 + +- **Host tsdown:** `node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE host`,每次运行前删除全部 `lib/typert.*`;各变体交替顺序运行,每组 3–4 个样本。第 2–4 项取自第一轮测量(起点为基线),第 5–7 项取自第二轮测量(起点为第 2–4 项合入后的代码)。产物比较覆盖 `packages/*/*/lib`、`apps/*/lib`、`vendor/*/lib` 顶层的 743 个文件(含 140 个 Typert 产物)的 SHA-256。相同代码的两组中位数相差 0–0.11 s,组内样本跨度最大 2.5 s。 +- **Client 测试聚合:** references 预先构建后运行 `tsc -p tsconfig.client.json --extendedDiagnostics`,使用全新的 tsBuildInfo 文件;相同代码 6 个样本的 Check 跨度为 3.8 s(约 6%)。 +- **Client tsdown:** 已构建的树上运行 `node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE client`。第 8 项的原型 3 轮轮换顺序,基线跨度 1.56 s;最终实现与原实现交替运行,各 3 个样本。产物比较覆盖 `{packages,apps,vendor,native}/**/lib/**`(排除 `*.tsbuildinfo`)的全部文件。 + +### 1. React 类型包只由根 manifest 声明 + +- **目的:** 把 Client 测试聚合程序看到的 `@types/react` 位置数 N 从 65 降到 1,消除随 N 线性增长的 module specifier 计算。 +- **改动:** 根 `package.json` 的 `devDependencies` 声明 `@types/react` `~18.3.1` 与 `@types/react-dom` `~18.3.0`,沿用各包原先统一的版本范围;`apps/*`、`packages/*/*` 与 `vendor/*` 的 manifest 在任何依赖区段都不声明它们。TypeScript 从任一包向上查找时都解析到根 `node_modules/@types/react`。runtime `react` 与 `react-dom` 仍按 [Client 依赖声明规则](../../../../packages/client/AGENTS.md#dependency-declaration)由各包声明;只通过 `import type` 引用 React 的 `packages/client/ui-slots` 在 `devDependencies` 中声明 `react`。pnpm `dedupeDirectDeps` 保持关闭。lockfile 的变化集中在 importers 区段(另有一条 integrity 相同的 `papaparse` 条目重排)。 +- **防回退:** [`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 在 `hygiene` 与 CI 中要求根 manifest 的 `devDependencies` 声明这两个包,并拒绝 `apps/*`、`packages/*/*`、`vendor/*` 中任一 manifest 在 `dependencies`、`devDependencies`、`optionalDependencies` 或 `peerDependencies` 中声明它们;诊断指明要删除的条目,并链接 Client 依赖声明规则第 8 条。规则只匹配这两个精确包名,其他 `@types/*` 包仍按 DefinitelyTyped 类型提供方处理。依赖区段推导不把这两个包视为 `react`/`react-dom` 的类型提供方:包缺少 `react` 声明时,验证器报告 `react` 缺失,`--fix` 拒绝修复并要求先声明版本范围;`--fix` 也不写回或删除这两个包。[`verify-package-dependencies.spec.ts`](../../../../scripts/verify-package-dependencies.spec.ts) 覆盖各依赖区段的拒绝、根 manifest 缺失声明、被接受的布局,以及类型提供方排除。 +- **性能:** + + | 测量项 | 各包自声明(N = 65) | 根 manifest 声明(N = 1) | + |---|---|---| + | Client 测试聚合 Check | 59.08 s(3 个样本的中位数) | 26.28 s(5 个样本的中位数),−56% | + | Client 测试聚合 wall | 68–71 s | 33.6–34.9 s | + | Client 测试聚合 user CPU | 79–83 s | 44.5–45.3 s | + | 76 个 client-only 包项目的 Check 总和 | 34.6 s(2 个样本的中位数) | 26.5 s(2 个样本的中位数),−23% | + | 76 个 client-only 包项目的 wall | 55.7 s(2 个样本的中位数) | 44.5 s(2 个样本的中位数) | + +- **无损依据:** 66 个包级链接与根链接指向同一个 realpath(`.pnpm/@types+react@18.3.31`),因此程序读取的声明内容相同;程序文件数保持 2,613,`tsc -b --force tsconfig.client.json` 以 0 退出且无诊断。`devDependencies` 不随已发布的包安装,[发布依赖面](2026-08-26-published-dependency-faces.zh.md)中独立 TypeScript 消费者自行安装所引用类型包的结论不变;runtime 解析不读取 `@types/*`。 +- **取舍:** 包 manifest 不列出自己的 React 类型依赖,所有 workspace 包共用同一个 React 类型版本;某个包需要不同版本时,需要新的 Agent Note 推翻本项。检查只覆盖 `apps/*`、`packages/*/*` 与 `vendor/*`;`website`、`benchmarks`、`native/system` 与 `python/sdk-runtime` 等其他 pnpm 成员不在范围内,目前它们都不声明 React 类型包。 + +### 2. Typert 先按 manifest 选择包 + +- **目的:** workspace 模式只为声明 Typert 导出的包生成产物,避免对其余候选包做表面遍历。 +- **改动:** `WorkspaceAnalyzer.discoverPackages(includeManifest?)` 与 `WorkspaceTypertGenerator.discover(faces?, includeManifest?)` 接受可选的 `package.json` 谓词,在表面遍历之前应用;被拒绝的包既不读取源码,也不解析项目配置。`WorkspaceTypertGenerator.generateContributors(faces?)` 以 `hasTypertExport`(`exports` 含 `./typert`、`./client/typert` 或 `./remote`)为谓词。不传谓词的调用方(Cordis 目录、不指定包的 `generate()`)行为不变。 +- **性能:** Host tsdown 22.62 s → 19.94 s(各 3 个样本的中位数)。 +- **无损依据:** 谓词就是原先在发现之后应用的同一过滤条件,被它拒绝的包本来就不产生产物;743 个产物文件逐字节一致(Typert generator 自身的 `lib/index.js` 除外,其源码即本项改动)。 + +### 3. Typert 不展开聚合配置的 `include` + +- **目的:** 分析器只读取聚合配置的编译选项与 project references,展开 `include` 只为填充从不使用的 `fileNames`。 +- **改动:** `WorkspaceCaches.aggregate(path)` 返回并缓存 `AggregateConfig { path, options, projectReferences }`。解析自身声明 `references` 的聚合配置时,使用 `readDirectory` 返回空列表的 `ParseConfigHost`;不声明 `references` 的聚合配置照常展开。 +- **性能:** Host tsdown 19.94 s → 16.99 s(各 3 个样本的中位数)。 +- **无损依据:** TypeScript 只对既无 `files` 也无 `references` 的配置报告 TS18003(无输入),因此声明 `references` 的聚合配置在不展开时得到相同的诊断、选项与 references。强制读取每个惰性配置后导出的注册清单(408 个注册项,含 `fileNames` 与选项,以及聚合配置的选项和 references)与基线逐字节一致;产物逐字节一致。测试 `reads aggregate options and references without expanding include patterns` 与 `reports an aggregate without references or inputs` 固定这两种配置的行为。 + +### 4. Typert 惰性解析包 tsconfig + +- **目的:** 只解析被选中分析的包的 tsconfig,而不是聚合配置引用的全部包。 +- **改动:** `PackageRegistration.config` 是 `caches.config(configPath)` 的 getter,首次读取时解析。注册清单的顺序、face、包名、根目录与导出子路径不变。 +- **性能:** Host tsdown 16.99 s → 16.25 s(各 3 个样本的中位数),在 2.5 s 的组内跨度以内。第 2–4 项合计在隔离基准中把 Typert 发现从 5.9–7.1 s 降到 0.36–0.40 s,tsconfig 解析从 362 次降到 28 次,表面遍历从 322 次降到 28 次,Typert 总耗时从 9.7–11.8 s 降到 4.4–4.5 s。 +- **无损依据:** 注册清单比较与第 3 项相同(逐字节一致),产物逐字节一致。测试 `reads only the project configs of manifests selected for discovery` 固定只解析所选包的配置。 +- **取舍(非无损):** Typert 不解析它不分析的包的 tsconfig,因此这类 tsconfig 的语法或配置错误不会使 Typert 失败。同一构建中,先于 Host bundle 运行的 Host 包编译 `tsc -b` 与 Client 包编译 `tsc -b` 解析每个被引用的 tsconfig,并拒绝其中的错误;`PackageRegistration.config` 的 JSDoc 与 [Typert generator README](../../../../packages/typert/generator/README.zh.md) 写明这一归属。单独调用 Typert 而不运行 `tsc -b` 的使用方不会得到这些诊断。 + +### 5. Typert 工作区根目录同时要求 `packages/` + +- **目的:** 修正工作区根目录的判定。24 个双 face 包带有自己的 `tsconfig.host.json` 叶子配置;只按 `tsconfig.host.json` 向上查找时,每个这样的包都被当作根目录,workspace 模式每次构建为它们各做一次空发现,`package` 模式下双 face 包得不到产物且没有报错。 +- **改动:** 工作区根目录是打包输出最近的、同时包含 `tsconfig.host.json` 与 `packages/` 的祖先目录。 +- **性能:** Host tsdown 14.82 s → 15.39 s(各 3 个样本的中位数),在噪声内。 +- **无损依据:** 仓库根 [`tsdown.config.ts`](../../../../tsdown.config.ts) 只使用 workspace 模式,743 个产物文件逐字节一致。测试 `resolves the workspace root above a dual-face package leaf` 固定该判定。 +- **行为差异:** `package` 模式的使用方构建双 face 包时得到该包的 Typert 产物;仓库构建不使用 `package` 模式。 + +### 6. Typert 在 Worker Thread 中生成 + +- **目的:** 让 Typert 分析与 tsdown 主线程上的打包重叠,而不是在第一个 `writeBundle` 中阻塞全部包构建。 +- **改动:** [`src/worker.ts`](../../../../packages/typert/generator/src/worker.ts) 是 Worker Thread 入口,运行 `generateContributors(faces)` 并回传一条 `artifacts` 或 `failure` 消息。workspace 模式下,本次构建的第一个 `buildStart` 以 `options.cwd` 定位工作区根目录并启动生成;`writeBundle` 是异步的,第一个完成的打包认领写入并等待结果,之后的打包立即返回。分析错误、worker 崩溃或提前退出、格式错误或无法反序列化的响应、worker 无法终止,都以指明工作区与原因的 `TypertWorkerError` 使认领写入的打包失败,从而使整个 tsdown 阶段失败;结果敲定前 worker 已终止,未被认领的结果不会成为未处理的 rejection。构建产物从 `./worker.js` 启动 worker;源码运行(测试)通过 `tsx/esm/api` 加载 `worker.ts`。`package` 模式仍在主线程中生成。 +- **性能:** Host tsdown 15.39 s → 10.25 s(各 3 个样本的中位数)。最终配置(第 2–7 项)的 profile:主线程 10.1 s,第一个 `buildStart` 出现在 4.18 s(tsdown 配置解析),主线程上的 `writeBundle` 只占 0.37 s;worker 5.0 s(模块加载约 0.4 s、发现 0.28 s、生成 3.88 s,其中 `createProgram` 2.21 s),约在 9.3 s 结束。Host tsdown 的 wall 约为 max(打包, 4.2 s + Typert)。 +- **无损依据:** 第二轮的 54 次运行全部以 0 退出,每次的 743 个产物文件均与基线一致(generator 自身的 `lib/index.js` 除外)。测试 `writes the artifacts in-process generation produces` 比较 worker 与进程内生成的产物;在临时工作区中把一个包的 `files` 改坏后,tsdown 在 2.4 s 内以 1 退出,报告 `TypertWorkerError` 及 worker 内的 `TypertAnalysisError`,不写入任何产物。各失败路径由 `typertPlugin workspace mode` 测试组覆盖。 +- **取舍:** worker 堆约 300–420 MiB;最终配置的 Host tsdown 最大 RSS 中位数为 6,296–6,408 MiB,基线为 5,676 MiB。共享主机高负载时 worker 可能落在关键路径上:一次样本为 21.91 s,拥有产物的包构建等待 worker 约 14 s,其后 10 个样本未复现;load average 约 100 时两次运行为 17.86 s 与 14.3 s。worker 在 1,024 MiB 堆上限下也能完成,因此不是 GC 压力;归因于主机争用尚未证实。 + +### 7. Typert `transform` 钩子按模块 id 过滤 + +- **目的:** 避免每个模块都从 rolldown 进入 JavaScript `transform` 钩子,只为被扩展名检查拒绝。 +- **改动:** `transform` 声明 `filter: { id: /^[^?]*\.[cm]?tsx?(?:\?|$)/ }`,处理函数仍以同一正则检查 id。 +- **性能:** 每次 Host 构建的钩子调用从 1,566 次降到 195 次(195 个 TypeScript 模块,其中 4 个含装饰器)。wall 中位数为 11.07 s(3 个样本)与 9.98 s(4 个样本),第 6 项为 10.25 s,相同代码的对照组为 11.07 s 与 9.87 s,差异在噪声内。 +- **无损依据:** 该正则只含 ASCII 构造,在 rolldown 的 Rust 正则与 JavaScript 中匹配相同的 id 集合;处理函数应用同一检查,因此不读取过滤器的宿主行为相同。产物逐字节一致;测试 `filters transform calls to TypeScript module ids` 固定过滤器。 + +### 8. Client tsdown 预设按包目录读取 manifest + +- **目的:** 每个包配置只读取自己的 `package.json`,消除按包名遍历全部 manifest 的 glob。 +- **改动:** [`packages/client/tsdown.client.ts`](../../../../packages/client/tsdown.client.ts) 的 `clientBundle`、`staticLinked` 与 `clientLibrary` 的第一个参数是包目录(通常为配置文件的 `import.meta.dirname`),预设读取一次 `/package.json`,以其中的 `name` 作为插件 id 与诊断名称;该 manifest 缺失、不可读或缺少 `name` 时,配置加载以指明路径与原因的错误失败。82 个包配置调用这些预设。预设不保留模块级 manifest 缓存;Node 半边的生产依赖 external 列表在首次 resolve 时由该 manifest 计算,并缓存在该配置的闭包中。 +- **性能:** Client tsdown 从 12.78 s 降到 8.73 s(各 3 个样本的中位数),−4.05 s(−31.7%),两组样本区间不重叠;user CPU 从 26.1 s 降到 25.2 s,sys 从 5.9 s 降到 4.9 s,因此收益主要来自解除主线程阻塞,而不是减少 CPU 总量。原型(一次 glob 建立按包名索引)为 11.72 s → 7.46 s(−36%)。Host tsdown 中只有 5 个 `hostPhase` 包经过该查找,一对样本为 11.92 s → 10.71 s,置信度低。 +- **无损依据:** 11 次运行的 11,566 个 lib 文件逐字节一致。82 个调用处改为传入包目录后,没有一个解析出的包名与原先手写的包名不同。进程内比较 82 个配置 × 3 个 face × 1,600 个 specifier 的 external 判定,结果与原实现完全一致;负对照(多加一个 external)使 75 个判定不同。`client-bundle-purity.spec.ts` 覆盖这些 manifest 错误。 +- **行为差异:** 预设的调用约定从包名改为包目录。 + +## 考虑过的替代方案 + +- **开启 pnpm `dedupeDirectDeps`(第 1 项):** 作用于所有直接依赖,改变每个 workspace 包的链接布局,副作用是全局的。 +- **给 TypeScript 打补丁(第 1 项):** 上游式补丁(预先计算候选扩展名并缓存规范化的 `paths` 模式)使 Check 降低 31.9 s,与提升相当;在已提升的树上不再带来收益(refs Check 25.76 s 对 26.47 s),且需随每次 TypeScript 升级维护。 +- **在 tsconfig `paths` 中为 `react` 加别名(第 1 项):** tsx、vitest 与 vite 也读取 `paths`,别名会改变它们对 runtime `react` 的解析。 +- **把 `paths` 收窄为各 face 的 reference closure(第 1 项):** 保留通配键的 328 条版本 Check 为 55.73 s 对 59.08 s(1 个样本),在噪声内;去掉通配键的版本产生 18 个诊断。 +- **拆分 Client 测试聚合程序(第 1 项):** 每个子程序都必须注入 242 个含模块增强的文件才能通过检查,可见位置数仍为 33–49;拆成 2、4、8 组时 Check 总和基本不变,user CPU 分别增加 9%、30%、57%,只降低 wall,且 4 组与 8 组各出现 1–2 个诊断。 +- **缓存解析结果并把发现队列改为 O(1)(Typert):** 第 2–4 项之后只剩 28 次表面遍历,约 0.18 s,低于噪声。 +- **在产物构建中把 Typert 移到独立构建步骤(Typert):** 第 6 项之后 worker 已与打包重叠,剩余收益不超过约 1 s。不需要 bundle 的类型检查运行只读源码的生成步骤([本地门禁提速](2026-10-12-local-gate-speedups.zh.md))。 +- **按代码内容过滤 `transform`(第 7 项):** rolldown 以 Rust 正则语义解释 `^` 与 `\s`,在孤立 CR、U+2028 与 BOM 上与 JavaScript 不同,可能漏掉含装饰器的模块。 +- **在 `globalThis` 上建立进程级 manifest 索引(第 8 项):** 原型收益与第 8 项相当,但它是没有 owner 的进程全局状态,违反[包规则](../../../../packages/AGENTS.md)中的共享状态归属要求。 +- **关闭 tsdown 的 gzip 报告(第 8 项):** `--no-report` 使 Client tsdown 降低 0.95 s,在 1.56 s 的基线跨度内。 +- **为 DSH 的 `resolveId`/`load` 钩子加 rolldown 过滤器(第 8 项):** 降低 1.16 s,在噪声内。 +- **按模块 id 缓存输入隔离检查(第 8 项):** 降低 0.93 s,在噪声内;叠加在 manifest 索引之上只再降低 0.18 s。 + +## 后果 + +Client 类型检查的 CPU 与 wall 时间、Host tsdown 与 Client tsdown 的 wall 时间都明显下降,且不改变 runtime 解析、发布安装语义或任何产物字节。串行构建的各段中(master → 本分支,各一个样本),Host tsdown 从 22.5 s 降到 11.7 s,Client 包编译从 55.6 s 降到 48.7 s,Client 聚合检查从 68.2 s 降到 35.9 s,Client tsdown 从 13.9 s 降到 10.5 s;串行各段合计在 master 上为 308.5 s,在本分支上为 254.9 s(见[构建拆分 Note](2026-10-11-build-artifacts-check-split.zh.md))。Host tsdown 位于 `build` 与 `build:artifacts` 的关键路径上,Client tsdown 位于 `build:artifacts` 的关键路径上,Client 聚合检查是 `build` 关键路径的最后一步。 + +代价:所有 workspace 包共用一个 React 类型版本;Typert 不校验它不分析的包 tsconfig,这些错误由 `tsc -b` 负责;Host tsdown 的最大 RSS 约增加 0.6 GiB,共享主机高负载时 worker 可能成为 Host tsdown 的关键路径。 + +重新评估条件:迁移到 TypeScript 7 时重新测量第 1 项,若位置数不再影响 Check 时间,可以重新评估包级声明。tsdown 的配置加载本身每次 Client 构建仍占 1.8–2.3 s(每个配置一个无缓存的模块实例),需要上游改动。第一个 `buildStart` 在 Host 构建开始约 4.2 s 后才出现;在插件构造时以显式根目录启动 worker 可以进一步提前 Typert。 + +验证:Typert generator 的 `tsdown-plugin.spec.ts`、`tsdown-plugin-worker.spec.ts` 与 `type-model.spec.ts` 固定第 2–7 项的行为;generator 测试 209 个通过、28 个跳过,`tsc -b tsconfig.host.json` 以 0 退出。仓库覆盖率 gate 不包含 `packages/typert/*/src/**`,在临时覆盖率配置下 `tsdown-plugin.ts` 的 statements、branches、functions 与 lines 均为 100%。五个负对照(旧根目录规则、立即解析包配置、展开聚合配置、不认领写入、跳过 worker 终止)各自使守护它的测试失败;真实 tsdown 失败路径在 2.4 s 内以 1 退出并报告 `TypertWorkerError`。第 1 项:`verify-package-dependencies` 以 0 退出(74 个包),其 spec 47/47 通过,`verify-client-packages` 以 0 退出,`tsc -b tsconfig.client.json` 无诊断,Client 聚合 Check 为 26.48 s。第 8 项:72 个相关 spec 全部通过。 diff --git a/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.i18n.yaml b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.i18n.yaml new file mode 100644 index 0000000000..80284ccdd5 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.i18n.yaml @@ -0,0 +1,49 @@ +# Bilingual-pair consistency record for 2026-10-12-local-gate-speedups.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/process/2026-10-12-local-gate-speedups.md +/agent-note-local-gate-hook-and-test-speed-ups: + en: d8edb2c26730e9af + zh: fb69bf2e7b602d4c +/agent-note-local-gate-hook-and-test-speed-ups/problem: + en: 1e2163ec378c44ff + zh: d93cb5970896fccb +/agent-note-local-gate-hook-and-test-speed-ups/decision: + en: 9984b4ad646bfaf2 + zh: 753287ae67e29605 +/agent-note-local-gate-hook-and-test-speed-ups/decision/measurement-conditions: + en: 7430353afba96352 + zh: 6d2a09619a67ecba +/agent-note-local-gate-hook-and-test-speed-ups/decision/1-type-checks-generate-typert-declarations-without-bundling: + en: 4dc48e24a3323b7d + zh: 7c88325160a41d12 +/agent-note-local-gate-hook-and-test-speed-ups/decision/2-the-pre-commit-hook-does-not-regenerate-third-party-notices: + en: e2e344f184621172 + zh: 97d8785aaf9105bf +/agent-note-local-gate-hook-and-test-speed-ups/decision/3-local-default-worker-budget: + en: e9649c40d67e26e0 + zh: c99f22a41c4fc4af +/agent-note-local-gate-hook-and-test-speed-ups/decision/4-repository-reference-candidates-resolve-against-one-object-listing: + en: 2c945250337c88cf + zh: d4bfa34cc858782b +/agent-note-local-gate-hook-and-test-speed-ups/decision/5-documentation-gates-parse-each-document-once: + en: c22d38399a2bb87f + zh: 9ae97e5a6e3fbf48 +/agent-note-local-gate-hook-and-test-speed-ups/decision/6-repository-file-walks-list-each-directory-once: + en: 2f0d35b0a688c938 + zh: 7184fd260c066a01 +/agent-note-local-gate-hook-and-test-speed-ups/decision/7-cli-help-reads-run-concurrently-doc-graph-probes-are-memoized: + en: 965503ba8a31c039 + zh: fe02a394a677d018 +/agent-note-local-gate-hook-and-test-speed-ups/decision/8-type-model-spec-ts-runs-against-an-in-memory-workspace: + en: d519ec362903aa2f + zh: f49ee6547cb954f6 +/agent-note-local-gate-hook-and-test-speed-ups/decision/repository-wide-comparison: + en: 7c9388dc859e0f93 + zh: 5df1fc58eb7a46e8 +/agent-note-local-gate-hook-and-test-speed-ups/alternatives-considered: + en: 763d94734b8967cf + zh: abe980d486e1d7da +/agent-note-local-gate-hook-and-test-speed-ups/consequences: + en: ca00b04567d09408 + zh: 97011043c60f4bc9 diff --git a/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.md b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.md new file mode 100644 index 0000000000..22144834ef --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.md @@ -0,0 +1,158 @@ +# Agent Note: Local gate, hook, and test speed-ups + +Status: implemented + +English | [中文](2026-10-12-local-gate-speedups.zh.md) + +## Problem + +On the baseline, master at the merge of #6013, local runs of the Git hooks, `pnpm run test`, `test:docs`, and `doc-sync` spend most of their wall time on work that their results do not depend on: + +- **Typert declarations from the bundler.** `typecheck`, the command the pre-push hook runs, executes `build:lib:host` (Host `tsc -b tsconfig.host.json`, the Host tsdown pass, and the Desktop bundle) before `tsc -b tsconfig.client.json`. The prerequisite of `lint`, `lint:fix`, `doc-typecheck`, and the CI `typert-contracts` gate runs Host package compilation and then the Host tsdown pass (`compile-referenced-projects.ts host-libraries`). Type checks read only package declarations and Typert declarations, yet `typecheck` with nothing to recheck takes 34.1/35.0 s and the prerequisite takes 52.2/29.6 s. +- **Third-party notices on every commit.** The pre-commit hook regenerates `THIRD_PARTY_NOTICES.md` whenever a staged file is a generator input. To find browser-side third-party imports, the generator test-bundles 75 Client packages and runs one Vite build, so every package source file is an input; on a commit that stages one file under a package `src/` subdirectory, the hooks take 16.5/17.8 s, of which the notices job takes 12.4/13.4 s. +- **Host CPUs as the local worker count.** `os.availableParallelism()` reports the host's CPUs (256 on the measurement host) rather than the container's CPU quota (64). With that default, `pnpm run test` starts 255 Vitest forks, peaks at 55.3 GiB RSS, and fails 12 additional files (14 timeouts) intermittently, while `doc-sync`, `test:docs`, `hygiene`, and `check:all` run at most 4 gates concurrently. +- **One Git prefix lookup per candidate.** `verify-repository-references` resolves each of about 26,000 abbreviated hexadecimal candidates with a Git prefix lookup through `git cat-file --batch-check`; the lookups take 135.7 s of the gate's 166.8 s. +- **Repeated Markdown parsing.** markdown links parses each anchor target twice more and probes target existence without a cache; translation pairing globs four times and parses each document again after link normalization; the package README model-experience and limitations checks each parse every README three times; type equivalence parses all 1,835 documents; Session format references parses each pair four times. +- **Repeated directory reads.** `uniqueRepoFiles`, the walker behind markdown wrap, doc refs, and package paths, reads each directory once per pattern and `**` level and resolves every matched file with `realpath`. +- **Serial help launches and repeated compiler probes.** `gen-cli-help` launches its 12 top-level source-mode `dsh … --help` processes one after another, each waiting mostly on profile boot I/O (49.9/53.9 s); `gen-doc-graphs` builds several compiler hosts and programs that repeat the same `ts.sys` file-system probes (48.7/44.7 s). +- **A test file bound by file-system probes.** Each of the 61 cases of the Typert generator's `type-model.spec.ts` copies its fixture to disk and runs a full analysis whose four TypeScript programs each load the DOM library, `@types/node`, and zod v4, about 22,000 stat calls per analysis. The file takes 316.3/329.1 s and is the long tail of `pnpm run test`. + +## Decision + +The eight decisions below shorten those runs. Each item states the evidence that its outputs and diagnostics stay the same, or the intended difference it accepts. + +| # | Decision | Affected commands | Measured wall | Outputs and diagnostics | Intended behavior difference | +|---|---|---|---|---|---| +| 1 | Type checks generate Typert declarations without bundling | `typecheck`, `lint`, `lint:fix`, `doc-typecheck`, CI `typert-contracts` | `typecheck` 34.1/35.0 → 21.9/20.0 s; prerequisite 52.2/29.6 → 12.5/11.3 s | 140 Typert files byte-identical; the same programs are checked | No runnable bundle and no client build record | +| 2 | The pre-commit hook does not regenerate third-party notices | `pre-commit` | 16.5/17.8 → 4.3/4.5 s | Freshness asserted in the test lane | Stale notices fail in the test lane, not at commit | +| 3 | Local default worker budget | run-gates aggregates, `build`, `typecheck`, `pnpm run test`, Oxlint, publint | `test` at 255 workers: 243.7 s, 55.3 GiB; `test:docs` 74.7 → 50.9 s | Same checks | Local defaults ignore CPUs above 12 or 48 | +| 4 | Repository-reference candidates resolve against one object listing | `verify-repository-references` | 166.8 → 15.7 s | 0 classification differences over 27,050 candidates | None | +| 5 | Documentation gates parse each document once | markdown links, translation pairing, README model experience and limitations, type equivalence, Session format references | markdown links 53.4/51.6 → 25.9/25.1 s; type equivalence 23.0/22.5 → 6.0/5.5 s | Byte-identical gate output | None | +| 6 | Repository file walks list each directory once | markdown wrap, doc refs, package paths | doc refs 8.8/7.0 → 3.2/3.4 s | Identical file lists | doc refs does not read nested `lib` directories | +| 7 | CLI help reads run concurrently; doc-graph probes are memoized | `verify-cli-help`, `verify-doc-graphs` | 49.9/53.9 → 6.9/7.2 s; 48.7/44.7 → 32.7/34.2 s | Byte-identical pages | CLI help uses about 3.6 GB at peak | +| 8 | `type-model.spec.ts` runs against an in-memory workspace | `pnpm run test` | 316.3/329.1 → 14.65/15.04 s | Same 61 cases and assertions; one snapshot line differs | Stubs replace zod, `@types/node`, and the DOM library outside the Emitter case | + +### Measurement conditions + +All numbers are wall times from one shared Linux container with a 64-CPU quota on a host that reports 256 CPUs, at a one-minute load average of 20–150, so samples carry noise. The working tree lives on a network filesystem, where each file-system probe costs more than on a local disk. `a/b` lists two samples, and `x → y` compares the baseline with this implementation. + +### 1. Type checks generate Typert declarations without bundling + +- **Purpose:** Give `typecheck` and the prerequisite of `lint`, `lint:fix`, `doc-typecheck`, and the CI `typert-contracts` gate the declarations they read, package declarations from `tsc` and Typert declarations, without the Host tsdown pass or the Desktop bundle. +- **Change:** `typecheck` is `tsx scripts/build.ts --scope typecheck`, which runs the steps below through the run-gates scheduler ([`scripts/build.ts`](../../../../scripts/build.ts)). The scope builds no native addon, tsdown bundle, Desktop bundle, or Web output, writes no client build record, and leaves runnable `lib/` output as the last `build:artifacts` left it; run `build:artifacts` before launching or testing built output. The prerequisite of `lint`, `lint:fix`, `doc-typecheck`, and `typert-contracts` is `compile-referenced-projects.ts host-contracts`, which runs host-src beside host-typert ([`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts)). `build`, `build:artifacts`, benchmark builds, and the PR rehearsal in `release-vendor.yml` run Typert inside the Host tsdown pass. + +| Step | Command | `needs` | +|---|---|---| +| host-src | `node --max-old-space-size=4096 -b ` | — | +| host-typert | `node --import tsx/esm scripts/generate-typert.ts` | — | +| client-src | ` -b ` | host-src, host-typert | +| host-check | `node --max-old-space-size=4096 -b tsconfig.host.json` | host-src | +| client-check | ` -b tsconfig.client.json` | client-src | + +- **Shared generation:** [`scripts/generate-typert.ts`](../../../../scripts/generate-typert.ts) calls `WorkspaceTypertGenerator.generateContributors()` for `WORKSPACE_TYPERT_FACES` ([`scripts/typert-faces.ts`](../../../../scripts/typert-faces.ts)), the faces the Host tsdown plugin also generates, and writes the result with `writeWorkspaceArtifacts()` from [`artifact-files.ts`](../../../../packages/typert/generator/src/artifact-files.ts), the writer the plugin uses. Like the plugin, it skips TypeScript diagnostics, which Host package compilation reports; a Typert analysis error fails host-typert as it fails the Host bundle in `build`. host-typert runs under Node with tsx's ESM hook rather than through `pnpm exec`, whose startup would lengthen the path to Client compilation. +- **Aggregate checks in build mode:** host-check and client-check start after their own face's package compilation, so build mode finds every reference up to date, checks only the aggregate root program, and skips a root program whose tsbuildinfo is current. +- **Source-only analysis:** host-typert can run beside host-src because its Host program reads no workspace build output. `tsconfig.base.json` maps `@deepseek-ai/dsh-client-file-upload/types` to source; without that alias the specifier resolves through package `exports` to `lib/types/types.d.ts`. With it, the Host program loads 1,289 files, none of them under a workspace package's `lib/`. `generate-typert.ts` enables `rejectBuildOutputs`: [`build-output-guard.ts`](../../../../packages/typert/generator/src/build-output-guard.ts) fails analysis with a `TypertAnalysisError` when a face program loads a file from a workspace package's `lib/` directory, or cannot resolve an import of a workspace package, as on a tree whose packages are not yet compiled, naming each such import specifier and the `paths` entry that maps it to source. Either way a missing alias fails generation whether or not host-src has run. The tsdown plugin leaves the option off because in `build` Typert always runs after Host package compilation. The [Typert generator README](../../../../packages/typert/generator/README.md) documents the writers and the option. +- **Why it is lossless:** No workspace tsconfig sets `allowJs`, every `exports` `types` condition points into `lib/types/` or at `lib/typert.*`, and every tsdown config sets `dts: false`, so no type check reads tsdown output. The 140 Typert files (28 packages × 5 outputs) are byte-identical across the baseline tsdown pass, the current tsdown pass, `generate-typert.ts` after every Typert output is deleted, and `generate-typert.ts` with all 344 `lib/types` directories hidden; with one Remote method temporarily added to `packages/goal/goal`, the tsdown pass and `generate-typert.ts` also write identical files. `typecheck` checks every package project of both faces and both aggregate root programs, the programs that `build` checks. +- **Performance:** `typecheck` with nothing to recheck takes 34.1/35.0 → 21.9/20.0 s. When both aggregate root programs must be rechecked, it takes 83.5 → 76.0/72.1 s, and its critical path is the Host aggregate check at about 66–70 s. The prerequisite of `lint` and `doc-typecheck` takes 52.2/29.6 → 12.5/11.3 s. +- **Trade-off:** `typecheck`, `lint`, `lint:fix`, `doc-typecheck`, and `typert-contracts` write Typert declarations but no bundle, so runnable `lib/` JavaScript can lag behind those declarations until the next `build:artifacts`. +- **Relation to the build:** [The lossless build speed-ups note](2026-10-11-lossless-build-speedups.md) rejects moving Typert out of the Host tsdown pass for builds, where its Worker Thread already overlaps bundling and a separate step saves at most about 1 s. Type-check commands run no bundler, so generating without one removes the whole Host tsdown pass and the Desktop bundle from their critical path. [The build split note](2026-10-11-build-artifacts-check-split.md) lists every caller and its command. +- **Known gap:** Incremental `tsc -b` does not record `lib/typert.remote-client.d.ts`, which Client sources reach through package `exports`, as an input of the consuming project; when a Remote signature changes and no Client source does, an incremental check does not recheck the consumers. The gap is independent of which pass writes the declarations; Issue #6022 tracks it. + +### 2. The pre-commit hook does not regenerate third-party notices + +- **Change:** The `pre-commit` hook in [`lefthook.yml`](../../../../lefthook.yml) has no third-party notices job. [`gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) asserts the committed bytes of `THIRD_PARTY_NOTICES.md` in the test lane, including the required `node 24 / coverage` lane, and names `pnpm run gen-third-party-notices` when they are stale. [The generated third-party notices note](2026-07-30-generated-third-party-notices.md) owns this freshness decision. +- **Performance:** A commit that stages one file under a package `src/` subdirectory spends 4.3/4.5 s in the hooks instead of 16.5/17.8 s, of which the notices job took 12.4/13.4 s. +- **Trade-off:** A commit that leaves the notices stale fails in the test lane after push rather than being corrected at commit time. + +### 3. Local default worker budget + +- **Purpose:** Size default parallelism for a shared workstation or container, where Node reports host CPUs rather than the CPU quota. +- **Change:** [`scripts/parallelism.ts`](../../../../scripts/parallelism.ts) owns the default. With `CI=true` it is the CPU count Node reports. Otherwise it is capped at 12 (`LOCAL_PARALLELISM_CAP`) for the run-gates scheduler, `build`, `typecheck`, Oxlint, and publint, and at 48 (`LOCAL_TEST_PARALLELISM_CAP`) for Vitest forks and the local coverage budget. `doc-sync`, `test:docs`, `hygiene`, and `check:all`, whose gates each build a full `ts.Program`, run at most 8 gates concurrently, in CI as well. Explicit `DSH_GATE_CONCURRENCY`, `DSH_OXLINT_THREADS`, `DSH_COVERAGE_MAX_WORKERS`, and Vitest `--maxWorkers` take precedence; in CI, Vitest and Oxlint keep their own defaults unless the run-gates CI environment sets these variables. The [development guide](../../../../docs/development.md) states the defaults for contributors. + +`pnpm run test` at four fork counts, with the disk-backed `type-model.spec.ts` that item 8 replaces, which sets the wall of the 32-worker run: + +| Vitest workers | Wall | Peak RSS | Additional intermittent failures | Every file except the slowest finished at | +|---|---|---|---|---| +| 255 (the reported CPU count) | 243.7 s | 55.3 GiB | 12 files, 14 timeouts | about 135–170 s | +| 32 | 310.4 s | 15.7 GiB | 4 files, 4 timeouts | 193 s | +| 24 | 337.2 s | 13.2 GiB | 5 files, 5 timeouts | 233 s | +| 12 | 410.5 s | 8.4 GiB | 2 files, 3 timeouts | 406 s | + +- **Oxlint:** Oxlint's own default is 64 threads, read from the cgroup quota; at 12 threads its wall stays about 87–90 s with about 15% less CPU, and at 8 threads it takes about 4 s longer. +- **Documentation aggregates:** `test:docs` takes 50.9 s at 8 concurrent gates against 74.7 s at 4. `doc-sync`, measured without item 4, takes 341.8, 295.0, and 253.2 s at 4, 8, and 12 concurrent gates, with peak RSS of 10.0, 13.5, and 16.3 GiB. +- **Trade-off:** A machine whose CPU quota exceeds the caps gets no more default parallelism than 12 or 48 and needs the override variables; a container whose quota is below a cap still oversubscribes, because Node reports host CPUs. At 8 concurrent gates `doc-sync` peaks at 13.5 GiB RSS against 10.0 GiB at 4; 12 concurrent gates finish 41.8 s sooner at 16.3 GiB. The CI documentation workflow also runs `doc-sync` at 8 concurrent gates. + +### 4. Repository-reference candidates resolve against one object listing + +- **Change:** [`verify-repository-references.ts`](../../../../scripts/verify-repository-references.ts) lists every object name once with `git cat-file --batch-all-objects --batch-check=%(objectname)`, about 1.2 million objects in 0.6 s, sorts the list, and binary-searches each lowercase candidate prefix in memory; one `--batch-check=%(objectname) %(objecttype)` call then reads the types of the matched objects. A candidate is a commit reference only when its prefix matches exactly one available object and that object is a commit, the rule Git's own prefix lookup applies; the listing covers unreachable and alternate objects, as Git's lookup does. +- **Performance:** 166.8 → 15.7 s. +- **Why it is lossless:** Git's per-candidate prefix lookup and the in-memory search classify all 27,050 real and synthetic candidates identically, including ambiguous prefixes, blob and tree prefixes, and random strings. +- **Trade-off:** The listing is about 49 MiB of Git output for 1.2 million objects, read under a 1 GiB output limit. + +### 5. Documentation gates parse each document once + +- **Change:** Each gate below parses a Markdown document once and passes the tree to every consumer in that run. + +| Gate | Single-parse mechanism | Wall | +|---|---|---| +| markdown links ([`verify-md-links.ts`](../../../../scripts/verify-md-links.ts)) | One per-run cache parses each file once for its destinations and anchors and probes each resolved target once | 53.4/51.6 → 25.9/25.1 s | +| translation pairing ([`verify-translation-pairing.ts`](../../../../scripts/verify-translation-pairing.ts)) | One glob covers every scope pattern; the record reuses the authored tree under the guard below | 51.0/47.1 → 30.3/29.3 s | +| package README model experience, package README limitations | Each check parses a README once and passes the tree to `markdownProseLines()` and `markdownHeadingLines()` | about 12 → 5.9 s each | +| type equivalence ([`verify-type-equiv.ts`](../../../../scripts/verify-type-equiv.ts)) | A text prefilter skips documents that cannot hold a source-equivalence fence; each manifest TypeScript source is parsed once | 23.0/22.5 → 6.0/5.5 s | +| Session format references | The persistence renderer passes the trees it parses to the pairing record | 10.2 → 5.3 s | + +- **Pairing reuse guard:** `normalizedTopLevelBlocks()` in [`translation-pairing-record.ts`](../../../../scripts/translation-pairing-record.ts) reuses the authored tree for normalized text only when every link-normalization edit replaces a complete, title-free destination that does not start a line, the authored text equals the node's `url`, and both the authored and the replacement text contain only `[A-Za-z0-9._/:#%-]`. Those characters cannot end a destination or start or end another construct, so the normalized text parses to the same nodes with shifted offsets. Any other edit falls back to a full parse of the normalized text. +- **Type-equivalence prefilter:** [`type-equiv-prefilter.ts`](../../../../scripts/type-equiv-prefilter.ts) keeps a document for parsing when it contains `type-equiv` or `public-api`, or when a line containing `` ``` `` or `~~~` also contains `&` or `\`. Parsing changes an info string only by decoding character references and backslash escapes, so `ts type-equiv` and `ts type\-equiv` keep their documents; [`type-equiv-prefilter.spec.ts`](../../../../scripts/type-equiv-prefilter.spec.ts) covers both forms. +- **Why it is lossless:** Every gate's output on the repository is byte-identical, and seeded faults are reported identically. For pairing, the top-level blocks of all 1,886 corpus documents and the records of all 943 pairs match those of a full reparse, as do randomized destinations. + +### 6. Repository file walks list each directory once + +- **Change:** `uniqueRepoFiles()` in [`scripts/repo-files.ts`](../../../../scripts/repo-files.ts) shares one directory-listing cache across all patterns of a call, so each directory is read at most once, and calls `realpath` only for matches reached through a symbolic link; a regular file reached through real directories canonicalizes to the repository's realpath joined with its relative path. Its optional `skipDirectory` predicate keeps the walker out of a directory whose every path the caller excludes; [`verify-doc-refs.ts`](../../../../scripts/verify-doc-refs.ts) skips every `lib` directory below the first path segment, whose files it excludes in any case. +- **Performance:** markdown wrap 25.8/25.0 → 21.3/21.2 s; doc refs 8.8/7.0 → 3.2/3.4 s; package paths 9.9/8.1 → 7.2/5.5 s. +- **Why it is lossless:** All three gates receive identical file lists, and a broken or cyclic symbolic link still fails through `realpath`. +- **Behavior difference:** doc refs does not list a skipped `lib` directory, so an unreadable one does not fail the gate with EACCES. + +### 7. CLI help reads run concurrently; doc-graph probes are memoized + +- **CLI help:** `prefetchHelp()` in [`scripts/gen-cli-help.ts`](../../../../scripts/gen-cli-help.ts) starts the 12 top-level `dsh … --help` reads (the launcher, `plugin`, and each profile's shorthand and `--profile` forms) within the default worker budget, each with its own temporary `DSH_HOME` and `DSH_AGENTS_HOME`, and the in-order traversal consumes them; nested reads share the same limit. A failed read reaches the traversal when it requests that invocation, and `close()` aborts queued and running reads and waits for every started child. The gate takes 49.9/53.9 → 6.9/7.2 s. +- **Doc graphs:** `withMemoizedFileSystemProbes()` in [`scripts/gen-doc-graphs.ts`](../../../../scripts/gen-doc-graphs.ts) memoizes `ts.sys` `fileExists`, `directoryExists`, `realpath`, `readDirectory`, and `getDirectories` while `renderDocs` runs, so every compiler host and config parse in one run shares one view of the file system. The workspace must not change during the run, and the original methods are restored before pages are written. The gate takes 48.7/44.7 → 32.7/34.2 s. +- **Why it is lossless:** Both generators produce byte-identical pages. +- **Trade-off:** The concurrent help processes use about 3.6 GB of memory combined at peak. + +### 8. `type-model.spec.ts` runs against an in-memory workspace + +- **Change:** [`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) mocks `node:fs` with `memoryFileSystem()` and the `ts.sys` host from [`type-model-workspace.ts`](../../../../packages/typert/generator/tests/type-model-workspace.ts), so fixture copies, case edits, write-mode annotations, tsconfig expansion, and module resolution under the in-memory root never touch the disk. Each workspace mounts minimal `zod` and `@types/node` declaration stubs; only the Emitter case keeps real zod. The fixture's `tsconfig.base.json` sets `lib: ["ES2024"]`, which excludes the DOM library, and cases that read the unmodified fixture share one deep-frozen analysis. The generator source has no test-specific path. +- **Performance:** The file alone takes 316.3/329.1 → 14.65/15.04 s. +- **Why the behavior coverage stays:** The 61 case names and their assertions are the same. The snapshot differs in one line, the external zod symbol path, which names the stub (`:node_modules/zod/index.d.ts#ZodType`) rather than a pnpm store version. +- **Trade-off:** Outside the Emitter case, the spec does not exercise the analyzer against the real file system, the DOM library, or the real zod and `@types/node` declarations. The memory file system implements only the `node:fs` calls the analyzer makes (`existsSync`, `readFileSync`, `realpathSync`, and `writeFileSync`); another call on an in-memory path reaches the real file system, where the path does not exist. + +### Repository-wide comparison + +Both trees were measured in the same session, alternating baseline and implementation runs of each command after `pnpm run build:artifacts` (`pnpm run test` fails the same environment-dependent files on both trees, 42 on the baseline and a subset of 28 here): + +| Command | Baseline | This implementation | Change | Items | +|---|---|---|---|---| +| `pre-commit` hooks, one staged file under a package `src/` subdirectory | 16.5/17.8 s | 4.3/4.5 s | −75% | 2 | +| `pnpm run typecheck` (pre-push), nothing to recheck | 29.7/23.3 s | 14.2/16.1 s | −43% | 1, 3 | +| `pnpm run lint` | 110.0 s | 106.7 s | −3% | 1 | +| `pnpm run doc-typecheck` | 45.2 s | 36.1 s | −20% | 1 | +| `pnpm run test:docs` | 102.4/104.0 s | 26.8/29.3 s | −73% | 3–6 | +| `pnpm run doc-sync` | 186.8 s | 70.3 s | −62% | 3–7 | +| `pnpm run test` | 222.7 s (255 workers) | 148.1 s (48 workers) | −34% | 3, 8 | + +`lint` time is Oxlint itself; its prerequisite falls as item 1 states. CPU time falls with wall: `typecheck` user time 39.5 → 14.2 s, `test:docs` 212.6 → 126.3 s, `doc-sync` 628.2 → 503.2 s, and `test` 3,417 → 3,014 s. + +## Alternatives considered + +- **Cap every local default at 12, including Vitest forks (item 3):** `pnpm run test` takes 410.5 s at 12 workers against 243.7 s at 255, 68% longer. +- **Use half of the cgroup CPU quota as the local budget (item 3):** Reading the quota is a larger change and helps only inside containers. +- **Parse documents in parallel `worker_threads` (item 5):** It adds more complexity than parsing each document once, which removes the repeated work itself. +- **Regenerate third-party notices in the pre-commit hook (item 2):** Browser inputs make every package source file a generator input, so the hook runs the 16-second generator on nearly every commit while few commits change its output; [the generated third-party notices note](2026-07-30-generated-third-party-notices.md) owns the decision. + +## Consequences + +The pre-push `typecheck` with nothing to recheck takes about 14–22 s, the `lint` and `doc-typecheck` prerequisite about 12 s, and the hooks of a one-file commit about 4 s. `pnpm run test` starts at most 48 forks locally, and `test:docs` and `doc-sync` run up to 8 gates at once. Typert outputs, generated pages, file lists, and reference classifications are byte-identical to the baseline. + +Costs: type-check commands do not refresh runnable bundles; a stale notices file fails in the test lane after push; local defaults ignore CPUs above the caps and still oversubscribe a container whose quota is below them; 8 concurrent documentation gates raise the `doc-sync` RSS peak; CLI help uses about 3.6 GB at peak; doc graphs and markdown links assume the tree does not change during a run; `type-model.spec.ts` exercises stubs rather than real zod, `@types/node`, the DOM library, and disk I/O outside the Emitter case; and incremental `typecheck` inherits the Remote-declaration gap that Issue #6022 tracks. + +Verification: `generate-typert.spec.ts` pins byte-identical files against the workspace-mode plugin, rejection of a Host program that loads a compiled workspace declaration with the missing alias named, and each failure stage; `compile-referenced-projects.spec.ts` pins the `host-contracts` steps and their failure reporting; `build.spec.ts` pins the typecheck scope's steps; `parallelism.spec.ts`, `run-gates.spec.ts`, and `run-oxlint.spec.ts` pin the CI and local budgets and the overrides; `repo-files.spec.ts`, `markdown.spec.ts`, `translation-pairing.spec.ts`, `type-equiv-prefilter.spec.ts`, `verify-md-links.spec.ts`, `gen-cli-help.spec.ts`, and `gen-doc-graphs.spec.ts` pin single directory listing, parse reuse, the pairing guard, the prefilter, the prefetch order and failure path, and probe memoization with restoration. diff --git a/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.zh.md b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.zh.md new file mode 100644 index 0000000000..faf66b3153 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.zh.md @@ -0,0 +1,158 @@ +# Agent Note: 本地门禁、钩子与测试提速 + +Status: implemented + +[English](2026-10-12-local-gate-speedups.md) | 中文 + +## 问题 + +在基线(合并 #6013 时的 master)上,本地运行 Git 钩子、`pnpm run test`、`test:docs` 与 `doc-sync`(文档同步门禁)时,大部分 wall 时间花在结果并不依赖的工作上: + +- **经打包器获得 Typert 声明。** pre-push 钩子运行的 `typecheck` 先运行 `build:lib:host`(Host `tsc -b tsconfig.host.json`、Host tsdown 阶段与 Desktop bundle),再运行 `tsc -b tsconfig.client.json`。`lint`、`lint:fix`、`doc-typecheck` 与 CI `typert-contracts` 门禁的前置步骤先运行 Host 包编译,再运行 Host tsdown 阶段(`compile-referenced-projects.ts host-libraries`)。类型检查只读取包声明与 Typert 声明,但无需重查时 `typecheck` 仍需 34.1/35.0 s,前置步骤需 52.2/29.6 s。 +- **每次提交都生成第三方声明。** 只要暂存文件是生成器的输入,pre-commit 钩子就重新生成 `THIRD_PARTY_NOTICES.md`。为找出浏览器侧的第三方 import,生成器对 75 个 Client 包试打包并运行一次 Vite 构建,因此每个包源码文件都是它的输入;暂存一个位于包 `src/` 子目录下的文件的提交,钩子合计 16.5/17.8 s,其中第三方声明任务占 12.4/13.4 s。 +- **以宿主 CPU 数作为本地 worker 数。** `os.availableParallelism()` 报告的是宿主 CPU 数(测量主机上为 256),而不是容器的 CPU 配额(64)。按此默认值,`pnpm run test` 启动 255 个 Vitest fork,RSS 峰值 55.3 GiB,并偶发地多失败 12 个文件(14 次超时);同时 `doc-sync`、`test:docs`、`hygiene` 与 `check:all` 最多只并发 4 个门禁。 +- **每个候选一次 Git 前缀查找。** `verify-repository-references` 通过 `git cat-file --batch-check` 对约 26,000 个缩写十六进制候选逐个做 Git 前缀查找;这些查找占该门禁 166.8 s 中的 135.7 s。 +- **重复解析 Markdown。** markdown links 对每个锚点目标再多解析两次,且探测目标是否存在时不缓存;translation pairing 做四次 glob,并在链接归一化后再次解析每个文档;包 README 的 model experience 与 limitations 检查各自把每个 README 解析三次;type equivalence 解析全部 1,835 个文档;Session format references 把每个配对解析四次。 +- **重复读取目录。** markdown wrap、doc refs 与 package paths 背后的遍历器 `uniqueRepoFiles` 对每个 pattern 与每层 `**` 各读取一次每个目录,并对每个匹配文件调用 `realpath`。 +- **串行启动 help 与重复的编译器探测。** `gen-cli-help` 依次启动 12 个顶层源码模式的 `dsh … --help` 进程,每个进程大部分时间在等待 profile 启动的 I/O(49.9/53.9 s);`gen-doc-graphs` 构建多个编译器 host 与 program,重复执行同样的 `ts.sys` 文件系统探测(48.7/44.7 s)。 +- **受文件系统探测拖累的测试文件。** Typert generator 的 `type-model.spec.ts` 的 61 个用例各自把 fixture(测试前置数据)复制到磁盘并运行一次完整分析,分析中的四个 TypeScript program 各自加载 DOM lib、`@types/node` 与 zod v4,每次分析约 22,000 次 stat 调用。该文件耗时 316.3/329.1 s,是 `pnpm run test` 的长尾。 + +## 决策 + +以下八项决策缩短上述运行。每一项都给出其产物与诊断保持不变的依据,或它接受的有意差异。 + +| # | 决策 | 受影响的命令 | 实测 wall | 产物与诊断 | 有意的行为差异 | +|---|---|---|---|---|---| +| 1 | 类型检查不打包地生成 Typert 声明 | `typecheck`、`lint`、`lint:fix`、`doc-typecheck`、CI `typert-contracts` | `typecheck` 34.1/35.0 → 21.9/20.0 s;前置步骤 52.2/29.6 → 12.5/11.3 s | 140 个 Typert 文件逐字节一致;检查的程序相同 | 不产出可运行 bundle,不写 client build record | +| 2 | pre-commit 钩子不重新生成第三方声明 | `pre-commit` | 16.5/17.8 → 4.3/4.5 s | 新鲜度由测试 lane 断言 | 第三方声明过期时在测试 lane 失败,而不是在提交时 | +| 3 | 本地默认 worker 预算 | run-gates 聚合、`build`、`typecheck`、`pnpm run test`、Oxlint、publint | 255 个 worker 时 `test` 为 243.7 s、55.3 GiB;`test:docs` 74.7 → 50.9 s | 检查不变 | 本地默认值不使用超过 12 或 48 的 CPU | +| 4 | 仓库引用候选对照一次对象列表解析 | `verify-repository-references` | 166.8 → 15.7 s | 27,050 个候选的判定 0 差异 | 无 | +| 5 | 文档门禁每个文档只解析一次 | markdown links、translation pairing、README model experience 与 limitations、type equivalence、Session format references | markdown links 53.4/51.6 → 25.9/25.1 s;type equivalence 23.0/22.5 → 6.0/5.5 s | 门禁输出逐字节一致 | 无 | +| 6 | 仓库文件遍历每个目录只列一次 | markdown wrap、doc refs、package paths | doc refs 8.8/7.0 → 3.2/3.4 s | 文件列表一致 | doc refs 不读取嵌套的 `lib` 目录 | +| 7 | CLI(命令行界面)help 读取并发运行;doc graphs 的探测做 memo | `verify-cli-help`、`verify-doc-graphs` | 49.9/53.9 → 6.9/7.2 s;48.7/44.7 → 32.7/34.2 s | 页面逐字节一致 | CLI help 峰值约用 3.6 GB 内存 | +| 8 | `type-model.spec.ts` 在内存工作区上运行 | `pnpm run test` | 316.3/329.1 → 14.65/15.04 s | 61 个用例与断言不变;快照有一行不同 | 除 Emitter 用例外,桩替代 zod、`@types/node` 与 DOM lib | + +### 测量条件 + +所有数字都是 wall 时间,取自同一个共享 Linux 容器:CPU 配额 64 核,宿主报告 256 个 CPU,一分钟 load average 为 20–150,因此样本带有噪声。工作树位于网络文件系统上,每次文件系统探测的代价都高于本地磁盘。`a/b` 表示两个样本,`x → y` 比较基线与本实现。 + +### 1. 类型检查不打包地生成 Typert 声明 + +- **目的:** 让 `typecheck` 以及 `lint`、`lint:fix`、`doc-typecheck` 与 CI `typert-contracts` 门禁的前置步骤得到它们读取的声明,即 `tsc` 产出的包声明与 Typert 声明,而不运行 Host tsdown 阶段或 Desktop bundle。 +- **改动:** `typecheck` 是 `tsx scripts/build.ts --scope typecheck`,它通过 run-gates 调度器运行下表中的步骤([`scripts/build.ts`](../../../../scripts/build.ts))。该 scope 不构建 native addon、tsdown bundle、Desktop bundle 或 Web 产物,不写 client build record,可运行的 `lib/` 产物保持上一次 `build:artifacts` 的状态;启动或测试构建产物之前先运行 `build:artifacts`。`lint`、`lint:fix`、`doc-typecheck` 与 `typert-contracts` 的前置步骤是 `compile-referenced-projects.ts host-contracts`,它让 host-src 与 host-typert 并行([`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts))。`build`、`build:artifacts`、benchmark 构建与 `release-vendor.yml` 的 PR(Pull Request)演练在 Host tsdown 阶段内运行 Typert。 + +| 步骤 | 命令 | `needs` | +|---|---|---| +| host-src | `node --max-old-space-size=4096 -b ` | — | +| host-typert | `node --import tsx/esm scripts/generate-typert.ts` | — | +| client-src | ` -b ` | host-src、host-typert | +| host-check | `node --max-old-space-size=4096 -b tsconfig.host.json` | host-src | +| client-check | ` -b tsconfig.client.json` | client-src | + +- **共用生成:** [`scripts/generate-typert.ts`](../../../../scripts/generate-typert.ts) 对 `WORKSPACE_TYPERT_FACES`([`scripts/typert-faces.ts`](../../../../scripts/typert-faces.ts),即 Host tsdown 插件同样生成的 face)调用 `WorkspaceTypertGenerator.generateContributors()`,并用 [`artifact-files.ts`](../../../../packages/typert/generator/src/artifact-files.ts) 中插件同样使用的 `writeWorkspaceArtifacts()` 写出结果。与插件一样,它跳过 TypeScript 诊断,这些诊断由 Host 包编译报告;Typert 分析错误使 host-typert 失败,正如它在 `build` 中使 Host bundle 失败。host-typert 由 Node 加 tsx 的 ESM 钩子运行,而不经 `pnpm exec`,因为后者的启动开销会拉长通往 Client 编译的路径。 +- **以 build mode 运行聚合检查:** host-check 与 client-check 在本侧 face 的包编译之后才开始,因此 build mode 发现所有 references 都是最新的,只检查聚合根程序,并在根程序的 tsbuildinfo 为最新时跳过它。 +- **只读源码的分析:** host-typert 能与 host-src 并行,是因为它的 Host program 不读取任何工作区构建产物。`tsconfig.base.json` 把 `@deepseek-ai/dsh-client-file-upload/types` 映射到源码;没有这条别名时,该说明符经包 `exports` 解析到 `lib/types/types.d.ts`。有了它,Host program 加载 1,289 个文件,其中没有一个位于工作区包的 `lib/` 下。`generate-typert.ts` 开启 `rejectBuildOutputs`:face program 加载工作区包 `lib/` 目录下的文件,或无法解析对工作区包的 import(例如包尚未编译的工作区)时,[`build-output-guard.ts`](../../../../packages/typert/generator/src/build-output-guard.ts) 以 `TypertAnalysisError` 使分析失败,并指出每个这样的 import 说明符,以及把它映射到源码的 `paths` 条目。因此无论 host-src 是否已运行,缺失的别名都会使生成失败。tsdown 插件不开启该选项,因为在 `build` 中 Typert 总在 Host 包编译之后运行。[Typert generator README](../../../../packages/typert/generator/README.zh.md) 记录了这些写入函数与该选项。 +- **无损依据:** 没有任何工作区 tsconfig 设置 `allowJs`,每个 `exports` 的 `types` 条件都指向 `lib/types/` 内或 `lib/typert.*`,每个 tsdown 配置都设置 `dts: false`,因此没有类型检查读取 tsdown 产物。140 个 Typert 文件(28 个包 × 5 个输出)在以下场景中逐字节一致:基线 tsdown 阶段、当前 tsdown 阶段、删除全部 Typert 输出后运行 `generate-typert.ts`、隐藏全部 344 个 `lib/types` 目录后运行 `generate-typert.ts`;临时给 `packages/goal/goal` 加一个 Remote 方法时,tsdown 阶段与 `generate-typert.ts` 写出的文件也相同。`typecheck` 检查两个 face 的全部包 project 与两个聚合根程序,即 `build` 检查的程序。 +- **性能:** 无需重查时 `typecheck` 为 34.1/35.0 → 21.9/20.0 s。两个聚合根程序都需要重查时为 83.5 → 76.0/72.1 s,其关键路径是约 66–70 s 的 Host 聚合检查。`lint` 与 `doc-typecheck` 的前置步骤为 52.2/29.6 → 12.5/11.3 s。 +- **取舍:** `typecheck`、`lint`、`lint:fix`、`doc-typecheck` 与 `typert-contracts` 写出 Typert 声明但不写 bundle,因此在下一次 `build:artifacts` 之前,可运行的 `lib/` JavaScript 可能落后于这些声明。 +- **与构建的关系:** [无损构建提速 Note](2026-10-11-lossless-build-speedups.zh.md) 否决了在构建中把 Typert 移出 Host tsdown 阶段:构建中它的 Worker Thread 已与打包重叠,独立步骤最多只能省约 1 s。类型检查命令不运行打包器,因此不打包地生成会把整个 Host tsdown 阶段与 Desktop bundle 移出它们的关键路径。[构建拆分 Note](2026-10-11-build-artifacts-check-split.zh.md) 列出每个调用方及其命令。 +- **已知缺口:** 增量 `tsc -b` 不把 Client 源码经包 `exports` 访问到的 `lib/typert.remote-client.d.ts` 记为消费方 project 的输入;Remote 签名改变而 Client 源码未变时,增量检查不会重查消费方。这一缺口与由哪一种生成写出声明无关,由 Issue #6022 跟踪。 + +### 2. pre-commit 钩子不重新生成第三方声明 + +- **改动:** [`lefthook.yml`](../../../../lefthook.yml) 中的 `pre-commit` 钩子没有第三方声明任务。[`gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 在测试 lane(包括必过的 `node 24 / coverage` lane)中断言 `THIRD_PARTY_NOTICES.md` 已提交的字节,过期时指出 `pnpm run gen-third-party-notices`。[生成的第三方声明 Note](2026-07-30-generated-third-party-notices.zh.md) 拥有这一新鲜度决策。 +- **性能:** 暂存一个位于包 `src/` 子目录下的文件的提交,钩子耗时由 16.5/17.8 s 降为 4.3/4.5 s,其中第三方声明任务占 12.4/13.4 s。 +- **取舍:** 使第三方声明过期的提交会在推送后于测试 lane 失败,而不是在提交时被修正。 + +### 3. 本地默认 worker 预算 + +- **目的:** 按共享工作站或容器确定默认并行度;在这类环境中 Node 报告的是宿主 CPU 数而不是 CPU 配额。 +- **改动:** [`scripts/parallelism.ts`](../../../../scripts/parallelism.ts) 拥有默认值。`CI=true` 时它是 Node 报告的 CPU 数。否则,run-gates 调度器、`build`、`typecheck`、Oxlint 与 publint 的上限为 12(`LOCAL_PARALLELISM_CAP`),Vitest fork 与本地覆盖率预算的上限为 48(`LOCAL_TEST_PARALLELISM_CAP`)。`doc-sync`、`test:docs`、`hygiene` 与 `check:all` 的每个门禁都构建完整的 `ts.Program`,它们最多并发 8 个门禁,CI 中同样如此。显式的 `DSH_GATE_CONCURRENCY`、`DSH_OXLINT_THREADS`、`DSH_COVERAGE_MAX_WORKERS` 与 Vitest `--maxWorkers` 优先;CI 中,除非 run-gates 的 CI 环境设置了这些变量,Vitest 与 Oxlint 使用各自的默认值。[开发指南](../../../../docs/development.zh.md)向贡献者说明这些默认值。 + +四种 fork 数下的 `pnpm run test`,运行的是第 8 项所替换的落盘版 `type-model.spec.ts`,32 个 worker 那次的 wall 由它决定: + +| Vitest worker 数 | wall | RSS 峰值 | 额外的偶发失败 | 除最慢文件外其余文件完成于 | +|---|---|---|---|---| +| 255(报告的 CPU 数) | 243.7 s | 55.3 GiB | 12 个文件、14 次超时 | 约 135–170 s | +| 32 | 310.4 s | 15.7 GiB | 4 个文件、4 次超时 | 193 s | +| 24 | 337.2 s | 13.2 GiB | 5 个文件、5 次超时 | 233 s | +| 12 | 410.5 s | 8.4 GiB | 2 个文件、3 次超时 | 406 s | + +- **Oxlint:** Oxlint 自身的默认值是 64 线程(读取 cgroup 配额);12 线程时其 wall 保持约 87–90 s,CPU 少约 15%;8 线程时慢约 4 s。 +- **文档聚合:** 并发 8 个门禁时 `test:docs` 为 50.9 s,并发 4 个时为 74.7 s。不含第 4 项时测得的 `doc-sync` 在并发 4、8、12 个门禁时分别为 341.8、295.0、253.2 s,RSS 峰值分别为 10.0、13.5、16.3 GiB。 +- **取舍:** CPU 配额超过上限的机器,默认并行度也不超过 12 或 48,需要使用覆盖变量;配额低于上限的容器仍会超额订阅,因为 Node 报告的是宿主 CPU 数。并发 8 个门禁时 `doc-sync` 的 RSS 峰值为 13.5 GiB,并发 4 个时为 10.0 GiB;并发 12 个门禁快 41.8 s,但峰值为 16.3 GiB。CI 的文档 workflow 运行 `doc-sync` 时同样最多并发 8 个门禁。 + +### 4. 仓库引用候选对照一次对象列表解析 + +- **改动:** [`verify-repository-references.ts`](../../../../scripts/verify-repository-references.ts) 用 `git cat-file --batch-all-objects --batch-check=%(objectname)` 一次列出全部对象名(约 120 万个对象,0.6 s),排序后在内存中对每个小写候选前缀做二分查找;随后一次 `--batch-check=%(objectname) %(objecttype)` 调用读取命中对象的类型。只有当候选前缀恰好匹配一个可用对象且该对象是 commit 时,它才算 commit 引用,这与 Git 自身前缀查找的规则相同;与 Git 的查找一样,该列表也包含不可达对象与 alternate 对象。 +- **性能:** 166.8 → 15.7 s。 +- **无损依据:** 在 27,050 个真实与合成候选上(包括歧义前缀、blob 与 tree 前缀以及随机字符串),Git 的逐候选前缀查找与内存查找的判定完全一致。 +- **取舍:** 120 万个对象的列表约为 49 MiB 的 Git 输出,读取时的输出上限为 1 GiB。 + +### 5. 文档门禁每个文档只解析一次 + +- **改动:** 下表中的每个门禁对一个 Markdown 文档只解析一次,并把语法树传给本次运行中的所有使用方。 + +| 门禁 | 单次解析机制 | wall | +|---|---|---| +| markdown links([`verify-md-links.ts`](../../../../scripts/verify-md-links.ts)) | 一个按运行划分的缓存对每个文件只解析一次,同时取得其 destination 与锚点,并对每个解析后的目标只探测一次 | 53.4/51.6 → 25.9/25.1 s | +| translation pairing([`verify-translation-pairing.ts`](../../../../scripts/verify-translation-pairing.ts)) | 一次 glob 覆盖所有范围 pattern;record 在下述守卫条件下复用原文语法树 | 51.0/47.1 → 30.3/29.3 s | +| package README model experience、package README limitations | 每项检查对一个 README 只解析一次,并把语法树传给 `markdownProseLines()` 与 `markdownHeadingLines()` | 各约 12 → 5.9 s | +| type equivalence([`verify-type-equiv.ts`](../../../../scripts/verify-type-equiv.ts)) | 文本预筛跳过不可能含源码等价围栏的文档;manifest(元数据清单)中的每个 TypeScript 源文件只解析一次 | 23.0/22.5 → 6.0/5.5 s | +| Session format references | persistence 渲染器把它解析出的语法树传给配对 record | 10.2 → 5.3 s | + +- **配对复用守卫:** [`translation-pairing-record.ts`](../../../../scripts/translation-pairing-record.ts) 中的 `normalizedTopLevelBlocks()` 仅在以下条件同时成立时,才为归一化后的文本复用原文语法树:每处链接归一化编辑都替换一个完整、无 title 且不在行首开始的 destination,原文等于该节点的 `url`,且原文与替换文本都只含 `[A-Za-z0-9._/:#%-]`。这些字符既不能结束 destination,也不能开始或结束其他语法结构,因此归一化后的文本解析出相同的节点,只是偏移量随之移动。其他任何编辑都回退为对归一化文本的完整解析。 +- **type equivalence 预筛:** [`type-equiv-prefilter.ts`](../../../../scripts/type-equiv-prefilter.ts) 在文档含 `type-equiv` 或 `public-api`,或者某个含 `` ``` `` 或 `~~~` 的行同时含 `&` 或 `\` 时,保留该文档进行解析。解析只会通过解码字符引用与反斜杠转义改变信息字符串,因此 `ts type-equiv` 与 `ts type\-equiv` 所在的文档都会被保留;[`type-equiv-prefilter.spec.ts`](../../../../scripts/type-equiv-prefilter.spec.ts) 覆盖这两种写法。 +- **无损依据:** 每个门禁在仓库上的输出逐字节一致,人为注入的错误也被同样报告。对配对而言,语料中全部 1,886 个文档的顶层块与全部 943 个配对的 record 都与完整重新解析的结果一致,随机 destination 的结果也一致。 + +### 6. 仓库文件遍历每个目录只列一次 + +- **改动:** [`scripts/repo-files.ts`](../../../../scripts/repo-files.ts) 中的 `uniqueRepoFiles()` 在一次调用的所有 pattern 之间共享一个目录列表缓存,使每个目录最多读取一次,并且只对经符号链接到达的匹配调用 `realpath`;只经真实目录到达的普通文件,规范路径为仓库 realpath 拼接其相对路径。它的可选谓词 `skipDirectory` 让遍历器不进入调用方排除其下全部路径的目录;[`verify-doc-refs.ts`](../../../../scripts/verify-doc-refs.ts) 跳过第一段路径之后的每个 `lib` 目录,其下文件本来就全部被它排除。 +- **性能:** markdown wrap 25.8/25.0 → 21.3/21.2 s;doc refs 8.8/7.0 → 3.2/3.4 s;package paths 9.9/8.1 → 7.2/5.5 s。 +- **无损依据:** 三个门禁得到的文件列表逐项相同,损坏或循环的符号链接仍会经 `realpath` 失败。 +- **行为差异:** doc refs 不列出被跳过的 `lib` 目录,因此不可读的此类目录不会以 EACCES 使门禁失败。 + +### 7. CLI help 读取并发运行;doc graphs 的探测做 memo + +- **CLI help:** [`scripts/gen-cli-help.ts`](../../../../scripts/gen-cli-help.ts) 中的 `prefetchHelp()` 在默认 worker 预算内启动 12 个顶层 `dsh … --help` 读取(启动器、`plugin`,以及每个 profile 的简写与 `--profile` 形式),每个读取使用独立的临时 `DSH_HOME` 与 `DSH_AGENTS_HOME`,并由按原顺序进行的遍历消费;嵌套读取共享同一上限。失败的读取在遍历请求该调用时才交给遍历;`close()` 中止排队与运行中的读取,并等待每个已启动的子进程结束。该门禁为 49.9/53.9 → 6.9/7.2 s。 +- **doc graphs:** [`scripts/gen-doc-graphs.ts`](../../../../scripts/gen-doc-graphs.ts) 中的 `withMemoizedFileSystemProbes()` 在 `renderDocs` 运行期间对 `ts.sys` 的 `fileExists`、`directoryExists`、`realpath`、`readDirectory` 与 `getDirectories` 做 memo,使一次运行中的所有编译器 host 与配置解析共享同一个文件系统视图。运行期间工作区不得改变,写出页面之前恢复原方法。该门禁为 48.7/44.7 → 32.7/34.2 s。 +- **无损依据:** 两个生成器生成的页面逐字节一致。 +- **取舍:** 并发的 help 进程合计峰值约占 3.6 GB 内存。 + +### 8. `type-model.spec.ts` 在内存工作区上运行 + +- **改动:** [`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) 用 [`type-model-workspace.ts`](../../../../packages/typert/generator/tests/type-model-workspace.ts) 中的 `memoryFileSystem()` mock `node:fs` 与 `ts.sys` host,使内存根目录下的 fixture 复制、用例编辑、写模式注解、tsconfig 展开与模块解析都不触及磁盘。每个工作区挂载最小的 `zod` 与 `@types/node` 声明桩;只有 Emitter 用例保留真实 zod。fixture 的 `tsconfig.base.json` 设置 `lib: ["ES2024"]`,不含 DOM lib;读取未修改 fixture 的用例共享一份深度冻结的分析结果。generator 源码没有任何测试专用路径。 +- **性能:** 单独运行该文件为 316.3/329.1 → 14.65/15.04 s。 +- **行为覆盖保持不变的依据:** 61 个用例名及其断言相同。快照只有一行不同,即外部 zod 符号路径,它指向桩(`:node_modules/zod/index.d.ts#ZodType`),而不是某个 pnpm store 版本。 +- **取舍:** 除 Emitter 用例外,该 spec 不针对真实文件系统、DOM lib 以及真实 zod 与 `@types/node` 声明检验分析器。内存文件系统只实现分析器使用的 `node:fs` 调用(`existsSync`、`readFileSync`、`realpathSync` 与 `writeFileSync`);对内存路径的其他调用会落到真实文件系统,而该路径并不存在。 + +### 全仓对比 + +两棵树在同一时段测量,每个命令在 `pnpm run build:artifacts` 之后交替运行基线与本实现(`pnpm run test` 在两棵树上失败的都是依赖环境的文件,基线 42 个,本实现是其中的 28 个): + +| 命令 | 基线 | 本实现 | 变化 | 对应项 | +|---|---|---|---|---| +| `pre-commit` 钩子,暂存一个位于包 `src/` 子目录下的文件 | 16.5/17.8 s | 4.3/4.5 s | −75% | 2 | +| `pnpm run typecheck`(pre-push),无需重查 | 29.7/23.3 s | 14.2/16.1 s | −43% | 1、3 | +| `pnpm run lint` | 110.0 s | 106.7 s | −3% | 1 | +| `pnpm run doc-typecheck` | 45.2 s | 36.1 s | −20% | 1 | +| `pnpm run test:docs` | 102.4/104.0 s | 26.8/29.3 s | −73% | 3–6 | +| `pnpm run doc-sync` | 186.8 s | 70.3 s | −62% | 3–7 | +| `pnpm run test` | 222.7 s(255 个 worker) | 148.1 s(48 个 worker) | −34% | 3、8 | + +`lint` 的耗时主要是 Oxlint 本身,其前置步骤的下降见第 1 项。CPU 时间随 wall 一同下降:`typecheck` 的 user 时间 39.5 → 14.2 s,`test:docs` 212.6 → 126.3 s,`doc-sync` 628.2 → 503.2 s,`test` 3,417 → 3,014 s。 + +## 考虑过的替代方案 + +- **所有本地默认值一律以 12 为上限,包括 Vitest fork(第 3 项):** 12 个 worker 时 `pnpm run test` 为 410.5 s,255 个时为 243.7 s,慢 68%。 +- **以 cgroup CPU 配额的一半作为本地预算(第 3 项):** 读取配额的改动更大,且只在容器内有意义。 +- **用 `worker_threads` 并行解析文档(第 5 项):** 它带来的复杂度高于每个文档只解析一次,而后者直接消除了重复工作。 +- **在 pre-commit 钩子中重新生成第三方声明(第 2 项):** 浏览器输入使每个包源码文件都成为生成器输入,钩子会让几乎每次提交都运行约 16 秒的生成器,而改变输出的提交很少;[生成的第三方声明 Note](2026-07-30-generated-third-party-notices.zh.md) 拥有该决策。 + +## 后果 + +无需重查时 pre-push `typecheck` 约 14–22 s,`lint` 与 `doc-typecheck` 的前置步骤约 12 s,改动 1 个文件的提交的钩子约 4 s。`pnpm run test` 在本地最多启动 48 个 fork,`test:docs` 与 `doc-sync` 最多同时运行 8 个门禁。Typert 输出、生成的页面、文件列表与引用判定都与基线逐字节一致。 + +代价:类型检查命令不刷新可运行的 bundle;第三方声明过期时在推送后于测试 lane 失败;本地默认值不使用超过上限的 CPU,而配额低于上限的容器仍会超额订阅;并发 8 个文档门禁抬高 `doc-sync` 的 RSS 峰值;CLI help 峰值约用 3.6 GB 内存;doc graphs 与 markdown links 假定运行期间工作树不变;除 Emitter 用例外,`type-model.spec.ts` 检验的是桩,而不是真实 zod、`@types/node`、DOM lib 与磁盘 I/O;增量 `typecheck` 继承 Issue #6022 跟踪的 Remote 声明缺口。 + +验证:`generate-typert.spec.ts` 固定与 workspace 模式插件逐字节一致的文件、对加载已编译工作区声明的 Host program 的拒绝(并指出缺失的别名),以及每个失败阶段;`compile-referenced-projects.spec.ts` 固定 `host-contracts` 的步骤及其失败报告;`build.spec.ts` 固定 typecheck scope 的步骤;`parallelism.spec.ts`、`run-gates.spec.ts` 与 `run-oxlint.spec.ts` 固定 CI 与本地预算以及覆盖变量;`repo-files.spec.ts`、`markdown.spec.ts`、`translation-pairing.spec.ts`、`type-equiv-prefilter.spec.ts`、`verify-md-links.spec.ts`、`gen-cli-help.spec.ts` 与 `gen-doc-graphs.spec.ts` 固定目录只列一次、语法树复用、配对守卫、预筛、预取顺序与失败路径,以及探测 memo 及其恢复。 diff --git a/.agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.i18n.yaml b/.agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.i18n.yaml new file mode 100644 index 0000000000..4b3621d217 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-09-preset-owned-subagent-composition.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.md +/agent-note-preset-owned-subagent-composition: + en: 08d7745426af0373 + zh: 26f9afb3cc941a9a +/agent-note-preset-owned-subagent-composition/problem: + en: 97cd4f941fe8eaa5 + zh: b3dabbd7020a8991 +/agent-note-preset-owned-subagent-composition/decision: + en: 7dfefd4689562847 + zh: 0bbfd0a8ce41c9ac +/agent-note-preset-owned-subagent-composition/alternatives-considered: + en: c87c4638a36cc45d + zh: ee2c8e044108f0eb +/agent-note-preset-owned-subagent-composition/consequences: + en: a7404d17d0a62ad9 + zh: 783a490d3aafc5ca diff --git a/.agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.md b/.agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.md new file mode 100644 index 0000000000..88f2b5ab9d --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.md @@ -0,0 +1,31 @@ +# Agent Note: Preset-owned subagent composition + +Status: implemented + +English | [中文](2026-10-09-preset-owned-subagent-composition.zh.md) + +## Problem + +Agent Presets already compose persona and tool plugins. Independent child persona and tool-filter inputs create a second place to configure that composition. The shipped consumers of child filtering are the six schedule deny lists in the spawn and fork rows of the standard, PTC, and Cordis presets; reminder executors already refuse delegated callers. + +## Decision + +New subagent requests select an Agent Preset where the provider supports it. Persona and tools come from its plugin list. Fork inherits the parent's preset revision; model and effort selection keep their existing configuration. No preset-level filter or finer plugin split is added without a concrete consumer. + +`dsh-tool-subagent` rejects removed `persona` and `toolFilter` keys at load, including empty values, because deleting Schemastery fields alone does not reject unknown configuration. The [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.md) states the lost independent override capability and the migration. + +Released child descriptors keep their persona and filter fields, parsing, and cold-resume application. The [original composition note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) remains active for these historical semantics and the independent depth policy. The [spawn preset decision](../feature/2026-09-20-spawn-agent-preset-selection.md) continues to own selection and restoration identity. + +The shipped child schedule deny lists are absent. Reminder schemas remain visible when inherited from the preset; each executor rejects delegated callers. This avoids that source of parent/fork schema differences. Children receive four reminder schemas whose executors always refuse them. Neither their prompt cost nor a provider cache-hit improvement has been measured; other request inputs can still differ. + +## Alternatives considered + +**Move the filter into each preset.** This preserves the schedule deny lists and independent fork tool trimming, but retains two configuration mechanisms for tool availability. New children use plugin composition; fork deliberately inherits the parent's tools. + +**Keep per-request overrides alongside presets.** This retains independent fork overrides, their precedence rules, and provider capability flags. A single composition mechanism is preferred over that flexibility for new children. + +**Delete historical descriptor fields with the request fields.** This would change or reject restored sessions. New-request simplification does not authorize rewriting released Session data. + +## Consequences + +Preset selection owns new child persona and tools. Fork cannot independently replace persona or trim inherited tools, and JavaScript configuration with the removed keys fails explicitly. Historical restoration retains its previous semantics. Focused tests cover removed-key diagnostics, historical persona/filter restoration, inherited reminder schemas and executor refusal; recorded sessions cover preset-owned tool availability, fork reminder refusals, and messaging guidance. diff --git a/.agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.zh.md b/.agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.zh.md new file mode 100644 index 0000000000..c81919e7d8 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 由 preset 拥有 subagent 组装 + +Status: implemented + +[English](2026-10-09-preset-owned-subagent-composition.md) | 中文 + +## 问题 + +Agent Preset 已经组合 persona 和工具插件。独立的子级 persona 和工具过滤输入又增加了一处组装配置。已交付的子级过滤消费者是 standard、PTC 和 Cordis preset 中 spawn 与 fork 配置行的六份 schedule deny 列表;提醒执行器已经拒绝委派调用方。 + +## 决策 + +新 subagent 请求在提供方支持时选择 Agent Preset。persona 和工具来自其插件列表。fork 继承父级的 preset 代际;model 和 effort 选择保留现有配置。没有具体消费者时,不添加 preset 级过滤器或更细的插件拆分。 + +`dsh-tool-subagent` 在加载时拒绝已删除的 `persona` 和 `toolFilter` 键,包括空值,因为仅删除 Schemastery 字段不会拒绝未知配置。[升级指南](../../../../docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.zh.md)说明失去的独立覆盖能力及迁移方法。 + +已发布的子级 descriptor 保留 persona 和 filter 字段、解析及冷恢复应用。[原组装记录](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md)继续保留,以说明这些历史语义及独立的深度策略。[spawn preset 决策](../feature/2026-09-20-spawn-agent-preset-selection.zh.md)继续拥有选择与恢复身份的依据。 + +已交付配置中不含子级 schedule deny 列表。从 preset 继承的提醒 schema 仍然可见;每个执行器拒绝委派调用方。这消除了一处父级与 fork 子级 schema 差异的来源。子级会收到四个始终拒绝其调用的提醒 schema;尚未测量它们的提示成本或提供方缓存命中率改善,其他请求输入仍可能不同。 + +## 曾考虑的替代方案 + +**把过滤器移入各 preset。** 这保留了 schedule deny 列表和独立裁剪 fork 工具的能力,但仍有两种配置工具可用性的机制。新子级使用插件组装;fork 明确继承父级工具。 + +**在 preset 之外保留逐请求覆盖。** 这保留独立的 fork 覆盖、覆盖优先级规则和提供方能力标志。对新子级选择单一组装机制,放弃这项灵活性。 + +**随请求字段一起删除历史 descriptor 字段。** 这会改变或拒绝恢复的会话。简化新请求不意味着可以重写已发布的 Session 数据。 + +## 后果 + +preset 选择拥有新子级的 persona 和工具。fork 不能独立替换 persona 或裁剪继承工具,含已删除键的 JavaScript 配置会明确报错。历史恢复保留原有语义。针对性测试覆盖删除键的诊断、历史 persona/filter 恢复、继承的提醒 schema 及执行器拒绝;会话录制覆盖由 preset 控制的工具可用性、fork 提醒拒绝及消息指引。 diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml index e5f30837c8..9f7d9ba29e 100644 --- a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml @@ -9,8 +9,8 @@ en: 8d63b992ff6d3161 zh: 86d333255a5400d3 /agent-note-isolate-installed-update-materials-from-releases-and-user-data/decision: - en: 815a1c062134d958 - zh: 05d7d89099e6911c + en: 4b2464efa4292cfd + zh: 449d6ea0d903d7e7 /agent-note-isolate-installed-update-materials-from-releases-and-user-data/alternatives-considered: en: c608f186cc94a9d3 zh: 39750c8347e8bece diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md index 24209c1cce..88129f2cc2 100644 --- a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md +++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md @@ -10,7 +10,7 @@ An installed-update walkthrough needs two increasing versions with a shared appl ## Decision -Version derivation follows the [release version reference](../../../../apps/desktop/README.md); this record continues to govern material, application identity, and user-data isolation. +Version derivation follows the [release version reference](../../../../apps/desktop/packaging/README.md#release-versions); this record continues to govern material, application identity, and user-data isolation. The [material preparer](../../../../apps/desktop/scripts/prepare-installed-update-runtime.ts) accepts a retained test-only manifest with a random identity and distribution namespace. It copies a verified source runtime into separate derived test versions, changes only release-family versions and matching dependency references, and seals and verifies both inventories. The original runtime is verified again and its descriptor hash must remain unchanged. Existing output directories are not overwritten; a failed copy retains a failure record and cannot create a completion receipt. diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md index 22ceec3f7b..d0f9aa2bae 100644 --- a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md +++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -版本派生遵循[发布版本说明](../../../../apps/desktop/README.zh.md);本记录继续规定物料、应用身份和用户数据的隔离。 +版本派生遵循[发布版本说明](../../../../apps/desktop/packaging/README.zh.md#release-versions);本记录继续规定物料、应用身份和用户数据的隔离。 [物料准备器](../../../../apps/desktop/scripts/prepare-installed-update-runtime.ts)接受保留的 test 专用清单,其中包含随机身份和分发命名空间。它将已验证的原始运行时复制为独立的派生测试版本,仅修改发布家族版本和匹配的依赖引用,再生成并验证两个完整性清单。原始运行时再次验证,其描述文件哈希必须保持不变。已有输出目录不被覆盖;复制失败保留失败记录,不能生成完成回执。 diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml new file mode 100644 index 0000000000..7c41c4d039 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml @@ -0,0 +1,31 @@ +# Bilingual-pair consistency record for 2026-10-11-user-text-credential-matching.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/testing/2026-10-11-user-text-credential-matching.md +/agent-note-linear-credential-matching-and-performance-gates-for-user-text: + en: 4d3e2c3ad91a1a03 + zh: d08dd77dd0bd849c +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/problem: + en: ae449178436337ea + zh: 9eced057b7d9ef3d +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision: + en: a15c7c13f167e5ac + zh: ff261e3705c47ff0 +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/algorithm-what-each-part-does-and-costs: + en: aa1d65cd64a2a895 + zh: 4bf32f20d14ce452 +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/memory: + en: b91fc88cdd488ce3 + zh: 318db3f522681cca +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/trade-offs: + en: f07bb6fbbdd01fe4 + zh: 9313a0ef2a22af12 +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate: + en: 6a303115f85ed4e8 + zh: e894f3e9d630bf57 +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered: + en: 1e21f5f9ca959f7e + zh: 401e303071b8d426 +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences: + en: 8f9d25b72d3e7660 + zh: e5c6ae0194905876 diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md new file mode 100644 index 0000000000..d7995a1f93 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md @@ -0,0 +1,67 @@ +# Agent Note: Linear credential matching and performance gates for user text + +Status: implemented + +English | [中文](2026-10-11-user-text-credential-matching.zh.md) + +## Problem + +[ui-secret-mask](../../../../packages/client/ui-secret-mask/README.md) recognizes credentials in every sent user text. The UserText scheduler queries the matcher again after each accepted node, so a text costs the sum of all its queries. Most text contains no credential, so the first goal is that such text pays as little as possible; second, text with credentials pays in proportion to their number and length; adversarial input must stay linear. Constraints: no regex backtracking, prefix pruning, linear total over all queries of one text, and no allocation before a node is returned. + +## Decision + +### Algorithm: what each part does and costs + +| Part | Method | Cost over all queries of one text | +|---|---|---| +| Finding format starts | All starting literals (`sk-`, `sk_live_` and kin, `AIza`, `ghp_` and kin, `github_pat_`, `glpat-`, `npm_`, `hf_`, `AKIA`/`ASIA`, `LTAI`, `AKID`, `xox?-`, `whsec_`, `eyJ`, `-----BEGIN `, `://`) form one literal-only alternation grouped by shared leading characters, scanned with `test` | Θ(n) native comparisons; no quantifier, so no backtracking; two adjacent queries reread at most one literal | +| Overlapping literals | Where a literal's suffix begins another literal (the `-` of `sk-`, `glpat-`, `xox?-` and `-----BEGIN `), the scan resumes at that suffix; resume offsets are computed from the literal table at module load | At most one character back per hit | +| Identifying the hit | A table keyed by the hit's last two code units; only the three shared endings (`t_`, `e_`, `IA`) are told apart with `startsWith` | O(1) per hit | +| Format checks | Word boundaries, lengths, digit position, and the `.eyJ` separator are numeric comparisons; a private-key label reads at most 52 characters and a scheme at most 16 back; character runs are measured natively with sticky `[class]*` patterns, which stop at the first character outside the class and have nothing after them to backtrack into | O(1) per hit plus run measurement | +| Runs, next digit, next `END` line | Each matcher instance memoizes per text in one `Int32Array`: four slots for `[A-Za-z0-9_-]` (a JWT uses three runs at once), one slot per other class, the next digit, and the next valid `END` line. These are facts of the text, reused across queries and cleared when a query names another text | Each character measured at most once per class | +| Address passwords | When the `://` precedes the query offset and the password follows it, the matcher first confirms that `:`, a nonempty password, and `@` follow the offset, and only then walks back over the user name; an offset inside `://` is checked directly | At most 258 characters back, only where `:password@` follows the offset | +| Joining candidates | Candidates are processed in start order; an address password starts after its `://` and waits until the scan passes its start, and no other `://` can occur before then because user names and passwords exclude `/`; overlapping candidates join the earliest and take its mask; a range that reaches the end of the text ends the scan | O(1) per candidate, no list | +| Masks | A range of prefixed formats only is ASCII without line breaks, so its mask is a slice of its first 8 characters plus a slice of a shared mark string; a private-key body skips ordinary characters natively and takes one slice of a shared "marks plus line break" string per line; address passwords and other mixed ranges count code points character by character | Θ(masked characters + lines), the order of the output | + +### Memory + +- Nothing is allocated before a node is returned: `test` creates no match array, memo slots are a preallocated integer array, loops use indexes instead of iterators, and candidate ranges live in local numbers. +- Each credential allocates only its result: the node, its `data` object, and the mask. A one-line mask is the concatenation of two slices; a multi-line mask adds one slice and one concatenation per line. +- Each instance keeps a reference to the last queried text until a query names another text. + +### Trade-offs + +- **Scanning is native, decisions are JavaScript**: in V8 a per-character JavaScript loop is slower than a native literal scan or native run measurement, so all work proportional to input length (finding starts, measuring runs, skipping key bodies) uses non-backtracking native patterns, and JavaScript makes a constant number of decisions per hit. +- **Pruning depends on the formats only**: starts come from format literals, not from anchors chosen by character frequency in natural text, so text with any character distribution pays the same linear cost. +- **Memo per text, not per query**: a memo inside one query cannot stop later queries from measuring the same characters again; keyed by text and owned by the instance, cleared on a new text, it costs one retained string reference. +- **Masks use shared-string slices**: faster than writing into a buffer and decoding it; a multi-line mask allocates in proportion to its lines, which is part of the output. + +### Performance gate + +[benchmarks/user-text](../../../../benchmarks/user-text/user-text.bench.client.ts) runs in the required `node 24 / benchmarks` lane. Its compiled worker uses the production UserText registry and every shipped matcher (session, file, folder, skill, command, whitespace, credential) under plain Node, in two processes: timing and allocation. + +| Item | Content | +|---|---| +| Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time | +| Timing | Each sample builds a fresh registry, document, and matcher instance and times evaluation only; each workload is measured with and without the credential matcher, keeping the fastest of several samples and the process CPU time over the same interval | +| Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval | +| Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character | +| Excluded | React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report | + +Expectations come from an x64 development machine. CI time budgets are converted with the shared scale and headroom; time ratios, doubling ratios, and allocation budgets do not scale with machine speed. Negative controls: the same benchmark fails when pointed at the regular-expression implementation or at an implementation that rebuilds its memo per query. Equivalence tests compare this implementation with the regular-expression one ([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)) node by node through the production scheduler and from every query offset. + +## Alternatives considered + +- **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure. +- **One large quantified pattern for all formats**: one scan finds the leftmost match, but branch quantifiers still backtrack on failure. +- **Scanning for rare characters first, then looking back for prefixes**: faster on ordinary text, but cost depends on character distribution, and text dense in `-` or `_` becomes clearly slower. +- **A pure JavaScript Aho–Corasick automaton**: allocation-free, but per-character table lookups carry a state dependency and are slower than the native literal scan, so the most common text, without credentials, gets slower. +- **A memo rebuilt per query**: with accepted tokens between digit-free `sk-` prefixes, every query's digit search reaches the end of the text, which grows quadratically. +- **Building private-key masks in one buffer with `TextDecoder`**: one string per block, but each decode's fixed cost exceeds shared-string slices. +- **`exec` for hits and `for…of` over memo slots**: they allocate match arrays and iterator results. + +## Consequences + +- The added cost on text without credentials is one native scan plus checks at literals; it cannot drop further without depending on character distribution. +- A new format adds its literal and a forward-only check to the format table; resume offsets and the hit table are derived. The literal-pattern unit test rejects quantifiers, and the benchmark rejects superlinear scans and extra allocation. +- The plugin creates and owns the matcher instance in `apply`; tests and the benchmark create one per sample and share no memo. diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md new file mode 100644 index 0000000000..4da050e2e4 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md @@ -0,0 +1,67 @@ +# Agent Note: 用户文本凭据识别的线性匹配与性能门禁 + +Status: implemented + +[English](2026-10-11-user-text-credential-matching.md) | 中文 + +## 问题 + +[ui-secret-mask](../../../../packages/client/ui-secret-mask/README.zh.md) 在每条已发送用户文本上识别凭据。UserText 调度器在每个被采纳的节点之后再查询一次匹配器,一段文本的成本是它全部查询之和。大部分文本不含凭据,所以首要目标是让不含凭据的文本尽量少付成本;其次是含凭据文本的成本只与凭据个数和长度成正比;对抗输入必须保持线性。约束:正则不回溯;按前缀剪枝;一段文本的全部查询合计线性;返回节点之前不分配内存。 + +## 决定 + +### 算法:每部分的做法与复杂度 + +| 部分 | 做法 | 一段文本全部查询的代价 | +|---|---|---| +| 找格式起点 | 所有起始字面量(`sk-`、`sk_live_` 等、`AIza`、`ghp_` 等、`github_pat_`、`glpat-`、`npm_`、`hf_`、`AKIA`/`ASIA`、`LTAI`、`AKID`、`xox?-`、`whsec_`、`eyJ`、`-----BEGIN `、`://`)按共同前缀分组成一个只含字面量的交替正则,用 `test` 扫描 | Θ(n) 次原生比较,没有量词因而不回溯;相邻两次查询至多重读一个字面量 | +| 重叠的字面量 | 字面量的后缀若是另一个字面量的开头(`sk-`、`glpat-`、`xox?-` 的 `-` 与 `-----BEGIN `),扫描从该后缀处继续;续扫位置在模块加载时由字面量表算出 | 每个命中至多回退一个字符 | +| 识别命中的字面量 | 命中末尾两个码元查表;只有共享结尾的三组(`t_`、`e_`、`IA`)再用 `startsWith` 区分 | 每个命中 O(1) | +| 格式校验 | 单词边界、长度、数字位置、`.eyJ` 分隔用数值比较;私钥标签至多读 52 个字符,协议名至多回看 16 个字符;字符段用粘性 `[字符类]*` 原生测量,模式在第一个不属于该类的字符处停止,后面没有内容,不回溯 | 每个命中 O(1) 加字符段测量 | +| 字符段、下一个数字、下一行 `END` | 每个匹配器实例用一个 `Int32Array` 按文本记忆:`[A-Za-z0-9_-]` 四个槽(JWT 同时用三段),其余字符类各一个槽,另记下一个数字和下一行有效 `END` 的位置;这些是文本本身的事实,跨查询复用,查询换成另一段文本时清空 | 每类字符每个字符至多测量一次 | +| 地址密码 | `://` 在查询起点之前、密码在起点之后时,先确认起点后面紧跟 `:`、非空密码和 `@`,才回看用户名找 `://`;起点落在 `://` 中间时直接检查 | 回看至多 258 个字符,且只在 `:密码@` 紧跟起点时发生 | +| 合并候选 | 候选按起点顺序处理;地址密码在其 `://` 之后开始,延后到扫描越过它的起点,此前不会出现另一个 `://`(用户名和密码不含 `/`);重叠的候选并入最早的候选并使用其遮挡方式;范围到文本末尾即停止扫描 | 每个候选 O(1),不建列表 | +| 遮挡串 | 只由前缀格式组成的范围全是 ASCII 且没有换行,遮挡串是前 8 个字符的切片加一个共享星号串的切片;私钥正文用原生模式跳过普通字符,每行取一个"星号加换行"共享串的切片;地址密码和其他混合范围逐字符计数码点 | Θ(遮挡字符数 + 行数),与输出同阶 | + +### 内存 + +- 返回节点之前不分配:`test` 不产生匹配数组,记忆槽是预先分配的整数数组,遍历用下标而不是迭代器,候选范围存在局部数值里。 +- 每个凭据只分配结果:节点、`data` 对象和遮挡串。单行凭据的遮挡串是两个切片的拼接;多行遮挡每行再加一个切片和一次拼接。 +- 每个实例保留最近一次查询的文本引用,直到查询另一段文本。 + +### 取舍 + +- **扫描交给原生正则,判断交给 JavaScript**:V8 中逐字符的 JavaScript 循环比原生字面量扫描和原生字符段测量慢;凡是与输入长度成正比的工作(找起点、量字符段、跳过私钥正文)都用不回溯的原生模式,JavaScript 只做每个命中常数次的判断。 +- **只依赖格式本身的剪枝**:起点由格式字面量决定,不按字符在自然文本中的频率挑锚点,因此任何字符分布的文本都付同样的线性成本。 +- **记忆按文本而不按查询**:一次查询内的记忆不能阻止后续查询重新测量同一段字符;以文本为键、由实例持有,换文本即清空,代价只是保留一个字符串引用。 +- **遮挡串用共享串切片**:比先写入缓冲区再整体解码更快;多行遮挡的分配与行数成正比,仍属于输出本身。 + +### 性能门禁 + +[benchmarks/user-text](../../../../benchmarks/user-text/user-text.bench.client.ts) 在 `node 24 / benchmarks` 必跑 lane 中运行。编译后的 worker 使用生产的 UserText 注册表和全部已发布匹配器(会话、文件、文件夹、skill、命令、空白、凭据),在纯 Node 下分两个进程运行:计时与分配。 + +| 项 | 内容 | +|---|---| +| 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 | +| 计时 | 每个样本新建注册表、文档和匹配器实例,只计求值;每个负载分别测有无凭据匹配器,取多个样本中最快的一个,并记录同一区间的进程 CPU 时间 | +| 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC | +| 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 | +| 排除 | React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 | + +预期值来自 x64 开发机。CI 时间预算按共享比例与余量换算;耗时比、翻倍比和分配预算不随机器缩放。负对照:同一 benchmark 指向正则实现或"每次查询新建记忆"的实现时都会失败。等价性测试经生产调度器逐节点比较本实现与正则实现([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)),并对每个查询起点单独比较。 + +## 考虑过的替代方案 + +- **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。 +- **把全部格式合成一个带量词的大正则**:一次扫描能找到最左匹配,但各分支的量词失败时仍回溯。 +- **先找稀有字符再回看前缀**:普通文本扫描更快,但成本取决于字符分布,`-`、`_` 密集的文本明显变慢。 +- **纯 JavaScript 的 Aho–Corasick 自动机**:可以零分配,但逐字符查表有状态依赖,比原生字面量扫描慢,最常见的无凭据文本反而变慢。 +- **每次查询新建记忆**:被接受的 token 之间夹着无数字的 `sk-` 时,每次查询的数字查找都扫到文末,平方增长。 +- **用 `TextDecoder` 从缓冲区一次生成私钥遮挡串**:每块只分配一个字符串,但每次解码的固定开销高于共享串切片。 +- **用 `exec` 取命中、用 `for…of` 遍历记忆槽**:分别分配匹配数组和迭代器结果。 + +## 影响 + +- 无凭据文本的额外成本是一遍原生扫描加字面量处的校验;不依赖字符分布就无法再降。 +- 新增格式要在格式表里加字面量和只向前的校验;续扫位置与命中识别表自动生成。字面量正则的单测拒绝量词,benchmark 拒绝超线性扫描和多余分配。 +- 匹配器实例由插件在 `apply` 中创建并持有;测试和 benchmark 每个样本新建实例,不共享记忆。 diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.i18n.yaml new file mode 100644 index 0000000000..e780a00501 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.i18n.yaml @@ -0,0 +1,34 @@ +# Bilingual-pair consistency record for 2026-09-20-unified-typert-remote-metadata.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/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md +/agent-note-unified-typert-remote-metadata: + en: 463246d8c8732192 + zh: cdb9f8ca8cfe4635 +/agent-note-unified-typert-remote-metadata/problem: + en: 3aa575e4c0778df1 + zh: 5277fc49b040da50 +/agent-note-unified-typert-remote-metadata/technical-architecture: + en: eb12e56cdcc7c76b + zh: bebff68d354c1ba7 +/agent-note-unified-typert-remote-metadata/proposal: + en: f5dcecbcae90a898 + zh: 923cf3a3074a3a2d +/agent-note-unified-typert-remote-metadata/proposal/shared-generation-two-access-modes: + en: 733398b2346d7438 + zh: d4e827b9dbd16d67 +/agent-note-unified-typert-remote-metadata/proposal/downstream-migration-targets: + en: 261dab3eb480a0c0 + zh: 13c3226d316d8d5e +/agent-note-unified-typert-remote-metadata/proposal/paired-usage-examples: + en: 7ef7dd38e2015801 + zh: b97cd179bf828406 +/agent-note-unified-typert-remote-metadata/alternatives-considered: + en: 151b1f66af030bc5 + zh: 44fea0898acfbdcb +/agent-note-unified-typert-remote-metadata/acceptance-criteria: + en: d011ec1516706b20 + zh: 945c6e3eba101368 +/agent-note-unified-typert-remote-metadata/risks: + en: a770478eac8f5fd1 + zh: f24aa378f875f47f diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md new file mode 100644 index 0000000000..c99fd9effd --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md @@ -0,0 +1,191 @@ +# Agent Note: Unified Typert/Remote metadata + +Status: proposed + +English | [中文](2026-09-20-unified-typert-remote-metadata.zh.md) + +## Problem + +CLI help, argument parsing, and MCP tool discovery need local method descriptions, named-input parsers, tool JSON Schemas, and result metadata before connecting to a controlled Host. Browser, Node, and independent consumers also need typed invocations. Tool consumers need stricter input-object validation without changing ordinary Host dispatch. These requirements do not imply runtime TypeScript analysis, Host Service activation, or another business schema maintained by each consumer. + +The original #4644 source-definition decision delivered this information through `emitSourceDef`, committed generated files, and `./remote-source`. Host bindings carried those definitions; source generation had its own inventory, freshness, publication, and exclusion rules. This note replaces that delivery mechanism and makes input strictness consumer-owned. + +Both delivery paths derive from the same [compiler-independent model](../../implemented/architecture/2026-07-27-compiler-independent-typert-model.md). Separate Host and Client Programs require generated cross-face declarations, not committed generated source. An explicit prebuild is acceptable when exact schemas are needed; ordinary ESM artifacts can supply offline metadata without a live Host. The [targeted-call decision](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) still owns receiver lookup, Gateway dispatch, and contribution lifetimes. + +## Technical architecture + +| Export | Responsibility | Registration | +|---|---|---| +| Host `./typert` | `TYPERT`: local reflection, schemas, and an `invocations` getter exposing a private info instance's descriptors. | Loader imports and validates the contribution, then calls `ctx.typert.register(TYPERT)`. | +| Host-for-consumer `./remote` | Default export and `TYPERT_REMOTE`: a `TypertRemoteInfo` with descriptors and typed `methods`, plus generated Remote declarations/maps. | Harness metadata consumers call `ctx.typert.remotes.register(TYPERT_REMOTE)`; live consumers use `ctx.remote.$mount(TYPERT_REMOTE)`, which already registers it. Independent readers need no Registry. | +| Client `./client/typert` | The Client face's own local reflection and schemas. | Its local Registry; this is not the Host Remote projection. | + +[Registry](../../../../packages/typert/registry/src/service.ts) instances belong to their Cordis Context and process. Host and Client entries share implementation, not state. Existing local/Remote `get()` and `list()` preserve the registered descriptor objects; registration retains atomic duplicate checks, notifications, tombstones, and fiber-owned disposal. No Registry API, store, subscription, metadata RPC, or cross-process synchronization is added. Separate Programs and processes do not share object identity. + +The [emitter](../../../../packages/typert/generator/src/emitter.ts) writes one static `TypertRemoteMethodSpec` per endpoint and calls `createTypertRemoteInfo({ package, methods }, z)` from the published pure `@deepseek-ai/dsh-typert-protocol/remote-info` entry. The endpoint key supplies `namespace` and `method`, and `id` combines it with the package. Each spec keeps the service, receiver, ordered parameters, single `result` codec, and optional stream, scope, uplink, cancellation, documentation, and source-location fields. Every parameter has both `codec` and `strictCodec` from the same checker-resolved type model, with no per-method policy in the analyzer or model. Parameter `wire` defaults to `name`; parameter documentation stays on `parameters[].description`. There is no nested `metadata`, separate `output` or `parameterDescriptions`, or top-level `REMOTE_METHODS` export. + +The [factory](../../../../packages/typert/protocol/src/remote-info.ts) returns an actual class instance, not a class constructor or eager object literal. Construction retains only the spec and the Zod runtime explicitly supplied by the generated artifact, without constructing schemas. The first `descriptors` or `methods` read, including mounting or querying, builds and caches one shared index. Each `methods[endpoint]` aliases the exact direct descriptor object; Context-only receivers remain in descriptors and scoped declarations. Direct methods with scoped projections retain their scope fields but their generic named invoker requires an unscoped Remote. + +A direct method's `input(options?: TypertRemoteInputOptions)` returns a cached `TypertRemoteValue`; `strict?: boolean` is the only option. `method.input()` and `{ strict: false }` use normal codecs: declared objects accept and strip unknown fields. `{ strict: true }` rejects undeclared fields recursively. Both named-argument envelopes reject extra arguments; explicit JSON and dictionary keys stay open, and constrained dictionary values still validate. Consumers select one view for matching `input.create().parse(raw)` and `input.jsonSchema`. Host dispatch and the single `result` codec retain normal behavior, including binary `encode`/`decode`. + +Each input-policy view, its named parser, and its JSON Schema projection are independently lazy and cached. Individual generated Zod factories are also lazy and success-cached. The raw spec requires Zod factories internally, while consumer-facing `TypertSchema` promises only `parse(value: unknown): T`. JSON Schema remains an optional generator projection, not runtime `z.toJSONSchema()` conversion. Generated artifacts import and supply Zod, including Remote endpoints without parameters or exported schemas. The pure leaf retains only Zod type imports and has no runtime module dependencies; the protocol root re-exports the factory and imports Cordis without adding Zod to every protocol consumer's static module graph. Imports and metadata/schema access never invoke the network. + +Registry queries return base descriptors. `isTypertRemoteMethod(descriptor)` from the pure leaf or protocol root narrows direct capabilities with `object`/`unknown` types; exact endpoint types come from `TYPERT_REMOTE.methods`, not dynamic lookup. Unary `mode` is omitted; streaming `mode` is `'stream'`. `invoke(remote, input, signal?)` resolves the mounted namespace method at call time, preserving its `this`, optional positional holes, and signal. It forwards the same full `Promise>` or `RemoteStreamHandle` without an async wrapper; stream iteration, `send`, `end`, and `dispose` remain available. The fifth `TypertRemoteMethod` generic retains the carrier-decoded unary byte type independently of the result parser's authored output. + +Documentation and JSON Schema are optional. Each unsupported input variant or result projection records its own `jsonSchemaError` while preserving parsers and independent documentation; only the dedicated projection error is caught. A consumer explicitly selecting a tool requires its description and supported input JSON Schema. Output parsing remains required even when optional output JSON Schema is absent. Native `dsh-tools` registration additionally requires `output.schema`: a parser alone does not satisfy it. These consumer requirements must not prevent an otherwise valid Remote method from being generated. + +## Proposal + +Complete the ordinary-artifact migration in the downstream Session-client, CLI, MCP, and native-tool packages. This note remains proposed because that migration is not complete. + +### Shared generation, two access modes + +Framework consumers load ordinary metadata into their own Registry. Independent consumers may import `./remote` directly without Cordis, Host Service activation, or a connection. Both read the same generated business definition. Keep separate Host/Client Programs and analyze all contributors of each face so focused emission retains external declaration augmentations. Registration does not expose every reflected method as a callable endpoint or tool. + +Strict source startup explicitly prebuilds and loads ordinary `./typert` through Loader. Limited descriptor-free fallback still supports basic arguments, signals, cancellation, streams, JSON conversion, and unary byte attachments, but cannot recover erased fields, constraints, or tool documentation. Once a strict local descriptor has been observed, its withdrawal must refuse invocation rather than downgrade to fallback. `@Remote` accepts only `name` and `mode`; consumers choose input policy without a separate developer annotation. + +Host metadata readiness remains composition-owned. A Host requiring strict validation must await local registration before serving calls; `hasSeen()` does not protect an endpoint before its first registration. Returning a lazy info instance neither completes asynchronous Loader discovery nor establishes readiness. Loader validates static codec specs and callable `input`/`invoke` without calling the selector, parser factories, or projections to register a contribution. + +### Downstream migration targets + +The inspected #4111 tree contains Session-client, CLI, MCP, and native-tool consumers that are not present as complete packages on this branch. Its Session client selects 13 Session methods plus Workspace listing from source exports. Migrating that tree requires updating selection, connection-time mounts, offline CLI initialization, MCP presentation, and native-tool registration together; deleting source exports alone is insufficient. + +| Consumer | Metadata adaptation | Execution retained | +|---|---|---| +| Browser / Node Remote callers | Import ordinary `./remote`; mount once in the live Context. | Generated namespaces, Gateway, authenticated Connection, and target controllers. | +| Offline CLI help / parsing | Register before target discovery in the CLI profile's metadata Context; select `input({ strict: true })`. | No connection for help; a separate live Context mounts when calling. | +| MCP discovery / calls | Read selected Registry entries with `input({ strict: true })`; retain consumer-owned tool descriptions and envelopes. | `tools/list` is offline; `tools/call` uses the same Session client. External MCP clients receive protocol JSON, not Registry modules. | +| Native tools | Read Host local method info; require the tool registration's `output.schema`. | Parse at the tool boundary, then call same-process controllers directly. | +| Independent adapters | Read typed `TYPERT_REMOTE.methods` directly. | Use their chosen Remote runtime only when invoking. | + +Current-session selection and attachment preparation remain consumer-owned; CLI/MCP intake validates completed business arguments with the same strict input view used during selection. The downstream `follow` adapter's `activate: false` policy applies where that API exists; this branch's current `follow` has no such option. Aborting observation does not cancel accepted Session work. + +### Paired usage examples + +The old examples are historical comparisons of source-export consumers in the dependent changes. The new examples use ordinary artifacts. These fragments omit surrounding plugin declarations and include removed APIs. `raw`, `request`, `signal`, and an authenticated, mounted, unscoped `remote` are caller inputs, not new framework APIs. + +**Operation owner and strict Host.** Preserve the business signature; leave generated registration to ordinary Typert Loader instead of the Service constructor. + +```text +// Old source delivery. +import { TYPERT_REMOTE } from './remote.generated.ts' +// Constructor: super(ctx, 'sessionController', { namespace: 'session', definitions: TYPERT_REMOTE }) +@Remote({ name: 'page', emitSourceDef: true, rejectUnknownFields: true }) +page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.history.page(request, signal) +} + +// Ordinary delivery; no generated-source import or constructor definitions. +// Constructor: super(ctx, 'sessionController', { namespace: 'session' }) +@Remote({ name: 'page' }) +page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.history.page(request, signal) +} +``` + +**Browser and Node invocation.** A source-export consumer changes `/remote-source` to `/remote`; existing ordinary consumers already use this path. The live Context mounts once, without separate Registry registration. + +```text +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +await ctx.remote.$mount(TYPERT_REMOTE) +const result = await ctx.remote.session.page(request, signal) +const stream = ctx.remote.session.follow(followRequest, signal) +try { + for await (const frame of stream) consume(frame) +} finally { + await stream.dispose() +} +``` + +**Offline CLI and framework metadata.** The old selection reads a source table; the replacement reads the profile-owned Registry before target discovery. The two snippets are alternatives, not simultaneous declarations. + +```text +// Old selection. +import { REMOTE_METHODS } from '@deepseek-ai/dsh-api-session-controller/remote-source' +const page = REMOTE_METHODS['session/page'] +``` + +```text +// Metadata plugin: inject: ['typert']; no Gateway, Connection, or Host controller. +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +import { isTypertRemoteMethod } from '@deepseek-ai/dsh-typert-protocol/remote-info' +ctx.typert.remotes.register(TYPERT_REMOTE) +const page = ctx.typert.remotes.get('session/page') +if (page === undefined || !isTypertRemoteMethod(page)) { + throw new Error('session/page generated direct method is not loaded') +} +``` + +The registration belongs to that plugin's fiber. Help uses `description` and `input({ strict: true })`; the typed facade can retain type-only imports from ordinary generated declarations. Explicit tool selection does not mutate registered metadata. + +**CLI input and dispatch.** After local `current` resolution and attachment preparation, either metadata access path uses the same invocation sequence: + +```text +if (page.mode === 'stream') throw new Error('session/page must be unary') +const input = page.input({ strict: true }) +const arguments_ = input.create().parse(raw) +const result = await page.invoke(remote, arguments_, signal) +if (!result.ok) throw result.error +const output = page.result.create().parse(result.value) +``` + +**MCP discovery and calls.** Selection validates tool-specific requirements; generation itself does not require them. + +```text +const input = page.input({ strict: true }) +if (!page.description) throw new Error('session/page has no tool description') +if (input.jsonSchema === undefined) throw new Error('session/page has no supported tool input schema') +const tool = { + name: 'remote_session_page', + description: page.description, + inputSchema: input.jsonSchema, + ...(page.result.jsonSchema === undefined ? {} : { outputSchema: page.result.jsonSchema }), +} +``` + +This compact object shows only the metadata dependency. The real MCP adapter retains its `{ arguments, current, content }` input and `{ ok, value }` / failure output envelopes. Listing requires no connection; calling performs intake, local preparation, parsing through `input.create()`, invocation, and output parsing. Optional output projection does not remove output validation. + +**Native tools.** Query `ctx.typert.local.get('session/page')` and narrow it with `isTypertRemoteMethod` instead of reading the source table. Native tools can select `method.input({ strict: true })` for matching parsing and input projection. Native registration still requires supported input and result projections, including core Tool's mandatory `output.schema`; optional MCP output projection is not sufficient here. Once the facade obtains a typed request, both designs execute the same local call: + +```text +const value = await ctx.sessionController.page(request, signal) +``` + +**Independent consumers.** Read the ordinary info instance's typed index; no Registry is required: + +```text +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +const page = TYPERT_REMOTE.methods['session/page'] +const arguments_ = page.input().create().parse(raw) +const result = await page.invoke(remote, arguments_, signal) +``` + +**Strict source startup.** Build ordinary artifacts, then use the supported source launcher: + +```sh +pnpm run build +pnpm dsh --profile +``` + +## Alternatives considered + +- **Committed source:** meets offline needs but adds a redundant delivery and maintenance path; explicit ordinary prebuild is sufficient. +- **Nested descriptor metadata and a separate method table:** duplicate result and parameter facts and add another identity to track. One spec derives direct helpers on the descriptor itself; `TypertRemoteInfo` extends the existing contribution and retains `descriptors` so Gateway and Registry keep their current registration APIs. +- **Mandatory Registry for every importer:** adds lifecycle requirements without information for standalone consumers. +- **Fallback only:** cannot supply erased validation or tool documentation; retain it only for its supported subset. +- **Handwritten adapter schemas or runtime TypeScript analysis:** duplicate ownership or introduce runtime compiler requirements already handled by build-time extraction. + +## Acceptance criteria + +- Ordinary artifacts emit one static method spec per endpoint and return lazy class instances; direct method indexes alias descriptor objects, with full stream/binary typing, separate compiler faces, and full-face augmentation retention. +- Descriptor indexes and each input-policy view, parser, and projection are independently lazy and cached; Loader validates static specs and callable helpers without calling selectors, parsers, or projections. +- Every input parameter emits normal and strict codecs from the same model; consumers select policy without a method annotation. Both envelopes reject extra named arguments; strict input recursively closes declared objects without closing JSON or dictionary keys or changing Host dispatch and results. +- Protocol source and built entries have no Zod runtime dependency; generated artifacts explicitly supply Zod, zero-argument endpoints create empty inputs that reject extra fields, and the [production headless-profile Loader regression](<../../../../packages/context/time-context/tests/time-context.e2e.ts>) asserts actual Session events. +- Registry identity, atomic registration, withdrawal, and disposal remain intact. +- Missing documentation or unsupported optional projection does not disable generation; selected tools reject missing requirements and still parse outputs. +- Strict source/artifact invocation and limited fallback have separate tests; a withdrawn strict descriptor never enables fallback. +- Source static checks do not consume stale generated declarations; artifact checks declare and rebuild their inputs. No source-definition opt-in, source export, inventory, or freshness rule remains in production. + +## Risks + +Full downstream profile composition remains follow-up work in those packages; the generated metadata is not evidence that every #4111 operation or CLI/MCP application has migrated. The deliberate trade-off is an explicit prebuild for full source-runtime metadata. Missing or stale artifacts require regeneration, not a validation downgrade or a second handwritten schema. diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md new file mode 100644 index 0000000000..9370060e1e --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md @@ -0,0 +1,191 @@ +# Agent Note: 统一 Typert/Remote 元数据 + +Status: proposed + +[English](2026-09-20-unified-typert-remote-metadata.md) | 中文 + +## Problem + +CLI 帮助、参数解析和 MCP 工具发现需要在连接受控 Host 前取得本地方法说明、具名输入解析器、工具 JSON Schema 和结果元数据。Browser、Node 与独立消费方还需要类型化调用。工具消费方需要更严格的输入对象校验,但不能改变普通 Host 分发。这些需求不意味着运行时 TypeScript 分析、Host Service 激活,或每个消费方另行维护业务 schema。 + +#4644 原有的源码定义决策通过 `emitSourceDef`、提交的生成文件和 `./remote-source` 交付这些信息;Host 绑定携带定义,源码生成另有清单、新鲜度、发布和排除规则。本 note 替代该交付机制,并将输入严格性改为由消费方选择。 + +两种交付路径均来自同一个[编译器无关模型](../../implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md)。独立的 Host 与 Client Program 要求生成跨编译面声明,而非提交生成源码。需要精确 schema 时,显式预构建是可接受的;普通 ESM 产物无需运行中的 Host 即可提供离线元数据。[定向调用决策](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)仍负责接收者查找、Gateway 分发和贡献生命周期。 + +## 技术架构 + +| 导出 | 职责 | 注册方式 | +|---|---|---| +| Host `./typert` | `TYPERT`:本地反射、schema,以及暴露私有 info 实例描述符的 `invocations` getter。 | Loader 导入并验证 contribution,再调用 `ctx.typert.register(TYPERT)`。 | +| 面向消费方的 Host `./remote` | 默认导出和 `TYPERT_REMOTE`:带有描述符和类型化 `methods` 的 `TypertRemoteInfo`,以及生成的 Remote 声明/映射。 | Harness 元数据消费方调用 `ctx.typert.remotes.register(TYPERT_REMOTE)`;实际调用方使用 `ctx.remote.$mount(TYPERT_REMOTE)`,它已经包含注册。独立读取方无需 Registry。 | +| Client `./client/typert` | Client 编译面自己的本地反射与 schema。 | 本地 Registry;这不是 Host Remote 投影。 | + +[Registry](../../../../packages/typert/registry/src/service.ts) 实例属于各自的 Cordis Context 和进程。Host 与 Client 入口共享实现,而非状态。既有本地/Remote `get()` 与 `list()` 保留已注册的描述符对象;注册保留原子重复检查、通知、tombstone 和 fiber 管理的释放。不新增 Registry API、存储、订阅、元数据 RPC 或跨进程同步。独立 Program 和进程不共享对象身份。 + +[发射器](../../../../packages/typert/generator/src/emitter.ts)为每个端点写入一份静态 `TypertRemoteMethodSpec`,并调用已发布纯入口 `@deepseek-ai/dsh-typert-protocol/remote-info` 提供的 `createTypertRemoteInfo({ package, methods }, z)`。端点键提供 `namespace` 和 `method`,`id` 由包名与端点键组合。每份 spec 保留 service、接收者、有序参数、唯一的 `result` codec,以及可选的流模式、scope、uplink、取消、文档和源码位置字段。每个参数都有从同一 checker 解析后类型模型派生的 `codec` 和 `strictCodec`,分析器与模型不携带逐方法策略。参数 `wire` 默认使用 `name`;参数文档保留在 `parameters[].description`。不存在嵌套 `metadata`、独立 `output` 或 `parameterDescriptions`,也不导出顶层 `REMOTE_METHODS`。 + +[factory](../../../../packages/typert/protocol/src/remote-info.ts)返回真正的类实例,而非类构造函数或立即求值的对象字面量。构造时只保留 spec 和生成产物显式传入的 Zod 运行时,不构造 schema。首次读取 `descriptors` 或 `methods` 时(包括挂载或查询时)才构建并缓存一个共享索引。每个 `methods[endpoint]` 就是同一个直接方法描述符对象;仅使用 Context 接收者的方法保留在描述符和作用域声明中。带作用域投影的直接方法保留 scope 字段,但其通用具名调用器要求无作用域 Remote。 + +直接方法的 `input(options?: TypertRemoteInputOptions)` 返回缓存的 `TypertRemoteValue`;唯一选项是 `strict?: boolean`。`method.input()` 与 `{ strict: false }` 使用普通 codec:已声明对象接受并剥离未知字段。`{ strict: true }` 递归拒绝未声明字段。两种具名参数封装都拒绝额外参数;显式 JSON 与字典键保持开放,有约束的字典值仍然校验。消费方选择一个视图,通过 `input.create().parse(raw)` 与 `input.jsonSchema` 取得匹配的解析和投影。Host 分发与唯一的 `result` codec 保留普通行为,包括二进制 `encode`/`decode`。 + +每种输入策略的视图、具名解析器及 JSON Schema 投影都独立惰性求值并缓存。生成的独立 Zod factory 同样惰性求值且只缓存成功结果。原始 spec 内部要求 Zod factory,而面向消费方的 `TypertSchema` 只承诺 `parse(value: unknown): T`。JSON Schema 仍是可选的生成器投影,而非运行时 `z.toJSONSchema()` 转换。生成产物导入并传入 Zod,包括没有参数或导出 schema的 Remote 端点。纯叶入口只保留 Zod 类型导入,没有运行时模块依赖;协议根入口重新导出 factory 并导入 Cordis,但不会因此将 Zod 加入所有协议消费方的静态模块图。导入和元数据/schema 读取绝不发起网络调用。 + +Registry 查询返回基础描述符。纯叶入口或协议根入口的 `isTypertRemoteMethod(descriptor)` 以 `object`/`unknown` 类型收窄直接调用能力;精确端点类型来自 `TYPERT_REMOTE.methods`,而非动态查询。一元 `mode` 省略;流式 `mode` 为 `'stream'`。`invoke(remote, input, signal?)` 在调用时解析已挂载的命名空间方法,保留其 `this`、可选位置参数空位和 signal。它不使用 async 包装,直接转发同一个完整 `Promise>` 或 `RemoteStreamHandle`;流迭代、`send`、`end` 和 `dispose` 均保留。`TypertRemoteMethod` 的第五个泛型参数独立于结果解析器的原始输出,保留载体解码后的一元字节类型。 + +文档和 JSON Schema 可选。每个不受支持的输入变体或结果投影记录各自的 `jsonSchemaError`,同时保留解析器及独立文档;只捕获专用投影错误。显式选择工具的消费方要求工具说明和受支持的输入 JSON Schema。即使缺少可选输出 JSON Schema,输出解析仍然必需。原生 `dsh-tools` 注册额外要求 `output.schema`:仅有解析器并不满足要求。这些消费方要求不得阻止本来有效的 Remote 方法生成。 + +## Proposal + +在下游 Session-client、CLI、MCP 和原生工具包中完成普通产物迁移。本 note 保持 proposed,因为该迁移尚未完成。 + +### 共享生成,两种访问方式 + +框架消费方将普通元数据加载到自己的 Registry。独立消费方可以直接导入 `./remote`,无需 Cordis、Host Service 激活或连接。两者读取同一份生成业务定义。保留独立的 Host/Client Program,并分析每个编译面的所有贡献方,使局部生成保留外部声明合并。注册不会将每个反射方法都暴露为可调用 endpoint 或工具。 + +严格源码启动显式预构建,并通过 Loader 加载普通 `./typert`。有限的无 descriptor fallback 仍支持基本参数、signal、取消、流式调用、JSON 转换和一元字节附件,但无法恢复被擦除的字段、约束或工具文档。一旦观察到严格的本地 descriptor,撤销后必须拒绝调用,而不是降级到 fallback。`@Remote` 只接受 `name` 和 `mode`;消费方选择输入策略,无需另加开发者注解。 + +Host 元数据就绪仍由组合负责。要求严格校验的 Host 必须等待本地注册完成后再接受调用;`hasSeen()` 不会保护首次注册之前的端点。返回惰性 info 实例既不完成异步 Loader 发现,也不建立就绪保证。Loader 校验静态 codec spec 及可调用的 `input`/`invoke`,注册贡献时不调用选择器、解析器 factory 或投影。 + +### 下游迁移目标 + +已检查的 #4111 树包含 Session-client、CLI、MCP 和原生工具消费方,当前分支并不包含这些完整 package。其 Session client 从源码导出选择 13 个 Session 方法及 Workspace 列举。迁移该树需要一并更新选择、连接时挂载、离线 CLI 初始化、MCP 呈现及原生工具注册;仅删除源码导出并不足够。 + +| 消费方 | 元数据适配 | 保留的执行方式 | +|---|---|---| +| Browser / Node Remote 调用方 | 导入普通 `./remote`;在实际调用 Context 中挂载一次。 | 生成 namespace、Gateway、认证 Connection 和目标 controller。 | +| 离线 CLI 帮助 / 解析 | 在 CLI profile 的元数据 Context 中,于目标发现之前注册;选择 `input({ strict: true })`。 | 帮助不连接;调用时由独立的实际调用 Context 挂载。 | +| MCP 发现 / 调用 | 读取选中的 Registry 条目并选择 `input({ strict: true })`;保留消费方自有工具说明和 envelope。 | `tools/list` 离线;`tools/call` 使用相同 Session client。外部 MCP 客户端收到协议 JSON,而不是 Registry 模块。 | +| 原生工具 | 读取 Host 本地方法信息;要求工具注册的 `output.schema`。 | 在工具边界解析,然后直接调用同进程 controller。 | +| 独立适配器 | 直接读取类型化 `TYPERT_REMOTE.methods`。 | 仅调用时使用其选择的 Remote 运行时。 | + +当前 Session 选择和附件准备仍由消费方拥有;CLI/MCP 输入接收使用选择时的同一严格输入视图验证补全后的业务参数。下游 `follow` 适配器的 `activate: false` 策略只适用于具有该 API 的版本;当前分支的 `follow` 没有此选项。中止观察不等于取消已接受的 Session 工作。 + +### 新旧接入示例 + +旧示例是依赖改动中源码导出消费方的历史对照。新示例使用普通产物。这些片段省略外围插件声明,且包含已移除的 API。`raw`、`request`、`signal` 和已认证、已挂载、无作用域的 `remote` 是调用方输入,不是新增框架 API。 + +**操作拥有者与严格 Host。** 保留业务签名;将生成定义注册交给普通 Typert Loader,而非 Service 构造函数。 + +```text +// Old source delivery. +import { TYPERT_REMOTE } from './remote.generated.ts' +// Constructor: super(ctx, 'sessionController', { namespace: 'session', definitions: TYPERT_REMOTE }) +@Remote({ name: 'page', emitSourceDef: true, rejectUnknownFields: true }) +page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.history.page(request, signal) +} + +// Ordinary delivery; no generated-source import or constructor definitions. +// Constructor: super(ctx, 'sessionController', { namespace: 'session' }) +@Remote({ name: 'page' }) +page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.history.page(request, signal) +} +``` + +**Browser 与 Node 调用。** 源码导出消费方将 `/remote-source` 改为 `/remote`;已有普通消费方本来就使用此路径。实际调用 Context 挂载一次,不额外向 Registry 注册。 + +```text +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +await ctx.remote.$mount(TYPERT_REMOTE) +const result = await ctx.remote.session.page(request, signal) +const stream = ctx.remote.session.follow(followRequest, signal) +try { + for await (const frame of stream) consume(frame) +} finally { + await stream.dispose() +} +``` + +**离线 CLI 与框架元数据。** 旧选择读取源码方法表;替代方式在目标发现之前读取 profile 拥有的 Registry。两个片段是不同选择,不是同时声明。 + +```text +// Old selection. +import { REMOTE_METHODS } from '@deepseek-ai/dsh-api-session-controller/remote-source' +const page = REMOTE_METHODS['session/page'] +``` + +```text +// Metadata plugin: inject: ['typert']; no Gateway, Connection, or Host controller. +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +import { isTypertRemoteMethod } from '@deepseek-ai/dsh-typert-protocol/remote-info' +ctx.typert.remotes.register(TYPERT_REMOTE) +const page = ctx.typert.remotes.get('session/page') +if (page === undefined || !isTypertRemoteMethod(page)) { + throw new Error('session/page generated direct method is not loaded') +} +``` + +注册属于该插件的 fiber。帮助使用 `description` 和 `input({ strict: true })`;类型化 facade 可以保留对普通生成声明的 type-only 导入。显式工具选择不修改已注册元数据。 + +**CLI 输入与分发。** 本地 `current` 解析和附件准备之后,两种元数据访问方式使用同样的调用步骤: + +```text +if (page.mode === 'stream') throw new Error('session/page must be unary') +const input = page.input({ strict: true }) +const arguments_ = input.create().parse(raw) +const result = await page.invoke(remote, arguments_, signal) +if (!result.ok) throw result.error +const output = page.result.create().parse(result.value) +``` + +**MCP 发现与调用。** 选择时验证工具特有要求;生成过程本身不要求它们。 + +```text +const input = page.input({ strict: true }) +if (!page.description) throw new Error('session/page has no tool description') +if (input.jsonSchema === undefined) throw new Error('session/page has no supported tool input schema') +const tool = { + name: 'remote_session_page', + description: page.description, + inputSchema: input.jsonSchema, + ...(page.result.jsonSchema === undefined ? {} : { outputSchema: page.result.jsonSchema }), +} +``` + +这个简化对象只展示元数据依赖。真实 MCP 适配器保留 `{ arguments, current, content }` 输入及 `{ ok, value }` / 失败输出 envelope。列举无需连接;调用执行输入接收、本地准备、通过 `input.create()` 解析、调用及输出解析。可选输出投影不会移除输出验证。 + +**原生工具。** 查询 `ctx.typert.local.get('session/page')`,并用 `isTypertRemoteMethod` 收窄,而非读取源码方法表。原生工具可以选择 `method.input({ strict: true })`,取得匹配的解析与输入投影。原生注册仍要求受支持的输入与结果投影,包括 core Tool 必需的 `output.schema`;MCP 的可选输出投影在这里并不足够。facade 取得类型化请求后,两种设计执行同样的本地调用: + +```text +const value = await ctx.sessionController.page(request, signal) +``` + +**独立消费方。** 读取普通 info 实例的类型化索引,不要求 Registry: + +```text +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +const page = TYPERT_REMOTE.methods['session/page'] +const arguments_ = page.input().create().parse(raw) +const result = await page.invoke(remote, arguments_, signal) +``` + +**严格源码启动。** 构建普通产物,再使用受支持的源码启动器: + +```sh +pnpm run build +pnpm dsh --profile +``` + +## Alternatives considered + +- **提交源码:** 满足离线需求,但增加冗余交付和维护路径;显式普通预构建已经足够。 +- **嵌套描述符元数据与独立方法表:** 重复结果和参数事实,并增加需要跟踪的对象身份。一份 spec 直接在描述符上派生调用辅助能力;`TypertRemoteInfo` 扩展既有 contribution 并保留 `descriptors`,让 Gateway 与 Registry 沿用现有注册 API。 +- **所有导入方强制使用 Registry:** 为独立消费方增加生命周期要求,却不增加信息。 +- **仅用 fallback:** 无法提供被擦除的验证或工具文档;仅为受支持子集保留。 +- **手写适配器 schema 或运行时 TypeScript 分析:** 重复承担所有权,或引入构建期提取已解决的运行时编译器要求。 + +## Acceptance criteria + +- 普通产物为每个端点生成一份静态方法 spec,并返回惰性类实例;直接方法索引引用描述符对象,保留完整流式/二进制类型、独立编译面及完整编译面的声明合并。 +- 描述符索引与每种输入策略的视图、解析器和投影都独立惰性求值并缓存;Loader 校验静态 spec 及可调用辅助能力,不调用选择器、解析器或投影。 +- 每个输入参数从同一模型生成普通与严格 codec;消费方无需方法注解即可选择策略。两种封装都拒绝额外具名参数;严格输入递归关闭已声明对象,但不关闭 JSON 或字典键,也不改变 Host 分发和结果。 +- 协议源码与构建入口没有 Zod 运行时依赖;生成产物显式提供 Zod,无参端点也能创建拒绝额外字段的空输入对象;[生产 headless profile 的 Loader 回归](<../../../../packages/context/time-context/tests/time-context.e2e.ts>)断言真实 Session 事件。 +- Registry 身份、原子注册、撤销和释放保持不变。 +- 缺少文档或不支持可选投影不阻止生成;选中的工具拒绝缺少必需信息的条目,并仍然解析输出。 +- 严格源码/产物调用与有限 fallback 分别测试;撤销严格 descriptor 绝不启用 fallback。 +- 源码静态检查不消费过期生成声明;产物检查声明并重建输入。生产代码不保留源码定义 opt-in、源码导出、清单或新鲜度规则。 + +## Risks + +完整下游 profile 组合仍需在相应 package 中继续完成;生成的元数据不代表每个 #4111 操作或 CLI/MCP 应用都已迁移。明确接受的取舍是完整源码运行时元数据需要显式预构建。缺失或过期产物要求重新生成,而不是降低验证或另建一套手写 schema。 diff --git a/.agents/skills/dsh-ci-test-reliability/SKILL.md b/.agents/skills/dsh-ci-test-reliability/SKILL.md index d9fd0f1700..ba97a37745 100644 --- a/.agents/skills/dsh-ci-test-reliability/SKILL.md +++ b/.agents/skills/dsh-ci-test-reliability/SKILL.md @@ -10,7 +10,7 @@ Build tests that remain correct under the repository's real CI topology, not onl ## Read the owning rules - Use [the testing policy](../../../docs/testing.md) to select unit, coverage, expected-output, snapshot, browser, or real-API evidence. -- Use [the defensive patterns](../../../docs/defensive-patterns.md) for lifecycle, subprocess, cancellation, and teardown behavior. +- Use [the defensive patterns](../../../docs/defensive-patterns/general.md) for lifecycle, subprocess, cancellation, and teardown behavior. - Read the active Vitest config and GitHub workflow when their worker or job topology affects the test. - For recorded-session scenarios, also follow [the snapshot instructions](../../../snapshots/AGENTS.md). - Use [dsh-pre-push-checks](../dsh-pre-push-checks/SKILL.md) after the test design is sound to select outgoing validation. diff --git a/.agents/skills/dsh-client-ui-ux/SKILL.md b/.agents/skills/dsh-client-ui-ux/SKILL.md index fa6ebb70a1..4fbfa1a876 100644 --- a/.agents/skills/dsh-client-ui-ux/SKILL.md +++ b/.agents/skills/dsh-client-ui-ux/SKILL.md @@ -7,6 +7,13 @@ description: Design and review DeepSeek Harness client UI changes — visual tok This skill is guidance, not a complete checklist. It covers judgment calls that lint, typecheck, and the i18n gate cannot make: where feedback appears, what gets reused, and how surfaces behave at window edges and across platforms. Copy quality itself follows [dsh-prose-standard](../dsh-prose-standard/SKILL.md); locale ownership follows the [locale-owned copy decision](../../notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md); styling ownership and token rules live in [docs/web-styling.md](../../../docs/web-styling.md) and the [ui-primitives component catalog](../../../packages/client/ui-primitives/README.md#component-catalog) — this skill adds judgment on top of those rules, not a second copy of them. +## Architecture prerequisites + +Component structure, state flow, and effect usage follow the architecture rules; read them before redesigning a component: + +- [packages/client/AGENTS.md](../../../packages/client/AGENTS.md) — Slot and props discipline, reactive read, export, ctx, and layering rules. +- [Client component rules](../../../docs/defensive-patterns/client-components.md) — those requirements stated with the defects they prevent. + ## Visual foundation - **Use existing tokens for color, radius, and spacing.** Before introducing a literal value, search the target page and its nearest established sibling (the Plugins page is the usual reference for full-page surfaces) for the token or value already in use, and adopt it. The same applies to whole treatments — toasts, menus, in-message artifact previews, sidebar tab layout, colors, clearance rules, page padding: reuse the established component or rule wherever one exists instead of inventing a parallel one. @@ -47,6 +54,10 @@ Every menu, popover, and tooltip must be verified for all three before merge: Scrollbars stay inside their container: use the shared scrollbar styles rather than component-specific selectors, and verify the bar neither overflows the container nor gets clipped by its rounded corners or edges — keep a small inset instead of letting it sit flush against the container edge. +## Responsive layouts + +Use [dsh-responsive-ui](../dsh-responsive-ui/SKILL.md) for responsive layouts, text truncation, title wrapping, icon-only controls, and focus during width changes. + ## Platform window adaptation - Any new header element or full page must account for macOS and Windows window chrome. On macOS the titlebar clearance is `--dsh-frame-top-clearance`, consumed by Menu, Modal, and overlay primitives as their top safe margin — **never change the global variable to fix one page**. Add a scoped `[data-platform='darwin']` override on the page's own inset instead, and assert the override in the page's stylesheet spec. diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index e75ca07744..ff242b4819 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -7,10 +7,17 @@ description: Use when reviewing a pull request in the deepseek-harness repo — **This skill is guidance, not a complete checklist.** Verify and fetch the PR's live base and exact head, then run `pnpm --silent run change-scope --base --head ` before reading the diff and enough surrounding code to understand the design. The report identifies paths and dirty layers but does not replace semantic review. Re-establish the base and rerun it after a retarget or merge. Prioritize correctness, lifecycle, security, and broken required behavior over style; a short review with one substantiated blocker is better than a list of nits. +**Read the requirements before judging the diff.** Map the changed paths to the artifacts below, open each owning page from [Sources of truth](#sources-of-truth), and only then read the diff against those requirements. Judging a diff against memory of the rules is how a violation passes review. + ## Sources of truth - [AGENTS.md](../../../AGENTS.md) and [packages/AGENTS.md](../../../packages/AGENTS.md): standing repository and package authoring rules. -- [docs/defensive-patterns.md](../../../docs/defensive-patterns.md): subprocess, callback, async-state, and disposal bug classes. +- [docs/defensive-patterns/](../../../docs/defensive-patterns/README.md): the architecture requirements a change must satisfy and the habits behind them. Each page owns one artifact; read every page the diff touches: + - [general.md](../../../docs/defensive-patterns/general.md) — timing, async state, value representation, shared ownership, decentralization, refactors, migrations, performance evidence, disposal, callbacks, subprocesses. + - [package-surface.md](../../../docs/defensive-patterns/package-surface.md) — exports, package shape, build faces, dependency declarations, resolve hooks. + - [remote-and-api.md](../../../docs/defensive-patterns/remote-and-api.md) — remote services, `@Remote` methods, API dispatch, business ownership in `packages/api`. + - [client-components.md](../../../docs/defensive-patterns/client-components.md) — client components, slots, stores, hooks, effects, shared base packages. + - [conversation-and-session.md](../../../docs/defensive-patterns/conversation-and-session.md) — projection reads, Session retention, conversation nodes, renderers. - [docs/AGENTS.md](../../../docs/AGENTS.md): documentation placement and prose discipline. - [dsh-prose-standard](../dsh-prose-standard/SKILL.md): required coverage and editorial judgment for comments, docs, prompts, and visible strings. - [dsh-ci-test-reliability](../dsh-ci-test-reliability/SKILL.md): isolation and regression-proof rules for resource-owning, asynchronous, or flaky tests and fixtures. @@ -33,7 +40,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — - **Intent and interface contracts:** trace both sides of every changed interface. Confirm the implementation matches the PR and any Agent Note, including errors, cancellation, ownership, and disposal. - **Error handling:** apply [dsh-error-handling](../dsh-error-handling/SKILL.md) to changed failure paths, including their state guarantees and final user-visible reports. Reject catch-and-continue unless the state owner establishes that subsequent work's preconditions hold, including after recovery. -- **Lifecycle and concurrency:** for async setup, callbacks, processes, or teardown, apply [defensive-patterns.md](../../../docs/defensive-patterns.md). Check races before publication, cancellation during awaits, independent error reporting, callback containment, ownership before reentry, complete detach cleanup, and quiescent disposal. +- **Lifecycle and concurrency:** for async setup, callbacks, processes, or teardown, apply [defensive-patterns/general.md](../../../docs/defensive-patterns/general.md). Check races before publication, cancellation during awaits, independent error reporting, callback containment, ownership before reentry, complete detach cleanup, and quiescent disposal. - **Capability and consumer fit:** trace every current consumer, then flag consumer-specific behavior leaking into the interface under [the package rules](../../../packages/AGENTS.md). Flag the inverse too: a new public method on a generic service (registry, session, agent) whose only caller is one internal consumer is an unnecessary API expansion — require a private capability closure handed to that consumer at construction instead. - **Scope, ownership, and necessity:** map each abstraction, state machine, option, defensive copy, and compatibility path to its current contract, production consumer, and owning plugin or service. Challenge unrelated features and speculative generality, then test the PR against [the root rules](../../../AGENTS.md#conventions). - **Configuration and public choices:** ask what current-consumer evidence or prior art supports each default, public operation set, format, or imported external concept. Require an explicit choice or deferral when that evidence is absent. diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index fe98baba29..46955d8abd 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -9,7 +9,7 @@ Find changes that remove maintained obligations: APIs, representations, lifecycl ## Establish scope and constraints -Read `AGENTS.md`, [architecture](../../../docs/architecture.md) before judging packages, [defensive patterns](../../../docs/defensive-patterns.md), and [testing policy](../../../docs/testing.md). Consult the relevant active Agent Notes for intentional decisions; use the [note rules](../../notes/README.md) for new records. Archived notes are historical examples, not current authority: never inspect, verify, or repair their outbound links. +Read `AGENTS.md`, [architecture](../../../docs/architecture.md) before judging packages, [defensive patterns](../../../docs/defensive-patterns/README.md), and [testing policy](../../../docs/testing.md). Consult the relevant active Agent Notes for intentional decisions; use the [note rules](../../notes/README.md) for new records. Archived notes are historical examples, not current authority: never inspect, verify, or repair their outbound links. The two LLM adapters are intentional. JSONL is the sole first-party Session persistence provider, while the backend-neutral Service Definition supports out-of-tree providers. Do not propose deleting an LLM twin or the persistence seam unless the user explicitly overrides that constraint. Unused members within a protected design remain candidates when their removal preserves its purpose. diff --git a/.agents/skills/dsh-official-plugin-artwork/SKILL.md b/.agents/skills/dsh-official-plugin-artwork/SKILL.md new file mode 100644 index 0000000000..2336c7dbc5 --- /dev/null +++ b/.agents/skills/dsh-official-plugin-artwork/SKILL.md @@ -0,0 +1,37 @@ +--- +name: dsh-official-plugin-artwork +description: Draw and review official DeepSeek Harness plugin icons, including experimental plugins. Use for package icon.svg assets and PluginArtwork glyphs; covers semantic references, proportions, lettering, transparency, package metadata, and designer review. Not for toolbar or action icons. +--- + +# Official plugin artwork + +Draw recognizable artwork that belongs beside the existing official icons. Official and official experimental plugins share this visual language; the UI label communicates experimental status. This guidance does not replace designer approval. + +## Establish the subject + +- Inspect the actual feature, its output, and its owning manifest before drawing. A badge-producing skill may need a tag, not a medal. Reuse designer-supplied assets; do not invent a brand mark or change product names, identifiers, or unrelated icons without approval. +- Read the [existing artwork](../../../packages/client/ui-primitives/src/plugin-artwork.tsx) and the nearest relevant package SVGs. Borrow one coherent treatment, not every reference's effects. Action icons remain with [dsh-client-ui-ux](../dsh-client-ui-ux/SKILL.md#reuse-before-adding). +- Check every affected entry, list, detail, and component row. Bundle artwork and a component's name and icon can have separate metadata owners; changing one does not update the other. + +## Draw and refine + +1. **Start simple.** Use one dominant subject and only the detail needed to explain it. Related capabilities can share a metaphor; distinguish them through proportions, silhouette, or palette rather than extra panels, symbols, or badges. +2. **Judge optical size.** Use `width="36" height="36" viewBox="0 0 36 36"`. Center the artwork with deliberate margins; 18–22 units is a useful starting extent, not a requirement for every silhouette or wide text tag. The [page renderer](../../../packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx) owns display sizes and outer tile framing. Do not bake that tile into the SVG or enlarge a toolbar glyph to fill it. +3. **Keep geometry consistent.** Balance the main subject and secondary element. Align repeated marks and corners; keep tails and handles intentional. Refine one requested dimension at a time without changing approved parts. +4. **Use restrained color by default.** Balance saturation and brightness with neighboring icons; prefer existing palettes such as [Auto Authorization Review](../../../packages/experimental/auto-review/icon.svg) and [Codex subagent](../../../packages/subagent/subagent-codex/icon.svg). Reserve unusually vivid, bright, or neon colors for approved brand requirements, essential contrast, or explicit designer direction. Tune saturation separately from hue and lightness; do not make artwork gray or muddy merely to reduce saturation. Keep flat fills or short gradients and limited layering; avoid glow, drop shadows, glossy highlights, and emoji. Page CSS still uses semantic theme tokens. +5. **Align lettering by visible ink.** Use consistent letterforms, cap height, baseline, weight, and spacing. Compare rendered stroke edges, not just path coordinates: stroke caps and joins can make equal coordinates appear unequal. Export lettering as paths without a font dependency, and keep it readable at actual size. +6. **Make empty areas genuinely transparent.** A hollow lens must reveal the background, including where it overlaps another shape. Remove the underlying fill with a local mask or cutout; do not substitute a white or dark disk. Check both themes. + +## Export at the owner + +- Use the manifest's `icon` path and publish its resources. Update intentional bundle/component copies together; verify localized display names and descriptions when those are in scope. Do not change the feature's produced assets merely to change its plugin icon. +- Package images render through ``: export self-contained SVG geometry with local, uniquely named gradient, mask, and clip references. No scripts, event handlers, animation, external images/styles, fonts, or `foreignObject`. Inline React artwork needs per-instance IDs; separate image documents do not share IDs. +- Keep the asset change local. Do not add runtime dependencies, primitive exports, or renderer special cases just to display it. + +## Review and verify + +1. Show a few numbered alternatives before polishing uncertain geometry. Preserve their labels through review and apply the chosen version; an existing file or generated draft does not establish designer approval. Keep comparison artifacts in a gitignored directory, not the product package. +2. Compare old and new artwork beside unchanged references in light and dark themes, at actual row/card sizes as well as enlarged. Check balance, lettering, empty space, clipping, and image decoding. +3. Run `pnpm run verify-package-meta` for [package metadata and SVG artwork](../../../scripts/verify-package-meta.ts), `pnpm run constraints` for the canonical package payload, and `pnpm run verify-official-bundle-catalog`. Regenerate a stale catalog with `pnpm run gen-official-bundle-catalog`, never by hand. Structural checks do not replace visual review. +4. Build affected artifacts and inspect the real page through `dsh web`. Batch approved refinements before restarting a metadata-caching Host, then verify the entry and nested component rows in both themes. +5. Follow the repository's UI testing policy and [record-browser-gif](../record-browser-gif/SKILL.md) for a GUI PR. Obtain designer approval before landing new glyphs. Record reusable drawing principles here, not the history of rejected drafts. diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 6567dde5dc..3d55a9dece 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -37,6 +37,7 @@ When the outgoing change adds or changes a resource-owning or asynchronous test, - **Expected-output placement:** a test whose selected recorded Session generation is replay input and expected persisted output belongs under top-level `snapshots/`, with `snapshot.yml` naming its shipped `dsh` profile and composition/header pin. Canonical parent files are `session[.vN].jsonl`, children are `session.[.vN].jsonl`, and the harness selects the highest generation per role. ARIA, geometry, generator, CLI, and unit expectations without that Session round trip stay beside their owning test under `tests/expected/`; do not place them in `snapshots/` or give them a `*.snapshot.ts` owner. Use the owning `test:expected`, `test:web`, or `test` lane. - **Profile and configuration placement:** cross-package behavior of a shipped `dsh` profile belongs under `apps/cli/tests/profiles/`; a package-specific Loader composition belongs under that package's `tests/fixtures/`. User-facing optional overlays live under `apps/cli/config/examples/` and pair with a guide under `docs/user/`. - **Package manifests, public exports, build configuration, worker/bin entries, or built runtime paths:** run `pnpm run build`, the relevant hygiene checks, and the owning built-artifact smoke. +- **Repository test and script types:** `pnpm run build:artifacts` checks package projects only. Before claiming completion of TypeScript changes, run `pnpm run build:check` after the latest `build:artifacts`, or run `pnpm run build`; `build:check` reads the existing `build:artifacts` output and never rebuilds it. - **Real provider or agent behavior:** run the relevant `pnpm run test:e2e` target when credentials are available; never print secrets. Do not manually repeat a passing check merely because commit or push follows. In particular, do not run typecheck immediately before pushing solely to duplicate the pre-push hook. diff --git a/.agents/skills/dsh-responsive-ui/SKILL.md b/.agents/skills/dsh-responsive-ui/SKILL.md new file mode 100644 index 0000000000..a9ee2f3a37 --- /dev/null +++ b/.agents/skills/dsh-responsive-ui/SKILL.md @@ -0,0 +1,54 @@ +--- +name: dsh-responsive-ui +description: Use when designing, implementing, or reviewing responsive client layouts, text truncation, title wrapping, icon-only controls, mobile hover, tooltips and keyboard behavior, and focus during width changes in DeepSeek Harness. +--- + +# Responsive client UI + +## 1. Minimum viewport + +Use a viewport width of 390 CSS px as the primary acceptance minimum. There must be no unintended overlap, clipping, page-wide horizontal scrolling, or inaccessible primary actions. Below 390px, layout requirements may be relaxed. + +## 2. Decision order + +1. First adjust space allocation or wrap elements onto another line; then consider text truncation, icon-only controls, or moving icons into the More menu. +2. Primary actions and primary states must not be omitted. +3. Do not hide conflicts through global clipping, reducing all font sizes, or shrinking click targets. +4. Reduce font sizes only when specifically required by product design. + +## 3. Text truncation + +1. Truncated content must be fully available on a secondary page or by opening a menu. Example: Session statistics below the input box. +2. Truncation must not lose errors or other key information. +3. Prefer single-line truncation for tab navigation, visible selectors, buttons, tags, and similar components. +4. Important information that is not available on another page must be displayed in full without truncation. + +## 4. Titles + +1. Prefer displaying page titles in full. Move the action area onto another line when necessary. Example: the Plugins page's title wrapping behavior. +2. Prefer natural wrapping for card titles. If the number of lines is limited, the full title must remain available to view. + +## 5. Icon-only controls + +1. Reducing a control to an icon requires both: + - The icon clearly conveys its meaning, or the operation is familiar to users. + - A tooltip appears on hover; add one if needed. +2. Do not reduce “More account information” to an icon, because it navigates to a separate page. +3. The model selector in the input box can be reduced to an icon because users see it frequently and can open its menu to switch models. +4. Another example is the linked conversation in an automation task. + +## 6. Mobile hover behavior + +1. Enable mouse-hover effects only on devices that support hovering. Use `@media (hover: hover)`, not viewport width, to determine this. +2. Tapping on a phone must not leave behind backgrounds, highlights, or action-button visibility intended only for hover. +3. Preserve normal selected, enabled, and pressed states, as well as focus indicators for keyboard interaction. +4. Try to preserve focus when the width changes, unless the focused element disappears from the page as a result. + +## 7. Mobile tooltips + +1. Do not show tooltips that only provide auxiliary information such as action names or keyboard shortcuts on mobile. For example, omit the “Copy” tooltip on a copy button; tapping still performs the original action directly. +2. Tooltips that provide supplementary explanations must support tap-to-show on mobile. For example, the information tooltip beside the Plugins page description. + +## 8. Mobile keyboard + +On mobile, do not raise the input keyboard automatically unless it is explicitly intended. diff --git a/.agents/skills/dsh-speed-up-perf/SKILL.md b/.agents/skills/dsh-speed-up-perf/SKILL.md index f8b5eb6141..491e39f81c 100644 --- a/.agents/skills/dsh-speed-up-perf/SKILL.md +++ b/.agents/skills/dsh-speed-up-perf/SKILL.md @@ -9,7 +9,7 @@ Turn a broad “make it faster” request into reproducible user-path measuremen ## Establish scope and current authority -Read [AGENTS.md](../../../AGENTS.md), [architecture](../../../docs/architecture.md), [testing policy](../../../docs/testing.md), [defensive patterns](../../../docs/defensive-patterns.md), and the affected packages’ instructions and Agent Notes. Use [CI test reliability](../dsh-ci-test-reliability/SKILL.md) for processes, clocks, browser tests, and asynchronous cleanup. +Read [AGENTS.md](../../../AGENTS.md), [architecture](../../../docs/architecture.md), [testing policy](../../../docs/testing.md), [defensive patterns](../../../docs/defensive-patterns/README.md), and the affected packages’ instructions and Agent Notes. Use [CI test reliability](../dsh-ci-test-reliability/SKILL.md) for processes, clocks, browser tests, and asynchronous cleanup. Agree on the user-visible endpoint, workload range, resource constraints, acceptable minor behavior differences, and stopping rule. Keep backend and browser end-to-end measurements separate: a fast history iterator or Client fold does not prove fast transport, paint, scrolling, or input response. Exclude model/network latency when measuring local overhead, and state that exclusion rather than calling the result complete product latency. diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 9ea51311b6..0c869d008a 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -153,11 +153,15 @@ jobs: retention-days: 7 # Blacksmith x64 CI builds use 2 vCPU for compilation, bundling, and wheel smokes. + # Release macOS builds use larger GitHub runners; ordinary CI and direct + # builder dispatches retain the standard native target matrix. build: needs: [plan, sdk-wheel] name: ${{ matrix.target }} runs-on: >- - ${{ inputs.ci && !inputs.release && matrix.target == 'node24-linux-x64' + ${{ inputs.release && matrix.target == 'node24-macos-x64' && 'macos-15-large' + || inputs.release && matrix.target == 'node24-macos-arm64' && 'macos-15-xlarge' + || inputs.ci && !inputs.release && matrix.target == 'node24-linux-x64' && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-2vcpu-ubuntu-2404' || inputs.ci && !inputs.release && matrix.target == 'node24-win-x64' && vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-2vcpu-windows-2025' @@ -262,7 +266,7 @@ jobs: DSH_BUILD_CLIENT_PROFILE: official run: >- pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }} - ${{ inputs.ci && github.event_name == 'pull_request' && '--artifacts-only' || '' }} + ${{ inputs.ci && github.event_name == 'pull_request' && '--build-artifacts' || '' }} - name: Resolve platform outputs (POSIX) id: runtime-posix diff --git a/.github/workflows/build-preview-cloudflare.yml b/.github/workflows/build-preview-cloudflare.yml index e8ad9bd2ec..c2ff5d3231 100644 --- a/.github/workflows/build-preview-cloudflare.yml +++ b/.github/workflows/build-preview-cloudflare.yml @@ -71,7 +71,7 @@ jobs: # 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-only + run: pnpm run build:artifacts - name: Build the preview page and pack the VFS image env: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 397ac07b45..726bd070a5 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -107,7 +107,7 @@ jobs: # the built artifact under plain Node, resolving plugins through real package # exports — the shape a real consumer runs. That requires a prior build. - name: Build (lib for the e2e example bins) - run: ${{ github.event_name == 'pull_request' && 'pnpm run build --artifacts-only --profile official' || 'pnpm run build:official' }} + run: ${{ github.event_name == 'pull_request' && 'pnpm run build:artifacts --profile official' || 'pnpm run build:official' }} - name: Prepare Office runtime run: pnpm run prepare:primary-runtime --target linux-x64 --output .cache/e2e-office diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml new file mode 100644 index 0000000000..28dac193e0 --- /dev/null +++ b/.github/workflows/macos-package.yml @@ -0,0 +1,93 @@ +name: Package Desktop · macOS + +on: + workflow_dispatch: + inputs: + preset: + description: 'Machine preset (configured on the packaging runner)' + type: choice + options: [test, production] + default: test + required: true + skip_notarization: + description: 'Skip notarization/stapling: internal debugging only, not for distribution or updates' + type: boolean + default: false + required: true + build_version: + description: 'Version: auto recommended; explicit version must match the checkout product prefix' + type: string + default: auto + required: true + +permissions: + contents: read + +concurrency: + group: macos-desktop-package + cancel-in-progress: false + +jobs: + package: + runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial] + timeout-minutes: 180 + defaults: + run: + shell: bash + env: + PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }} + DSH_TELEMETRY_DISABLED: '1' + DSH_PACKAGING_PRESET: ${{ inputs.preset }} + steps: + - uses: actions/checkout@v7.0.1 + id: checkout + with: + persist-credentials: false + clean: true + - 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: '24' + - name: Install locked dependencies + run: pnpm install --frozen-lockfile + - name: Validate preset and resolve build version + id: settings + env: + PACKAGE_BUILD_VERSION: ${{ inputs.build_version }} + run: node apps/desktop/scripts/prepare-macos-ci.mjs + - name: Check packaging configuration and toolchain + env: + PACKAGE_VERSION: ${{ steps.settings.outputs.version }} + run: | + args=() + if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi + pnpm run package:desktop:mac:arm64 --check --build-version "$PACKAGE_VERSION" "${args[@]}" + - name: Build signed installers + env: + PACKAGE_VERSION: ${{ steps.settings.outputs.version }} + run: | + args=() + if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi + pnpm run package:desktop:mac:arm64 --build-version "$PACKAGE_VERSION" "${args[@]}" + - name: Retain packaging diagnostics + if: always() && steps.checkout.outcome == 'success' + uses: actions/upload-artifact@v7.0.1 + with: + name: macos-package-logs-${{ github.run_id }}-${{ github.run_attempt }} + include-hidden-files: true + path: apps/desktop/.desktop-build/packaging-runs/ + if-no-files-found: warn + retention-days: 14 + - name: Retain installers for the selected mode + if: success() + uses: actions/upload-artifact@v7.0.1 + with: + name: macos-arm64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + include-hidden-files: true + path: | + apps/desktop/.desktop-build/targets/mac-arm64/${{ steps.settings.outputs.artifacts }}/*.dmg + apps/desktop/.desktop-build/targets/mac-arm64/${{ steps.settings.outputs.artifacts }}/*.zip + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7fd8c9ab8a..b9def1dec1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -146,7 +146,7 @@ jobs: run: pnpm run release:verify --family dsh - name: Build - run: ${{ github.event_name == 'pull_request' && 'pnpm run build --artifacts-only --profile official' || 'pnpm run build:official' }} + run: ${{ github.event_name == 'pull_request' && 'pnpm run build:artifacts --profile official' || 'pnpm run build:official' }} # Concurrency here is rehearsal-only: the credentialed publish workflows # invoke release:pack without the flag and keep the strictly serial path. diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 56bcb47a96..d92472e6b2 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -1,27 +1,25 @@ -name: Windows installer (unsigned) +name: Package Desktop · Windows on: workflow_dispatch: inputs: - build_version: - description: 'Build version: auto generates a version; otherwise enter a full version' - type: string - default: auto - required: true - deployment: - description: Update policy deployment + preset: + description: 'Machine preset (configured on the packaging runner)' type: choice options: [test, production] default: test required: true - policy_origin: - description: 'Required update policy HTTPS origin (no path)' - type: string + signing: + description: 'unsigned: test installer; signed: use the configured EV hardware token' + type: choice + options: [unsigned, signed] + default: unsigned required: true - login_origins: - description: 'Required for test: comma-separated allowed login HTTPS origins' + build_version: + description: 'Version: auto recommended; or checkout product version / e.g. 0.2.1-alpha.1.20261009.1 (prefix must match checkout)' type: string - required: false + default: auto + required: true permissions: contents: read @@ -39,7 +37,20 @@ jobs: shell: pwsh env: DSH_TELEMETRY_DISABLED: '1' + DSH_PACKAGING_PRESET: ${{ inputs.preset }} + PACKAGE_SIGNING: ${{ inputs.signing }} steps: + - name: Reset packaging checkout + working-directory: ${{ runner.temp }} + run: | + $workspace = [IO.Path]::GetFullPath($env:GITHUB_WORKSPACE) + $parent = [IO.Path]::GetFullPath($env:RUNNER_WORKSPACE) + if ([IO.Path]::GetDirectoryName($workspace) -ne $parent) { throw 'Checkout must be a direct child of RUNNER_WORKSPACE' } + for ($item = [IO.DirectoryInfo]::new($workspace); $null -ne $item; $item = $item.Parent) { + if ($item.Exists -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Checkout ancestors cannot be links' } + } + 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' } - uses: actions/checkout@v7.0.1 id: checkout with: @@ -55,25 +66,26 @@ jobs: run: | pnpm install --frozen-lockfile exit $LASTEXITCODE - - name: Validate inputs and prepare public package settings + - name: Validate preset and resolve build version id: settings env: PACKAGE_BUILD_VERSION: ${{ inputs.build_version }} - PACKAGE_DEPLOYMENT: ${{ inputs.deployment }} - PACKAGE_POLICY_ORIGIN: ${{ inputs.policy_origin }} - PACKAGE_LOGIN_ORIGINS: ${{ inputs.login_origins }} run: node apps/desktop/scripts/prepare-windows-ci.mjs - name: Check packaging configuration and toolchain env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: | - pnpm run package:desktop:win:x64:unsigned --check --build-version "$env:PACKAGE_VERSION" + $packageArgs = @('run', 'package:desktop:win:x64', '--check', '--build-version', $env:PACKAGE_VERSION) + if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' } + pnpm @packageArgs exit $LASTEXITCODE - name: Build installer and verify packaged runtime env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: | - pnpm run package:desktop:win:x64:unsigned --build-version "$env:PACKAGE_VERSION" + $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 packaging diagnostics if: always() && steps.checkout.outcome == 'success' @@ -84,7 +96,8 @@ jobs: path: apps/desktop/.desktop-build/packaging-runs/ if-no-files-found: warn retention-days: 14 - - name: Retain verified installer + - name: Retain verified unsigned installer + if: success() && inputs.signing == 'unsigned' uses: actions/upload-artifact@v7.0.1 with: name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} @@ -92,9 +105,12 @@ jobs: path: apps/desktop/.desktop-build/targets/win-x64/unsigned-artifacts/*.exe if-no-files-found: error retention-days: 14 - - name: Remove generated package settings - if: always() && steps.checkout.outcome == 'success' - run: | - if (Test-Path -LiteralPath apps/desktop/.env.windows) { - Remove-Item -LiteralPath apps/desktop/.env.windows - } + - name: Retain verified signed installer + if: success() && inputs.signing == 'signed' + uses: actions/upload-artifact@v7.0.1 + with: + name: windows-x64-signed-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + include-hidden-files: true + path: apps/desktop/.desktop-build/targets/win-x64/artifacts/*.exe + if-no-files-found: error + retention-days: 14 diff --git a/AGENTS.md b/AGENTS.md index c29ff51cff..7bae0a79c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,9 @@ pnpm run test:snapshot:record # re-record expected outputs (needs key) pnpm run typecheck pnpm run lint pnpm run duplication # cross-file TypeScript clone detection -pnpm run build # tsc emits lib/types, tsdown bundles runtime +pnpm run build # build:artifacts + build:check, concurrently +pnpm run build:artifacts # artifacts: tsc emits lib/types, tsdown bundles runtime, Web +pnpm run build:check # typecheck aggregate tests/scripts against current build:artifacts output pnpm run hygiene # publint + workspace/package/dependency checks + NodeNext consumer check pnpm run check:windows-wine # ONLY when diagnosing a known Windows failure (needs wine); CI owns this signal pnpm run doc-sync # documentation gates (scripts/run-gates.ts) @@ -100,7 +102,7 @@ pnpm run test:docs # quick documentation checks (no build; doc-quick aggreg pnpm run website:build # VitePress build (doubles as dead-link check) pnpm dsh --profile headless "task" # run one task from source (needs DEEPSEEK_API_KEY) pnpm run demo:ptc -- "task" # headless PTC mode run (needs key) -pnpm run dev:web | dev:desktop # build, then launch; Web also rebuilds client bundles on edits. start:web | start:desktop skip the build +pnpm run dev:web | dev:desktop # build:artifacts, then launch; Web also rebuilds client bundles on edits. start:web | start:desktop skip the build make web|dev-web|desktop|dev-desktop|build # the same commands; ARGS='--no-open' forwards options ``` @@ -115,11 +117,12 @@ Before pushing, follow [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/ - Match evidence to the surface: focused behavior tests, model/user-output snapshots, `doc-sync` for docs, built smokes for published paths, and real-API e2e for providers. - Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change. - `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)). +- Develop with `build:artifacts`; before claiming completion, run `build:check` after the latest `build:artifacts`, or `build` ([layout](docs/development.md#typescript-project-layout)). - **Web browser automation and GIF recording:** launch with `pnpm dsh web --patch apps/web/tests/pin-browse-picker.overlay.yml` to use the [in-page directory picker](apps/web/tests/pin-browse-picker.overlay.yml); omit this override only when testing native picker behavior explicitly. ## Secrets / .env -Windows packaging/signing: [required reading](apps/desktop/README.md#windows-ev-signing). +Windows packaging/signing: [required reading](apps/desktop/windows-signing/README.md). Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and root `.env`. cordis.yml allows `!!js` (never `!js`) under plugin `config` and entry `disabled`; other metadata stays literal, so conditional composition also uses overlays ([primer](docs/cordis-primer.md#loader-configuration)). Never commit credentials. CI e2e skips without a key; [testing.md](docs/testing.md) owns key policy. @@ -150,6 +153,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. - **Create Agent Notes only for durable decision rationale;** mechanical/local edits are exempt, including local UI changes ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). +- **Official plugin artwork:** use [dsh-official-plugin-artwork](.agents/skills/dsh-official-plugin-artwork/SKILL.md) for official and official experimental plugin icons; obtain designer approval before landing new glyphs. - **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless recorded-session snapshot; [snapshot ownership](snapshots/AGENTS.md) reserves the top-level tree for session-driven cases and keeps other expected output owner-local. Fixtures replay on macOS/Linux; fix fixtures, not normalizers. - **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)). @@ -162,7 +166,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and ## Defensive patterns -Read [docs/defensive-patterns.md](docs/defensive-patterns.md) before lifecycle, concurrency, subprocess, or teardown work. +Read [docs/defensive-patterns/README.md](docs/defensive-patterns/README.md) before changing a package surface, remote service, client component or state, conversation or Session behavior, or lifecycle, concurrency, subprocess, and teardown code. Its [review principles](docs/defensive-patterns/README.md#why-these-rules) apply per artifact. ## Type safety and documentation diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2dbbb5cd40..d20c5c2acb 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,7 +5,7 @@ DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. -This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. +This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Run `pnpm run gen-third-party-notices` after changing one of its inputs, or `pnpm run verify-third-party-notices` for the standalone check. The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python SDK closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock). @@ -141,11 +141,12 @@ External packages installed for runtime use or distributed inside the prebuilt b pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: -- `@earendil-works/pi-ai@1.0.2` — [`patches/@earendil-works__pi-ai@1.0.2.patch`](patches/@earendil-works__pi-ai@1.0.2.patch) +- `@earendil-works/pi-ai@1.1.0` — [`patches/@earendil-works__pi-ai@1.1.0.patch`](patches/@earendil-works__pi-ai@1.1.0.patch) - `@electron/osx-sign@1.3.3` — [`patches/@electron__osx-sign@1.3.3.patch`](patches/@electron__osx-sign@1.3.3.patch) - `@fortune-sheet/core@1.0.4` — [`patches/@fortune-sheet__core@1.0.4.patch`](patches/@fortune-sheet__core@1.0.4.patch) - `@fortune-sheet/react@1.0.4` — [`patches/@fortune-sheet__react@1.0.4.patch`](patches/@fortune-sheet__react@1.0.4.patch) - `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch) +- `app-builder-lib@26.17.0` — [`patches/app-builder-lib@26.17.0.patch`](patches/app-builder-lib@26.17.0.patch) - `exceljs@4.4.0` — [`patches/exceljs@4.4.0.patch`](patches/exceljs@4.4.0.patch) - `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch) @@ -269,6 +270,7 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm | Package | License | Role | | --- | --- | --- | | [`hatchling`](https://github.com/pypa/hatch) | MIT | build backend | +| [`pillow`](https://github.com/python-pillow/Pillow) | MIT-CMU | Python project dependency | | [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness-sdk` | | [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only | | [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool | diff --git a/apps/cli/package.json b/apps/cli/package.json index e7291abcc7..b841d617f0 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 69b8cfee07..7cc7f2a17a 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -21,8 +21,8 @@ en: 8d2cfff9bab7327f zh: 21515ab6e6798504 /dsh-cli-behavior-reference/web-profile: - en: ee6c00e55a2f0a10 - zh: 404942e397e213ab + en: d768b6213e75a890 + zh: 731791b496ce14ef /dsh-cli-behavior-reference/shared-deployment-behavior: en: 6a7abc9339e8f37f zh: 133adb834c8e195d diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 3123b7898e..8195b3e5c5 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -126,11 +126,11 @@ The production Web runner needs built package and frontend artifacts (`pnpm run Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed. -The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its sandbox-policy root but intentionally omits filesystem tools, instruction discovery, and SQLite. An enabled HMR plugin watches both `cordis.patch.yml` layers (profile and home) and reapplies valid edits transactionally; without HMR, the process applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers. +The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its sandbox-policy root and includes `read_image`, but omits text filesystem tools, instruction discovery, and SQLite. An enabled HMR plugin watches both `cordis.patch.yml` layers (profile and home) and reapplies valid edits transactionally; without HMR, the process applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers. New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service. -`DSH_TOOLS_MODE` selects `native` or `ptc` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only the platform-selected persistent shell. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place. +`DSH_TOOLS_MODE` selects `native` or `ptc` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and provides the platform-selected persistent shell, `read_image`, and `working_directory`. It supports automatic context compaction and the human `/compact` command. Select 极简模式 when creating a Web session; the shared browser, workspace, persistence, sandbox, and permission host remains in place. ## Shared deployment behavior diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 9a43ca2858..e1dbf1ffe8 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -128,11 +128,11 @@ dsh web --help 进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。 -基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为沙箱策略根目录,但刻意省略文件系统工具、指令发现与 SQLite。启用的 HMR 插件会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;未启用 HMR 时只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose 所有实时监视器。 +基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为沙箱策略根目录并包含 `read_image`,但省略文本文件系统工具、指令发现与 SQLite。启用的 HMR 插件会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;未启用 HMR 时只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose 所有实时监视器。 基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。 -`DSH_TOOLS_MODE` 为进程选择 `native` 或 `ptc`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合按平台选择的持久 shell。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 +`DSH_TOOLS_MODE` 为进程选择 `native` 或 `ptc`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并提供按平台选择的持久 shell、`read_image` 和 `working_directory`。它支持自动上下文压缩和用户 `/compact` 命令。创建 Web 会话时请选择极简模式;共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 ## 共享部署行为 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 4ad8abce2d..0b92215c86 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1249,9 +1249,14 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ['llm-deepseek', '@deepseek-ai/dsh-llm-deepseek-api-key'], ['sandbox', '@deepseek-ai/dsh-sandbox-local'], ['filesystem', '@deepseek-ai/dsh-fs-local'], + ['attachment-local', '@deepseek-ai/dsh-attachment-local'], + ['tool-fs', '@deepseek-ai/dsh-tool-fs'], ['working-directory', '@deepseek-ai/dsh-working-directory'], ['tool-working-directory', '@deepseek-ai/dsh-tool-working-directory'], ['session-projection', '@deepseek-ai/dsh-session-projection'], + ['token-meter', '@deepseek-ai/dsh-token-meter'], + ['compaction-basic', '@deepseek-ai/dsh-compaction-basic'], + ['image-offload', '@deepseek-ai/dsh-compaction-image-offload'], ['sandbox-policy', '@deepseek-ai/dsh-sandbox-policy'], ['subprocess', '@deepseek-ai/dsh-subprocess-local'], ['pty', '@deepseek-ai/dsh-terminal'], diff --git a/apps/cli/tests/optional-bundle-transitions.e2e.ts b/apps/cli/tests/optional-bundle-transitions.e2e.ts index 623958ea9c..f084cb29cb 100644 --- a/apps/cli/tests/optional-bundle-transitions.e2e.ts +++ b/apps/cli/tests/optional-bundle-transitions.e2e.ts @@ -171,7 +171,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option const minimal = await agent('minimal') const standard = await agent() expect(names()).toEqual(['working_directory']) - expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'working_directory']) + expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'read_image', 'working_directory']) for (const name of ['str_replace_editor', 'session_search', 'terminal_open', 'ralph']) expect(names(standard.agent)).not.toContain(name) expect((await ctx.skills.list({ cwd: workspace, scope: standard.agent })).some(skill => skill.name === 'dsh-badge')).toBe(false) }) @@ -245,7 +245,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option expect(toolResult(owner.agent, 'badge-on')).toContain('dsh-badge.png') const minimal = await agent('minimal') expect((await ctx.skills.list({ cwd: workspace, scope: minimal.agent })).map(skill => skill.name)).toContain('dsh-badge') - expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'working_directory']) + expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'read_image', 'working_directory']) await select(bundles.badge, false) expect((await load('badge-after')).isError).toBe(true) }) diff --git a/apps/cli/tests/profiles/headless/tests/credentials.e2e.ts b/apps/cli/tests/profiles/headless/tests/credentials.e2e.ts new file mode 100644 index 0000000000..f1323add9e --- /dev/null +++ b/apps/cli/tests/profiles/headless/tests/credentials.e2e.ts @@ -0,0 +1,203 @@ +/** Credential selection through the shipped headless profile and real HTTP adapters. */ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { once } from 'node:events' +import type { AddressInfo } from 'node:net' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' +import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { describe, expect, it } from 'vitest' + +const binScript = fileURLToPath(new URL('../../../../../../apps/cli/src/bin.ts', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url)) +const savedKey = 'headless-saved-key' +const launchKey = 'headless-launch-key' +const projectKey = 'headless-project-key' +const homeKey = 'headless-home-key' +const processTimeoutMs = 60_000 + +/** Anthropic SDK events require the SSE event name beside the JSON payload. */ +async function startAnthropicServer() { + const keys: Array = [] + const server = createServer((request, response) => { + keys.push(request.headers['x-api-key']) + request.resume() + response.writeHead(200, { 'content-type': 'text/event-stream' }) + for (const event of [ + { type: 'message_start', message: { id: 'auth-test', type: 'message', role: 'assistant', model: 'headless-auth-model', content: [], usage: { input_tokens: 1, output_tokens: 0 } } }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'mock response recovered' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } }, + { type: 'message_stop' }, + ]) response.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`) + response.end() + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + return { + keys, + baseURL: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + async close() { + server.closeAllConnections() + await new Promise((resolve, reject) => server.close((error) => { + if (error === undefined) resolve() + else reject(error) + })) + }, + } +} + +const credentials = JSON.stringify({ + version: 1, + refs: { DEEPSEEK_API_KEY: savedKey, ANTHROPIC_API_KEY: savedKey }, + records: { + 'llm-pi-ai/anthropic': { kind: 'api-key', key: savedKey }, + 'llm-pi-ai/openai-codex': { + kind: 'grant', payload: { type: 'oauth', access: 'headless-saved-access', refresh: 'headless-saved-refresh', expires: 1 }, + }, + }, +}) + '\n' + +describe('headless credential policy', { retry: 0 }, () => { + it.each([ + { name: 'launch environment', launch: launchKey, project: projectKey, home: homeKey, expected: launchKey }, + { name: 'project .env', launch: undefined, project: projectKey, home: homeKey, expected: projectKey }, + { name: 'home .env', launch: undefined, project: undefined, home: homeKey, expected: homeKey }, + ])('uses $name instead of saved credentials', async (scenario) => { + const server = await startMockLlmServer({ sequence: ['success'], repeatLast: true, apiKey: scenario.expected }) + try { + const result = await runLoaderSmoke({ + label: `headless credentials: ${scenario.name}`, + tempDirPrefix: 'headless-credentials-', binScript, tsconfigPath, sourceImport: 'tsx/esm', + configPath: '', binArgs: ['headless', 'Reply briefly.'], processTimeoutMs, + env: { + DEEPSEEK_API_KEY: scenario.launch, DEEPSEEK_BASE_URL: server.baseURL, DSH_TELEMETRY_DISABLED: '1', + }, + prepare: async (cwd) => { + await mkdir(join(cwd, '.dsh'), { recursive: true }) + await writeFile(join(cwd, '.dsh', '.credentials.yaml'), credentials, { mode: 0o600 }) + if (scenario.project !== undefined) await writeFile(join(cwd, '.env'), `DEEPSEEK_API_KEY=${scenario.project}\n`) + if (scenario.home !== undefined) await writeFile(join(cwd, '.dsh', '.env'), `DEEPSEEK_API_KEY=${scenario.home}\n`) + }, + inspect: async (cwd) => { + expect(await readFile(join(cwd, '.dsh', '.credentials.yaml'), 'utf8')).toBe(credentials) + }, + }) + expect(result.stdout.trim()).toBe('mock response recovered') + expect(result.stderr).toBe('') + expect(server.requests.length).toBeGreaterThan(0) + expect(server.requests.every(request => request.headers['x-api-key'] === scenario.expected)).toBe(true) + } finally { + await server.close() + } + }, processTimeoutMs + 15_000) + + it.each(['named reference', 'native discovery'] as const)('uses launch credentials for pi-ai %s', async (mode) => { + const server = await startAnthropicServer() + try { + const result = await runLoaderSmoke({ + label: `headless pi-ai credentials: ${mode}`, + tempDirPrefix: 'headless-pi-credentials-', binScript, tsconfigPath, sourceImport: 'tsx/esm', + configPath: '', binArgs: ['headless', '--patch', './auth.patch.json', 'Reply briefly.'], processTimeoutMs, + env: { + ANTHROPIC_API_KEY: launchKey, ANTHROPIC_AUTH_TOKEN: undefined, ANTHROPIC_OAUTH_TOKEN: undefined, + ANTHROPIC_FEDERATION_RULE_ID: undefined, ANTHROPIC_ORGANIZATION_ID: undefined, + ANTHROPIC_IDENTITY_TOKEN_FILE: undefined, ANTHROPIC_SERVICE_ACCOUNT_ID: undefined, ANTHROPIC_WORKSPACE_ID: undefined, + DEEPSEEK_API_KEY: undefined, DSH_TELEMETRY_DISABLED: '1', + }, + prepare: async (cwd) => { + await mkdir(join(cwd, '.dsh'), { recursive: true }) + await writeFile(join(cwd, '.dsh', '.credentials.yaml'), credentials, { mode: 0o600 }) + await writeFile(join(cwd, 'auth.patch.json'), JSON.stringify([ + { id: 'agent-default-model', config: { provider: 'anthropic', model: 'headless-auth-model' } }, + { id: 'llm-pi-ai', config: { providers: { anthropic: { + ...mode === 'named reference' ? { apiKeyEnv: 'ANTHROPIC_API_KEY' } : {}, + baseURL: server.baseURL, + models: [{ id: 'headless-auth-model', contextWindow: 32_768, maxTokens: 1024 }], + } } } }, + ])) + }, + inspect: async (cwd) => { + expect(await readFile(join(cwd, '.dsh', '.credentials.yaml'), 'utf8')).toBe(credentials) + }, + }) + expect(result.stdout.trim()).toBe('mock response recovered') + expect(result.stderr).toBe('') + expect(server.keys.length).toBeGreaterThan(0) + expect(server.keys.every(key => key === launchKey)).toBe(true) + } finally { + await server.close() + } + }, processTimeoutMs + 15_000) + + it.each(['no environment key', 'empty environment key', 'invalid stored document', 'restore stored credentials'] as const)('%s', async (mode) => { + const restoring = mode === 'restore stored credentials' + const empty = mode === 'empty environment key' + const missing = mode === 'no environment key' || empty + const document = mode === 'invalid stored document' ? 'invalid: [\n' : credentials + const server = await startMockLlmServer({ sequence: ['success'], repeatLast: true, apiKey: restoring ? savedKey : launchKey }) + try { + const result = await runLoaderSmoke({ + label: `headless credential boundary: ${mode}`, + tempDirPrefix: 'headless-credential-boundary-', binScript, tsconfigPath, sourceImport: 'tsx/esm', + configPath: '', + binArgs: ['headless', ...restoring ? ['--patch', './auth.patch.json'] : [], 'Reply briefly.'], + processTimeoutMs, expectedExitCode: missing ? 1 : 0, + env: { DEEPSEEK_API_KEY: empty ? '' : missing ? undefined : launchKey, DEEPSEEK_BASE_URL: server.baseURL, DSH_TELEMETRY_DISABLED: '1' }, + prepare: async (cwd) => { + await mkdir(join(cwd, '.dsh'), { recursive: true }) + await writeFile(join(cwd, '.dsh', '.credentials.yaml'), document, { mode: 0o600 }) + if (empty) await writeFile(join(cwd, '.env'), `DEEPSEEK_API_KEY=${projectKey}\n`) + if (restoring) await writeFile(join(cwd, 'auth.patch.json'), JSON.stringify( + ['credentials', 'authorization', 'deepseek-account'].map(id => ({ id, disabled: false })), + )) + }, + inspect: async (cwd) => { + expect(await readFile(join(cwd, '.dsh', '.credentials.yaml'), 'utf8')).toBe(document) + }, + }) + if (missing) { + expect(result.stdout).toBe('\n') + expect(result.stderr).toContain('MISSING_CREDENTIAL') + expect(server.requests).toHaveLength(0) + } else { + expect(result.stdout.trim()).toBe('mock response recovered') + expect(result.stderr).toBe('') + expect(server.requests.length).toBeGreaterThan(0) + } + } finally { + await server.close() + } + }, processTimeoutMs + 15_000) + + it.each([ + { provider: 'deepseek-account', model: 'deepseek-v4-flash', code: 'ACCOUNT_SIGN_IN_REQUIRED' }, + { provider: 'openai-codex', model: 'gpt-5.4', code: 'PI_AI_ERROR: Provider is not configured: openai-codex' }, + ])('fails without saved sign-in for $provider', async ({ provider, model, code }) => { + const result = await runLoaderSmoke({ + label: `headless saved sign-in: ${provider}`, + tempDirPrefix: 'headless-saved-sign-in-', binScript, tsconfigPath, sourceImport: 'tsx/esm', + configPath: '', binArgs: ['headless', '--patch', './auth.patch.json', 'Reply briefly.'], + processTimeoutMs, expectedExitCode: 1, + env: { DEEPSEEK_API_KEY: undefined, OPENAI_API_KEY: undefined, DSH_TELEMETRY_DISABLED: '1' }, + prepare: async (cwd) => { + await mkdir(join(cwd, '.dsh'), { recursive: true }) + await writeFile(join(cwd, '.dsh', '.credentials.yaml'), credentials, { mode: 0o600 }) + await writeFile(join(cwd, 'auth.patch.json'), JSON.stringify([ + { id: 'agent-default-model', config: { provider, model } }, + ...provider === 'openai-codex' ? [{ id: 'llm-pi-ai', config: { providers: { + [provider]: { models: [{ id: model, contextWindow: 32_768, maxTokens: 1024 }] }, + } } }] : [], + ])) + }, + inspect: async (cwd) => { + expect(await readFile(join(cwd, '.dsh', '.credentials.yaml'), 'utf8')).toBe(credentials) + }, + }) + expect(result.stdout).toBe('\n') + expect(result.stderr).toContain(code) + expect(result.stderr).not.toContain('pending') + }, processTimeoutMs + 15_000) +}) diff --git a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl index 904ee65726..bfcfc3ca75 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl @@ -15,6 +15,8 @@ {"type":"session/title-llm-request","data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.\nIf the messages give little to name, still return a short best-effort title, such as Greeting, instead of explaining.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"Prove the product headless profile path with one real tool round trip.\"}]"}],"source":{"kind":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":4096,"reasoningEffort":"off"}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Inspecting the task before the tool call."},{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":0,"index":0,"dt":[],"texts":["Inspecting the task before the tool call."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Inspecting the task before the tool call."}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":1,"dt":[],"id":"cli-smoke-call","name":"bash","args":["{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}} +{"type":"job/registered","data":{"jobId":"{{id:1}}","owner":"{{sessionId}}","kind":"bash","label":"printf CLI_TOOL_ROUND_TRIP","startedAt":0,"toolCall":{"callId":"cli-smoke-call","rootCallId":"cli-smoke-call"}}} +{"type":"job/settled","data":{"jobId":"{{id:1}}","owner":"{{sessionId}}","kind":"bash","label":"printf CLI_TOOL_ROUND_TRIP","startedAt":0,"toolCall":{"callId":"cli-smoke-call","rootCallId":"cli-smoke-call"},"finishedAt":0,"status":"completed","cause":"producer","producerReported":true,"detail":"exit code: 0"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cli-smoke-call"},"toolCallId":"cli-smoke-call","content":[{"type":"text","text":"CLI_TOOL_ROUND_TRIP"}],"isError":false,"id":"{{sessionId}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl index 00868634d4..b0372d96c6 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl @@ -8,7 +8,7 @@ {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your parent agent id is \"{{sessionId}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{sessionId}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Use the write tool exactly","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl index 1b82a99cf0..c07532bd7d 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl @@ -8,7 +8,7 @@ {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your parent agent id is \"{{sessionId}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{sessionId}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Reply with exactly CHILD_RESULT and","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs index 119380327b..9c1bedd00e 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs +++ b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs @@ -183,17 +183,18 @@ class TeamFixtureAdapter extends LlmAdapter { throw new Error('Team profile exposes a direct subagent tool') } const initial = options.messages.findLast(message => message.role === 'user' && message.source.kind === 'user') - const identity = initial?.content[0]?.text?.trimEnd() - if (identity !== 'TEAM_WORKFLOW_CHILD' && (!tools.includes('spawn_teammate') || !tools.includes('workflow'))) { + const workflowChild = initial?.content[0]?.text === 'TEAM_WORKFLOW_CHILD' + const context = options.messages.findLast(message => message.source?.kind === 'runtime-context') + const identity = context?.source.sections?.find(section => section.name === 'team:identity')?.text + if (!workflowChild && (!tools.includes('spawn_teammate') || !tools.includes('workflow'))) { throw new Error('Team profile is missing teammate or workflow tools') } - const chunks = identity === 'TEAM_WORKFLOW_CHILD' - ? textChunks('Fresh workflow child complete.') - : identity === '\nYou are teammate "researcher".\nYour Team Lead is named "lead".\nUse list_agents({}) to find your teammates and their names.\nTo message your Team Lead, use send_message({ target: "lead", message: "..." }).\nTo message another teammate, use send_message({ target: "", message: "..." }).\n' - ? researcher(options.messages) - : identity === '\nYou are teammate "implementer".\nYour Team Lead is named "lead".\nUse list_agents({}) to find your teammates and their names.\nTo message your Team Lead, use send_message({ target: "lead", message: "..." }).\nTo message another teammate, use send_message({ target: "", message: "..." }).\n' - ? implementer(options.messages) - : lead(options.messages) + let chunks + if (workflowChild) chunks = textChunks('Fresh workflow child complete.') + else if (identity?.startsWith('You are teammate "researcher".')) chunks = researcher(options.messages) + else if (identity?.startsWith('You are teammate "implementer".')) chunks = implementer(options.messages) + else if (identity?.startsWith('You are the Team Lead named "lead".')) chunks = lead(options.messages) + else throw new Error('Team runtime context is missing a recognized identity') for (const chunk of chunks) { options.signal?.throwIfAborted() yield chunk diff --git a/apps/cli/tests/profiles/headless/tests/harness.ts b/apps/cli/tests/profiles/headless/tests/harness.ts index cf51dc23a6..43164c9d7b 100644 --- a/apps/cli/tests/profiles/headless/tests/harness.ts +++ b/apps/cli/tests/profiles/headless/tests/harness.ts @@ -11,7 +11,7 @@ import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import TokenMeter from '@deepseek-ai/dsh-token-meter' import ToolResultPruner from '@deepseek-ai/dsh-compaction-tool-result-pruner' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as SessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy' import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic' import type { BasicCompactionConfig } from '@deepseek-ai/dsh-compaction-basic' diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index 6406d63753..2e45acbb6c 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -985,22 +985,26 @@ describe('headless stream-json snapshots', () => { if (data.name !== 'team_task_update' || typeof data.arguments !== 'string') return false return (JSON.parse(data.arguments) as JsonObject).action === 'complete' }) - const identityReminders = logs.flatMap((log) => { + const identities = logs.flatMap((log) => { if (log === parent || log === workflowChild) return [] const initial = parseJsonl(log.content).find(row => row.type === 'user/message' && ((row.data as JsonObject).source as JsonObject).kind === 'user') if (initial === undefined) throw new Error('Teammate Session has no initial task') const content = (initial.data as JsonObject).content as JsonObject[] - expect(content).toHaveLength(2) - const identity = content[0]!.text - if (typeof identity !== 'string') throw new Error('Teammate initial task has no identity text') - return [identity.trimEnd()] + expect(content).toHaveLength(1) + const context = parseJsonl(log.content).find(row => row.type === 'user/message' + && ((row.data as JsonObject).source as JsonObject).kind === 'runtime-context') + const source = (context?.data as JsonObject)?.source as JsonObject + const sections = source?.sections as JsonObject[] | undefined + const identity = sections?.find(section => section.name === 'team:identity')?.text + if (typeof identity !== 'string') throw new Error('Teammate runtime context has no identity') + return [identity] }).sort() projection = { sessions: logs.length, workflowStopReason: (rows.find(row => row.type === 'tool-workflow/run-end')?.data as JsonObject)?.stopReason, memberEdges: members.length, - identityReminders, + identities, activeMembers: members.filter(member => member.phase === 'active').map(member => member.name).sort(), tasks: latestTasks.map(task => ({ subject: task.subject, @@ -1035,21 +1039,17 @@ describe('headless stream-json snapshots', () => { ], "checkedRoster": true, "deliveredMessages": 0, - "identityReminders": [ - " - You are teammate "implementer". + "identities": [ + "You are teammate "implementer". Your Team Lead is named "lead". - Use list_agents({}) to find your teammates and their names. To message your Team Lead, use send_message({ target: "lead", message: "..." }). - To message another teammate, use send_message({ target: "", message: "..." }). - ", - " - You are teammate "researcher". + Use list_agents({}) to find your teammates and their names. + To message another teammate, use send_message({ target: "", message: "..." }).", + "You are teammate "researcher". Your Team Lead is named "lead". - Use list_agents({}) to find your teammates and their names. To message your Team Lead, use send_message({ target: "lead", message: "..." }). - To message another teammate, use send_message({ target: "", message: "..." }). - ", + Use list_agents({}) to find your teammates and their names. + To message another teammate, use send_message({ target: "", message: "..." }).", ], "memberEdges": 4, "queuedMessages": 0, diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index 806f081a77..cce567d62e 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -212,7 +212,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => { }); return started.jobId; `)) - expect(jobId).toBe('bash-1') + expect(jobId).toMatch(/^bash-[0-9a-f]{16}$/) const polled = completion(await runCode(ctx, ` return await tools.job_output({ job_id: ${JSON.stringify(jobId)}, wait: true, timeout_ms: 5000 }); @@ -257,7 +257,8 @@ describe('PTC mode typed values: keyless real-process contracts', () => { }), ]) const job = ctx.jobs.list()[0] - expect(job).toMatchObject({ id: 'bash-1', status: 'running' }) + expect(job).toMatchObject({ status: 'running' }) + expect(job?.id).toMatch(/^bash-[0-9a-f]{16}$/) afterPublication.abort('outer-call-cancelled') expect((await running).isError).toBe(true) expect(ctx.jobs.list()[0]).toMatchObject({ id: job!.id, status: 'running' }) @@ -293,9 +294,9 @@ describe('PTC mode typed values: keyless real-process contracts', () => { await ctx.plugin(CordisInspectProviders) await ctx.plugin(ToolCordis) const agent = { + ctx, id: SessionId('ptc-cordis'), session: ctx.sessions.create(SessionId('ptc-cordis'), { meta: { cwd: process.cwd() } }), - ctx, } as Agent const value = completion(await runCode(ctx, ` diff --git a/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts index e2815248f0..6ef2b9b331 100644 --- a/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts @@ -11,7 +11,7 @@ import { import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createUserMessage, ToolCallId, createMessage } from '@deepseek-ai/dsh-llm' import { SessionSeq, SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { logPath } from '../../../../../../packages/session/session-persistence-jsonl/src/format.ts' import { describe, expect, it } from 'vitest' diff --git a/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts index 1574c2cab1..e5a66ed914 100644 --- a/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts @@ -17,7 +17,7 @@ import { type SessionEvent, type SessionHeader, } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { generationLogFilename, generationLogPath, diff --git a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts index cec8642309..941d95c31d 100644 --- a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts @@ -16,7 +16,7 @@ import { import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionSeq, SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { describe, expect, it } from 'vitest' const fixtureDir = fileURLToPath(new URL('./expected/subagent-diagnostic', import.meta.url)) diff --git a/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts index 52df6db0d9..adaf678278 100644 --- a/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts @@ -16,7 +16,7 @@ import { import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createMessage, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { SessionSeq, SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { describe, expect, it } from 'vitest' const fixtureDir = fileURLToPath(new URL('./expected/subagent-inheritance', import.meta.url)) diff --git a/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts index aa6da3ad23..6dcc842cf9 100644 --- a/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts @@ -23,7 +23,7 @@ import { type SessionEvent, type SessionHeader, } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { logPath } from '../../../../../../packages/session/session-persistence-jsonl/src/format.ts' import { renderAgentInstructions } from '@deepseek-ai/dsh-agent-instructions' import { resolveConfig, workspaceBaselineIdentity } from '@deepseek-ai/dsh-agent-instructions/src/config.ts' diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 64c1c20ab3..5c51bd36ae 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -202,10 +202,14 @@ describe('Python SDK dsh profile keyless smoke', () => { }, 40_000) it.each([ - { label: 'boots the standalone minimal profile through its generated manifest', editorEnabled: false }, - { label: 'executes the documented editor opt-in patch with sdk-minimal', editorEnabled: true }, - ])('$label', async ({ editorEnabled }) => { + { label: 'boots the standalone minimal profile through its generated manifest', editorEnabled: false, imageEnabled: false, optOut: false, offloadEnabled: false }, + { label: 'executes the documented editor opt-in patch with sdk-minimal', editorEnabled: true, imageEnabled: false, optOut: false, offloadEnabled: false }, + { label: 'sends a read_image result to the model with sdk-minimal', editorEnabled: false, imageEnabled: true, optOut: false, offloadEnabled: false }, + { label: 'offloads an over-budget image and continues with sdk-minimal', editorEnabled: false, imageEnabled: true, optOut: false, offloadEnabled: true }, + { label: 'retains the previous tools through the documented opt-out patch', editorEnabled: false, imageEnabled: false, optOut: true, offloadEnabled: false }, + ])('$label', async ({ editorEnabled, imageEnabled, optOut, offloadEnabled }) => { const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-minimal-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) const editorPatch = join(root, 'editor.patch.yml') if (editorEnabled) { const guide = await readFile(join(repoRoot, 'docs/user/guide/python-sdk.md'), 'utf8') @@ -214,7 +218,24 @@ describe('Python SDK dsh profile keyless smoke', () => { expect(yaml).toBeDefined() await writeFile(editorPatch, yaml!) } + if (optOut) { + const guide = await readFile(join(repoRoot, 'docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.md'), 'utf8') + const yaml = guide.match(/```yaml\n([\s\S]*?)```/)?.[1] + expect(yaml).toBeDefined() + await writeFile(editorPatch, yaml!) + } + if (offloadEnabled) await writeFile(editorPatch, [ + '- id: llm-deepseek', + ' config:', + ' apiKeyEnv: DEEPSEEK_API_KEY', + ' maxRequestFilesBytes: 1', + ' imageOffloadByteQuantum: 1', + '', + ].join('\n')) const editorFile = join(root, 'editor.txt') + if (imageEnabled) await writeFile(join(root, 'red.png'), Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64', + )) const editorContent = 'sdk-minimal editor opt-in\n' const editorCalls = editorEnabled ? [ { command: 'create', path: editorFile, file_text: editorContent }, @@ -222,17 +243,29 @@ describe('Python SDK dsh profile keyless smoke', () => { ] : [] const modelRequests: Record[] = [] const modelServer = createServer((request, response) => { + if (imageEnabled && request.url?.endsWith('/files')) { + request.resume() + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ + id: 'file-minimal-image', type: 'file', size_bytes: 69, + created_at: new Date().toISOString(), + filename: 'red.png', mime_type: 'image/png', + })) + return + } let body = '' request.setEncoding('utf8') request.on('data', (chunk: string) => { body += chunk }) request.on('end', () => { modelRequests.push(JSON.parse(body) as Record) response.writeHead(200, { 'content-type': 'text/event-stream' }) - const toolCall = editorCalls[modelRequests.length - 1] + const toolCall = imageEnabled && modelRequests.length === 1 + ? { file_path: 'red.png' } + : editorCalls[modelRequests.length - 1] response.end(messagesResponse(toolCall ? { type: 'tool_use', - id: `editor-${toolCall.command}`, - name: 'str_replace_editor', + id: imageEnabled ? 'minimal-image-read' : `editor-${editorCalls[modelRequests.length - 1]?.command}`, + name: imageEnabled ? 'read_image' : 'str_replace_editor', input: toolCall, } : { type: 'text', text: 'done' }, toolCall ? 'tool_use' : 'end_turn')) }) @@ -244,7 +277,7 @@ describe('Python SDK dsh profile keyless smoke', () => { ...launch.args, '--profile', 'sdk-minimal', - ...(editorEnabled ? ['--patch', editorPatch] : []), + ...(editorEnabled || optOut || offloadEnabled ? ['--patch', editorPatch] : []), ], { cwd: repoRoot, env: { @@ -274,7 +307,7 @@ describe('Python SDK dsh profile keyless smoke', () => { jsonrpc: '2.0', id: 1, method: 'initialize', - params: { cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-pro' }, + params: { cwd: root, provider: 'deepseek-official', model: imageEnabled ? 'deepseek-flash' : 'deepseek-v4-pro' }, })}\n`) await waitForLine(lines, value => value.id === 1, () => stderr) child.stdin.write(`${JSON.stringify({ @@ -283,9 +316,11 @@ describe('Python SDK dsh profile keyless smoke', () => { method: 'session/prompt', params: { sessionId: 'minimal', contentBlocks: [{ type: 'text', text: 'inspect tools' }] }, })}\n`) + const offloads: Record[] = [] const turnEnd = await waitForLine(lines, (value) => { const params = value.params as Record | undefined const event = params?.event as Record | undefined + if (params?.sessionId === 'minimal' && event?.type === 'image/offload') offloads.push(event) return params?.sessionId === 'minimal' && event?.type === 'turn/end' }, () => stderr) expect(turnEnd, `${JSON.stringify(turnEnd)}\n${stderr}`).toMatchObject({ @@ -302,10 +337,31 @@ describe('Python SDK dsh profile keyless smoke', () => { const tools = modelRequests[0]?.tools as { name?: string }[] expect(tools.map(tool => tool.name)).toEqual([ process.platform === 'win32' ? 'pwsh' : 'bash', + ...(optOut ? [] : ['read_image']), ...(editorEnabled ? ['str_replace_editor'] : []), 'working_directory', ]) - expect(modelRequests).toHaveLength(editorEnabled ? 3 : 1) + expect(modelRequests).toHaveLength(editorEnabled ? 3 : imageEnabled ? 2 : 1) + if (imageEnabled) { + const messages = modelRequests[1]?.messages as { + content: { type: string; tool_use_id?: string; content?: { type: string; source?: { type: string; file_id: string } }[] }[] + }[] + const imageResult = messages.flatMap(message => message.content) + .find(block => block.type === 'tool_result' && block.tool_use_id === 'minimal-image-read') + if (offloadEnabled) { + expect(offloads).toHaveLength(1) + expect(offloads[0]).toMatchObject({ data: { targets: [{ imageIndexes: [0] }] } }) + expect(imageResult?.content?.some(block => block.type === 'image')).toBe(false) + expect(JSON.stringify(imageResult?.content)).toContain('red.png') + } else { + expect(offloads).toEqual([]) + expect(imageResult?.content).toEqual(expect.arrayContaining([ + { type: 'image', source: { type: 'file', file_id: 'file-minimal-image' } }, + ])) + } + const stored = await readdir(join(root, '.dsh', 'attachments', 'v1', 'objects'), { recursive: true }) + expect(stored.some(file => /[a-f0-9]{64}$/u.test(file))).toBe(true) + } if (editorEnabled) { expect(await readFile(editorFile, 'utf8')).toBe(editorContent) expect(modelRequests[2]?.messages).toEqual(expect.arrayContaining([ @@ -334,7 +390,6 @@ describe('Python SDK dsh profile keyless smoke', () => { child.kill('SIGKILL') await child await new Promise(resolve => modelServer.close(() => { resolve() })) - await rm(root, { recursive: true, force: true }) } }, 40_000) @@ -494,8 +549,11 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli }, () => stderr) const names = (requests[0]!.tools as { name: string }[]).map(value => value.name) expect(names.includes('load_workspace_dependencies')).toBe(enabled) + expect(JSON.stringify(requests[0]!.messages).includes('`office`')).toBe(enabled && mode !== 'missing-assets') + expect(names).not.toContain('office_tools') + expect(names).not.toContain('render_document') for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { - expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(enabled && mode !== 'missing-assets' && mode !== 'python-only') + expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(enabled && mode !== 'missing-assets') } if (mode === 'wrong-type') { expect(requests).toHaveLength(2) @@ -524,8 +582,8 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli expect.objectContaining({ role: 'user', content }), ])) } - if (mode === 'python-only') expect(stderr).toContain('node') - if (mode === 'missing-assets') expect(stderr).toContain('check_office.py') + if (mode === 'python-only') expect(stderr).not.toContain('ENOENT') + if (mode === 'missing-assets') expect(stderr).toContain('SKILL.md') await send(3, 'shutdown') const exit = await child expect(exit.timedOut).toBe(false) diff --git a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts index d94a88a304..2c90e5fc6e 100644 --- a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts +++ b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts @@ -2,7 +2,7 @@ * Agent discovery of the installed Office CLI through the shipped SDK profile. * Defaults to built Node packages. DSH_OFFICE_TEST_EXECUTABLE/ENTRY select a * relocated Node or Electron carrier; CARRIER=sdk selects the standalone executable. - * NODE/CLI/ASSETS under the same prefix supply Desktop resources outside ASAR. + * NODE/CLI/ASSETS under the same prefix select Desktop command and skill resources outside ASAR. * API=openai-completions selects a development gateway instead of the default Messages API. */ import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile, chmod } from 'node:fs/promises' @@ -29,16 +29,13 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent 'word/document.xml': strToU8('Office CLI discovery'), })) const patch = join(root, 'office.patch.yml') + const node = process.env.DSH_OFFICE_TEST_NODE, cli = process.env.DSH_OFFICE_TEST_CLI + const assetRoot = process.env.DSH_OFFICE_TEST_ASSETS await writeFile(patch, JSON.stringify([ ...(process.env.DSH_OFFICE_TEST_API === 'openai-completions' ? [{ id: 'llm-deepseek', disabled: true }, { id: 'llm-pi-ai', config: { providers: { 'office-test': { api: 'openai-completions', apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: process.env.DEEPSEEK_BASE_URL, compat: { thinkingFormat: 'deepseek' }, models: [{ id: process.env.MODEL_NAME ?? 'deepseek-v4-flash', contextWindow: 128000, maxTokens: 8000 }] } } } }] : []), { id: 'session-persistence-jsonl', config: { root: join(root, 'sessions'), compression: 'none' } }, - ...carrier === 'sdk' ? [] : [{ insert: [{ - id: 'office-cli-discovery', name: '@deepseek-ai/dsh-skill-office', config: { - ...(process.env.DSH_OFFICE_TEST_NODE ? { node: process.env.DSH_OFFICE_TEST_NODE } : {}), - ...(process.env.DSH_OFFICE_TEST_CLI ? { cli: process.env.DSH_OFFICE_TEST_CLI } : {}), - ...(process.env.DSH_OFFICE_TEST_ASSETS ? { assetRoot: process.env.DSH_OFFICE_TEST_ASSETS } : {}), - }, - }] }], + { id: 'skill-office', disabled: false }, + { id: 'skill-office', config: { ...assetRoot === undefined ? {} : { assetRoot }, ...node === undefined ? {} : { node }, ...cli === undefined ? {} : { cli } } }, ])) // A private POSIX tool directory also excludes /usr/bin/node on Linux. const path = join(root, 'bin') @@ -54,7 +51,7 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent await writeFile(decoy, '#!/bin/sh\nprintf invoked > ' + "'" + decoyMarker.replaceAll("'", "'\\''") + "'" + '\nexit 86\n') await chmod(decoy, 0o755) } - const lookup = await execa('/bin/sh', ['-c', 'command -v node || command -v libreoffice-kit'], { env: { PATH: path }, reject: false }) + const lookup = await execa('/bin/sh', ['-c', 'command -v node || command -v dsoffice'], { env: { PATH: path }, reject: false }) expect(lookup.exitCode, 'the fixture must not expose Node or the Office CLI through PATH').not.toBe(0) const child = execa(executable, [ ...carrier === 'sdk' ? [] : [entry], '--profile', 'sdk', '--patch', patch, @@ -103,10 +100,12 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent const commands = calls.filter(call => call.name === 'bash') expect(commands.length, diagnostic).toBeGreaterThan(0) expect(calls.findIndex(call => call.name === 'skill'), diagnostic).toBeLessThan(calls.findIndex(call => call.name === 'bash')) - const commandText = JSON.stringify(commands) - for (const fragment of ['cli.js', 'capabilities', 'convert']) expect(commandText, diagnostic).toContain(fragment) + const commandText = commands.map((call) => { + if (typeof call.arguments !== 'string') throw new Error('Shell tool call did not contain serialized arguments') + return (JSON.parse(call.arguments) as { command: string }).command + }).join('\n') + for (const operation of ['capabilities', 'convert']) expect(commandText, diagnostic).toMatch(new RegExp(`\\s${operation}\\b`, 'u')) expect(existsSync(decoyMarker), 'the agent must not invoke system Office when the bundled CLI is supplied').toBe(false) - expect(commandText, 'the skill supplies an entry; searching for other Office binaries is unnecessary').not.toMatch(/(?:which|whereis|command -v|find).{0,100}(?:libreoffice|soffice)/iu) const capabilities = JSON.parse(await readFile(join(root, 'capabilities.json'), 'utf8')) as { runtime: { version: string; cliPath: string } } expect(capabilities.runtime.version).toBe('0.1.5') expect(capabilities.runtime.cliPath).toMatch(/lib[/\\]cli\.js$/u) diff --git a/apps/cli/tests/profiles/web/tests/office.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/office.expected.e2e.ts new file mode 100644 index 0000000000..9c47584c49 --- /dev/null +++ b/apps/cli/tests/profiles/web/tests/office.expected.e2e.ts @@ -0,0 +1,12 @@ +/** Ordinary Web leaves Office authoring composition to its carrier. */ +import { expect, it } from 'vitest' +import { withDefaultWeb } from './default-web-process.ts' + +it('does not mount Office skills, commands, or authoring dependencies in ordinary Web', async (test) => { + await withDefaultWeb(test, async ({ request }) => { + const roster = await request('roster') + for (const name of ['skill-office', 'office-commands', 'tool-workspace-dependencies']) { + expect(roster.plugins.some(plugin => plugin.name === name)).toBe(false) + } + }) +}) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index db0d8e26e3..4d1f9210a2 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -1,6 +1,6 @@ import { createRequire } from 'node:module' import { randomUUID } from 'node:crypto' -import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' @@ -25,7 +25,8 @@ import { createPluginProfile } from '../../desktop/src/project-manager.ts' const SETTINGS_NAMESPACE = 'agent-preset-registry' const SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE = 'subagent-model-selection-settings' import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent' -import { ToolCallId } from '@deepseek-ai/dsh-llm' +import { ToolCallId, LlmAdapter, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-compaction-basic' import type {} from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-tools' @@ -45,6 +46,48 @@ const CLAUDE_CODE_PACKAGE_DIR = join(REPO_ROOT, 'packages/subagent/subagent-clau /** The installation anchor whose dependency surface the runtime resolution mirrors. */ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') const MINIMAL_PROMPT = 'You are a helpful software engineer assistant.' +/** Deterministic model responses for the shipped minimal compaction path. */ +class MinimalCompactionAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model, context: { contextWindow: 100_000 } }) + } + + override async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'block-end', index: 0, block: { + type: 'text', text: options.purpose === 'compaction' ? 'Preserve the requested project task.' : 'Ready.', + } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} +/** Emits one image read, then retains the request carrying its settled result. */ +class MinimalImageAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model, context: { contextWindow: 1_000_000 }, + inputModalities: ['text', 'image'] }) + } + + override async * stream(options: GenerateOptions): AsyncIterable { + if (options.purpose !== undefined) { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Image inspection' } } + yield { type: 'finish', reason: { kind: 'stop' } } + return + } + this.requests.push(options) + const first = this.requests.length === 1 + yield { type: 'block-start', index: 0, blockType: first ? 'tool-call' : 'text' } + yield { type: 'block-end', index: 0, block: first + ? { type: 'tool-call', id: ToolCallId('minimal-image-read'), name: 'read_image', arguments: '{"file_path":"red.png"}' } + : { type: 'text', text: 'Image inspected.' } } + yield { type: 'finish', reason: { kind: first ? 'tool-calls' : 'stop' } } + } +} const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. * Network access depends on the task environment. Prefer configured mirrors/proxies when they are available. @@ -74,6 +117,7 @@ async function bootWeb( { id: 'storage-json', config: { root: storageRoot } }, // Fixed Session IDs must stay inside this boot's temporary profile root. { id: 'session-persistence-jsonl', config: { root: join(profileHome, 'sessions') } }, + { id: 'attachment-local', config: { dshHome: profileHome } }, // Host rows with side effects outside this process: a bound port, a served // asset tree, a telemetry exporter. `api-gateway` and `directory-picker` // stay ENABLED on purpose — the api-proxy is the host row that injects @@ -310,15 +354,112 @@ describe('the shipped Web composition', () => { expect(assembly.sections).toEqual([ { name: 'deployment:persona-prefix', text: MINIMAL_PROMPT }, ]) - expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'working_directory']) + expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'read_image', 'working_directory']) expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION) expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined() // serviceFor reports preset-owned providers; unisolated consumers inherit the host fs. expect(ctx.agentPresets.serviceFor(handle.agent, 'fs')).toBeUndefined() expect(ctx.get('fs')?.sandboxMode).toBeDefined() expect(handle.agent.ctx.get('fs')?.sandboxMode).toBe(ctx.get('fs')?.sandboxMode) - expect(ctx.agentPresets.serviceFor(handle.agent, 'compaction')).toBeUndefined() + expect(ctx.agentPresets.serviceFor(handle.agent, 'compaction')).toBeDefined() expect(handle.agent.ctx.get('compaction')).toBeUndefined() + expect(ctx.commands.find(handle.agent, 'compact')).toBeDefined() + expect(await ctx.commands.execute(handle.agent, '/compact', [], new AbortController().signal)) + .toMatchObject({ result: { kind: 'success', text: 'No compactable history yet.' } }) + expect(handle.agent.session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) + } finally { + await handle.dispose() + } + }) + + it('reads an image in minimal mode and carries its durable reference into the next request', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-minimal-image-')) + onTestFinished(() => rm(cwd, { recursive: true, force: true })) + await writeFile(join(cwd, 'red.png'), Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64')) + const adapter = new MinimalImageAdapter() + const unregister = ctx.llm.registerAdapter(['minimal-image-test'], adapter) + onTestFinished(unregister) + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-minimal-image'), meta: { cwd }, + agentOptions: { provider: 'minimal-image-test', model: 'minimal-image-test' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Inspect red.png.' }], source: { kind: 'user' } })) + await handle.agent.whenIdle() + expect(adapter.requests).toHaveLength(2) + const result = handle.agent.session.snapshotEvents().find(event => event.type === 'tool/result') + expect(result?.data.message.isError, JSON.stringify(result?.data.message.content)).toBe(false) + const image = handle.agent.session.deriveMessages().flatMap(message => message.content) + .find(block => block.type === 'image') + expect(image?.type).toBe('image') + if (image?.type !== 'image') throw new Error('minimal image read produced no durable image') + expect((await ctx.attachments.readImage(image.attachment)).data.byteLength).toBe(image.attachment.bytes) + expect(JSON.stringify(adapter.requests[1]?.messages)).toContain(image.attachment.attachmentId) + expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['bash', 'read_image', 'working_directory']) + expect(result?.data.meta).toMatchObject({ path: await realpath(join(cwd, 'red.png')) }) + } finally { + await handle.dispose() + } + }) + + it('compacts minimal history through /compact and continues from the checkpoint', async () => { + const adapter = new MinimalCompactionAdapter() + const unregister = ctx.llm.registerAdapter(['minimal-compaction-test'], adapter) + onTestFinished(unregister) + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-minimal-compaction'), + agentOptions: { provider: 'minimal-compaction-test', model: 'minimal-compaction-test' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + handle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'Remember the project requirements. '.repeat(200) }], source: { kind: 'user' }, + })) + await handle.agent.whenIdle() + const before = handle.agent.session.surface.replaceGeneration + const execution = await ctx.commands.execute(handle.agent, '/compact', [], new AbortController().signal) + expect(execution?.result.kind).toBe('success') + expect(handle.agent.session.surface.replaceGeneration).toBeGreaterThan(before) + const summary = handle.agent.session.snapshotEvents().find(event => event.type === 'compaction/summary') + expect(summary?.data.sourceCommandId).toBe(execution?.commandId) + expect(adapter.requests.filter(request => request.purpose === 'compaction')).toHaveLength(1) + handle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'Continue the project task.' }], source: { kind: 'user' }, + })) + await handle.agent.whenIdle() + const last = adapter.requests.at(-1) + expect(last?.messages.flatMap(message => message.content) + .some(block => block.type === 'text' && block.text.includes('Preserve the requested project task.'))).toBe(true) + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) + } finally { + await handle.dispose() + } + }) + + it('automatically compacts minimal history under context pressure', async () => { + const adapter = new MinimalCompactionAdapter() + const unregister = ctx.llm.registerAdapter(['minimal-pressure-test'], adapter) + onTestFinished(unregister) + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-minimal-pressure'), + agentOptions: { provider: 'minimal-pressure-test', model: 'minimal-pressure-test' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + for (let turn = 0; turn < 3; turn++) { + handle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'Remember the project requirements. '.repeat(6000) }], + source: { kind: 'user' }, + })) + await handle.agent.whenIdle() + } + const summaries = handle.agent.session.snapshotEvents().filter(event => event.type === 'compaction/summary') + expect(summaries.length).toBeGreaterThan(0) + expect(summaries.every(event => event.data.sourceCommandId === undefined)).toBe(true) + expect(handle.agent.session.snapshotEvents().some(event => event.type === 'compaction/start' + && event.data.turn !== null)).toBe(true) + expect(adapter.requests.some(request => request.purpose === 'compaction')).toBe(true) } finally { await handle.dispose() } @@ -334,8 +475,10 @@ describe('the shipped Web composition', () => { setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) try { - expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'working_directory']) + expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'read_image', 'working_directory']) expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) + expect(ctx.agentPresets.serviceFor(minimal.agent, 'compaction')) + .not.toBe(ctx.agentPresets.serviceFor(full.agent, 'compaction')) await minimal.dispose() @@ -500,7 +643,7 @@ describe('the shipped Web composition', () => { // stays the preset's choice — minimal mounts no `tool-skill`, so its // tool table has no loader even though the global layer is readable. expect((await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)).toContain('dsh-badge') - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'working_directory']) + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) } finally { await handle.dispose() } @@ -771,18 +914,19 @@ describe('a forked session', () => { }) describe('a delegated child', () => { - it('runs on the composition its parent runs on', async () => { + it.each([false, true])('inherits preset tools with seeded=%s and refuses child reminders', async (seeded) => { const parent = await ctx.agents.create({ - sessionId: SessionId('preset-child-parent'), + sessionId: SessionId(`preset-child-parent-${seeded}`), meta: { agentPreset: 'standard' }, setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), }) // Exactly what an in-process subagent driver's creation window does. const child = await parent.agent.ctx.agents.create({ - sessionId: SessionId('preset-child'), - meta: childSessionMeta(parent.agent, 1, false), + sessionId: SessionId(`preset-child-${seeded}`), + meta: childSessionMeta(parent.agent, 1, seeded), + ...(seeded ? { seed: [], inheritedEventCount: SessionLogOffset(0) } : {}), setup: (agentCtx) => { - applyChildComposition(agentCtx, parent.agent, {}) + return applyChildComposition(agentCtx, parent.agent, {}) }, }) try { @@ -790,6 +934,21 @@ describe('a delegated child', () => { // The shipped `standard` preset is the whole coding agent; an empty // child here is the defect, and equality alone would not catch it. expect(toolNames(ctx, child.agent)).toContain('bash') + expect(ctx.tools.schemas(child.agent)).toEqual(ctx.tools.schemas(parent.agent)) + const calls = [ + ['schedule_create', { prompt: 'Check', after_seconds: 60, title: 'Check' }], + ['schedule_list', {}], + ['schedule_delete', { id: 'task-1' }], + ['schedule_update', { id: 'task-1', title: 'Check' }], + ] as const + for (const [name, args] of calls) { + expect(toolNames(ctx, child.agent)).toContain(name) + const result = await ctx.tools.execute({ + callId: ToolCallId(`child-${name}`), signal: new AbortController().signal, + name, arguments: args, agent: child.agent, + }) + expect(result.value).toEqual({ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }) + } expect(child.agent.session.header.agentPreset).toBe('standard') } finally { await child.dispose() @@ -808,7 +967,7 @@ describe('a delegated child', () => { sessionId: SessionId('preset-child-switch'), meta: childSessionMeta(parent.agent, 1, false), setup: (agentCtx) => { - applyChildComposition(agentCtx, parent.agent, {}) + return applyChildComposition(agentCtx, parent.agent, {}) }, }) try { @@ -837,7 +996,7 @@ describe('the default preset as a user setting', () => { }) try { // An omitted preset id resolves the stored default instead of the composed default. - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'working_directory']) + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) } finally { await handle.dispose() } @@ -883,7 +1042,7 @@ describe('a profile patch stored before Developer tools owned preset selection', setup: agentCtx => legacy.agentPresets.mount(agentCtx).then(() => undefined), }) try { - expect(toolNames(legacy, handle.agent)).toEqual(['bash', 'working_directory']) + expect(toolNames(legacy, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) } finally { await handle.dispose() } diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index f2a75bfa51..c442c84f56 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-alpha.1", + "version": "0.2.1-alpha.2", "private": true, "license": "MIT", "type": "module", @@ -11,7 +11,7 @@ "lib/cli.js" ], "dependencies": { - "koffi": "^3.1.0", + "koffi": "3.1.1", "@deepseek-ai/libreoffice-kit": "^0.1.5", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh": "workspace:*", diff --git a/apps/desktop-host/src/cli.ts b/apps/desktop-host/src/cli.ts index 97a23b88a2..6c890f8990 100644 --- a/apps/desktop-host/src/cli.ts +++ b/apps/desktop-host/src/cli.ts @@ -7,7 +7,7 @@ import { installOfficeEngineResolution, runtimeArchivePath } from './office-engi /** * Run the ordinary CLI with Desktop's bundled package manager and reserved-profile plugin access. * @param runtimeDir - Prepared or ASAR-contained production DSH package tree. - * @param supportDir - Physical Desktop runtime directory containing pnpm. + * @param supportDir - Physical Desktop runtime directory containing the primary-runtime payload and Node launchers. * @returns Completion of the selected CLI command; profile plugins own their process lifetime. */ export async function runDesktopCli(runtimeDir: string, supportDir: string): Promise { @@ -16,7 +16,7 @@ export async function runDesktopCli(runtimeDir: string, supportDir: string): Pro manageDesktopProfile: true, packageManager: { command: process.execPath, - args: ['--expose-internals', join(supportDir, 'pnpm', 'bin', 'pnpm.mjs')], + args: ['--expose-internals', join(supportDir, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs')], env: { ELECTRON_RUN_AS_NODE: '1', DSH_DESKTOP_NODE_EXECUTABLE: process.execPath, diff --git a/apps/desktop-host/tests/office.spec.ts b/apps/desktop-host/tests/office.spec.ts index 862f832029..02e741d4ce 100644 --- a/apps/desktop-host/tests/office.spec.ts +++ b/apps/desktop-host/tests/office.spec.ts @@ -1,6 +1,6 @@ import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, sep } from 'node:path' import { pathToFileURL } from 'node:url' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' @@ -9,12 +9,14 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SkillRegistry from '@deepseek-ai/dsh-skill' import ToolRuntime from '@deepseek-ai/dsh-tools' -import { expect, it } from 'vitest' +import { expect, it, vi } from 'vitest' import * as desktopOffice from '../src/office.ts' it('loads Desktop Office CLI paths and removes them on disposal', async () => { const root = await mkdtemp(join(tmpdir(), 'desktop-office-')) + vi.stubEnv('DSH_HOME', root) const ctx = new Context() + try { const assets = join(root, 'runtime', 'office-skills') await cp(new URL('../../../packages/skill/skill-office/assets/', import.meta.url), assets, { recursive: true }) @@ -47,10 +49,10 @@ it('loads Desktop Office CLI paths and removes them on disposal', async () => { await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(config).href } }) await ctx.loader.await() for (const entry of ctx.loader.entries()) await entry.fiber?.await() - expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['office-docx', 'office-pptx', 'office-xlsx']) - expect((await ctx.skills.get('office-pptx'))?.resourceBase).toEqual({ kind: 'directory', path: join(assets, 'office-pptx') }) - expect((await ctx.skills.get('office-pptx'))?.content).toContain('libreofficeKit') - expect((await ctx.skills.get('office-pptx'))?.content).toContain(JSON.stringify(nodeDirectory).slice(1, -1)) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['office', 'office-docx', 'office-export-pdf', 'office-pptx', 'office-recalculate', 'office-render', 'office-xlsx']) + expect((await ctx.skills.get('office-pptx'))?.resourceBase).toEqual({ kind: 'directory', path: join(assets, 'office-pptx').split(sep).join('/') }) + expect((await ctx.skills.get('office-pptx'))?.content).toContain('office-render') + expect((await ctx.skills.get('office-pptx'))?.content).not.toContain(nodeDirectory) expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['load_workspace_dependencies']) const entry = [...ctx.loader.entries()].find(entry => entry.options.name === 'office') expect(entry).toBeDefined() @@ -58,7 +60,10 @@ it('loads Desktop Office CLI paths and removes them on disposal', async () => { expect(await ctx.skills.list()).toEqual([]) expect(ctx.tools.schemas()).toEqual([]) } finally { - await ctx.fiber.dispose() - await rm(root, { recursive: true, force: true }) + try { await ctx.fiber.dispose() } + finally { + vi.unstubAllEnvs() + await rm(root, { recursive: true, force: true }) + } } }) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 2d82f686ea..6060f79da9 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,24 +2,36 @@ # 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/README.md +/: + en: 0d36989eb614345d + zh: a0a1a18c07621393 /deepseek-harness-desktop: - en: 001b7d11c237078b - zh: eda6b580dc224c7f + en: 69a6e0c6712e463b + zh: 84e1d0b09c803704 +/deepseek-harness-desktop/summary: + en: 7bc8bf30de693222 + zh: e85993d8852b6cab +/deepseek-harness-desktop/table-of-contents: + en: 05fd227f99ee535a + zh: 60d7d62bc0026973 +/deepseek-harness-desktop/application-overview: + en: e9b22f232837e131 + zh: fc5f3834b969d2d2 /deepseek-harness-desktop/terminal-command: - en: bef92953c237363f - zh: d21d6effd21b8c00 + en: 5f2d25a1d8472647 + zh: 2acb5c5d7d1917e2 /deepseek-harness-desktop/closing-the-window-and-quitting: - en: c323e02e3f0305fe - zh: 3c730db632136443 + en: 24387a07ae4eb8e7 + zh: 0119352685cf273c /deepseek-harness-desktop/key-technical-decisions: - en: 069fdfd72d2ea26a + en: 3724c53da4f7647b zh: 76ae549d43c99bb4 /deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies: - en: 0f9b6c1d6761d108 - zh: 77127063f244c361 + en: 3368ff9b955ada69 + zh: e9209ef6ad4fd305 /deepseek-harness-desktop/bundled-command-runtime: - en: 09f0b75af9f0ed14 - zh: 60bbc2e67d5e87a8 + en: 372297005b57a08b + zh: 074a635ac17e4362 /deepseek-harness-desktop/installation-ownership: en: 22153a298a6662e1 zh: 3600803c8c9d0274 @@ -27,8 +39,8 @@ en: a1a47b09f4a72ba4 zh: 7b1cf27f0b72cf31 /deepseek-harness-desktop/installation-ownership/host-environment: - en: 84a9cc7ca9455aa8 - zh: 4091b8aa8231b74a + en: 98cf848762482d48 + zh: 74f9100a39ec8c2f /deepseek-harness-desktop/develop: en: fe6d23e6a3d3bc66 zh: d60c8d3a55747916 @@ -36,41 +48,17 @@ en: a51346b8d880ddba zh: 7913214c1c66063c /deepseek-harness-desktop/develop/welcome-window-appearance: - en: ae319a496639b38d - zh: ef044103240ac9da + en: 617a89aeb28131b9 + zh: 4d84bd5d86501c86 /deepseek-harness-desktop/package: - en: 4cd8a8da6977928d - zh: 9f624855a2edac37 -/deepseek-harness-desktop/package/release-versions: - en: d938302f92fc3a1a - zh: 29df50bc8791baa7 -/deepseek-harness-desktop/package/local-macos-dmg-without-release-signing: - en: 57869a5e3885f843 - zh: 7cfc01b2dcbebd78 -/deepseek-harness-desktop/package/local-official-bundles-from-a-loopback-registry: - en: 5f05072ec82cc62d - zh: a3d63b58d1ef2db7 -/deepseek-harness-desktop/package/runtime-file-selection: - en: e7c1eb8e78fe46e3 - zh: b7ea497dd75be524 -/deepseek-harness-desktop/package/upload-updates: - en: f48baa680e841b0d - zh: 25ded1aeaac6dad1 -/deepseek-harness-desktop/package/unsigned-windows-test-installer: - en: fb6de542360dbae7 - zh: 0e17d7765fb10c48 -/deepseek-harness-desktop/package/windows-installer-interface: - en: 1fc1db8f54475c8f - zh: dd834ed206070396 -/deepseek-harness-desktop/package/windows-ev-signing: - en: 64836513eb7eda6e - zh: 6ebb9184a7aa51de -/deepseek-harness-desktop/package/desktop-runtime-preparation: - en: 32ef30fc38462109 - zh: 093b1b57d084f5bb + en: ac31ebdd9de54f34 + zh: 921e534d5c4b3a16 +/deepseek-harness-desktop/windows-installer-interface: + en: 8cb4b7babbd8714d + zh: b4aebc1d2d152e01 /deepseek-harness-desktop/updates: - en: 41d0f50eb7173342 - zh: ce940ef3cbcc6fa5 + en: 4788b6b60489ef92 + zh: 6312c5590efa27f1 /deepseek-harness-desktop/updates/local-desktop-settings: en: 2e57457c44c85b5f zh: 5b8510a3dc4c6c9d @@ -78,14 +66,14 @@ en: 54596bef0706d305 zh: 48737302dcf3c939 /deepseek-harness-desktop/updates/local-updater-qualification: - en: bc50fd8a4d96c227 - zh: fb0a6ce80c31cda6 + en: 68ec2f8bd5763ea6 + zh: 23b932dfe8503602 /deepseek-harness-desktop/low-level-development-overrides: - en: 0b225f914142ef0b - zh: 9ffd07768d37824e + en: 8264b135871e8978 + zh: 0939aafb1b2961fc /deepseek-harness-desktop/known-limitations: - en: 5b77213c86bbcfb6 - zh: b34bf21773bb852b + en: c63d4dc7a9104954 + zh: a4c676e5b46a6ac0 /deepseek-harness-desktop/dev-note: en: 8e26b460ba31f21b zh: fbcd0307b3867125 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index c9d14c4511..e0bb92c5b7 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,7 +1,35 @@ +--- +description: "Desktop runs the Harness workspace in a native application. This reference covers application behavior, development and integration; the packaging, Windows signing and publication guides own build and release operations." +--- + # DeepSeek Harness Desktop English | [中文](README.zh.md) +## Summary + +Desktop runs the Harness workspace in a native application. This reference covers application behavior, development and integration; the packaging, Windows signing and publication guides own build and release operations. + +## Table of Contents + +- [Application overview](#application-overview) +- [Terminal command](#terminal-command) +- [Closing the window and quitting](#closing-the-window-and-quitting) +- [Key technical decisions](#key-technical-decisions) +- [Bundled command runtime](#bundled-command-runtime) +- [Installation ownership](#installation-ownership) +- [Develop](#develop) +- [Package](#package) +- [Windows installer interface](#windows-installer-interface) +- [Updates](#updates) +- [Low-level development overrides](#low-level-development-overrides) +- [Known limitations](#known-limitations) +- [Dev Note](#dev-note) + + + +## Application overview + Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. Update installation waits for its local analytics intake before locking API admission and stopping the Host. Intake has a one-second deadline; failure does not prevent installation, and collector delivery is not awaited. The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop listens on an OS-assigned port by default, so it cannot collide with Web’s `3080` or a reserved port; a `webserver.config.port` patch can override it. @@ -12,7 +40,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. -Creator and the Web Plugin Manager use Desktop’s bundled pnpm under Electron Node mode without requiring pnpm on PATH. The private Node launcher environment applies only to package operations. +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. 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. @@ -24,6 +52,8 @@ Desktop microphone access is restricted to audio requests from the primary `dsh- Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Shift+I on Windows to toggle DevTools for the focused application page, including in packaged builds. These native shortcuts use hidden application-menu items. Update overlays and packaged embedded browser guests disable DevTools. + + ## Terminal command The application menu's **Manage dsh Command…** entry, immediately below **Check for Updates…**, shows the current command and offers Install, Repair, and Remove. The command uses Desktop's installed runtime and the ordinary [dsh CLI](../cli/README.md), including when the Desktop application is closed. Open a new terminal after installation and run `dsh --version`. @@ -32,6 +62,8 @@ On macOS, installation creates `/usr/local/bin/dsh` and requests administrator a Command registration is optional after installing Desktop. Use **Manage dsh Command… → Remove** before uninstalling Desktop to remove its CLI registration; the application uninstaller does not remove it. Finish CLI commands before updating or uninstalling Desktop. The CLI runtime version follows the installed Desktop release. See [bundled command runtime](#bundled-command-runtime) for Desktop plugin commands and runtime limitations. + + ## Closing the window and quitting Closing the main window (macOS close button and ⌘W; Windows ×, Alt+F4, and the taskbar Close window command) hides it; Windows asks for acknowledgement before the first hide. The page and the Host keep running, tasks continue, and the next show presents the same document with its session, drafts, and scroll position; a fullscreen macOS window leaves fullscreen before hiding. The window returns through the Dock icon, a second launch, or `dsh://open` on macOS, and through the tray on Windows. Minimize is unchanged. Closing the welcome window before the workspace opens quits on Windows and, on macOS, keeps the application in the Dock without a window. @@ -46,6 +78,8 @@ The confirmation is skipped when the installer restart already confirmed task in The tray renderer enlarges the whale by 20% around the tile center while preserving the background and aspect ratio; application and installer icons retain their original proportions. + + ## Key technical decisions The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation. @@ -56,21 +90,25 @@ The macOS File menu displays the accepted single-key binding, including arrow ke The macOS PNG uses an inset rounded background for legacy ICNS packaging, with representations up to 1024 pixels. It is a flattened icon, not an Icon Composer document. Apple's [app icon guidance](https://developer.apple.com/design/human-interface-guidelines/app-icons) describes unmasked layers for Icon Composer; those inputs require a separate macOS export and must not reuse the rounded ICNS artwork. Verify Finder and Dock appearance on supported macOS versions before release. + + ### Bundled workspace dependencies electron-builder copies only the manifest's `dependencies` into `app.asar/node_modules`, so the Electron main bundle `lib/main.js` inlines its workspace devDependencies and leaves `electron`, Node builtins, and those `dependencies` as its only bare imports; a sandboxed preload may leave only `electron`, `events`, `timers`, and `url`, the modules its `require` polyfill resolves. The main bundle resolves the inlined packages from their `lib/` output, so the root `build:lib:host` bundles `apps/desktop` after the concurrent workspace tsdown pass, and [`desktop-bundle-imports`](scripts/desktop-bundle-imports.mjs) fails any Desktop bundle whose static, dynamic, or `require()` import the packaged application cannot resolve. Without that check an import rolldown could not resolve ships as an external specifier and fails at launch with `ERR_MODULE_NOT_FOUND`. The [archived bundle-order decision](../../.agents/notes/archived/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md) records the alternatives. -Signed Windows packaging scans the primary runtime and application production dependencies for PE content, including files without a conventional extension. The final scan covers the entire unpacked application. Directory links, malformed `MZ` files and non-PE `.exe`, `.dll` or `.pyd` files stop packaging; data files beginning with `MZ` are also rejected unless they contain a valid PE header. It preserves valid vendor signatures and signs unsigned code before sealing runtime hashes or executing smoke checks. Public-key inspection uses batches of 32 files with at most four processes; hardware-token signing remains serial, and each new signature must match the configured certificate and carry a timestamp. Hardware signing and verification failures stop the run; isolated timestamp requests follow the bounded policy below. Electron-builder preserves copied runtime executables only after signature and exact-byte verification. A final PE-signature audit and a fresh-cache ASAR payload and Host smoke must pass before the release completion record is written. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. Passing smoke checks does not establish compatibility with every enterprise policy. +Signed Windows packaging scans the primary runtime and application production dependencies for PE content, including files without a conventional extension. The final scan covers the entire unpacked application. Directory links, malformed `MZ` files and non-PE `.exe`, `.dll` or `.pyd` files stop packaging; data files beginning with `MZ` are also rejected unless they contain a valid PE header. It preserves valid vendor signatures and signs unsigned code before sealing runtime hashes or executing smoke checks. Public-key inspection uses batches of 32 files with at most four processes; hardware-token signing remains serial, and each new signature must match the configured certificate and carry a timestamp. Hardware signing and verification failures stop the run; isolated timestamp requests follow the [bounded retry policy](windows-signing/README.md#windows-ev-signing). Electron-builder preserves copied runtime executables only after signature and exact-byte verification. A final PE-signature audit and a fresh-cache ASAR payload and Host smoke must pass before the release completion record is written. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. Passing smoke checks does not establish compatibility with every enterprise policy. Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory. -Desktop registers `office-docx`, `office-pptx`, and `office-xlsx` by default. The skills use the bundled Python libraries for creation and focused edits, then reopen the files and run a shared structural checker before delivery. PowerPoint creation and editing use python-pptx. Skill resources are copied to `runtime/office-skills` outside ASAR so Python can read the checker. An available `render_document` tool can add visual inspection; its absence does not prevent authoring or delivery. See the [Office skill package](../../packages/skill/skill-office/README.md) for checks and limitations. +Desktop exposes the compact `office` router and named authoring/operation subskills. Authoring uses bundled Python libraries; operation skills invoke the configured Node and CLI paths. Skills live in `runtime/office-skills` outside ASAR. See the [Office skill package](../../packages/skill/skill-office/README.md) for progressive loading and limitations. The payload follows the Desktop release. `runtime.json` records the Desktop version, target, top-level interpreter/package-manager versions and the Python distribution map, and a digest of the selected target’s locked payload inputs and assembly format. Distribution names use PEP 503 normalization; duplicate normalized names reject the manifest. Legacy `components` manifests remain readable through normalization, with their existing library-version consistency checks. Matching installations are reused; a dependency or archive change replaces the directory after a complete staged copy even when the Desktop version stays unchanged. Older manifests without a digest are replaced on their next installation. User-added Python packages remain only while the payload identity matches. A failed directory replacement retains the previous installation; Windows may refuse replacement while an interpreter is still running. The private Desktop `runtime/bin` directory is added only to package-installation processes, not the Host PATH inherited by PTC and agent shells. This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices. -Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8; the x64 target also receives `com.apple.security.cs.allow-unsigned-executable-memory` for executable code pages, including under Rosetta. ARM64 Node and Office helpers retain JIT-only permissions. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. +Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. macOS signs the versioned Python executable once, retaining the [two interpreter aliases](../../packages/skill/tool-workspace-dependencies/README.md) in external resources and installed copies. POSIX native-target checks require relative interpreter aliases. All native-target checks exercise command names, child execution and a pip-enabled venv, verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8; the x64 target also receives `com.apple.security.cs.allow-unsigned-executable-memory` for executable code pages, including under Rosetta. ARM64 Node and Office helpers retain JIT-only permissions. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. + +The shared Desktop/SDK runtime builder omits directories named `tests` beneath NumPy and pandas before copying or signing the payload, and removes their rows from each distribution’s `RECORD`. It retains `numpy.testing`, `pandas.testing`, `pandas._testing`, native extensions and licenses. Complete upstream suites are unavailable; native checks verify the retained testing APIs, absence of test directories and existence of recorded files. | Decision | Why | Direct consequence | |---|---|---| @@ -96,6 +134,8 @@ To manage Desktop's plugins, launch Desktop once to initialize its profile, full Finish CLI commands before updating or uninstalling Desktop; the installer does not coordinate with running commands. The CLI does not supply Office authoring-skill resource paths. Desktop's existing Office setup and Web attachment conversion retain their own resource configuration. `prepare:runtime` copies the launch scripts without compiling a native CLI launcher. + + ## Installation ownership Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot this profile. The Desktop-installed command can manage its plugins while the application is quit; npm-installed dsh cannot mutate it. @@ -142,6 +182,8 @@ Recovery waits for Host shutdown before changing plugin activation. The native r macOS and Linux GUI launches inherit only the session manager's environment, without variables exported by shell startup files. Before the first Host starts, Desktop runs the account's login shell (from the user database, not `$SHELL`) once as ` -ilc` and reads its `env -0` output between delimiters, so `~/.zprofile` and `~/.zshrc` (or the shell's equivalents) take effect for the Host, agent shells, terminals, and profile configuration. Reading runs in parallel with profile preparation. The probe has no terminal input and sets `DISABLE_AUTO_UPDATE=true`, `ZSH_TMUX_AUTOSTARTED=true`, and `ZSH_TMUX_AUTOSTART=false` to keep oh-my-zsh and tmux plugins from blocking. Shell values replace inherited values except `PWD`, `OLDPWD`, `SHLVL`, `_`, those probe variables, and launcher-owned `DSH_*` and `ELECTRON_*` names; Desktop resolves paths such as `DSH_HOME` before the read, so the Host keeps the same values. The read completes at the closing delimiter, so background processes started by startup files may keep running with their output discarded. A candidate that fails to start, exits nonzero, prints no delimited output, or exceeds `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS` (integer milliseconds from 1000 through 2147483647, default `10000`; its process group is killed) is logged as a warning, and Desktop tries `/bin/zsh`, `/bin/bash`, and `/bin/sh` in turn; when all fail the Host uses the inherited environment. The dump command uses POSIX syntax, so an account shell such as csh, tcsh, or nushell fails and the Host receives the environment from the first system shell's startup files instead. Quitting Desktop during the read kills the running probe's process group. The result is read once per application process, so changes to shell startup files apply after quitting and reopening Desktop. Windows GUI launches already inherit the user and system environment variables from the registry, so Windows skips this step. + + ## Develop The development application menu offers Reload Page (Cmd+R on macOS, Ctrl+R elsewhere) and Restart App and Host. Restart waits for Host shutdown before relaunching Electron and starting a new Host; neither action rebuilds source files. @@ -182,272 +224,44 @@ The browser-login waiting page offers a copy-link action for the current pending The welcome window follows system appearance with the design’s Platform light/dark colors and shows the 600 × 700 entry layout and the API-key form, with native window controls, a draggable title area, a local brand SVG, system sans-serif fallbacks, and locally bundled Montserrat Light for non-button text. The window uses macOS menu vibrancy or Windows acrylic with the onboarding window tint: 40% white in light mode and 50% rgb(24 25 28) in dark mode. The local React welcome entry bundles React and the shared `StateDot` loading indicator with its CSS; it uses the isolated preload without loading the main Web application. The entry, sign-in status and API-key pages share a fixed bottom action row; the back-to-sign-in link sits below it. Buttons share the platform motion timings, and Reduce Motion disables their transitions. The OS owns blur strength and outer corners. macOS Reduce Transparency suppresses translucency, and Increase Contrast forces that setting on. Save and continue writes to the development credential store; Set up later opens the real workspace without saving a key or completion flag. The generated project links the declared workspace dependency closure as well as pnpm’s hoisted packages, so unhoisted configured plugins remain resolvable. [The window note](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md) owns the material and onboarding decisions. + + ## Package - -### Release versions - -Before each Desktop packaging run, first confirm the complete version string with the current user. Check the selected deployment, dsh base version, retained release records, and published objects, then propose the exact version for approval. Do not start packaging until the user confirms that version; the deployment setting alone does not authorize a version choice. - -Record the current dsh version as the base. A production Desktop release uses that exact version, including any `alpha`, `beta`, or `rc` identifiers. A test release preserves the complete prerelease base and appends `.YYYYMMDD.index`; a stable base uses `-test.YYYYMMDD.index` instead. - -| dsh base | Production Desktop | Test Desktop example | -|---|---|---| -| `0.1.6-alpha.1` | `0.1.6-alpha.1` | `0.1.6-alpha.1.20260916.1` | -| `0.1.6-beta.2` | `0.1.6-beta.2` | `0.1.6-beta.2.20260916.1` | -| `0.1.6-rc.3` | `0.1.6-rc.3` | `0.1.6-rc.3.20260916.1` | -| `0.1.6` | `0.1.6` | `0.1.6-test.20260916.1` | - -Use the actual creation date in Asia/Shanghai. For each base and date, start the index at 1 and increment after checking retained release records and published objects; never reuse a published version. Test distribution does not publish the corresponding unsuffixed base. - -Manual Windows CI uses a separate unpublished build identifier: `auto` appends the UTC date, workflow run number and attempt (`.YYYYMMDD.run.attempt`, with `-test` for a stable base). For example, `0.2.1-alpha.1.20261008.42.2`. These numbers are not reserved release sequence numbers. - -Pass the confirmed version to the packaging command as `--build-version`, which reaches the artifact names, the update feed, and the upload validation as one value. The manifests keep the product version, so a test build no longer rewrites the release family and leaves nothing to revert: - -```sh -pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.20260916.1 -``` - -`--build-version auto` proposes the next index for today, reading the published objects in the destination bucket and falling back to this target's local output directory when no bucket is configured or the listing cannot finish within its deadline. Confirm the proposal it prints before an upload; a run script forwards `--` on its own, and the packaging entry accepts it either way. - -A production release publishes the product version and takes no `--build-version`. Uploading one tags the packaged commit as `desktop-v` once the artifacts are public; a build from a modified checkout is not tagged, and a tagging failure prints the command to run by hand rather than failing a completed upload. Test and local builds are deliberately left untagged, and every artifact records `dshBuildCommit` and `dshBuildDirty` in its manifest so a build handed over directly remains traceable. - -Version derivation does not change the fixed update channel or `nightly.yml` / `nightly-mac.yml` filenames. SemVer orders `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`, and a stable base's test version precedes that stable release. Clients only accept a greater version: replacing a feed cannot move an installed higher version to a lower corrected version. Such clients need manual installation; keep automatic downgrade disabled. The [archived version decision](../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md) explains why the channel does not supply the prerelease identifier. - -Except for local unsigned macOS builds, packaging, upload, and manual macOS signature verification read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](.env.windows.example) or [macOS template](.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the target file, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. The published version is an argument rather than a release field, and upload reads it from the completion record the packaging run wrote. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. CI also creates the target file before invoking packaging. - -Every package command checks the application ID and its mode-specific update and signing configuration before building or downloading, then probes the external tools the run will use: the archive reader, and on a Windows target the installer compiler. macOS checks the identity, Team ID, one complete notarization strategy, readable local `CSC_LINK` p12 file, explicit `CSC_KEY_PASSWORD`, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only builds and explicit unsigned builds on either platform do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. `--build-version auto` does contact the destination bucket, including under `--check`. Run the same checks separately: - -```sh -pnpm --dir apps/desktop run check:package -``` - -`prepare:desktop` is not a prerequisite: - -```sh -pnpm run package:desktop -``` - -Release automation uses fixed target commands so runtime preparation, dsh preparation, and electron-builder receive the same platform and architecture: - -```sh -pnpm run package:desktop:mac:arm64 -pnpm run package:desktop:mac:x64 -pnpm run package:desktop:win:x64 -``` - -The macOS arm64 command requires Apple Silicon. The macOS x64 command runs on Intel macOS or Apple Silicon with Rosetta. The Windows x64 command requires Windows x64. Linux is not a supported Desktop release target. - -Each target owns its packed package inputs, prepared runtime, package set, dsh tree, pnpm preparation state, unpacked application, update metadata, and final artifacts under `apps/desktop/.desktop-build/targets//`. The Electron archive cache remains shared under `.desktop-build/downloads` because every archive name includes its version, platform, and architecture and is verified before extraction. A target build never consumes another target's mutable preparation state. - -### Local macOS DMG without release signing - -After `pnpm install`, run one command from the repository root. No `.env.macos`, Apple account, certificate, or update service configuration is required: - -```sh -pnpm run package:desktop:mac:arm64:unsigned -pnpm run package:desktop:mac:x64:unsigned -``` - -Add `--check` to validate local configuration and tools without building, or `--dir` to stop at the unpacked application. The DMG is `apps/desktop/.desktop-build/targets//unsigned-artifacts/deepseek-harness--mac--unsigned.dmg`. After all checks pass, the command prints `DMG:` followed by its absolute path. Open it and copy the application to a local test directory. - -These commands ignore `.env.macos` and inherited release settings, use `com.deepseek.harness` as the app ID, and omit automatic-update and mandatory-update configuration. Build tools, standard network proxies, and `DSH_DESKTOP_NPM_REGISTRY` remain available from the shell. They skip Developer ID signing, Apple notarization, temporary signing keychains, and notarization proxy changes. Electron receives a local ad-hoc signature without certificates so the modified executable can run; the DMG is unsigned. Both preparation and the assembled application still run runtime integrity and smoke checks. Local builds omit update feeds, ZIPs, and release completion records, and the upload commands cannot publish them. macOS may require approval in System Settings → Privacy & Security to open an unnotarized application. - -### Local Official bundles from a loopback registry - -The test DMG excludes the provider runtimes the Official on-demand catalog advertises, and a release publishes matching bundle versions to npm. To install locally built bundles before that release, start a loopback registry that packs and serves the catalog's complete local dependency closure: - -```sh -pnpm run dev:bundle-registry -``` - -The command validates the catalog and the workspace inventory, runs `pnpm run build:official`, packs the closure into a new `dist/test-bundles//` directory, writes `bundle-registry.json` last as the completion marker, and only then prints the artifact path, the registry URL, and the exact `name@version` specs. It serves `127.0.0.1` on an allocated port under a fresh namespace until Ctrl+C or SIGTERM, and stopping it leaves both the artifacts and the installed packages on disk. It needs no `.env.macos`, Apple credentials, or npm credentials, and it does not change `pnpm run package:desktop:unsigned`. - -In the packaged application, open Plugins → Add plugin, paste one printed spec, choose **Custom address**, paste the printed registry URL, and install. A custom registry is asked alone, so this process must answer every dependency: repository-owned packages come from the run's own archives, and third-party packages keep npm's real metadata and bytes through a fixed redirect to `registry.npmjs.org`, which still requires network access. The installed entry stays Official because its package name is in the catalog. The registry choice is remembered in this browser; choose an offered registry again to switch back. - -A run's artifacts are immutable, so a rebuild serves a new namespace and a new URL, and the Official **Update** control does not appear while the installed and running versions match. The profile records the previous run's tarball URL in `pnpm-lock.yaml`, and pnpm verifies recorded entries, so installing or reinstalling that bundle against a new URL is refused with `ERR_PNPM_TARBALL_URL_MISMATCH` and can leave the earlier files in place. To test changed code at the same version, clear the stale resolution first — `pnpm clean --lockfile` in `$DSH_HOME/profiles/`, or install into a fresh profile — then install again from the new URL. `bundle-registry.json` records each archive's version, filename, size, and SHA-512 integrity together with the packing checkout's commit and dirty flag. - -The served `time` is the real packing time, so pnpm 11's default 24-hour `minimumReleaseAge` records the exemption it granted in the profile's `pnpm-workspace.yaml` (`minimumReleaseAgeExclude`); an explicitly stricter policy there fails the installation with pnpm's own message instead of being overridden. A `@scope:registry` route in `~/.npmrc` or the profile's `.npmrc` overrides `--registry` for that scope, so it must not route the served scope. - -### Runtime file selection - -Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. Dependency manifests pass through electron-builder's metadata cleanup before integrity sealing, so archiving preserves their recorded bytes. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. - -The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). - -The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. The Host smoke also obtains CLI paths from the loaded skill and runs capabilities and DOCX conversion with an empty PATH. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization. - -Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory; pass the prepared `window-frame.dll` as `-FrameLibrary` to exercise the native extraction path and its failure report as well. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane. - -```powershell -pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll -``` - -The Windows installer checks for a running application at startup and after destination selection, before extracting the new version beside the installation directory. It checks again before replacing directories through same-volume renames. A running application blocks installation; update launches allow up to ten seconds for it to exit. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow. - -When extraction fails, the installer writes a report with the 7-Zip result and its complete error output to the updater cache directory as `%LOCALAPPDATA%\-updater\installer-logs\extract-failure-.log` (currently `@deepseek-aidsh-desktop-updater`) and shows the first error line with a **Copy error details** button; silent installs write only the report. Unsigned Windows builds (`DSH_DESKTOP_UNSIGNED=1`) name their installer `deepseek-harness--win-x64-unsigned.exe` so they cannot be mistaken for release artifacts. - + + + + + + -### Upload updates +Use the dedicated guides for build and release operations. Shared Windows runners have accepted credential and device isolation limits; read them before signing or executing branch code. -Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Deployment infrastructure must configure revalidation or purge cached fixed downloads after replacement. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. - -Test and production uploads through `upload:*` retain a fresh `.desktop-build/upload-records/--*` directory after release preflight. `plan.json` records destination, version, every file's size/SHA-512, and published YAML bytes; flushed `events.jsonl` records PUT intent and available response status/request ID; `result.json` records completion or the last failed stage. A missing final result means interruption or unavailable storage, not success. No credential values, authorization headers, or raw SDK errors are recorded. Audit-write failures stop later PUTs. Every object is one streamed Tencent COS PUT with an explicit length and Content-MD5; the COS SDK repeats a request only when its body is not a stream, and this uploader does not retry either. Retain partial records and inspect remote state before another operation: a timeout or failed receipt write does not prove that the object was not stored. These records are local, not tamper-proof or automatically backed up; archive each release's records with its build evidence in controlled storage. Public CDN readback remains separate release qualification, explicitly marked `not-performed` in the upload result. - -Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. - -`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds//` and binaries use `dsh-desk/bin//`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk//feeds//` and `dsh-desk//bin//`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`. - -The update destination and upload credentials follow the selected deployment: - -| Environment | Public origin | COS bucket | COS credentials | -|---|---|---|---| -| `test` or unset | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`, `DOWNLOAD_TEST_COS_SECRET_KEY` | -| `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`, `DOWNLOAD_PROD_COS_SECRET_KEY` | - -Generate a fresh ID for each test release batch with the command below and copy its output into `DOWNLOAD_TEST_RELEASE_ID` in `.env.macos` or `.env.windows`. These Git-ignored platform files own the value; shell variables do not override it, and dotenv values are not shell-expanded. Keep the ID through packaging, upload, and retries; upload rejects a completion record with a different update URL. To test an upgrade between versions, reuse the installed client’s ID for the later version. Production does not use this field. - -```sh -node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(16).toString('hex'))" -``` - -Distribute each test batch through its complete download links. Installed test clients retain their batch feed and do not discover a new ID automatically. Format validation cannot establish randomness; use the generator output. Random paths reduce guessing, not access by link holders; withdrawing a batch requires deleting its COS objects and purging its CDN directory. - -Configure the update origin and selected COS bucket, SecretId, and SecretKey in the target dotenv file, then package and upload the same target: - -```sh -pnpm run package:desktop:mac:arm64 -pnpm run upload:mac:arm64 -``` - -The target dotenv file for internal-test packaging explicitly sets `DSH_DESKTOP_AUTO_UPDATE_ENV=test` and `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`; uploads use `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` and separate test credentials. Both test and production use the fixed Nightly channel; deployment selection does not enable channel switching. - -Early internal-test packages use the `test` deployment. Select `production` explicitly only for a production release; changing upload credentials does not retarget an existing package. Packaging requires no COS credentials, disables electron-builder publishing, strips COS credentials from child processes, and records completion only after signing and notarization succeed. Upload validates this record, deployment, target, shared version, filenames, sizes, and SHA-512 before reading credentials. Packages and blockmaps upload before YAML; historical objects are retained. Every release publishes `nightly.yml` or `nightly-mac.yml`; stable releases also publish `latest.yml` or `latest-mac.yml` pointing at the same artifacts. Published YAML uses absolute binary URLs. The uploader leaves Cache-Control unset, including the empty header the COS SDK would otherwise add: deployment infrastructure owns cache policy, with uncached feeds and separately configurable binary caching. Serialize publication per target and verify public artifacts and feed contents before release qualification. - -The signed macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. Signed macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The signed fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Signed directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. `CSC_LINK` must name a local p12 containing the Developer ID Application certificate and private key; URLs and Base64 inputs are not supported. `CSC_KEY_PASSWORD` is its export password, not an Apple account or login password; an explicitly empty value supports an unencrypted p12. Before building, packaging creates and unlocks a private temporary keychain, imports the p12, authorizes signing, and signs a small probe. Runtime and App signing use this keychain explicitly; existing login keychains require no setup or manual unlocking. Child processes receive its path without the p12 password. The keychain is deleted after success or ordinary failure; CI must clean temporary credentials after forced termination. CI writes the certificate and `.env.macos` from its secret store, restricts file access, and deletes both after the job. Ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. - -macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements. - -macOS runtime preparation caches verified single-architecture Mach-O signatures in `.desktop-build/targets//signature-cache`. Keys include input bytes and permissions, the signing probe’s actual leaf certificate, identifier, entitlement bytes, macOS version, and signing tools and policy. Git commits do not determine reuse: uncommitted byte changes invalidate the affected file. Every hit verifies cached bytes, the strict signature, certificate, identifier, entitlements, secure timestamp and hardened runtime before replacing an unchanged input. Universal binaries are signed each time. Runtime integrity and smoke checks, App signing, and Apple notarization still run. The cache requires trusted local build storage. Cache corruption or unsafe links fail the build; stop packaging before deleting the affected cache directory and retrying. Successful signing stages prune complete entries to a one-GiB payload budget; interrupted temporary entries require manual removal. Concurrent eviction can fail a reader safely. Logs report hit, miss and uncached counts. - -Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (published version, product version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted; environments and notarytool authentication arguments are not recorded. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together. - -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`: - -| Setting | Default | Scope | -|---|---|---| -| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | Workers for first-party and vendor workspace tarballs; requires a positive integer. | -| `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. | - -The proxy fields are independent and reject URL credentials, paths, queries and fragments. Empty values preserve inherited networking. An explicit download proxy replaces child-process proxy variables and bypasses local hosts only; it does not change system settings. Windows and standalone `release:pack` retain their existing concurrency defaults. Company proxy addresses belong in the ignored local file; see internal documentation for addresses. - -Apple tooling uses the active macOS network service's HTTP/HTTPS proxies. Configured notarization routing checks proxy reachability, saves that service's settings, enables the proxy around both artifact lanes, and restores the saved settings after both lanes settle. An originally disabled proxy with an empty server and port zero is restored by disabling it; the temporary server and port may remain stored but inactive. Directory-only builds apply it to App notarization after the signed directory build. This temporarily affects other applications and requires permission to change system proxies; PAC, auto-discovery, SOCKS and authenticated proxy configurations must be disabled first. Packaging and recovery acquire the same per-user POSIX file lock before reading recovery data or changing proxies; process exit releases ownership, while the lock file remains in place. The lock loads `@deepseek-ai/node-addon-system/flock` at first use rather than at script start, so `check:package` and the packaging entry load on a checkout that has not built `native/system`; when the host addon binary or the entry's JavaScript is missing at lock time, the loader runs `pnpm run build:native-system` and `pnpm --dir native/system run build:ts` before locking, so recovery works on such a checkout as well. This prevents overlapping proxy transactions across checkouts; other users and network-setting tools must not change these settings concurrently. SIGINT/SIGTERM wait for active work before restoration. After forced termination or a restoration error, stop any remaining notarization processes and run `pnpm --dir apps/desktop run restore:mac-proxy`; the saved record remains until restoration succeeds. Configuration checks validate URL syntax without changing system settings or contacting the proxy. - -### Unsigned Windows test installer - -On Windows x64, use the complete unsigned packaging command for local installation testing: - -```sh -pnpm run package:desktop:win:x64:unsigned -``` - -The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mandatory-update policy settings](#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. - -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. - -The [manual Windows workflow](../../.github/workflows/windows-package.yml) selects a self-hosted Windows x64 runner with the dedicated `dsh-win-package-trial` label and calls the same unsigned command. Provision PowerShell 7 and the tools listed above before starting the runner. It requires the policy deployment and HTTPS origin; test deployments also require comma-separated login origins. These public inputs generate a temporary `.env.windows` without signing or upload credentials. `build_version` defaults to `auto`, using the CI identifier described in [Release versions](#release-versions). An explicit version uses the existing product-version validator. The resolved version appears in the run summary before packaging; manifests remain unchanged. - -After this workflow reaches the default branch, select it in Actions, choose the source branch, fill the inputs and click Run workflow. It has no push or PR trigger and leaves existing automatic CI unchanged. Packaging logs and successful EXE artifacts are retained for 14 days; installation, signing and publication are separate operations. Run it once from the default branch and inspect the retained logs and installer before relying on Actions packaging. Checkout cleans the runner-owned source directory, including ignored files; never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run can remain pending, and a newer dispatch replaces that pending run. Diagnostics are uploaded only after a successful clean checkout, and generated settings are removed even when preparation fails after writing them. +- [Desktop packaging](packaging/README.md): local and Actions builds, versions, deployment settings, artifacts, logs and verification. +- [Windows signing and runner operations](windows-signing/README.md): machine setup, accepted isolation risks, signing credentials, token failures and cache maintenance. +- [Desktop publication](publishing/README.md): upload credentials, release identity, fixed downloads, update feeds and upload recovery. -### Windows installer interface + + +## Windows installer interface The Windows installer uses native NSIS pages with light and dark palettes, system shadows, an editable installation directory, and a finish page whose launch checkbox is selected by default. Installation is restricted to the current user. Clicking Install or pressing Enter validates the current path; drive roots remain invalid after normalization, including repeated trailing separators. New destinations must be empty, and nonempty destinations must be registered installations. Running executables at the affected installation path produce a native prompt and remain running; same-named applications in other directories do not block installation. Silent updates wait up to ten seconds for the affected application to exit, then stop with exit code 2 if it is still running. -The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME=auto` select a palette explicitly. The window appears after its branded controls are ready. When the welcome page first appears, the installer moves above ordinary windows once and flashes its taskbar button if another window has focus; it does not remain always on top. Progress reads the pinned 7-Zip extractor’s percentage; directory promotion, registration, and cleanup retain bounded estimates. The weighted percentage does not predict remaining time. After NSIS reports success, the bar fills over 600 ms and displays 100% briefly before the finish page appears; the transition targets 750 ms. The finish page preserves the window position. Finish dismisses the installer before launching the installed executable; a launch failure restores the page for retry. Directory replacement and failure recovery follow the installation flow described above. First-launch profile preparation remains a separate Desktop operation. +The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME=auto` select a palette explicitly. The window appears after its branded controls are ready. When the welcome page first appears, the installer moves above ordinary windows once and flashes its taskbar button if another window has focus; it does not remain always on top. Progress reads the pinned 7-Zip extractor’s percentage; directory promotion, registration, and cleanup retain bounded estimates. The weighted percentage does not predict remaining time. After NSIS reports success, the bar fills over 600 ms and displays 100% briefly before the finish page appears; the transition targets 750 ms. The finish page preserves the window position. Finish dismisses the installer before launching the installed executable; a launch failure restores the page for retry. Directory replacement and failure recovery follow the [installation checks](packaging/README.md#runtime-file-selection). First-launch profile preparation remains a separate Desktop operation. Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The preparation hook leaves production dependency collection to electron-builder on every platform. -Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The optional `--signed` flag uses the Windows EV configuration below to sign test executables and the helper before embedding them; it does not enable an update feed. +Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The optional `--signed` flag uses the [Windows EV configuration](windows-signing/README.md#signing-configuration) to sign test executables and the helper before embedding them; it does not enable an update feed. The Windows uninstaller removes the Electron user-data directory (browser storage and caches under `%APPDATA%`, nested under the package scope), the `%APPDATA%` product directory, and the updater download cache under `%LOCALAPPDATA%` together with the application, then removes ordinary empty scope directories below `%APPDATA%`. The Harness home (`~/.dsh` or `DSH_HOME`: sessions, settings, credentials, plugins) is never touched, and a `DSH_HOME` published as a Windows environment variable additionally protects any target overlapping it. Silent uninstalls remove the same data; an uninstaller started with `--updated` or `/KEEP_APP_DATA`, as electron-builder does for in-place updates and for replacing an older installation from another directory, retains it. Removal runs through the native helper, which refuses protected Windows folders and paths overlapping the installation or the home, requires a fixed local drive, leaves a linked root or ancestor in place, unlinks reparse points without entering their targets, clears read-only attributes, and keeps deleting siblings after a locked file; residue never stops uninstallation and is not reported. Installation records `InstallLocation` on the Windows uninstall entry as standard inventory metadata; on Windows 11, Uninstall in the Start menu context menu opens the installed-apps list for every Win32 application, and only MSIX packages uninstall directly from there. The uninstaller declares DPI awareness and uses Microsoft YaHei UI for Chinese. This behavior is Windows-only. Use `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --compile-only` to compile isolated English and Chinese fixtures with a per-run scoped package name. Omit `--compile-only` to run the native remover regression and the interactive, silent, `--updated`, `/KEEP_APP_DATA` and `DSH_HOME`-inside-Electron-data checks against seeded data. Compilation does not establish installed-uninstall behavior. -### Windows EV signing - -Runtime signing shares complete signed files across the current Windows account's worktrees. `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` in `.env.windows` selects an absolute fixed-drive directory; the default is `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`. Cache directories must belong to the current account and exclude other ordinary accounts from their access permissions; linked paths are rejected. Entries identify the original bytes, public certificate and signing toolchain. Every restoration checks the digest, Windows trust, timestamp and certificate before replacing the unsigned file; invalid entries stop packaging without hardware fallback. Cache age alone does not trigger signing. The cache trusts programs running as the same account and does not defend against administrators. The [runtime signature cache decision](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md) owns qualification and design limits. - -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. - -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. - -Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect structurally complete-entry bytes and counts, or `--from ` to import an explicitly selected cache owned by the same account. Migration leaves the source unchanged, skips staging names, rejects corrupt entries and retains valid existing entries even when their timestamp bytes differ. `--clear` explicitly removes complete entries under the stage lock; no automatic capacity eviction runs. `--directory ` selects a maintenance destination without loading release credentials. Incomplete staging entries remain untouched and are counted separately; inspect them only after all builds have stopped. These commands never clear hardware failure evidence. Default storage is outside AppData so MSIX launcher virtualization does not split the account's cache; redirected overrides fail explicitly. - -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. - -Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the installer. The blockmap is uploaded before channel YAML; NSIS installer metadata does not require the embedded `blockMapSize` used by the separate web-installer format. File-plan tests use the pinned builder's blockmap generator, not a hand-authored embedded-map field. - -Signed Windows configuration derives the updater's `publisherName` from the same public certificate's `CN`, `O`, and `C` attributes. Each must be present, nonempty, and single-valued. These identity attributes allow certificate renewal without pinning a leaf thumbprint. The installed application's `app-update.yml` carries the expected publisher; the downloaded feed does not choose it. Unsigned test builds omit updater configuration. See the [signature qualification record](tests/README.md) for real-file verification and its limits. - -For this project's SafeNet token, `SignTool Error: No private key is available.` indicates an incorrect PIN. Stop all signing attempts immediately and wait for the user to correct the PIN before continuing. Five incorrect PIN attempts lock the token. Do not retry packaging or signing probes after this error. The signer serializes token operations and rejects all queued tasks after the first failure. - -Windows package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `.desktop-build/packaging-runs/`. Each run retains `run.json`, timestamped `events.jsonl`, redacted `stdout.log` and `stderr.log`, and `result.json`. A signing failure also writes `fatal.json` and notifies the parent through stderr; the supervisor immediately requests termination of its stage process tree and waits for exit. Failed stages cannot start subsequent stages or create a release completion record. Journal failures also stop the run. Termination errors remain failures and require operator inspection; a missing final record means completion was not established. - -Hardware signing requires a supervised run. Before invoking the command interpreter, the signer atomically acquires `%USERPROFILE%/.dsh-desktop-signing/attempt.json` and records the attempt. Only successful signing followed by verification of the configured primary signature releases that file; timestamps are completed afterward without hardware access. Failure, interruption, an existing interlock, or unavailable audit storage prevents further hardware access, including from another signer instance, process, or checkout under the same Windows account. There is no timed recovery or automatic retry. An administrator must inspect the retained evidence and token state before explicitly authorizing interlock recovery; logging into the token or replacing a PIN file does not clear it. The records distinguish signing intent, command-interpreter PID, and completion; they do not measure internal CSP/token authentication attempts. Command arguments, PINs, and credential environments are not recorded. Independent Windows accounts and unrelated signing programs are outside this interlock. - -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. - -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. - -Fill in `.env.windows` with `DSH_DESKTOP_WINDOWS_CER_FILE` (public EV leaf certificate), `DSH_DESKTOP_WINDOWS_SIGNTOOL` (SafeNet-compatible SignTool), `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` (matching private-key container), and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` (Token Password). The private key stays on the USB token; keep the certificate and local credential file out of Git. - -```sh -pnpm run package:desktop:win:x64 -``` - -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). - -The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives signing preflight, the dedicated runtime signing stages and electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` stores the PIN in plaintext and needs restricted file access; CI uses a temporary file and deletes it after the job. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure. - -Create a runnable application directory instead of an installer by using the matching `:dir` command, such as: - -```sh -pnpm run package:desktop:dir -pnpm run package:desktop:mac:arm64:dir -``` - -To inspect or troubleshoot the prepared host-target resources without invoking electron-builder, stop the same pipeline after preparation: - -```sh -pnpm run prepare:desktop -``` - -This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or dsh content. - - - -### Desktop runtime preparation - -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 and 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. - -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. + ## Updates @@ -455,7 +269,7 @@ On Windows, the downloaded-update confirmation explains that the application clo Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its input interception and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace. -Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page. +Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page. On Windows, dismissal hides the modal before destroying it so the parent is enabled before native window activation changes. `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries. @@ -532,10 +346,14 @@ The command builds the Desktop shell and runs its coordinator with real Electron The downloaded bytes are inert, and the installation call is recorded rather than executed. The test substitutes browser opening and clipboard writing to avoid external navigation and clipboard changes. It does not boot the full product workspace, qualify a real installer or restart, verify publisher signatures, or exercise differential updates or macOS. Stalled policy requests, feed requests, and payload transfers exercise real deadlines and recovery. The actual ordinary dialog verifies isolated preload loading, card geometry, an unfiltered parent page, cancellation, task-warning choices, and explicit installation approval; account-row component tests provide separate evidence. The [local qualification decision](../../.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md) and [verification record](tests/README.md) preserve these limits; production release requirements remain unchanged. + + ## Low-level development overrides An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY` and `DSH_DESKTOP_DSH_DIR` are optional overrides with application-path defaults. `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` is required for every unpackaged launch: the development launchers (`dev:desktop`, `start:desktop`, and the workspace-update qualification runner) set it to the primary-runtime directory of the target they prepared, and a launch without it fails with the fatal startup dialog. The launcher must set it because the shell cannot derive that directory from `process.arch`: the build target fixes Windows to x64 while the host may be arm64. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile. + + ## Known limitations - Account sign-in is not connected; the Sign in button is disabled. Windows material rendering still requires platform QA. @@ -559,6 +377,8 @@ Private Platform deployment headers are injected by the embedded browser session The account provider’s `embeddedPageDist` configuration adds a `dist` query parameter to embedded Usage and Top-up URLs. Its default is empty; private frontend branch selectors belong in the local profile patch. It does not change API URLs or credential delivery. + + ## Dev Note Pre-launch CDN and capacity decisions are tracked in the [Desktop update proposal](../../.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md#cdn-and-capacity-qualification). diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index d5c52ebc1b..430ac4ad3d 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -1,7 +1,35 @@ +--- +description: "Desktop 在原生应用中运行 Harness 工作区。本参考文档说明应用行为、开发与集成;打包、Windows 签名和发布指南分别维护构建与发布操作。" +--- + # DeepSeek Harness 桌面端 [English](README.md) | 中文 +## 概述 + +Desktop 在原生应用中运行 Harness 工作区。本参考文档说明应用行为、开发与集成;打包、Windows 签名和发布指南分别维护构建与发布操作。 + +## 目录 + +- [应用概览](#application-overview) +- [终端命令](#terminal-command) +- [关闭窗口与退出](#closing-the-window-and-quitting) +- [关键技术决策](#key-technical-decisions) +- [内置命令运行时](#bundled-command-runtime) +- [安装归属](#installation-ownership) +- [开发](#develop) +- [打包](#package) +- [Windows 安装界面](#windows-installer-interface) +- [更新](#updates) +- [底层开发覆盖项](#low-level-development-overrides) +- [已知限制](#known-limitations) +- [开发备注](#dev-note) + + + +## 应用概览 + 桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。安装更新会等待该操作的本地埋点接收请求结束,再锁定 API 准入并停止 Host。接收请求的时限为一秒,失败不会阻止安装,也不等待收集端完成发送。 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认监听系统分配的端口,因此不会与 Web 的 `3080` 或系统保留端口冲突;可通过 `webserver.config.port` patch 覆盖。 @@ -12,7 +40,7 @@ Desktop 绑定 `127.0.0.1`,即 Electron 为就绪 URL 与 WebSocket 凭据过 Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。 -Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 pnpm,无需 PATH 中存在 pnpm。私有 Node 启动器环境仅应用于包操作。 +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 功能返回栏下方的视口。 @@ -24,6 +52,8 @@ Desktop Host 的 Platform API 请求与更新策略请求用相同的 Platform 按 F12(多媒体功能键键盘上为 Fn+F12)、macOS 的 Command+Option+I 或 Windows 的 Ctrl+Shift+I,可切换当前获得焦点的应用页面的 DevTools,打包版本同样支持。这些原生快捷键通过隐藏的应用菜单项注册。更新遮罩和打包版本的内嵌浏览器禁用 DevTools。 + + ## 终端命令 应用菜单中的**管理 dsh 命令…**位于**检查更新…**下方,显示当前命令,并提供安装、修复和移除操作。命令复用 Desktop 已安装的运行时和普通 [dsh CLI](../cli/README.zh.md),Desktop 应用关闭后也可以使用。安装后打开新终端,运行 `dsh --version`。 @@ -32,6 +62,8 @@ macOS 安装会创建 `/usr/local/bin/dsh`;目录权限需要时,系统会 命令注册是安装 Desktop 后的可选操作。卸载 Desktop 前,请通过**管理 dsh 命令… → 移除**删除其 CLI 注册;应用卸载程序不会移除该注册。更新或卸载 Desktop 前请结束 CLI 命令。CLI 运行时版本随已安装的 Desktop 版本变化。Desktop 插件命令与运行时限制见[内置命令运行时](#bundled-command-runtime)。 + + ## 关闭窗口与退出 关闭主窗口(macOS 的关闭按钮和 ⌘W;Windows 的 ×、Alt+F4 和任务栏"关闭窗口")会隐藏窗口;Windows 首次隐藏前需要确认。页面和 Host 继续运行,任务不受影响,下次显示时仍是原来的文档,会话、草稿和滚动位置都保留;macOS 全屏窗口先退出全屏再隐藏。macOS 通过 Dock 图标、再次启动或 `dsh://open` 找回窗口,Windows 通过托盘找回。最小化行为不变。进入工作区前关闭欢迎窗口,Windows 上走退出流程,macOS 上应用留在 Dock 中且没有窗口。 @@ -46,6 +78,8 @@ Windows 在整个运行期间常驻托盘图标。悬停提示为产品名,单 托盘渲染器以底板中心为基准将鲸鱼放大 20%,保留背景和宽高比;应用和安装器图标保持原有比例。 + + ## 关键技术决策 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。 @@ -62,17 +96,19 @@ macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含 electron-builder 只把清单中的 `dependencies` 复制进 `app.asar/node_modules`,因此 Electron 主进程 bundle `lib/main.js` 内联其工作区 devDependencies,裸导入只剩 `electron`、Node 内置模块与这些 `dependencies`;沙箱 preload 只能留下 `electron`、`events`、`timers` 与 `url`,即其 `require` polyfill 能解析的模块。主进程 bundle 从被内联包的 `lib/` 产物解析它们,所以根 `build:lib:host` 在并发的工作区 tsdown 阶段之后才为 `apps/desktop` 打 bundle,并由 [`desktop-bundle-imports`](scripts/desktop-bundle-imports.mjs) 让任何静态、动态或 `require()` 导入无法在打包应用内解析的 Desktop bundle 直接失败。没有这项检查时,rolldown 无法解析的导入会作为外部说明符进入产物,并在启动时以 `ERR_MODULE_NOT_FOUND` 失败。[已归档的bundle 顺序决策](../../.agents/notes/archived/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md)记录了备选方案。 -Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产依赖,包括没有常规扩展名的文件。最终扫描覆盖整个解包应用。目录链接、格式错误的 `MZ` 文件以及非 PE 的 `.exe`、`.dll` 或 `.pyd` 文件会使打包停止;以 `MZ` 开头的数据文件也会被拒绝,除非包含有效 PE 头。它保留有效的上游签名,并在记录运行时哈希或执行冒烟检查前为未签名代码补签。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程;硬件令牌签名仍串行执行,每个新签名必须匹配配置的证书且带时间戳。硬件签名或验签失败会停止本轮执行;独立的时间戳请求遵循下文的有界重试规则。electron-builder 只有在验签和逐字节比对通过后,才保留复制后运行时可执行文件的签名。写入发布完成记录前,必须通过最终 PE 签名检查,以及使用全新缓存的 ASAR 载荷和 Host 冒烟检查。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表兼容所有企业策略。 +Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产依赖,包括没有常规扩展名的文件。最终扫描覆盖整个解包应用。目录链接、格式错误的 `MZ` 文件以及非 PE 的 `.exe`、`.dll` 或 `.pyd` 文件会使打包停止;以 `MZ` 开头的数据文件也会被拒绝,除非包含有效 PE 头。它保留有效的上游签名,并在记录运行时哈希或执行冒烟检查前为未签名代码补签。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程;硬件令牌签名仍串行执行,每个新签名必须匹配配置的证书且带时间戳。硬件签名或验签失败会停止本轮执行;独立的时间戳请求遵循[有界重试规则](windows-signing/README.zh.md#windows-ev-signing)。electron-builder 只有在验签和逐字节比对通过后,才保留复制后运行时可执行文件的签名。写入发布完成记录前,必须通过最终 PE 签名检查,以及使用全新缓存的 ASAR 载荷和 Host 冒烟检查。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表兼容所有企业策略。 Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。 -Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技能使用内置 Python 库创建文件和进行定点编辑,随后重新打开文件,并在交付前运行共享结构检查器。PowerPoint 的创建和编辑使用 python-pptx。技能资源复制到 ASAR 外的 `runtime/office-skills`,让 Python 可以读取检查器。可用的 `render_document` 工具可以补充视觉检查;缺少该工具不妨碍创作或交付。检查范围与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。 +Desktop 展示精简的 `office` 路由及可具名调用的创作/操作子技能。创作使用内置 Python 库;操作技能调用配置的 Node 和 CLI 路径。技能位于 ASAR 外的 `runtime/office-skills`。渐进加载与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。 该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台、顶层解释器和包管理器版本及 Python 分发包版本表,以及所选目标的锁定产物输入与组装格式的摘要。分发包名称按 PEP 503 归一化;名称归一化后重复时,清单会被拒绝。旧 `components` 清单通过归一化继续可读,并保留其原有库版本一致性校验。匹配的安装会被复用;依赖或压缩包变化后,即使 Desktop 版本不变,也会在完整暂存副本完成后替换目录。不含摘要的旧清单会在下次安装时被替换。用户自行添加的 Python 包仅在产物身份一致时保留。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。 Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。 -Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限;x64 目标还获得 `com.apple.security.cs.allow-unsigned-executable-memory`,允许可执行代码页,包括在 Rosetta 下运行时。ARM64 Node 和 Office 辅助程序保留仅 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 +Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。macOS 只对版本化 Python 可执行文件签名一次,在外部资源及安装副本中保留[两个解释器别名](../../packages/skill/tool-workspace-dependencies/README.zh.md)。POSIX 本机目标检查要求解释器别名为相对链接。所有本机目标检查验证命令名、子进程执行及包含 pip 的 venv,并在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限;x64 目标还获得 `com.apple.security.cs.allow-unsigned-executable-memory`,允许可执行代码页,包括在 Rosetta 下运行时。ARM64 Node 和 Office 辅助程序保留仅 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 + +Desktop/SDK 共享运行时构建器在复制或签名产物前,裁剪 NumPy 和 pandas 下名为 `tests` 的目录,并移除各分发包 `RECORD` 中对应的记录。它保留 `numpy.testing`、`pandas.testing`、`pandas._testing`、原生扩展和许可证。完整上游测试套件不可用;本机检查验证保留的 testing API、测试目录已移除,以及清单所列文件存在。 | 决策 | 原因 | 直接结果 | |---|---|---| @@ -98,6 +134,8 @@ Welcome 加载共享 Toast 的配色和阴影变量,挂载在 body 下的通 更新或卸载 Desktop 前请结束 CLI 命令;安装程序不与运行中的命令协调。CLI 不提供 Office 创作 skill 的资源路径。Desktop 现有 Office 设置与 Web 附件转换保留各自的资源配置。`prepare:runtime` 复制启动脚本,无需编译原生 CLI 启动器。 + + ## 安装归属 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动此 profile。Desktop 内置命令可在应用退出后管理其插件;npm 安装的 dsh 不能修改它。 @@ -144,6 +182,8 @@ macOS 上自定义菜单保留 Electron 的标准 Window 菜单及应用隐藏 macOS 和 Linux 从图形界面启动的程序只继承会话管理器提供的环境,不包含 shell 启动文件导出的变量。第一个 Host 启动之前,Desktop 以 ` -ilc` 运行一次账户的登录 shell(取自用户数据库,不看 `$SHELL`),读取定界符之间的 `env -0` 输出,使 `~/.zprofile` 和 `~/.zshrc`(或该 shell 的对应文件)对 Host、agent shell、终端和 profile 配置生效。读取与 profile 准备并行进行。读取进程没有终端输入,并设置 `DISABLE_AUTO_UPDATE=true`、`ZSH_TMUX_AUTOSTARTED=true` 和 `ZSH_TMUX_AUTOSTART=false`,避免 oh-my-zsh 和 tmux 插件阻塞。shell 的值覆盖继承的值,但 `PWD`、`OLDPWD`、`SHLVL`、`_`、上述读取变量以及启动方自有的 `DSH_*` 和 `ELECTRON_*` 除外;Desktop 在读取之前已按 `DSH_HOME` 等变量解析路径,因此 Host 保持相同的值。读取在结束定界符出现时完成,因此启动文件启动的后台进程可以继续运行,其输出被丢弃。候选 shell 无法启动、以非零状态退出、没有输出定界内容或超过 `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS`(1000 到 2147483647 的整数毫秒,默认 `10000`;超时会结束其进程组)时,Desktop 记录一条警告,并依次尝试 `/bin/zsh`、`/bin/bash` 和 `/bin/sh`;全部失败时 Host 使用继承的环境。读取命令使用 POSIX 语法,因此 csh、tcsh 或 nushell 等账户 shell 会失败,Host 改为获得第一个系统 shell 的启动文件所设置的环境。读取期间退出 Desktop 会结束正在运行的读取进程组。每个应用进程只读取一次,因此修改 shell 启动文件后需要退出并重新打开 Desktop。Windows 从图形界面启动的程序已经从注册表继承用户和系统环境变量,因此 Windows 跳过这一步。 + + ## 开发 开发环境应用菜单提供“刷新页面”(macOS 为 Cmd+R,其他平台为 Ctrl+R)和“重启应用与 Host”。重启会等待 Host 关闭,再重新启动 Electron 和新的 Host;这两项操作都不会重新构建源码。 @@ -184,272 +224,44 @@ Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否 欢迎窗口使用设计稿的 Platform light/dark 颜色跟随系统外观,展示 600 × 700 的入口布局和 API Key 表单,包含原生窗口控件、可拖动标题区域、本地品牌 SVG、系统无衬线字体回退,以及非按钮文字使用的本地 Montserrat Light 字体。窗口使用 macOS menu vibrancy 或 Windows acrylic,叠加 onboarding 的窗口背景色:浅色模式为 40% 白色,深色模式为 50% rgb(24 25 28)。本地 React 欢迎入口将 React、公共 `StateDot` 加载指示器及其 CSS 一起打包;它通过隔离 preload 工作,不加载主 Web 应用。入口、登录状态和 API Key 页面共用固定的底部操作行;“返回登录”链接位于操作行下方。按钮共用平台的过渡时序,开启“减少动态效果”会禁用过渡。操作系统控制模糊强度和外部圆角。macOS 的“降低透明度”会抑制半透明效果,“增强对比度”会强制开启该设置。“保存并继续”写入开发环境的凭证存储;“稍后配置”打开真实工作区,不保存密钥或完成标记。生成的开发项目同时链接已声明的 workspace 依赖闭包和 pnpm 提升的包,因此未提升的配置插件仍能解析。[窗口记录](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责材质与引导决策。 + + ## 打包 - -### 发布版本 - -每次 Desktop 打包前,第一步都要与当前用户确认完整版本号。检查所选部署环境、dsh 基础版本、保留的发布记录和已发布对象,再提出准确版本供用户确认。用户确认前不得启动打包;仅选择部署环境不代表用户已认可版本号。 - -记录当前 dsh 版本作为基础版本。production Desktop 使用完全相同的版本,包括其中的 `alpha`、`beta` 或 `rc` 标识。test 发布保留完整的预发布基础版本并追加 `.YYYYMMDD.index`;稳定基础版本则追加 `-test.YYYYMMDD.index`。 - -| dsh 基础版本 | production Desktop | test Desktop 示例 | -|---|---|---| -| `0.1.6-alpha.1` | `0.1.6-alpha.1` | `0.1.6-alpha.1.20260916.1` | -| `0.1.6-beta.2` | `0.1.6-beta.2` | `0.1.6-beta.2.20260916.1` | -| `0.1.6-rc.3` | `0.1.6-rc.3` | `0.1.6-rc.3.20260916.1` | -| `0.1.6` | `0.1.6` | `0.1.6-test.20260916.1` | - -日期使用实际创建时的 Asia/Shanghai 日期。每个基础版本、每天的序号从 1 开始,检查保留的发布记录与已发布对象后递增;绝不复用已发布版本。test 分发不发布对应的无后缀基础版本。 - -Windows 手动 CI 使用独立的未发布构建标识:`auto` 追加 UTC 日期、工作流运行编号和尝试次数(`.YYYYMMDD.run.attempt`,稳定基础版本使用 `-test`)。例如 `0.2.1-alpha.1.20261008.42.2`。这些编号不是预留的发布序号。 - -把确认后的版本通过 `--build-version` 传给打包命令,该值同时决定产物文件名、更新 feed 与上传校验。清单保留产品版本,因此 test 打包不再改写发布家族,也不留下需要还原的改动: - -```sh -pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.20260916.1 -``` - -`--build-version auto` 会给出当天的下一个序号:读取目标 bucket 中已发布的对象,未配置 bucket 或列举未能在期限内完成时回退到本目标的本地输出目录。上传前请确认它打印的版本号;run script 会自行透传 `--`,打包入口两种写法都接受。 - -production 发布使用产品版本本身,不传 `--build-version`。其上传成功后会把打包所用 commit 打成 `desktop-v<版本>` 标签;来自有改动工作区的构建不打标签,打标签失败也只打印手工命令,不会让已完成的上传变成失败。test 与本地构建有意不留标签,而所有产物的清单都记录 `dshBuildCommit` 与 `dshBuildDirty`,直接分发的构建同样可溯源。 - -版本派生不改变固定更新通道,也不改变 `nightly.yml` / `nightly-mac.yml` 文件名。SemVer 排序为 `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`,稳定基础版本的测试版低于该稳定版。客户端只接受更高版本:替换 feed 无法让已安装的较高版本更新到较低的纠正版。这类客户端需要手动安装;保持自动降级关闭。[已归档的版本决策](../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md)解释为什么不能用通道名替换预发布标识。 - -除本地 macOS 未签名构建外,打包、上传以及手动 macOS 签名检查使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](.env.windows.example) 或 [macOS 模板](.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从目标文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。发布版本是命令参数而非发布字段,上传从打包写下的完成记录中读取它。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。CI 同样在运行前生成目标文件。 - -每条打包命令在构建与下载前检查应用 ID 以及该模式需要的更新与签名配置,随后探测本次运行要用的外部工具:归档读取工具,以及 Windows 目标的安装器编译器。macOS 检查身份、Team ID、一套完整公证凭据、`CSC_LINK` 指定的可读本地 p12 文件、显式配置的 `CSC_KEY_PASSWORD`,以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源以及两个平台上的显式未签名打包均不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。`--build-version auto` 会访问目标 bucket,`--check` 下同样如此。单独运行相同检查: - -```sh -pnpm --dir apps/desktop run check:package -``` - -无需提前执行 `prepare:desktop`: - -```sh -pnpm run package:desktop -``` - -发布自动化使用固定目标命令,确保运行时准备、dsh 准备与 electron-builder 接收相同的平台和架构: - -```sh -pnpm run package:desktop:mac:arm64 -pnpm run package:desktop:mac:x64 -pnpm run package:desktop:win:x64 -``` - -macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 或带 Rosetta 的 Apple Silicon 上运行。Windows x64 命令要求 Windows x64。Linux 不是受支持的 Desktop 发布目标。 - -每个目标都在 `apps/desktop/.desktop-build/targets//` 下持有自己的打包输入、已准备运行时、包集合、dsh 依赖树、pnpm 准备状态、未打包应用、更新元数据和最终产物。Electron 归档缓存继续由 `.desktop-build/downloads` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。 - -### 无发布签名的本地 macOS DMG - -执行 `pnpm install` 后,在仓库根目录运行一条命令即可,无需 `.env.macos`、Apple 账号、证书或更新服务配置: - -```sh -pnpm run package:desktop:mac:arm64:unsigned -pnpm run package:desktop:mac:x64:unsigned -``` - -添加 `--check` 仅验证本地配置和工具而不构建,或添加 `--dir` 只生成解包应用。DMG 路径为 `apps/desktop/.desktop-build/targets//unsigned-artifacts/deepseek-harness--mac--unsigned.dmg`。所有检查通过后,命令打印 `DMG:` 及其绝对路径。打开后将应用复制到本地测试目录。 - -这些命令忽略 `.env.macos` 和继承的发布配置,使用 `com.deepseek.harness` 作为应用 ID,并省略自动更新和强制更新配置。构建工具、标准网络代理与 `DSH_DESKTOP_NPM_REGISTRY` 仍可从 shell 提供。它们跳过 Developer ID 签名、Apple 公证、临时签名钥匙串和公证代理修改。Electron 使用无需证书的本地 ad-hoc 签名,以便修改后的可执行文件能够运行;DMG 不签名。准备阶段和组装后的应用仍执行运行时完整性与冒烟检查。本地构建不生成更新源、ZIP 或发布完成记录,上传命令无法发布这些产物。macOS 可能要求在“系统设置 → 隐私与安全性”中批准打开未经公证的应用。 - -### 用回环 registry 安装本地 Official 包 - -测试 DMG 不包含 Official 按需目录所宣传的 provider 运行时,而正式发布才会把对应版本的 bundle 发到 npm。要在发布前安装本地构建的 bundle,请启动一个回环 registry,它会打包并提供该目录的完整本地依赖闭包: - -```sh -pnpm run dev:bundle-registry -``` - -该命令先校验目录与 workspace 清单,运行 `pnpm run build:official`,把闭包打包进新的 `dist/test-bundles//` 目录,最后写入 `bundle-registry.json` 作为完成标记,然后才打印产物路径、registry URL 和精确的 `name@version` 规格。它在分配到的端口上只监听 `127.0.0.1`,使用一次性的命名空间提供服务,直到 Ctrl+C 或 SIGTERM;停止后产物和已安装的包都保留在磁盘上。它不需要 `.env.macos`、Apple 凭据或 npm 凭据,也不改变 `pnpm run package:desktop:unsigned`。 - -在打包应用中打开“插件 → 添加插件”,粘贴一条打印出的规格,选择**自定义地址**,粘贴打印出的 registry URL 后安装。自定义 registry 会被单独询问,因此必须由本进程回答每一个依赖:仓库自有的包取自本次运行的产物,第三方包则通过固定重定向到 `registry.npmjs.org` 保留 npm 的真实元数据与字节,因此仍需要网络访问。安装后的条目仍归类为 Official,因为它的包名在目录中。registry 选择会在此浏览器中记住;再次选择某个提供的 registry 即可切回。 - -一次运行的产物是不可变的,所以重新构建会提供新的命名空间和新 URL;当已安装版本与运行版本一致时,Official 的**更新**控件不会出现。profile 会在 `pnpm-lock.yaml` 中记录上一次运行的 tarball URL,而 pnpm 会校验已记录的条目,因此用新 URL 再次安装或重装该 bundle 会被 `ERR_PNPM_TARBALL_URL_MISMATCH` 拒绝,并且可能继续保留旧文件。要在同一版本上验证改动后的代码,请先清除过期的解析结果——在 `$DSH_HOME/profiles/` 中运行 `pnpm clean --lockfile`,或改用全新的 profile——然后再从新 URL 安装。`bundle-registry.json` 记录每个归档的版本、文件名、大小和 SHA-512 完整性,以及打包所用 checkout 的 commit 和 dirty 标记。 - -所提供的 `time` 是真实的打包时间,因此 pnpm 11 默认的 24 小时 `minimumReleaseAge` 会在 profile 的 `pnpm-workspace.yaml` 中记录它授予的豁免(`minimumReleaseAgeExclude`);如果那里显式配置了更严格的策略,安装会以 pnpm 自己的报错失败,而不会被覆盖。`~/.npmrc` 或 profile 的 `.npmrc` 中的 `@scope:registry` 路由会覆盖该 scope 的 `--registry`,因此不能让它们指向正在提供服务的 scope。 - -### 运行时文件筛选 - -Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 - -[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。 - -打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。 - -Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录;通过 `-FrameLibrary` 传入已准备好的 `window-frame.dll`,即可同时覆盖原生解压路径及其失败报告。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。 - -```powershell -pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll -``` - -Windows 安装器在启动时和选定目标目录后检查应用是否正在运行,通过检查后才将新版本解压到安装目录旁边。通过同卷目录改名替换前,安装器会再次检查。运行中的应用会阻止安装;更新启动允许等待应用退出,最长十秒。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。 - -解压失败时,安装器会把 7-Zip 的结果和完整错误输出写入更新缓存目录 `%LOCALAPPDATA%\<按包名派生>-updater\installer-logs\extract-failure-<时间戳>.log`(当前为 `@deepseek-aidsh-desktop-updater`),并在弹窗中显示首条错误行和 **复制错误信息** 按钮;静默安装只写入报告。未签名的 Windows 构建(`DSH_DESKTOP_UNSIGNED=1`)会将安装包命名为 `deepseek-harness-<版本>-win-x64-unsigned.exe`,以免被误当作发布产物。 - + + + + + + -### 上传更新 +构建与发布操作请使用独立指南。共享 Windows runner 存在已接受的凭据和设备隔离限制;签名或执行分支代码前必须阅读。 -固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。部署基础设施必须配置重新验证,或在覆盖后清除固定下载的缓存。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 - -test 与 production 的 `upload:*` 上传在发布前置检查通过后,分别保留新的 `.desktop-build/upload-records/--*` 目录。`plan.json` 记录目标、版本、每个文件的大小/SHA-512 和发布的 YAML 字节;刷盘的 `events.jsonl` 记录 PUT 意图及可用的响应状态/请求 ID;`result.json` 记录完成结果或最后失败阶段。缺少最终结果表示中断或存储不可用,不表示成功。不记录凭据值、认证头或原始 SDK 错误。审计写入失败即停止后续 PUT。每个对象都以一次流式腾讯 COS PUT 上传,并携带显式长度与 Content-MD5;COS SDK 仅在请求体不是流时才会重发请求,上传器自身也不重试。保留部分记录,检查远端状态后再执行下一次操作:超时或回执写入失败不能证明对象未存储。这些记录仅在本地,不防篡改,也不会自动备份;每次发布应将它们与构建证据一同归档到受控存储。公网 CDN 回读仍是单独的发布验收,上传结果明确标记为 `not-performed`。 - -Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 - -`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。生产清单位于 `dsh-desk/feeds//`,安装包位于 `dsh-desk/bin//`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk//feeds//` 和 `dsh-desk//bin//`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。 - -更新目标与上传凭据都与所选环境对应: - -| 环境 | 公开 origin | COS bucket | COS 凭据 | -|---|---|---|---| -| `test` 或未设置 | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`、`DOWNLOAD_TEST_COS_SECRET_KEY` | -| `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`、`DOWNLOAD_PROD_COS_SECRET_KEY` | - -每个测试发布批次用下方命令生成新 ID,将输出填入 `.env.macos` 或 `.env.windows` 的 `DOWNLOAD_TEST_RELEASE_ID`。这两个被 Git 忽略的平台文件管理该值,shell 变量不能覆盖它,dotenv 值也不会进行 shell 展开。打包、上传和重试必须沿用同一个 ID;上传会拒绝更新 URL 不一致的完成记录。需要验证跨版本升级时,后续版本沿用已安装客户端的 ID。生产环境不使用该字段。 - -```sh -node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(16).toString('hex'))" -``` - -通过完整下载链接分发每个测试批次。已安装的测试客户端保留当前批次的清单地址,不会自动发现新 ID。格式校验无法判断随机性,请使用生成器的输出。随机路径降低被猜中的概率,不限制持有链接者访问;撤下批次需要删除其 COS 对象并清除对应 CDN 目录缓存。 - -在目标 `.env` 中配置更新地址与所选 COS bucket、SecretId、SecretKey,再打包并上传同一个目标: - -```sh -pnpm run package:desktop:mac:arm64 -pnpm run upload:mac:arm64 -``` - -内测打包在目标 `.env` 中显式设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=test` 和 `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`;上传使用 `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` 及独立测试凭据。test 和 production 都使用固定 Nightly 通道,部署选择不提供通道切换。 - -前期内测包使用 `test` 部署。只有正式发布才显式选择 `production`;更换上传凭据不会改变已有安装包的更新目标。打包不需要 COS 凭据,会禁用 electron-builder 发布、移除子进程的 COS 凭据,并且仅在签名与公证成功后记录完成状态。上传在读取凭据前验证该记录、部署、目标、共同版本号、文件名、大小与 SHA-512。安装包和 blockmap 先于 YAML 上传;历史对象继续保留。每个版本发布 `nightly.yml` 或 `nightly-mac.yml`;稳定版本还发布指向相同产物的 `latest.yml` 或 `latest-mac.yml`。发布的 YAML 使用安装包绝对 URL。上传器不设置 Cache-Control,包括 COS SDK 否则会添加的空头部:缓存策略由部署基础设施负责,清单不缓存,安装包缓存单独配置。同一目标应串行发布,并在发布验收前验证公网产物与清单内容。 - -macOS 签名配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 签名打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 签名固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。签名的仅目录命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。`CSC_LINK` 必须指向包含 Developer ID Application 证书及私钥的本地 p12,不支持 URL 或 Base64 输入。`CSC_KEY_PASSWORD` 是其导出密码,不是 Apple 账号或登录密码;未加密的 p12 可显式填写空值。构建前,打包流程自动创建并解锁私有临时钥匙串、导入 p12、授权签名并签署小型探针。运行时与 App 签名显式使用该钥匙串,无需预先配置或手动解锁登录钥匙串。子进程只接收钥匙串路径,不接收 p12 密码。成功或普通失败后删除临时钥匙串;强制终止后由 CI 清理临时凭据。CI 从密钥存储生成证书文件和 `.env.macos`,限制文件访问权限,并在作业结束后删除二者。环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 - -macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。 - -macOS 运行时准备将已验证的单架构 Mach-O 签名缓存在 `.desktop-build/targets//signature-cache`。缓存键包含输入字节与权限、签名探针实际使用的叶证书、签名标识、entitlement 字节、macOS 版本,以及签名工具与策略。复用不取决于 Git 提交:未提交的字节变更会使对应文件失效。每次命中都验证缓存字节、严格签名、证书、标识、entitlements、安全时间戳和 hardened runtime,再替换未被并发修改的输入。通用二进制每次重新签名。运行时完整性与 smoke 检查、App 签名和 Apple 公证仍会执行。缓存要求受信任的本地构建存储。缓存损坏或不安全链接会令构建失败;停止打包后删除对应缓存目录再重试。成功的签名阶段会将完整缓存条目的内容总量裁剪至一 GiB;中断留下的临时条目需手动清理。并发淘汰可能使读取方安全失败。日志记录命中、未命中及未缓存数量。 - -Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(发布版本、产品版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖;不记录环境变量全集或 notarytool 认证参数。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。 - -App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。 - -Mac 打包从 `.env.macos` 读取三个调优字段: - -| 设置 | 默认值 | 作用范围 | -|---|---|---| -| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | 第一方与 vendor workspace tarball 的打包 worker 数;必须是正整数。 | -| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | 空 | Electron、运行时资源、pnpm 安装及 builder 下载使用的 HTTP/HTTPS 代理 origin。 | -| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | 空 | 通过临时系统代理设置供 Apple 工具使用的 HTTP 代理 origin。 | - -两个代理字段互相独立,拒绝 URL 中的凭证、路径、查询参数和片段。空值沿用继承的网络设置。显式下载代理会替换子进程的代理变量,仅绕过本地主机;它不修改系统设置。Windows 与独立 `release:pack` 保持现有并发默认值。公司代理地址仅写入 Git 忽略的本地文件;具体地址参见内部文档。 - -Apple 工具使用 macOS 当前活动网络服务的 HTTP/HTTPS 代理。配置公证代理后,打包会检查代理可达性、保存该服务的设置,在两条产物任务期间启用代理,并在两条任务均结束后恢复原设置。对于原本关闭、服务器为空且端口为零的代理,恢复时仅关闭代理;临时服务器和端口可能保留,但不生效。仅生成目录的打包会在签名目录构建完成后的 App 公证期间启用代理。这会临时影响其他应用,并要求修改系统代理的权限;必须先禁用 PAC、自动发现、SOCKS 及需要认证的代理配置。打包和恢复在读取恢复记录或修改代理前获取同一个用户级 POSIX 文件锁;进程退出会释放锁的持有权,锁文件保留。该锁在首次使用时才加载 `@deepseek-ai/node-addon-system/flock`,而不是在脚本启动时加载,因此 `check:package` 和打包入口在未构建 `native/system` 的 checkout 上也能加载;加锁时若宿主 addon 二进制或入口的 JavaScript 缺失,加载器会先运行 `pnpm run build:native-system` 和 `pnpm --dir native/system run build:ts` 再加锁,因此恢复命令在这样的 checkout 上同样可用。这会阻止不同 checkout 的代理事务重叠;其他用户及网络设置工具不得同时修改这些设置。SIGINT/SIGTERM 会等待活动任务结束后恢复。强制终止或恢复失败后,先停止残留公证进程,再运行 `pnpm --dir apps/desktop run restore:mac-proxy`;保存的记录会保留到恢复成功。配置检查仅验证 URL 语法,不修改系统设置或连接代理。 - -### 未签名 Windows 测试安装包 - -在 Windows x64 上,使用完整的未签名打包命令进行本地安装测试: - -```sh -pnpm run package:desktop:win:x64:unsigned -``` - -该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](#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 或上传凭据。签名打包和上传仍遵循正式发布要求。 - -先运行 `pnpm install --frozen-lockfile`,再运行 `pnpm run package:desktop:win:x64:unsigned --check` 校验配置和工具,最后执行上面的完整命令。打包自行执行完整构建和隔离的运行时 smoke,不调用在线模型。阶段日志保留在 `.desktop-build/packaging-runs/`;仅构建成功不代表安装包验证通过。依赖安装和运行时准备需要访问 npm、GitHub release 资源和 nodejs.org;网络需要时使用现有 registry 和代理配置。缓存与工具位置属于构建机器配置,不在跟踪文件中固定个人路径。 - -[Windows 手动工作流](../../.github/workflows/windows-package.yml) 选择带有专用标签 `dsh-win-package-trial` 的自托管 Windows x64 runner,并调用同一个未签名命令。启动 runner 前需准备 PowerShell 7 和上述工具。必须填写策略部署环境和 HTTPS origin;测试部署还需填写逗号分隔的登录 origin。这些公开输入生成临时 `.env.windows`,不包含签名或上传凭据。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中说明的 CI 标识。显式版本复用现有产品版本校验器。解析后的版本在打包前显示于运行摘要,manifest 保持不变。 - -工作流进入默认分支后,在 Actions 中选择它,选择源码分支、填写输入并点击 Run workflow。它不监听 push 或 PR,不改变现有自动 CI。打包日志和成功生成的 EXE 产物保留 14 天;安装、签名和发布是独立操作。依赖 Actions 打包前,先从默认分支运行一次,并检查保留的日志和安装包。Checkout 会清理 runner 专用源码目录,包括被忽略的文件;不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新的手动触发会替换该待运行任务。仅在成功完成干净检出后上传诊断日志;即使配置准备在写入后失败,也会清理生成的配置。 +- [Desktop 打包](packaging/README.zh.md):本地与 Actions 构建、版本、部署配置、产物、日志和验收。 +- [Windows 签名与 runner 运维](windows-signing/README.zh.md):机器部署、已接受的隔离风险、签名凭据、Token 失败与缓存维护。 +- [Desktop 发布](publishing/README.zh.md):上传凭据、发布标识、固定下载、更新清单和上传恢复。 -### Windows 安装界面 + + +## Windows 安装界面 Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影、可编辑的安装目录,以及默认勾选立即启动的完成页。安装仅面向当前用户。点击安装或按 Enter 均校验当前路径;路径规范化后仍拒绝磁盘根目录,包括带重复末尾分隔符的写法。新安装位置必须为空,非空位置必须是已登记的安装目录。受影响安装路径中的程序运行时显示系统提示,并保持应用运行;其他目录中的同名应用不阻止安装。静默更新最多等待受影响应用退出十秒,若仍在运行则以退出码 2 结束。 -主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。欢迎页首次出现时,安装窗口会一次性移到普通窗口前方;若焦点在其他窗口,任务栏按钮会闪烁提示,但安装窗口不会始终置顶。进度读取锁定版本的 7-Zip 解压器百分比;目录替换、注册和清理仍使用有界估算。加权百分比不代表剩余时间。NSIS 报告成功后,进度条用 600 毫秒补满并短暂显示 100%,再显示完成页;切换目标时长为 750 毫秒。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。目录替换和失败恢复遵循上文描述的安装流程。首次启动的配置档案准备仍属于独立的 Desktop 操作。 +主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。欢迎页首次出现时,安装窗口会一次性移到普通窗口前方;若焦点在其他窗口,任务栏按钮会闪烁提示,但安装窗口不会始终置顶。进度读取锁定版本的 7-Zip 解压器百分比;目录替换、注册和清理仍使用有界估算。加权百分比不代表剩余时间。NSIS 报告成功后,进度条用 600 毫秒补满并短暂显示 100%,再显示完成页;切换目标时长为 750 毫秒。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。目录替换和失败恢复遵循[安装检查](packaging/README.zh.md#runtime-file-selection)。首次启动的配置档案准备仍属于独立的 Desktop 操作。 Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。 -在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。可选的 `--signed` 标志使用下文的 Windows EV 配置,在嵌入前对测试程序和辅助库签名;它不会启用更新源。 +在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。可选的 `--signed` 标志使用[Windows EV 配置](windows-signing/README.zh.md#signing-configuration),在嵌入前对测试程序和辅助库签名;它不会启用更新源。 Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APPDATA%` 下按包作用域嵌套的浏览器存储与缓存)、`%APPDATA%` 下的产品目录,以及 `%LOCALAPPDATA%` 下的更新下载缓存,随后移除 `%APPDATA%` 之下普通且已空的作用域目录。Harness 主目录(`~/.dsh` 或 `DSH_HOME`:会话、设置、凭据、插件)不会被触碰;以 Windows 环境变量发布的 `DSH_HOME` 还会保护所有与其重叠的目标。静默卸载删除相同的数据;以 `--updated` 或 `/KEEP_APP_DATA` 启动的卸载程序保留数据,electron-builder 在原地更新和从其他目录替换旧安装时正是这样启动它。删除通过原生辅助程序执行:它拒绝受保护的 Windows 目录以及与安装目录或主目录重叠的路径,要求固定的本地驱动器,链接的根目录或祖先目录原样保留,遇到重解析点只解除链接而不进入目标,清除只读属性,并在遇到被占用文件后继续删除其余兄弟项;残余不会中止卸载,也不会提示。安装时在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据;在 Windows 11 上,开始菜单右键菜单中的“卸载”对所有 Win32 应用都会打开已安装应用列表,只有 MSIX 包能从那里直接卸载。卸载程序声明 DPI 感知,中文使用微软雅黑 UI。此行为仅适用于 Windows。 使用 `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --compile-only` 以每次运行唯一的带作用域包名编译独立的中英文夹具。省略 `--compile-only` 可对预置数据运行原生删除器回归,以及交互、静默、`--updated`、`/KEEP_APP_DATA` 和 `DSH_HOME` 位于 Electron 数据内的检查。编译本身不能证明已安装卸载行为。 -### Windows EV 签名 - -运行时签名在当前 Windows 账户的各 worktree 间共享完整的已签名文件。`.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` 指定固定本地磁盘上的绝对目录;默认值为 `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`。缓存目录必须属于当前账户,访问权限不得向其他普通账户开放;带链接的路径会被拒绝。缓存项标识原始字节、公钥证书和签名工具链。每次恢复都检查摘要、Windows 信任状态、时间戳和证书,再替换未签名文件;缓存项无效会停止打包,不回退到硬件签名。不会仅因缓存较旧而重新签名。缓存信任同账户运行的程序,不防御管理员。[运行时签名缓存决策](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md)定义验收要求和设计限制。 - -预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。 - -缓存命中的复制、摘要和逐文件信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复及后置验签完成后,未命中项才进入串行硬件签名。恢复或验签失败会停止派发新任务,并在释放阶段锁前等待在途工作结束;硬件签名、运行时 smoke 检查和最终完整性校验仍须执行。 - -每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。 - -使用 `pnpm --dir apps/desktop run cache:windows-signatures --usage` 查看结构完整的缓存项字节数和数量,或用 `--from ` 导入显式指定、属于同账户的旧缓存。迁移不修改源目录,跳过暂存名称,拒绝损坏项,并保留已有有效项,即使它们的时间戳字节不同。`--clear` 在阶段锁保护下显式删除完整缓存项;不执行自动容量淘汰。`--directory ` 指定维护目标目录,无需加载发布凭据。不完整暂存项保持原状并单独计数;仅在所有构建停止后检查它们。这些命令绝不清除硬件失败证据。默认存储位于 AppData 之外,避免 MSIX 启动器虚拟化将账户缓存拆开;被重定向的覆盖目录会明确失败。 - -Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检的整体 60 秒期限包含时间戳尝试;超时、硬件签名报错或验签失败都会停止本轮流程,不再次调用硬件。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。 - -Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blockmap 先于通道 YAML 上传;NSIS 安装包元数据不要求另一种 web-installer 格式使用的内嵌 `blockMapSize`。文件清单测试使用固定版本构建器的 blockmap 生成器,而不是手工编造内嵌映射字段。 - -签名 Windows 配置从同一份公开证书的 `CN`、`O` 和 `C` 属性生成 updater 的 `publisherName`。每个属性都必须存在、非空且只有一个值。这些身份属性允许证书续期,无需固定叶证书指纹。已安装应用的 `app-update.yml` 保存预期发布者,下载的清单不能选择该身份。未签名测试构建省略 updater 配置。真实文件验证及其限制见[签名验收记录](tests/README.zh.md)。 - -本项目使用的 SafeNet Token 出现 `SignTool Error: No private key is available.` 时,说明 PIN(密码)错误。立即停止所有签名尝试,等待用户处理 PIN 后再继续。PIN 输错达到五次会锁定 Token。遇到该错误后,不得重试打包或签名探针。签名器串行执行 Token 操作,首次失败后拒绝所有排队任务。 - -Windows 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `.desktop-build/packaging-runs/` 下的唯一目录。每次运行保留 `run.json`、带时间戳的 `events.jsonl`、脱敏后的 `stdout.log` 和 `stderr.log`,以及 `result.json`。签名失败还会写入 `fatal.json` 并通过 stderr 通知父进程;监督程序立即请求终止当前阶段的进程树并等待退出。失败阶段不能启动后续阶段或生成发布完成记录。日志写入失败也会停止运行。终止错误仍按失败处理,需要操作者检查;缺少最终记录表示尚未确认完成。 - -硬件签名必须属于受监督的打包运行。调用命令解释器前,签名器原子获取 `%USERPROFILE%/.dsh-desktop-signing/attempt.json` 并记录本次尝试。只有签名成功且配置证书的主签名通过验证后才释放该文件;随后完成时间戳,不再访问硬件。失败、中断、已有锁定文件或审计存储不可用都会阻止再次访问硬件,包括同一 Windows 账户下的另一个签名器实例、进程或代码检出目录。没有定时恢复或自动重试。管理员必须检查保留的证据及令牌状态,再明确授权恢复锁定状态;登录令牌或替换 PIN 文件不会清除它。记录区分签名意图、命令解释器 PID 和完成结果,不计量 CSP/令牌内部的认证次数。不记录命令参数、PIN 或凭据环境。其他 Windows 账户及无关签名程序不在此锁定机制的保护范围内。 - -Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。 - -NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。 - -在 `.env.windows` 中填写 `DSH_DESKTOP_WINDOWS_CER_FILE`(公开 EV 叶证书)、`DSH_DESKTOP_WINDOWS_SIGNTOOL`(SafeNet 兼容的 SignTool)、`DSH_DESKTOP_WINDOWS_KEY_CONTAINER`(匹配的私钥容器)和 `DSH_DESKTOP_WINDOWS_TOKEN_PIN`(Token Password)。私钥仍保留在 USB Token;不要把证书或本地凭据文件提交到 Git。 - -```sh -pnpm run package:desktop:win:x64 -``` - -打包前插入并解锁 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)。 - -PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向签名预检、独立的第一方运行时签名阶段与 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 明文保存 PIN,应限制文件访问权限;CI 使用临时文件并在任务结束后删除。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。 - -使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如: - -```sh -pnpm run package:desktop:dir -pnpm run package:desktop:mac:arm64:dir -``` - -需要检查或诊断为宿主目标准备的资源而不调用 electron-builder 时,可以让同一流水线在准备完成后停止: - -```sh -pnpm run prepare:desktop -``` - -这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。 - - - -### Desktop 运行时准备 - -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。`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 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 - -macOS 签名打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。 - -未压缩产物包含 Electron、物化后的 dsh 生产依赖树、pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。 + ## 更新 @@ -457,7 +269,7 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完 原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。 -打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。 +打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。Windows 在销毁模态窗口前先隐藏它,确保原生窗口激活切换前父窗口已恢复启用。 `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。 @@ -534,10 +346,14 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd 下载内容是不可执行的测试字节,安装调用仅记录而不执行。测试替换浏览器打开与剪贴板写入,避免外部导航和剪贴板修改。它不启动完整产品工作区,不验证真实安装器或重启,不验证发布者签名,也不覆盖差分更新或 macOS。停滞的策略请求、清单请求和负载传输会执行真实截止时间及恢复。真实常规弹窗验证隔离预加载、卡片尺寸、未施加模糊的父页面、取消、任务警告选项与显式安装批准;账户行组件测试另行提供证据。[本地验证决策](../../.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.zh.md)和[验证记录](tests/README.zh.md)保留这些限制;生产发布要求保持不变。 + + ## 底层开发覆盖项 未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 是带应用路径默认值的可选覆盖项。每次未打包启动都必须设置 `DSH_DESKTOP_PRIMARY_RUNTIME_DIR`:开发启动器(`dev:desktop`、`start:desktop` 及工作区更新验证运行器)会把它设置为自己已准备目标的 primary-runtime 目录;缺少该变量的启动会以致命启动对话框失败。启动器必须设置它,因为壳无法从 `process.arch` 推导该目录:构建目标将 Windows 固定为 x64,而宿主可能是 arm64。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。 + + ## 已知限制 - 账号登录尚未接入;登录按钮禁用。Windows 材质效果仍需平台验证。 @@ -561,6 +377,8 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd 账号提供者的 `embeddedPageDist` 配置为内嵌用量和充值页面 URL 添加 `dist` 查询参数。默认值为空;私有前端分支选择值应写在本地 profile patch 中。此配置不改变 API 地址或凭证传递方式。 + + ## 开发备注 上线前 CDN 与容量决策见[桌面更新提案](../../.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md#cdn-and-capacity-qualification)。 diff --git a/apps/desktop/config/macos-packaging/macos-signing.env.example b/apps/desktop/config/macos-packaging/macos-signing.env.example new file mode 100644 index 0000000000..b4ec0dbe3f --- /dev/null +++ b/apps/desktop/config/macos-packaging/macos-signing.env.example @@ -0,0 +1,9 @@ +# Copy to macos-signing.env with mode 0600; keep this directory outside the runner checkout. +DSH_DESKTOP_MACOS_SIGNING_IDENTITY= +DSH_DESKTOP_MACOS_TEAM_ID= +CSC_LINK=../certificates/developer-id.p12 +CSC_KEY_PASSWORD= +# Configure exactly one notarization strategy; this template uses an App Store Connect API key. +APPLE_API_KEY=../certificates/notarization.p8 +APPLE_API_KEY_ID= +APPLE_API_ISSUER= diff --git a/apps/desktop/config/macos-packaging/production.env.example b/apps/desktop/config/macos-packaging/production.env.example new file mode 100644 index 0000000000..18360eadef --- /dev/null +++ b/apps/desktop/config/macos-packaging/production.env.example @@ -0,0 +1,9 @@ +# Copy to production.env and configure the production service before selecting this preset. +DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_AUTO_UPDATE_ENV=production +DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN= +DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4 +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. +# DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR= diff --git a/apps/desktop/config/macos-packaging/test.env.example b/apps/desktop/config/macos-packaging/test.env.example new file mode 100644 index 0000000000..aa40a119ce --- /dev/null +++ b/apps/desktop/config/macos-packaging/test.env.example @@ -0,0 +1,14 @@ +# Copy to test.env; paths in secret configuration resolve beside macos-signing.env. +DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_AUTO_UPDATE_ENV=test +DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN= +DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}' +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_DOWNLOAD_PROXY= +# Temporarily changes the active network service's system HTTP/HTTPS proxies. +DSH_DESKTOP_MACOS_NOTARIZATION_PROXY= +# Optional absolute private directory; setup fills this in for machine presets. Omit to use checkout-local storage. +# DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR= diff --git a/apps/desktop/config/windows-packaging/production.env.example b/apps/desktop/config/windows-packaging/production.env.example new file mode 100644 index 0000000000..2e05825590 --- /dev/null +++ b/apps/desktop/config/windows-packaging/production.env.example @@ -0,0 +1,7 @@ +# Copy to production.env in the same machine configuration directory and fill in the values. +DSH_DESKTOP_APP_ID=com.deepseek.harness +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 diff --git a/apps/desktop/config/windows-packaging/test.env.example b/apps/desktop/config/windows-packaging/test.env.example new file mode 100644 index 0000000000..718481f13a --- /dev/null +++ b/apps/desktop/config/windows-packaging/test.env.example @@ -0,0 +1,13 @@ +# 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_AUTO_UPDATE_ENV=test +DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN= +# Replace login.example.com with the real login origin before packaging. +DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}' +# Required for signed test packages; retain the release ID for an upgrade test batch. +DOWNLOAD_TEST_ORIGIN= +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 diff --git a/apps/desktop/config/windows-packaging/windows-signing.env.example b/apps/desktop/config/windows-packaging/windows-signing.env.example new file mode 100644 index 0000000000..d086a84ae7 --- /dev/null +++ b/apps/desktop/config/windows-packaging/windows-signing.env.example @@ -0,0 +1,7 @@ +# Copy to windows-signing.env in the same machine configuration directory and fill in the values. +# Restrict this file to the runner account, SYSTEM and administrators. +# File paths resolve relative to this file; use the same account as local signing. +DSH_DESKTOP_WINDOWS_CER_FILE=../certificates/windows.cer +DSH_DESKTOP_WINDOWS_SIGNTOOL= +DSH_DESKTOP_WINDOWS_KEY_CONTAINER= +DSH_DESKTOP_WINDOWS_TOKEN_PIN= diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index 4e978ec00f..0682e6e9ad 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -9,10 +9,7 @@ export interface DesktopElectronBuilderConfig { readonly output: string } readonly files: readonly [ - string, - string, - string, - string, + ...string[], { readonly from: string, readonly to: 'dsh', readonly filter: readonly ['**/*'] }, { readonly from: string, readonly to: 'dsh/node_modules', readonly filter: readonly ['**/*'] }, ] @@ -33,6 +30,7 @@ export interface DesktopElectronBuilderConfig { readonly signIgnore: readonly string[] } readonly dmg: { + readonly format: 'ULMO' readonly sign: boolean readonly writeUpdateInfo: boolean } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index fb8cee6fdd..34e859cd20 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-alpha.1", + "version": "0.2.1-alpha.2", "private": true, "license": "MIT", "type": "module", @@ -68,15 +68,13 @@ "js-yaml": "^4.3.2", "koffi": "3.1.1", "pnpm": "11.28.5", - "sharp": "^0.35.5", + "sharp": "0.35.5", "typescript": "^6.0.3", "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@types/ws": "^8.18.1", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "react": "^18.2.0", "react-dom": "^18.2.0", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "vite": "^6.4.3", "@deepseek-ai/dsh-client-ui-theme": "workspace:*", "@deepseek-ai/dsh-client-shortcuts": "workspace:*", diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml new file mode 100644 index 0000000000..1cd123a28c --- /dev/null +++ b/apps/desktop/packaging/README.i18n.yaml @@ -0,0 +1,58 @@ +# 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/packaging/README.md +/: + en: 9bc7767fc1bced33 + zh: 4f6198337a573cf4 +/desktop-packaging: + en: b35e10c88ade04fe + zh: a2d09add03407b91 +/desktop-packaging/summary: + en: e98e516b9e1bd0f4 + zh: 1a2abd7d89ef78a1 +/desktop-packaging/table-of-contents: + en: a6203dc2e690c7e1 + zh: 07b516549b8e3328 +/desktop-packaging/choose-a-build-path: + en: 17c6bcdefa05a8f8 + zh: 4ee18f832276144d +/desktop-packaging/release-versions: + en: d80a92c02a583b80 + zh: b27f75ff29fcea1a +/desktop-packaging/configuration-and-local-builds: + en: e4f526604231ed43 + zh: d412179ffdc402ba +/desktop-packaging/windows-actions-packaging: + en: e3f51e7130a0fe05 + zh: 70fd6a40d5899871 +/desktop-packaging/macos-self-hosted-packaging: + en: a3af9523fdc60036 + zh: 5e18d4740e9b7f57 +/desktop-packaging/unsigned-windows-test-installer: + en: 6843a0fdc03ead58 + zh: 957f92fc8126e23e +/desktop-packaging/local-macos-dmg-without-release-signing: + en: 2feaa0412247d81f + zh: 461dde8e13fd4734 +/desktop-packaging/macos-release-signing-and-diagnostics: + en: 62c125856aa03c26 + zh: 9fc00a43c8b1e8e2 +/desktop-packaging/directory-builds-and-preparation: + en: 30d58517c9c2044f + zh: 16d26bdc923fc4ba +/desktop-packaging/local-official-bundles-from-a-loopback-registry: + en: b42f103358d0dfd7 + zh: a534164ad9eb00da +/desktop-packaging/runtime-file-selection-and-installation-checks: + en: 06d5c666a3e7f2e4 + zh: c1d4a55cc29e11e3 +/desktop-packaging/desktop-runtime-preparation: + en: a7aa3066e649ffe8 + zh: 4334637910058c09 +/desktop-packaging/installation-and-release-verification: + en: 0006b6b1ae4998f4 + zh: 2bab6918b0c37b25 +/desktop-packaging/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md new file mode 100644 index 0000000000..e1633e6cbd --- /dev/null +++ b/apps/desktop/packaging/README.md @@ -0,0 +1,273 @@ +--- +description: "Build Desktop installers locally or through Windows or macOS Actions, choose deployment and signing mode, and retrieve artifacts and diagnostics. This guide keeps build operations separate from publishing and installed-application qualification. Platform signing details and shared-runner risks must be checked before using hardware credentials." +--- + +# Desktop packaging + +English | [中文](README.zh.md) + +## Summary + +Build Desktop installers locally or through Windows or macOS Actions, choose deployment and signing mode, and retrieve artifacts and diagnostics. This guide keeps build operations separate from publishing and installed-application qualification. Platform signing details and shared-runner risks must be checked before using hardware credentials. + +## Table of Contents + +- [Choose a build path](#choose-a-build) +- [Release versions](#release-versions) +- [Configuration and local builds](#configuration-and-local-builds) +- [Windows Actions packaging](#windows-actions) +- [macOS self-hosted packaging](#macos-actions) +- [Unsigned Windows test installer](#unsigned-windows-test-installer) +- [Local macOS DMG without release signing](#local-macos-dmg-without-release-signing) +- [macOS release signing and diagnostics](#macos-release-signing) +- [Directory builds and preparation](#directory-and-preparation) +- [Local Official bundles from a loopback registry](#local-official-bundles-from-a-loopback-registry) +- [Runtime file selection and installation checks](#runtime-file-selection) +- [Desktop runtime preparation](#desktop-runtime-preparation) +- [Installation and release verification](#verification) + + + +## Choose a build path + +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. + + + +## Release versions + +Before each Desktop packaging run, first confirm the complete version string with the current user. Check the selected deployment, dsh base version, retained release records, and published objects, then propose the exact version for approval. Do not start packaging until the user confirms that version; the deployment setting alone does not authorize a version choice. + +Record the current dsh version as the base. A production Desktop release uses that exact version, including any `alpha`, `beta`, or `rc` identifiers. A test release preserves the complete prerelease base and appends `.YYYYMMDD.index`; a stable base uses `-test.YYYYMMDD.index` instead. + +| dsh base | Production Desktop | Test Desktop example | +|---|---|---| +| `0.1.6-alpha.1` | `0.1.6-alpha.1` | `0.1.6-alpha.1.20260916.1` | +| `0.1.6-beta.2` | `0.1.6-beta.2` | `0.1.6-beta.2.20260916.1` | +| `0.1.6-rc.3` | `0.1.6-rc.3` | `0.1.6-rc.3.20260916.1` | +| `0.1.6` | `0.1.6` | `0.1.6-test.20260916.1` | + +Use the actual creation date in Asia/Shanghai. For each base and date, start the index at 1 and increment after checking retained release records and published objects; never reuse a published version. Test distribution does not publish the corresponding unsuffixed base. + +Manual Desktop CI uses a separate unpublished build identifier: `auto` appends the UTC date, workflow run number and attempt (`.YYYYMMDD.run.attempt`, with `-test` for a stable base). For example, `0.2.1-alpha.1.20261008.42.2`. These numbers are not reserved release sequence numbers. + +Pass the confirmed version to the packaging command as `--build-version`, which reaches the artifact names, the update feed, and the upload validation as one value. The manifests keep the product version, so a test build no longer rewrites the release family and leaves nothing to revert: + +```sh +pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.20260916.1 +``` + +`--build-version auto` proposes the next index for today, reading the published objects in the destination bucket and falling back to this target's local output directory when no bucket is configured or the listing cannot finish within its deadline. Confirm the proposal it prints before an upload; a run script forwards `--` on its own, and the packaging entry accepts it either way. + +A production release publishes the product version and takes no `--build-version`. Uploading one tags the packaged commit as `desktop-v` once the artifacts are public; a build from a modified checkout is not tagged, and a tagging failure prints the command to run by hand rather than failing a completed upload. Test and local builds are deliberately left untagged, and every artifact records `dshBuildCommit` and `dshBuildDirty` in its manifest so a build handed over directly remains traceable. + +Version derivation does not change the fixed update channel or `nightly.yml` / `nightly-mac.yml` filenames. SemVer orders `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`, and a stable base's test version precedes that stable release. Clients only accept a greater version: replacing a feed cannot move an installed higher version to a lower corrected version. Such clients need manual installation; keep automatic downgrade disabled. The [archived version decision](../../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md) explains why the channel does not supply the prerelease identifier. + + + +## Configuration and local builds + +Set `DSH_PACKAGING_CONFIG_DIR` to the absolute machine configuration directory and `DSH_PACKAGING_PRESET` to `test` or `production` for local Windows commands. The loader reads `presets/.env`; signed commands additionally read `secrets/windows-signing.env`. Relative certificate and SignTool paths resolve beside the file declaring them. Public presets reject credentials, and the signing file cannot override public settings. Missing configuration fails without falling back to `.env.windows`; commands without a preset retain the existing dotenv behavior. Unsigned and preparation-only presets never read the signing file. Cache maintenance also reads only public preset settings. Test signed packages also require the existing download origin and release ID, but no upload credentials. Local preset commands with `--build-version auto` number from local artifacts only; Actions uses its run number and attempt. + +Outside machine-preset mode, packaging, upload, and manual macOS signature verification (except local unsigned macOS builds) read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](../.env.windows.example) or [macOS template](../.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the selected configuration files, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. The published version is an argument rather than a release field, and upload reads it from the completion record the packaging run wrote. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. Windows CI reads the same machine presets. + +Every package command checks the application ID and its mode-specific update and signing configuration before building or downloading, then probes the external tools the run will use: the archive reader, and on a Windows target the installer compiler. macOS checks the identity, Team ID, one complete notarization strategy, readable local `CSC_LINK` p12 file, explicit `CSC_KEY_PASSWORD`, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only builds and explicit unsigned builds on either platform do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. Without a machine preset, `--build-version auto` contacts the destination bucket, including under `--check`. Run the same checks separately: + +```sh +pnpm --dir apps/desktop run check:package +``` + +`prepare:desktop` is not a prerequisite: + +```sh +pnpm run package:desktop +``` + +Release automation uses fixed target commands so runtime preparation, dsh preparation, and electron-builder receive the same platform and architecture: + +```sh +pnpm run package:desktop:mac:arm64 +pnpm run package:desktop:mac:x64 +pnpm run package:desktop:win:x64 +``` + +The macOS arm64 command requires Apple Silicon. The macOS x64 command runs on Intel macOS or Apple Silicon with Rosetta. The Windows x64 command requires Windows x64. Linux is not a supported Desktop release target. + +Both release and local unsigned macOS DMGs use LZMA compression (`dmg.format: 'ULMO'`) in [the electron-builder configuration](../scripts/electron-builder-config.mjs). A [build-time patch](../../../patches/app-builder-lib@26.17.0.patch) adds this format to the pinned builder's validation and TypeScript declarations. + +Each target owns its packed package inputs, prepared runtime, package set, dsh tree, pnpm preparation state, unpacked application, update metadata, and final artifacts under `apps/desktop/.desktop-build/targets//`. The Electron archive cache remains shared under `.desktop-build/downloads` because every archive name includes its version, platform, and architecture and is verified before extraction. A target build never consumes another target's mutable preparation state. + + + +## Windows Actions packaging + +The `Package Desktop · Windows` [manual workflow](../../../.github/workflows/windows-package.yml) selects the dedicated Windows x64 runner label `dsh-win-package-trial` and the machine preset `test` or `production`. Choose `unsigned` (default) or `signed`; signed mode uses the existing EV token preflight, signature cache and failure interlock. It does not publish or upload a release. `build_version` defaults to `auto`, using the CI identifier in [Release versions](#release-versions); an explicit value may be the checkout's product version or an extended build version such as `0.2.1-alpha.1.20261009.1` for product `0.2.1-alpha.1`. The version prefix must match the selected checkout; validation errors show its current example. Manifests remain unchanged. + +Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Before checkout, Node removes the runner-owned source directory without following dependency junctions; the workflow validates its location and rejects linked ancestors. Never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts. + +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. + + + +## 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. + +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. + +Install the user service from the checkout as the same account that registered the runner, without `sudo`: + +```sh +bash apps/desktop/scripts/macos-runner-service.sh install "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" /opt/homebrew/opt/node@24/bin +bash apps/desktop/scripts/macos-runner-service.sh start "$HOME/dsh-ci/runner" +bash apps/desktop/scripts/macos-runner-service.sh status "$HOME/dsh-ci/runner" +``` + +The installer copies its startup script into `runner/dsh-service`, sets the service PATH to Node 24 plus system tools, and uses GitHub's `svc.sh` and `runsvc.sh`. The LaunchAgent survives SSH disconnection, but requires that account's desktop login after reboot and stops on desktop logout. FileVault disk unlock remains an operator step; this does not provide unattended boot. Logs are `runner/dsh-service/stdout.log` and `stderr.log`; verify `Connected to GitHub` and `Listening for Jobs`. To update installed startup code or tool paths, wait until the runner is idle, run `stop`, then `uninstall`, and repeat installation. Neither action unregisters the runner or removes packaging credentials. Uninstall retains `runner/dsh-service` (including logs) and the explicit `.path`; reinstall replaces the startup snapshot and PATH. + + + +## Unsigned Windows test installer + +On Windows x64, use the complete unsigned packaging command for local installation testing: + +```sh +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. + +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. + + + +## Local macOS DMG without release signing + +After `pnpm install`, run one command from the repository root. No `.env.macos`, Apple account, certificate, or update service configuration is required: + +```sh +pnpm run package:desktop:mac:arm64:unsigned +pnpm run package:desktop:mac:x64:unsigned +``` + +Add `--check` to validate local configuration and tools without building, or `--dir` to stop at the unpacked application. The DMG is `apps/desktop/.desktop-build/targets//unsigned-artifacts/deepseek-harness--mac--unsigned.dmg`. After all checks pass, the command prints `DMG:` followed by its absolute path. Open it and copy the application to a local test directory. + +These commands ignore `.env.macos` and inherited release settings, use `com.deepseek.harness` as the app ID, and omit automatic-update and mandatory-update configuration. Build tools, standard network proxies, and `DSH_DESKTOP_NPM_REGISTRY` remain available from the shell. They skip Developer ID signing, Apple notarization, temporary signing keychains, and notarization proxy changes. Electron receives a local ad-hoc signature without certificates so the modified executable can run; the DMG is unsigned. Both preparation and the assembled application still run runtime integrity and smoke checks. Local builds omit update feeds, ZIPs, and release completion records, and the upload commands cannot publish them. macOS may require approval in System Settings → Privacy & Security to open an unnotarized application. + + + +## macOS release signing and diagnostics + +The signed macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. Signed macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The signed fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Signed directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. `CSC_LINK` must name a local p12 containing the Developer ID Application certificate and private key; URLs and Base64 inputs are not supported. `CSC_KEY_PASSWORD` is its export password, not an Apple account or login password; an explicitly empty value supports an unencrypted p12. Before building, packaging creates and unlocks a private temporary keychain, imports the p12, authorizes signing, and signs a small probe. Runtime and App signing use this keychain explicitly; existing login keychains require no setup or manual unlocking. Child processes receive its path without the p12 password. The keychain is deleted after success or ordinary failure; CI must clean temporary credentials after forced termination. Ephemeral CI writes the certificate and `.env.macos` from its secret store, restricts file access, and deletes both after the job; self-hosted machine presets retain private files outside the checkout. Ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. + +macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements. + +macOS runtime preparation caches verified single-architecture Mach-O signatures in `.desktop-build/targets//signature-cache` unless `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` selects external storage. An explicit path must be absolute and already exist, contain no linked ancestors, and be owned by the current account with mode `0700` and read/write/search access; invalid or empty settings fail preflight without fallback. Omit the field to retain target-local storage. Keys include input bytes and permissions, the signing probe’s actual leaf certificate, identifier, entitlement bytes, macOS version, and signing tools and policy. Git commits do not determine reuse: uncommitted byte changes invalidate the affected file. Every hit verifies cached bytes, the strict signature, certificate, identifier, entitlements, secure timestamp and hardened runtime before replacing an unchanged input. Universal binaries are signed each time. Runtime integrity and smoke checks, App signing, and Apple notarization still run. The cache requires trusted local build storage. Cache corruption or unsafe links fail the build; stop packaging before deleting the affected cache directory and retrying. Successful signing stages prune complete entries to a one-GiB payload budget; interrupted temporary entries require manual removal. Concurrent eviction can fail a reader safely. Logs report hit, miss and uncached counts. + +Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (published version, product version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted; environments and notarytool authentication arguments are not recorded. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together. + +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`: + +| Setting | Default | Scope | +|---|---|---| +| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | Workers for first-party and vendor workspace tarballs; requires a positive integer. | +| `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. | + +The proxy fields are independent and reject URL credentials, paths, queries and fragments. Empty values preserve inherited networking. An explicit download proxy replaces child-process proxy variables and bypasses local hosts only; it does not change system settings. Windows and standalone `release:pack` retain their existing concurrency defaults. Company proxy addresses belong in the ignored local file; see internal documentation for addresses. + +Apple tooling uses the active macOS network service's HTTP/HTTPS proxies. Configured notarization routing checks proxy reachability, saves that service's settings, enables the proxy around both artifact lanes, and restores the saved settings after both lanes settle. An originally disabled proxy with an empty server and port zero is restored by disabling it; the temporary server and port may remain stored but inactive. Directory-only builds apply it to App notarization after the signed directory build. This temporarily affects other applications and requires permission to change system proxies; PAC, auto-discovery, SOCKS and authenticated proxy configurations must be disabled first. Packaging and recovery acquire the same per-user POSIX file lock before reading recovery data or changing proxies; process exit releases ownership, while the lock file remains in place. The lock loads `@deepseek-ai/node-addon-system/flock` at first use rather than at script start, so `check:package` and the packaging entry load on a checkout that has not built `native/system`; when the host addon binary or the entry's JavaScript is missing at lock time, the loader runs `pnpm run build:native-system` and `pnpm --dir native/system run build:ts` before locking, so recovery works on such a checkout as well. This prevents overlapping proxy transactions across checkouts; other users and network-setting tools must not change these settings concurrently. SIGINT/SIGTERM wait for active work before restoration. After forced termination or a restoration error, stop any remaining notarization processes and run `pnpm --dir apps/desktop run restore:mac-proxy`; the saved record remains until restoration succeeds. Configuration checks validate URL syntax without changing system settings or contacting the proxy. + + + +## Directory builds and preparation + +Create a runnable application directory instead of an installer by using the matching `:dir` command, such as: + +```sh +pnpm run package:desktop:dir +pnpm run package:desktop:mac:arm64:dir +``` + +To inspect or troubleshoot the prepared host-target resources without invoking electron-builder, stop the same pipeline after preparation: + +```sh +pnpm run prepare:desktop +``` + +This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or dsh content. + + + +## Local Official bundles from a loopback registry + +The test DMG excludes the provider runtimes the Official on-demand catalog advertises, and a release publishes matching bundle versions to npm. To install locally built bundles before that release, start a loopback registry that packs and serves the catalog's complete local dependency closure: + +```sh +pnpm run dev:bundle-registry +``` + +The command validates the catalog and the workspace inventory, runs `pnpm run build:official`, packs the closure into a new `dist/test-bundles//` directory, writes `bundle-registry.json` last as the completion marker, and only then prints the artifact path, the registry URL, and the exact `name@version` specs. It serves `127.0.0.1` on an allocated port under a fresh namespace until Ctrl+C or SIGTERM, and stopping it leaves both the artifacts and the installed packages on disk. It needs no `.env.macos`, Apple credentials, or npm credentials, and it does not change `pnpm run package:desktop:unsigned`. + +In the packaged application, open Plugins → Add plugin, paste one printed spec, choose **Custom address**, paste the printed registry URL, and install. A custom registry is asked alone, so this process must answer every dependency: repository-owned packages come from the run's own archives, and third-party packages keep npm's real metadata and bytes through a fixed redirect to `registry.npmjs.org`, which still requires network access. The installed entry stays Official because its package name is in the catalog. The registry choice is remembered in this browser; choose an offered registry again to switch back. + +A run's artifacts are immutable, so a rebuild serves a new namespace and a new URL, and the Official **Update** control does not appear while the installed and running versions match. The profile records the previous run's tarball URL in `pnpm-lock.yaml`, and pnpm verifies recorded entries, so installing or reinstalling that bundle against a new URL is refused with `ERR_PNPM_TARBALL_URL_MISMATCH` and can leave the earlier files in place. To test changed code at the same version, clear the stale resolution first — `pnpm clean --lockfile` in `$DSH_HOME/profiles/`, or install into a fresh profile — then install again from the new URL. `bundle-registry.json` records each archive's version, filename, size, and SHA-512 integrity together with the packing checkout's commit and dirty flag. + +The served `time` is the real packing time, so pnpm 11's default 24-hour `minimumReleaseAge` records the exemption it granted in the profile's `pnpm-workspace.yaml` (`minimumReleaseAgeExclude`); an explicitly stricter policy there fails the installation with pnpm's own message instead of being overridden. A `@scope:registry` route in `~/.npmrc` or the profile's `.npmrc` overrides `--registry` for that scope, so it must not route the served scope. + + + +## Runtime file selection and installation checks + +Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](../scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory and Yarn development metadata, selected native compiler outputs, and node-pty prebuilds for other platforms. Non-Windows targets also omit `node-pty/third_party/conpty`; Windows targets retain that directory. It preserves runtime JavaScript, target native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. Dependency manifests pass through electron-builder's metadata cleanup before integrity sealing, so archiving preserves their recorded bytes. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. + +The [Electron shell file list](../scripts/electron-builder-config.mjs) excludes recognized JavaScript/CSS/TypeScript source maps and TypeScript build caches from the separate `resources/app.asar/node_modules` dependency collection. These exclusions preserve runtime code, native modules, WASM, data files (including ordinary `.map` assets), licenses, and notices; [shell archive tests](../tests/shell-file-selection.spec.ts) check retained bytes and exclusions. + +The [Office conversion provider](../../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). + +The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](../tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](../tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](../scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. The Host smoke also obtains CLI paths from the loaded skill and runs capabilities and DOCX conversion with an empty PATH. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization. + +Windows release qualification also runs [directory and replacement checks](../scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory; pass the prepared `window-frame.dll` as `-FrameLibrary` to exercise the native extraction path and its failure report as well. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane. + +```powershell +pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll +``` + +The Windows installer checks for a running application at startup and after destination selection, before extracting the new version beside the installation directory. It checks again before replacing directories through same-volume renames. A running application blocks installation; update launches allow up to ten seconds for it to exit. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow. + +When extraction fails, the installer writes a report with the 7-Zip result and its complete error output to the updater cache directory as `%LOCALAPPDATA%\-updater\installer-logs\extract-failure-.log` (currently `@deepseek-aidsh-desktop-updater`) and shows the first error line with a **Copy error details** button; silent installs write only the report. Unsigned Windows builds (`DSH_DESKTOP_UNSIGNED=1`) name their installer `deepseek-harness--win-x64-unsigned.exe` so they cannot be mistaken for release artifacts. + +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. + +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 + +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. + +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. + + + +## Installation and release verification + +Keep the generated installer, its version and packaging records together. Review signatures and runtime checks before installation, then follow the [installed-update qualification procedure](../tests/installed-update/README.md) for installed launch and upgrade evidence. The [Desktop test record](../tests/README.md) owns verification limits; a successful workflow alone does not establish production preset, new-workstation or installed-upgrade acceptance. + +## Dev Note + +None. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md new file mode 100644 index 0000000000..c5458b979b --- /dev/null +++ b/apps/desktop/packaging/README.zh.md @@ -0,0 +1,273 @@ +--- +description: "在本地或 Windows/macOS Actions 中生成 Desktop 安装包,选择部署与签名模式,并获取产物和诊断。本指南区分构建操作、发布与已安装应用验收。使用硬件凭据前,必须检查平台签名要求与共享 runner 风险。" +--- + +# Desktop 打包 + +[English](README.md) | 中文 + +## 概述 + +在本地或 Windows/macOS Actions 中生成 Desktop 安装包,选择部署与签名模式,并获取产物和诊断。本指南区分构建操作、发布与已安装应用验收。使用硬件凭据前,必须检查平台签名要求与共享 runner 风险。 + +## 目录 + +- [选择打包方式](#choose-a-build) +- [发布版本](#release-versions) +- [配置与本地构建](#configuration-and-local-builds) +- [Windows Actions 打包](#windows-actions) +- [macOS 自托管打包](#macos-actions) +- [未签名 Windows 测试安装包](#unsigned-windows-test-installer) +- [无发布签名的本地 macOS DMG](#local-macos-dmg-without-release-signing) +- [macOS 发布签名与诊断](#macos-release-signing) +- [目录构建与准备](#directory-and-preparation) +- [用回环 registry 安装本地 Official 包](#local-official-bundles-from-a-loopback-registry) +- [运行时文件筛选与安装检查](#runtime-file-selection) +- [Desktop 运行时准备](#desktop-runtime-preparation) +- [安装与发布验收](#verification) + + + +## 选择打包方式 + +安装锁定依赖后,从仓库根目录运行本地命令。本地构建使用平台命令,已注册的自托管 runner 使用对应平台的 Actions。test/production 部署与 signed/unsigned 模式分别选择。任何 Windows 签名构建或共享 runner 使用前,先阅读 [Windows 签名与 runner 运维](../windows-signing/README.zh.md#trust-and-isolation)。安装与打包分开进行;[发布](../publishing/README.zh.md)还需要单独的显式操作。 + + + +## 发布版本 + +每次 Desktop 打包前,第一步都要与当前用户确认完整版本号。检查所选部署环境、dsh 基础版本、保留的发布记录和已发布对象,再提出准确版本供用户确认。用户确认前不得启动打包;仅选择部署环境不代表用户已认可版本号。 + +记录当前 dsh 版本作为基础版本。production Desktop 使用完全相同的版本,包括其中的 `alpha`、`beta` 或 `rc` 标识。test 发布保留完整的预发布基础版本并追加 `.YYYYMMDD.index`;稳定基础版本则追加 `-test.YYYYMMDD.index`。 + +| dsh 基础版本 | production Desktop | test Desktop 示例 | +|---|---|---| +| `0.1.6-alpha.1` | `0.1.6-alpha.1` | `0.1.6-alpha.1.20260916.1` | +| `0.1.6-beta.2` | `0.1.6-beta.2` | `0.1.6-beta.2.20260916.1` | +| `0.1.6-rc.3` | `0.1.6-rc.3` | `0.1.6-rc.3.20260916.1` | +| `0.1.6` | `0.1.6` | `0.1.6-test.20260916.1` | + +日期使用实际创建时的 Asia/Shanghai 日期。每个基础版本、每天的序号从 1 开始,检查保留的发布记录与已发布对象后递增;绝不复用已发布版本。test 分发不发布对应的无后缀基础版本。 + +Desktop 手动 CI 使用独立的未发布构建标识:`auto` 追加 UTC 日期、工作流运行编号和尝试次数(`.YYYYMMDD.run.attempt`,稳定基础版本使用 `-test`)。例如 `0.2.1-alpha.1.20261008.42.2`。这些编号不是预留的发布序号。 + +把确认后的版本通过 `--build-version` 传给打包命令,该值同时决定产物文件名、更新 feed 与上传校验。清单保留产品版本,因此 test 打包不再改写发布家族,也不留下需要还原的改动: + +```sh +pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.20260916.1 +``` + +`--build-version auto` 会给出当天的下一个序号:读取目标 bucket 中已发布的对象,未配置 bucket 或列举未能在期限内完成时回退到本目标的本地输出目录。上传前请确认它打印的版本号;run script 会自行透传 `--`,打包入口两种写法都接受。 + +production 发布使用产品版本本身,不传 `--build-version`。其上传成功后会把打包所用 commit 打成 `desktop-v<版本>` 标签;来自有改动工作区的构建不打标签,打标签失败也只打印手工命令,不会让已完成的上传变成失败。test 与本地构建有意不留标签,而所有产物的清单都记录 `dshBuildCommit` 与 `dshBuildDirty`,直接分发的构建同样可溯源。 + +版本派生不改变固定更新通道,也不改变 `nightly.yml` / `nightly-mac.yml` 文件名。SemVer 排序为 `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`,稳定基础版本的测试版低于该稳定版。客户端只接受更高版本:替换 feed 无法让已安装的较高版本更新到较低的纠正版。这类客户端需要手动安装;保持自动降级关闭。[已归档的版本决策](../../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md)解释为什么不能用通道名替换预发布标识。 + + + +## 配置与本地构建 + +本地 Windows 命令通过 `DSH_PACKAGING_CONFIG_DIR` 指定机器配置绝对目录,通过 `DSH_PACKAGING_PRESET` 选择 `test` 或 `production`。加载器读取 `presets/.env`;签名命令另读 `secrets/windows-signing.env`。相对证书和 SignTool 路径以声明它们的文件所在目录为基准。公开 preset 拒绝凭据,签名文件不能覆盖公开配置。配置缺失立即失败,不回退到 `.env.windows`;不选择 preset 的命令保留原 dotenv 行为。未签名和仅准备 preset 不读取签名文件。缓存维护也只读取公开 preset 配置。签名测试包还需现有下载 origin 和 release ID,但无需上传凭据。本地 preset 命令使用 `--build-version auto` 时仅按本地产物编号;Actions 使用运行编号和重试次数。 + +不选择机器 preset 时,打包、上传以及手动 macOS 签名检查(本地 macOS 未签名构建除外)使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](../.env.windows.example) 或 [macOS 模板](../.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从选定的配置文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。发布版本是命令参数而非发布字段,上传从打包写下的完成记录中读取它。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。Windows CI 读取相同的机器 preset。 + +每条打包命令在构建与下载前检查应用 ID 以及该模式需要的更新与签名配置,随后探测本次运行要用的外部工具:归档读取工具,以及 Windows 目标的安装器编译器。macOS 检查身份、Team ID、一套完整公证凭据、`CSC_LINK` 指定的可读本地 p12 文件、显式配置的 `CSC_KEY_PASSWORD`,以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源以及两个平台上的显式未签名打包均不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。未选择机器 preset 时,`--build-version auto` 会访问目标 bucket,`--check` 下同样如此。单独运行相同检查: + +```sh +pnpm --dir apps/desktop run check:package +``` + +无需提前执行 `prepare:desktop`: + +```sh +pnpm run package:desktop +``` + +发布自动化使用固定目标命令,确保运行时准备、dsh 准备与 electron-builder 接收相同的平台和架构: + +```sh +pnpm run package:desktop:mac:arm64 +pnpm run package:desktop:mac:x64 +pnpm run package:desktop:win:x64 +``` + +macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 或带 Rosetta 的 Apple Silicon 上运行。Windows x64 命令要求 Windows x64。Linux 不是受支持的 Desktop 发布目标。 + +macOS 正式发布和本地未签名 DMG 都通过 [electron-builder 配置](../scripts/electron-builder-config.mjs)中的 `dmg.format: 'ULMO'` 使用 LZMA 压缩。[构建时补丁](../../../patches/app-builder-lib@26.17.0.patch)为固定版本 builder 的校验规则和 TypeScript 声明添加此格式。 + +每个目标都在 `apps/desktop/.desktop-build/targets//` 下持有自己的打包输入、已准备运行时、包集合、dsh 依赖树、pnpm 准备状态、未打包应用、更新元数据和最终产物。Electron 归档缓存继续由 `.desktop-build/downloads` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。 + + + +## Windows Actions 打包 + +`Package Desktop · Windows` [手动工作流](../../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `unsigned`(默认)或 `signed`;签名模式复用现有 EV token 预检、签名缓存与失败 interlock,不发布或上传 release。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中的 CI 标识;显式值可以是源码产品版本,或扩展构建版本,例如产品 `0.2.1-alpha.1` 对应 `0.2.1-alpha.1.20261009.1`。版本前缀必须匹配所选源码;校验错误显示其当前示例。manifest 保持不变。 + +在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。检出前,Node 删除 runner 专用源码目录,不跟随依赖 junction;工作流校验目标位置并拒绝链接祖先目录。不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。 + +在 Actions 打开完成的运行并下载 Artifacts。`windows-x64-signed---` 与 `windows-x64-unsigned---` 包含对应 EXE;`windows-package-logs--` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。 + + + +## 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 运行后再依赖该流程。 + +运行 `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 或仅准备构建组合。 + +在 checkout 中,以注册 runner 的同一账户安装用户服务,不使用 `sudo`: + +```sh +bash apps/desktop/scripts/macos-runner-service.sh install "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" /opt/homebrew/opt/node@24/bin +bash apps/desktop/scripts/macos-runner-service.sh start "$HOME/dsh-ci/runner" +bash apps/desktop/scripts/macos-runner-service.sh status "$HOME/dsh-ci/runner" +``` + +安装脚本将启动脚本复制到 `runner/dsh-service`,将服务 PATH 设为 Node 24 和系统工具,并使用 GitHub 的 `svc.sh` 与 `runsvc.sh`。LaunchAgent 在 SSH 断开后继续运行,但重启后需要该账户登录桌面,注销桌面时停止。FileVault 磁盘解锁仍由操作人员完成,这不提供无人值守开机。日志位于 `runner/dsh-service/stdout.log` 和 `stderr.log`;验收时确认出现 `Connected to GitHub` 与 `Listening for Jobs`。更新已安装的启动脚本或工具路径时,等待 runner 空闲,依次执行 `stop`、`uninstall`,再重新安装。这两个操作都不会注销 runner 或删除打包凭据。卸载保留 `runner/dsh-service`(包括日志)和显式 `.path`;重新安装会替换启动快照和 PATH。 + + + +## 未签名 Windows 测试安装包 + +在 Windows x64 上,使用完整的未签名打包命令进行本地安装测试: + +```sh +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 或上传凭据。签名打包和上传仍遵循正式发布要求。 + +先运行 `pnpm install --frozen-lockfile`,再运行 `pnpm run package:desktop:win:x64:unsigned --check` 校验配置和工具,最后执行上面的完整命令。打包自行执行完整构建和隔离的运行时 smoke,不调用在线模型。阶段日志保留在 `.desktop-build/packaging-runs/`;仅构建成功不代表安装包验证通过。依赖安装和运行时准备需要访问 npm、GitHub release 资源和 nodejs.org;网络需要时使用现有 registry 和代理配置。缓存与工具位置属于构建机器配置,不在跟踪文件中固定个人路径。 + + + +## 无发布签名的本地 macOS DMG + +执行 `pnpm install` 后,在仓库根目录运行一条命令即可,无需 `.env.macos`、Apple 账号、证书或更新服务配置: + +```sh +pnpm run package:desktop:mac:arm64:unsigned +pnpm run package:desktop:mac:x64:unsigned +``` + +添加 `--check` 仅验证本地配置和工具而不构建,或添加 `--dir` 只生成解包应用。DMG 路径为 `apps/desktop/.desktop-build/targets//unsigned-artifacts/deepseek-harness--mac--unsigned.dmg`。所有检查通过后,命令打印 `DMG:` 及其绝对路径。打开后将应用复制到本地测试目录。 + +这些命令忽略 `.env.macos` 和继承的发布配置,使用 `com.deepseek.harness` 作为应用 ID,并省略自动更新和强制更新配置。构建工具、标准网络代理与 `DSH_DESKTOP_NPM_REGISTRY` 仍可从 shell 提供。它们跳过 Developer ID 签名、Apple 公证、临时签名钥匙串和公证代理修改。Electron 使用无需证书的本地 ad-hoc 签名,以便修改后的可执行文件能够运行;DMG 不签名。准备阶段和组装后的应用仍执行运行时完整性与冒烟检查。本地构建不生成更新源、ZIP 或发布完成记录,上传命令无法发布这些产物。macOS 可能要求在“系统设置 → 隐私与安全性”中批准打开未经公证的应用。 + + + +## macOS 发布签名与诊断 + +macOS 签名配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 签名打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 签名固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。签名的仅目录命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。`CSC_LINK` 必须指向包含 Developer ID Application 证书及私钥的本地 p12,不支持 URL 或 Base64 输入。`CSC_KEY_PASSWORD` 是其导出密码,不是 Apple 账号或登录密码;未加密的 p12 可显式填写空值。构建前,打包流程自动创建并解锁私有临时钥匙串、导入 p12、授权签名并签署小型探针。运行时与 App 签名显式使用该钥匙串,无需预先配置或手动解锁登录钥匙串。子进程只接收钥匙串路径,不接收 p12 密码。成功或普通失败后删除临时钥匙串;强制终止后由 CI 清理临时凭据。临时 CI 从密钥存储生成证书文件和 `.env.macos`,限制文件访问权限,并在作业结束后删除二者;自托管机器 preset 在 checkout 外保留私有文件。环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 + +macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。 + +macOS 运行时准备将已验证的单架构 Mach-O 签名缓存在 `.desktop-build/targets//signature-cache`,除非通过 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 选择外置存储。显式路径必须是已存在的绝对目录,祖先路径不能包含链接,目录须归当前账户所有、权限为 `0700` 且可读写及遍历;非法或空配置在预检时失败,不回退。省略该字段保留目标本地缓存。缓存键包含输入字节与权限、签名探针实际使用的叶证书、签名标识、entitlement 字节、macOS 版本,以及签名工具与策略。复用不取决于 Git 提交:未提交的字节变更会使对应文件失效。每次命中都验证缓存字节、严格签名、证书、标识、entitlements、安全时间戳和 hardened runtime,再替换未被并发修改的输入。通用二进制每次重新签名。运行时完整性与 smoke 检查、App 签名和 Apple 公证仍会执行。缓存要求受信任的本地构建存储。缓存损坏或不安全链接会令构建失败;停止打包后删除对应缓存目录再重试。成功的签名阶段会将完整缓存条目的内容总量裁剪至一 GiB;中断留下的临时条目需手动清理。并发淘汰可能使读取方安全失败。日志记录命中、未命中及未缓存数量。 + +Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(发布版本、产品版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖;不记录环境变量全集或 notarytool 认证参数。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。 + +App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。 + +Mac 打包从 `.env.macos` 读取三个调优字段: + +| 设置 | 默认值 | 作用范围 | +|---|---|---| +| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | 第一方与 vendor workspace tarball 的打包 worker 数;必须是正整数。 | +| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | 空 | Electron、运行时资源、pnpm 安装及 builder 下载使用的 HTTP/HTTPS 代理 origin。 | +| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | 空 | 通过临时系统代理设置供 Apple 工具使用的 HTTP 代理 origin。 | + +两个代理字段互相独立,拒绝 URL 中的凭证、路径、查询参数和片段。空值沿用继承的网络设置。显式下载代理会替换子进程的代理变量,仅绕过本地主机;它不修改系统设置。Windows 与独立 `release:pack` 保持现有并发默认值。公司代理地址仅写入 Git 忽略的本地文件;具体地址参见内部文档。 + +Apple 工具使用 macOS 当前活动网络服务的 HTTP/HTTPS 代理。配置公证代理后,打包会检查代理可达性、保存该服务的设置,在两条产物任务期间启用代理,并在两条任务均结束后恢复原设置。对于原本关闭、服务器为空且端口为零的代理,恢复时仅关闭代理;临时服务器和端口可能保留,但不生效。仅生成目录的打包会在签名目录构建完成后的 App 公证期间启用代理。这会临时影响其他应用,并要求修改系统代理的权限;必须先禁用 PAC、自动发现、SOCKS 及需要认证的代理配置。打包和恢复在读取恢复记录或修改代理前获取同一个用户级 POSIX 文件锁;进程退出会释放锁的持有权,锁文件保留。该锁在首次使用时才加载 `@deepseek-ai/node-addon-system/flock`,而不是在脚本启动时加载,因此 `check:package` 和打包入口在未构建 `native/system` 的 checkout 上也能加载;加锁时若宿主 addon 二进制或入口的 JavaScript 缺失,加载器会先运行 `pnpm run build:native-system` 和 `pnpm --dir native/system run build:ts` 再加锁,因此恢复命令在这样的 checkout 上同样可用。这会阻止不同 checkout 的代理事务重叠;其他用户及网络设置工具不得同时修改这些设置。SIGINT/SIGTERM 会等待活动任务结束后恢复。强制终止或恢复失败后,先停止残留公证进程,再运行 `pnpm --dir apps/desktop run restore:mac-proxy`;保存的记录会保留到恢复成功。配置检查仅验证 URL 语法,不修改系统设置或连接代理。 + + + +## 目录构建与准备 + +使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如: + +```sh +pnpm run package:desktop:dir +pnpm run package:desktop:mac:arm64:dir +``` + +需要检查或诊断为宿主目标准备的资源而不调用 electron-builder 时,可以让同一流水线在准备完成后停止: + +```sh +pnpm run prepare:desktop +``` + +这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。 + + + +## 用回环 registry 安装本地 Official 包 + +测试 DMG 不包含 Official 按需目录所宣传的 provider 运行时,而正式发布才会把对应版本的 bundle 发到 npm。要在发布前安装本地构建的 bundle,请启动一个回环 registry,它会打包并提供该目录的完整本地依赖闭包: + +```sh +pnpm run dev:bundle-registry +``` + +该命令先校验目录与 workspace 清单,运行 `pnpm run build:official`,把闭包打包进新的 `dist/test-bundles//` 目录,最后写入 `bundle-registry.json` 作为完成标记,然后才打印产物路径、registry URL 和精确的 `name@version` 规格。它在分配到的端口上只监听 `127.0.0.1`,使用一次性的命名空间提供服务,直到 Ctrl+C 或 SIGTERM;停止后产物和已安装的包都保留在磁盘上。它不需要 `.env.macos`、Apple 凭据或 npm 凭据,也不改变 `pnpm run package:desktop:unsigned`。 + +在打包应用中打开“插件 → 添加插件”,粘贴一条打印出的规格,选择**自定义地址**,粘贴打印出的 registry URL 后安装。自定义 registry 会被单独询问,因此必须由本进程回答每一个依赖:仓库自有的包取自本次运行的产物,第三方包则通过固定重定向到 `registry.npmjs.org` 保留 npm 的真实元数据与字节,因此仍需要网络访问。安装后的条目仍归类为 Official,因为它的包名在目录中。registry 选择会在此浏览器中记住;再次选择某个提供的 registry 即可切回。 + +一次运行的产物是不可变的,所以重新构建会提供新的命名空间和新 URL;当已安装版本与运行版本一致时,Official 的**更新**控件不会出现。profile 会在 `pnpm-lock.yaml` 中记录上一次运行的 tarball URL,而 pnpm 会校验已记录的条目,因此用新 URL 再次安装或重装该 bundle 会被 `ERR_PNPM_TARBALL_URL_MISMATCH` 拒绝,并且可能继续保留旧文件。要在同一版本上验证改动后的代码,请先清除过期的解析结果——在 `$DSH_HOME/profiles/` 中运行 `pnpm clean --lockfile`,或改用全新的 profile——然后再从新 URL 安装。`bundle-registry.json` 记录每个归档的版本、文件名、大小和 SHA-512 完整性,以及打包所用 checkout 的 commit 和 dirty 标记。 + +所提供的 `time` 是真实的打包时间,因此 pnpm 11 默认的 24 小时 `minimumReleaseAge` 会在 profile 的 `pnpm-workspace.yaml` 中记录它授予的豁免(`minimumReleaseAgeExclude`);如果那里显式配置了更严格的策略,安装会以 pnpm 自己的报错失败,而不会被覆盖。`~/.npmrc` 或 profile 的 `.npmrc` 中的 `@scope:registry` 路由会覆盖该 scope 的 `--registry`,因此不能让它们指向正在提供服务的 scope。 + + + +## 运行时文件筛选与安装检查 + +Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](../scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录及 Yarn 开发元数据、选定的原生编译器输出和其他平台的 node-pty 预构建文件。非 Windows 目标还会排除 `node-pty/third_party/conpty`;Windows 目标保留该目录。它保留运行时 JavaScript、目标平台的原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 + +[Electron 外壳文件清单](../scripts/electron-builder-config.mjs)从单独收集的 `resources/app.asar/node_modules` 依赖中排除已识别的 JavaScript/CSS/TypeScript source map 和 TypeScript 构建缓存。这些排除规则保留运行时代码、原生模块、WASM、数据文件(包括普通 `.map` 资源)、许可证和 notices;[外壳归档测试](../tests/shell-file-selection.spec.ts)检查保留文件的字节和排除项。 + +[Office 转换提供方](../../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。 + +打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](../tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](../tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](../scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。 + +Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](../scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录;通过 `-FrameLibrary` 传入已准备好的 `window-frame.dll`,即可同时覆盖原生解压路径及其失败报告。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。 + +```powershell +pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll +``` + +Windows 安装器在启动时和选定目标目录后检查应用是否正在运行,通过检查后才将新版本解压到安装目录旁边。通过同卷目录改名替换前,安装器会再次检查。运行中的应用会阻止安装;更新启动允许等待应用退出,最长十秒。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。 + +解压失败时,安装器会把 7-Zip 的结果和完整错误输出写入更新缓存目录 `%LOCALAPPDATA%\<按包名派生>-updater\installer-logs\extract-failure-<时间戳>.log`(当前为 `@deepseek-aidsh-desktop-updater`),并在弹窗中显示首条错误行和 **复制错误信息** 按钮;静默安装只写入报告。未签名的 Windows 构建(`DSH_DESKTOP_UNSIGNED=1`)会将安装包命名为 `deepseek-harness-<版本>-win-x64-unsigned.exe`,以免被误当作发布产物。 + +Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。 + +NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。 + + + +## Desktop 运行时准备 + +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 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 + +macOS 签名打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。 + +未压缩产物包含 Electron、物化后的 dsh 生产依赖树、pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。 + + + +## 安装与发布验收 + +一起保留生成的安装包、版本和打包记录。安装前检查签名与运行时验证结果,再按[已安装更新验收流程](../tests/installed-update/README.zh.md)记录安装后启动及升级证据。[Desktop 测试记录](../tests/README.zh.md)维护验证限制;工作流成功本身不代表 production preset、新工作站或已安装升级验收通过。 + +## 开发备注 + +无。 diff --git a/apps/desktop/publishing/README.i18n.yaml b/apps/desktop/publishing/README.i18n.yaml new file mode 100644 index 0000000000..570a6157aa --- /dev/null +++ b/apps/desktop/publishing/README.i18n.yaml @@ -0,0 +1,37 @@ +# 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/publishing/README.md +/: + en: 32f414f3e27d5afd + zh: e16c49177f708c47 +/desktop-publication: + en: 03551b589405750e + zh: ccbf7e38dcf5d452 +/desktop-publication/summary: + en: 722633597f74ace6 + zh: 5303bcf5567a8511 +/desktop-publication/table-of-contents: + en: 64af8f421bc63580 + zh: 69844c0a0d4771f6 +/desktop-publication/publication-prerequisites: + en: b407de63118c9bf9 + zh: de19f1ba9feacbbf +/desktop-publication/deployment-and-test-release-identity: + en: 405588e021bcfb60 + zh: d9f9700b0b6db6f8 +/desktop-publication/publish-installers-and-update-feeds: + en: 3c3e3a09573c4386 + zh: c851ae6a94f2284a +/desktop-publication/fixed-installer-downloads: + en: 38054a5f89590cfa + zh: 9e115f9bdeeab213 +/desktop-publication/upload-credentials: + en: 1c5689525753b08d + zh: 93089de765806733 +/desktop-publication/upload-audit-and-recovery: + en: ec85f43ece09a5e3 + zh: 2de5bf801c9c9e39 +/desktop-publication/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/apps/desktop/publishing/README.md b/apps/desktop/publishing/README.md new file mode 100644 index 0000000000..0a698741fa --- /dev/null +++ b/apps/desktop/publishing/README.md @@ -0,0 +1,86 @@ +--- +description: "Publish completed Desktop installers and update feeds to the selected download service. This reference covers deployment identity, upload credentials, fixed downloads and audit records. Build configuration and signing operations have separate owners." +--- + +# Desktop publication + +English | [中文](README.zh.md) + +## Summary + +Publish completed Desktop installers and update feeds to the selected download service. This reference covers deployment identity, upload credentials, fixed downloads and audit records. Build configuration and signing operations have separate owners. + +## Table of Contents + +- [Publication prerequisites](#prerequisites) +- [Deployment and test release identity](#upload-updates) +- [Publish installers and update feeds](#publish-feeds) +- [Fixed installer downloads](#fixed-downloads) +- [Upload credentials](#credentials) +- [Upload audit and recovery](#audit-and-recovery) + + + +## Publication prerequisites + +Publication requires completed release artifacts and explicit operator authorization. Building an installer or downloading an Actions artifact does not publish it. Read the [release version rules](../packaging/README.md#release-versions) and retain the packaging evidence. Machine presets are for packaging, not upload/COS commands: unset `DSH_PACKAGING_PRESET` and provide the existing target dotenv upload configuration. The configuration and actual COS/CDN acceptance must be verified by the release operator. + + + +## Deployment and test release identity + +`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds//` and binaries use `dsh-desk/bin//`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk//feeds//` and `dsh-desk//bin//`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`. + +The update destination and upload credentials follow the selected deployment: + +| Environment | Public origin | COS bucket | COS credentials | +|---|---|---|---| +| `test` or unset | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`, `DOWNLOAD_TEST_COS_SECRET_KEY` | +| `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`, `DOWNLOAD_PROD_COS_SECRET_KEY` | + +Generate a fresh ID for each test release batch with the command below and copy its output into `DOWNLOAD_TEST_RELEASE_ID` in `.env.macos` or `.env.windows`. These Git-ignored platform files own the value; shell variables do not override it, and dotenv values are not shell-expanded. Keep the ID through packaging, upload, and retries; upload rejects a completion record with a different update URL. To test an upgrade between versions, reuse the installed client’s ID for the later version. Production does not use this field. + +```sh +node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(16).toString('hex'))" +``` + +Distribute each test batch through its complete download links. Installed test clients retain their batch feed and do not discover a new ID automatically. Format validation cannot establish randomness; use the generator output. Random paths reduce guessing, not access by link holders; withdrawing a batch requires deleting its COS objects and purging its CDN directory. + + + +## Publish installers and update feeds + +Configure the update origin and selected COS bucket, SecretId, and SecretKey in the target dotenv file, then package and upload the same target: + +```sh +pnpm run package:desktop:mac:arm64 +pnpm run upload:mac:arm64 +``` + +The target dotenv file for internal-test packaging explicitly sets `DSH_DESKTOP_AUTO_UPDATE_ENV=test` and `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`; uploads use `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` and separate test credentials. Both test and production use the fixed Nightly channel; deployment selection does not enable channel switching. + +Early internal-test packages use the `test` deployment. Select `production` explicitly only for a production release; changing upload credentials does not retarget an existing package. Packaging requires no COS credentials, disables electron-builder publishing, strips COS credentials from child processes, and records completion only after signing and notarization succeed. Upload validates this record, deployment, target, shared version, filenames, sizes, and SHA-512 before reading credentials. Packages and blockmaps upload before YAML; historical objects are retained. Every release publishes `nightly.yml` or `nightly-mac.yml`; stable releases also publish `latest.yml` or `latest-mac.yml` pointing at the same artifacts. Published YAML uses absolute binary URLs. The uploader leaves Cache-Control unset, including the empty header the COS SDK would otherwise add: deployment infrastructure owns cache policy, with uncached feeds and separately configurable binary caching. Serialize publication per target and verify public artifacts and feed contents before release qualification. + +Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the installer. The blockmap is uploaded before channel YAML; NSIS installer metadata does not require the embedded `blockMapSize` used by the separate web-installer format. File-plan tests use the pinned builder's blockmap generator, not a hand-authored embedded-map field. + + + +## Fixed installer downloads + +Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Deployment infrastructure must configure revalidation or purge cached fixed downloads after replacement. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. + + + +## Upload credentials + +Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](../scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. + + + +## Upload audit and recovery + +Test and production uploads through `upload:*` retain a fresh `.desktop-build/upload-records/--*` directory after release preflight. `plan.json` records destination, version, every file's size/SHA-512, and published YAML bytes; flushed `events.jsonl` records PUT intent and available response status/request ID; `result.json` records completion or the last failed stage. A missing final result means interruption or unavailable storage, not success. No credential values, authorization headers, or raw SDK errors are recorded. Audit-write failures stop later PUTs. Every object is one streamed Tencent COS PUT with an explicit length and Content-MD5; the COS SDK repeats a request only when its body is not a stream, and this uploader does not retry either. Retain partial records and inspect remote state before another operation: a timeout or failed receipt write does not prove that the object was not stored. These records are local, not tamper-proof or automatically backed up; archive each release's records with its build evidence in controlled storage. Public CDN readback remains separate release qualification, explicitly marked `not-performed` in the upload result. + +## Dev Note + +None. diff --git a/apps/desktop/publishing/README.zh.md b/apps/desktop/publishing/README.zh.md new file mode 100644 index 0000000000..7f7f230065 --- /dev/null +++ b/apps/desktop/publishing/README.zh.md @@ -0,0 +1,86 @@ +--- +description: "将完整 Desktop 安装包与更新清单发布到选定下载服务。本参考文档说明部署标识、上传凭据、固定下载和审计记录。打包配置与签名操作由独立文档维护。" +--- + +# Desktop 发布 + +[English](README.md) | 中文 + +## 概述 + +将完整 Desktop 安装包与更新清单发布到选定下载服务。本参考文档说明部署标识、上传凭据、固定下载和审计记录。打包配置与签名操作由独立文档维护。 + +## 目录 + +- [发布前提](#prerequisites) +- [部署环境与测试发布标识](#upload-updates) +- [发布安装包与更新清单](#publish-feeds) +- [固定安装包下载](#fixed-downloads) +- [上传凭据](#credentials) +- [上传审计与恢复](#audit-and-recovery) + + + +## 发布前提 + +发布需要完整的 release 产物和操作者明确授权。生成安装包或下载 Actions 产物不会发布它。先阅读[发布版本规则](../packaging/README.zh.md#release-versions)并保留打包证据。机器 preset 用于打包,不支持 upload/COS 命令:取消设置 `DSH_PACKAGING_PRESET`,并提供既有目标 dotenv 上传配置。配置以及真实 COS/CDN 验收由发布操作者验证。 + + + +## 部署环境与测试发布标识 + +`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。生产清单位于 `dsh-desk/feeds//`,安装包位于 `dsh-desk/bin//`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk//feeds//` 和 `dsh-desk//bin//`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。 + +更新目标与上传凭据都与所选环境对应: + +| 环境 | 公开 origin | COS bucket | COS 凭据 | +|---|---|---|---| +| `test` 或未设置 | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`、`DOWNLOAD_TEST_COS_SECRET_KEY` | +| `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`、`DOWNLOAD_PROD_COS_SECRET_KEY` | + +每个测试发布批次用下方命令生成新 ID,将输出填入 `.env.macos` 或 `.env.windows` 的 `DOWNLOAD_TEST_RELEASE_ID`。这两个被 Git 忽略的平台文件管理该值,shell 变量不能覆盖它,dotenv 值也不会进行 shell 展开。打包、上传和重试必须沿用同一个 ID;上传会拒绝更新 URL 不一致的完成记录。需要验证跨版本升级时,后续版本沿用已安装客户端的 ID。生产环境不使用该字段。 + +```sh +node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(16).toString('hex'))" +``` + +通过完整下载链接分发每个测试批次。已安装的测试客户端保留当前批次的清单地址,不会自动发现新 ID。格式校验无法判断随机性,请使用生成器的输出。随机路径降低被猜中的概率,不限制持有链接者访问;撤下批次需要删除其 COS 对象并清除对应 CDN 目录缓存。 + + + +## 发布安装包与更新清单 + +在目标 `.env` 中配置更新地址与所选 COS bucket、SecretId、SecretKey,再打包并上传同一个目标: + +```sh +pnpm run package:desktop:mac:arm64 +pnpm run upload:mac:arm64 +``` + +内测打包在目标 `.env` 中显式设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=test` 和 `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`;上传使用 `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` 及独立测试凭据。test 和 production 都使用固定 Nightly 通道,部署选择不提供通道切换。 + +前期内测包使用 `test` 部署。只有正式发布才显式选择 `production`;更换上传凭据不会改变已有安装包的更新目标。打包不需要 COS 凭据,会禁用 electron-builder 发布、移除子进程的 COS 凭据,并且仅在签名与公证成功后记录完成状态。上传在读取凭据前验证该记录、部署、目标、共同版本号、文件名、大小与 SHA-512。安装包和 blockmap 先于 YAML 上传;历史对象继续保留。每个版本发布 `nightly.yml` 或 `nightly-mac.yml`;稳定版本还发布指向相同产物的 `latest.yml` 或 `latest-mac.yml`。发布的 YAML 使用安装包绝对 URL。上传器不设置 Cache-Control,包括 COS SDK 否则会添加的空头部:缓存策略由部署基础设施负责,清单不缓存,安装包缓存单独配置。同一目标应串行发布,并在发布验收前验证公网产物与清单内容。 + +Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blockmap 先于通道 YAML 上传;NSIS 安装包元数据不要求另一种 web-installer 格式使用的内嵌 `blockMapSize`。文件清单测试使用固定版本构建器的 blockmap 生成器,而不是手工编造内嵌映射字段。 + + + +## 固定安装包下载 + +固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。部署基础设施必须配置重新验证,或在覆盖后清除固定下载的缓存。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 + + + +## 上传凭据 + +Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](../scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 + + + +## 上传审计与恢复 + +test 与 production 的 `upload:*` 上传在发布前置检查通过后,分别保留新的 `.desktop-build/upload-records/--*` 目录。`plan.json` 记录目标、版本、每个文件的大小/SHA-512 和发布的 YAML 字节;刷盘的 `events.jsonl` 记录 PUT 意图及可用的响应状态/请求 ID;`result.json` 记录完成结果或最后失败阶段。缺少最终结果表示中断或存储不可用,不表示成功。不记录凭据值、认证头或原始 SDK 错误。审计写入失败即停止后续 PUT。每个对象都以一次流式腾讯 COS PUT 上传,并携带显式长度与 Content-MD5;COS SDK 仅在请求体不是流时才会重发请求,上传器自身也不重试。保留部分记录,检查远端状态后再执行下一次操作:超时或回执写入失败不能证明对象未存储。这些记录仅在本地,不防篡改,也不会自动备份;每次发布应将它们与构建证据一同归档到受控存储。公网 CDN 回读仍是单独的发布验收,上传结果明确标记为 `not-performed`。 + +## 开发备注 + +无。 diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index 9a8e408ed1..c7b8173ca1 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -5,6 +5,7 @@ export interface DesktopTargetBuildPaths { readonly root: string readonly artifacts: string readonly unsignedArtifacts: string + readonly unnotarizedArtifacts: string readonly runtime: string readonly packageSet: string readonly dsh: string @@ -72,3 +73,14 @@ export function developmentRuntimeDirectory( hostPlatform?: NodeJS.Platform, hostArch?: string, ): string + +/** + * Select the output directory and filename suffix for one packaging mode. + * @param paths Target artifact directories. + * @param mode Validated packaging mode. + * @returns Matching artifact destination and filename marker. + */ +export function desktopArtifactOutput( + paths: Pick, + mode: { unsigned: boolean, skipNotarization: boolean }, +): { directory: string, suffix: string } diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index 4f33e804c3..e577b1d3fe 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -38,7 +38,7 @@ 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, 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. + * @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. */ export function desktopTargetBuildPaths(target) { assertSupportedTarget(target) @@ -48,6 +48,7 @@ export function desktopTargetBuildPaths(target) { root, artifacts: join(root, 'artifacts'), unsignedArtifacts: join(root, 'unsigned-artifacts'), + unnotarizedArtifacts: join(root, 'unnotarized-artifacts'), runtime: join(root, 'runtime'), packageSet: join(root, 'package-set'), dsh: join(root, 'dsh'), @@ -105,3 +106,15 @@ export function developmentRuntimeDirectory( ) { return join(resolveDesktopTargetBuildPaths(env, hostPlatform, hostArch).runtime, 'primary-runtime') } + +/** + * Select the output directory and filename suffix for one packaging mode. + * @param {{ artifacts: string, unsignedArtifacts: string, unnotarizedArtifacts: string }} paths Target artifact directories. + * @param {{ unsigned: boolean, skipNotarization: boolean }} mode Validated packaging mode. + * @returns {{ directory: string, suffix: string }} Matching artifact destination and filename marker. + */ +export function desktopArtifactOutput(paths, mode) { + if (mode.unsigned) return { directory: paths.unsignedArtifacts, suffix: '-unsigned' } + if (mode.skipNotarization) return { directory: paths.unnotarizedArtifacts, suffix: '-unnotarized' } + return { directory: paths.artifacts, suffix: '' } +} diff --git a/apps/desktop/scripts/desktop-build-version-discovery.ts b/apps/desktop/scripts/desktop-build-version-discovery.ts index fd5a2c9e10..68291ca3b8 100644 --- a/apps/desktop/scripts/desktop-build-version-discovery.ts +++ b/apps/desktop/scripts/desktop-build-version-discovery.ts @@ -2,7 +2,7 @@ * Suggest the next build version for a product version, so the sequence number * is derived rather than remembered. * - * `apps/desktop/README.md` gives the form as `..` + * `apps/desktop/packaging/README.md#release-versions` gives the form as `..` * for a prerelease base and `-test..` for a * stable one. What makes an index correct is which ones are taken, and that is * recorded where the builds went: the update bucket for an uploaded build, the @@ -30,8 +30,8 @@ const LISTING_DEADLINE_MS = 8_000 /** Objects one listing page may return. */ const LISTING_PAGE_SIZE = 1000 -/** Artifact name electron-builder writes for one build, on either platform; unsigned builds add a suffix. */ -const ARTIFACT = /(?:^|\/)deepseek-harness-(?.+)-(?:mac|win)-(?:arm64|x64)(?:-unsigned)?\.(?:exe|dmg|zip)$/u +/** Artifact name electron-builder writes for one build, on either platform; unsigned and unnotarized builds add a suffix. */ +const ARTIFACT = /(?:^|\/)deepseek-harness-(?.+)-(?:mac|win)-(?:arm64|x64)(?:-unsigned|-unnotarized)?\.(?:exe|dmg|zip)$/u /** Inputs that decide which versions are already taken. */ export interface DesktopBuildVersionSuggestionOptions { diff --git a/apps/desktop/scripts/desktop-build-version.mjs b/apps/desktop/scripts/desktop-build-version.mjs index 411b5238fb..bddf4ca492 100644 --- a/apps/desktop/scripts/desktop-build-version.mjs +++ b/apps/desktop/scripts/desktop-build-version.mjs @@ -2,7 +2,7 @@ * Resolve the version one build publishes, which is not always the version the * repository declares. * - * Manual Windows CI uses UTC date, workflow run number and attempt as + * Manual Desktop CI uses UTC date, workflow run number and attempt as * `.YYYYMMDD.run.attempt`; these identify unpublished builds, not release sequences. * * A production release publishes the version in the manifests, aligned with the @@ -10,7 +10,7 @@ * sequence number, so one test feed can carry several builds of a single * product version: `0.1.6-alpha.1.20260916.1` from a prerelease base and * `0.1.6-test.20260916.1` from a stable one, as the release versions table in - * `apps/desktop/README.md` gives them. Passing that version here keeps it out + * `apps/desktop/packaging/README.md#release-versions` gives them. Passing that version here keeps it out * of the manifests, so the tracked version stays the product's while the build * version reaches electron-builder, the update feed, and the upload validation * as one input. diff --git a/apps/desktop/scripts/desktop-ci-version.d.mts b/apps/desktop/scripts/desktop-ci-version.d.mts new file mode 100644 index 0000000000..213e96fd1a --- /dev/null +++ b/apps/desktop/scripts/desktop-ci-version.d.mts @@ -0,0 +1,10 @@ +/** Resolve the version used by the manual Desktop packaging workflow. */ + +/** + * Resolve an explicit version or a UTC date/run/attempt identifier without release storage access. + * @param environment Manual version and GitHub run identity. + * @param productVersion Version declared by the selected checkout. + * @param now Time used for the UTC date segment. + * @returns Validated build version. + */ +export function desktopCiVersion(environment: NodeJS.ProcessEnv, productVersion: string, now?: Date): string diff --git a/apps/desktop/scripts/desktop-ci-version.mjs b/apps/desktop/scripts/desktop-ci-version.mjs new file mode 100644 index 0000000000..294d00ef3b --- /dev/null +++ b/apps/desktop/scripts/desktop-ci-version.mjs @@ -0,0 +1,28 @@ +/** Resolve manual Desktop CI build versions without release storage access. */ +import { desktopBuildVersionPrefix, validateDesktopBuildVersion } from './desktop-build-version.mjs' + +/** + * Resolve an explicit version or a UTC date/run/attempt identifier without release storage access. + * @param {NodeJS.ProcessEnv} environment Manual version and GitHub run identity. + * @param {string} productVersion Version declared by the selected checkout. + * @param {Date} now Time used for the UTC date segment. + * @returns {string} Validated build version. + */ +export function desktopCiVersion(environment, productVersion, now = new Date()) { + const requested = environment.PACKAGE_BUILD_VERSION?.trim() + const date = now.toISOString().slice(0, 10).replaceAll('-', '') + const example = `${desktopBuildVersionPrefix(productVersion)}${date}.1` + if (!requested) throw new Error(`Build version is required: use auto, ${productVersion}, or e.g. ${example}`) + let candidate = requested + if (requested === 'auto') { + for (const name of ['GITHUB_RUN_NUMBER', 'GITHUB_RUN_ATTEMPT']) { + if (!/^[1-9]\d*$/u.test(environment[name] ?? '')) throw new Error(`${name} must be a positive integer`) + } + candidate = `${desktopBuildVersionPrefix(productVersion)}${date}.${environment.GITHUB_RUN_NUMBER}.${environment.GITHUB_RUN_ATTEMPT}` + } + try { + return validateDesktopBuildVersion(candidate, productVersion) + } catch { + throw new Error(`Invalid build version for this checkout: use auto, ${productVersion}, or e.g. ${example}`) + } +} diff --git a/apps/desktop/scripts/desktop-package-environment.d.mts b/apps/desktop/scripts/desktop-package-environment.d.mts index a49ca46eb9..b8c6887657 100644 --- a/apps/desktop/scripts/desktop-package-environment.d.mts +++ b/apps/desktop/scripts/desktop-package-environment.d.mts @@ -8,16 +8,18 @@ export function localMacOSPackageEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv /** - * Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values. + * Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. * @param platform Target platform. * @param environment Parent environment, retained only for unrelated build tools. * @param appRoot Desktop application directory; relative credential paths resolve here. + * @param options Unsigned presets and Windows preparation-only presets never read signing credentials. * @returns Isolated environment with file-owned release settings. */ export function loadDesktopPackageEnvironment( platform: 'win32' | 'darwin', environment?: NodeJS.ProcessEnv, appRoot?: string, + options?: { unsigned?: boolean, prepareOnly?: boolean }, ): NodeJS.ProcessEnv /** @@ -30,5 +32,5 @@ export function loadDesktopPackageEnvironment( export function validateDesktopPackageEnvironment( environment: NodeJS.ProcessEnv, target: { platform: 'win32' | 'darwin', arch: string }, - options?: { unsigned?: boolean, prepareOnly?: boolean }, + options?: { unsigned?: boolean, prepareOnly?: boolean, skipNotarization?: boolean }, ): void diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 51b878100a..f3522be26d 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -1,13 +1,14 @@ /** Select local test defaults or platform release settings without changing the caller's environment. */ import { accessSync, constants, readFileSync, statSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' +import { dirname, isAbsolute, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parseEnv } from 'node:util' import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment, resolveNpmRegistry } from './desktop-release-environment.mjs' import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs' import { createWindowsTokenSigner } from './windows-sign.mjs' import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' +import { validateMacOSSignatureCacheDirectory } from './macos-signature-cache-directory.mjs' import { resolveMacOSPackageSettings } from './macos-package-settings.mjs' import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' @@ -15,7 +16,7 @@ import { resolveWindowsPackageSettings } from './windows-package-settings.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)$/u -const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|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 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 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'] @@ -33,14 +34,53 @@ export function localMacOSPackageEnvironment(environment = process.env) { } /** - * Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values. + * Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. * @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. + * @param {{ unsigned?: boolean, prepareOnly?: boolean }} options Unsigned presets and Windows preparation-only presets never read signing credentials. * @returns {NodeJS.ProcessEnv} Isolated environment with file-owned release settings. */ -export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT) { - const path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos') +export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT, options = {}) { + const preset = environment.DSH_PACKAGING_PRESET + let path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos') + if (preset !== undefined) { + if (preset !== 'test' && preset !== 'production') throw new Error('desktop package: preset must be test or production') + const root = environment.DSH_PACKAGING_CONFIG_DIR + if (!root || !isAbsolute(root)) throw new Error('desktop package: DSH_PACKAGING_CONFIG_DIR must be an absolute directory') + path = join(root, 'presets', `${preset}.env`) + } + const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING + const publicPlatformSetting = platform === 'win32' + ? /^DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_(?:DIR|CONCURRENCY)$/u + : /^DSH_DESKTOP_MACOS_(?:SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|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)) + if (preset !== undefined) { + if (settings.DSH_DESKTOP_AUTO_UPDATE_ENV !== preset) throw new Error('desktop package: preset deployment must match its name') + if (!options.unsigned && (platform === 'darwin' || !options.prepareOnly)) { + const filename = platform === 'win32' ? 'windows-signing.env' : 'macos-signing.env' + Object.assign(settings, readSettings(join(dirname(dirname(path)), 'secrets', filename), + name => platform === 'win32' + ? /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN)$/u.test(name) + : MACOS_SETTING.test(name) && !publicPlatformSetting.test(name))) + } + } + return { + ...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))), + ...settings, + ...(preset === undefined ? {} : { DSH_PACKAGING_PRESET: preset }), + } +} + +/** + * Read an explicitly selected dotenv without exposing credential-bearing parser errors. + * @param {string} path Selected file; relative file settings resolve beside it. + * @param {(name: string) => boolean} accepts Whether this file may declare a setting. + * @returns {NodeJS.ProcessEnv} Validated file settings. + */ +function readSettings(path, accepts) { let contents try { contents = readFileSync(path, 'utf8') @@ -56,9 +96,8 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en // Parser diagnostics can contain credential-bearing input. throw new Error(`desktop package: invalid dotenv syntax in ${path}`) } - const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING for (const name of Object.keys(settings)) { - if (!SHARED_SETTING.test(name) && !platformSetting.test(name)) { + if (!accepts(name)) { throw new Error(`desktop package: unsupported setting ${name} in ${path}; use the platform template`) } if (settings[name].includes('\0')) throw new Error(`desktop package: ${name} cannot contain a NUL character`) @@ -66,10 +105,7 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en for (const name of FILE_SETTINGS) { if (settings[name]?.trim()) settings[name] = resolve(dirname(path), settings[name].trim()) } - return { - ...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))), - ...settings, - } + return settings } function requireReadableFile(environment, name) { @@ -86,7 +122,7 @@ function requireReadableFile(environment, name) { * Validate the packaging mode before preparation without invoking a token or Apple's services. * @param {NodeJS.ProcessEnv} environment Selected local defaults or file-owned release settings. * @param {{ platform: 'win32' | 'darwin', arch: string }} target Selected release target. - * @param {{ unsigned?: boolean, prepareOnly?: boolean }} options Explicit packaging mode. + * @param {{ unsigned?: boolean, prepareOnly?: boolean, skipNotarization?: boolean }} options Explicit packaging mode. * @returns {void} */ export function validateDesktopPackageEnvironment(environment, target, options = {}) { @@ -106,18 +142,21 @@ export function validateDesktopPackageEnvironment(environment, target, options = }) if (!options.prepareOnly) resolveWindowsSignatureCacheDirectory(environment) } else { + if (environment.DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR !== undefined) validateMacOSSignatureCacheDirectory(environment.DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR) resolveMacOSSigningEnvironment(environment) - const strategies = [ - ['APPLE_ID', 'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_TEAM_ID'], - ['APPLE_API_KEY', 'APPLE_API_KEY_ID', 'APPLE_API_ISSUER'], - ['APPLE_KEYCHAIN_PROFILE', 'APPLE_KEYCHAIN'], - ] - if (strategies.filter(names => names.some(name => environment[name] !== undefined)).length > 1) { - throw new Error('desktop package: configure exactly one macOS notarization strategy; comment out the other strategies') + if (!options.skipNotarization) { + const strategies = [ + ['APPLE_ID', 'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_TEAM_ID'], + ['APPLE_API_KEY', 'APPLE_API_KEY_ID', 'APPLE_API_ISSUER'], + ['APPLE_KEYCHAIN_PROFILE', 'APPLE_KEYCHAIN'], + ] + if (strategies.filter(names => names.some(name => environment[name] !== undefined)).length > 1) { + throw new Error('desktop package: configure exactly one macOS notarization strategy; comment out the other strategies') + } + const credentials = resolveMacOSNotarizationEnvironment(environment) + if ('appleApiKey' in credentials) requireReadableFile(environment, 'APPLE_API_KEY') + if ('keychain' in credentials) requireReadableFile(environment, 'APPLE_KEYCHAIN') } - const credentials = resolveMacOSNotarizationEnvironment(environment) - if ('appleApiKey' in credentials) requireReadableFile(environment, 'APPLE_API_KEY') - if ('keychain' in credentials) requireReadableFile(environment, 'APPLE_KEYCHAIN') requireReadableFile(environment, 'CSC_LINK') if (environment.CSC_KEY_PASSWORD === undefined) { throw new Error('desktop package: CSC_KEY_PASSWORD must be set to the p12 export password (use an explicit empty value for an unencrypted p12)') diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index 1ae2f3e952..37515c20b0 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -93,7 +93,7 @@ async function launchElectron(): Promise { async function main(): Promise { const { values } = parseArgs({ options: { 'skip-build': { type: 'boolean', default: false } } }) if (!values['skip-build']) { - await runPackageScript('build', REPOSITORY_ROOT) + await runPackageScript('build:artifacts', REPOSITORY_ROOT) await runPackageScript('build', APP_ROOT) } for (const path of [ diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index f08b783865..2fa3759ed3 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -12,7 +12,7 @@ import { resolveMacOSSigningEnvironment, } from './desktop-release-environment.mjs' import { notarizeMacOSDiskImageArtifact } from './notarize-macos-disk-images.mjs' -import { verifyMacOSSignatureAfterSign } from './verify-macos-signature.mjs' +import { verifyMacOSDiskImageSignature, verifyMacOSSignatureAfterSign } from './verify-macos-signature.mjs' import { createWindowsTokenSigner, installWindowsNsisBootstrapSigner, @@ -23,7 +23,7 @@ import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environmen import { resolveDesktopBuildCommit } from './desktop-build-commit.mjs' import { resolveDesktopBuildVersion } from './desktop-build-version.mjs' import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' -import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' +import { desktopArtifactOutput, desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' import { installWindowsDirectoryInstaller } from './windows-directory-installer.mjs' import { preserveWindowsRuntimeSignature, signWindowsCode } from './windows-runtime-signature.mjs' import { prepareWindowsAsarUnpack, verifyWindowsAsarUnpack } from './windows-asar-unpack.mjs' @@ -58,13 +58,19 @@ export function createElectronBuilderConfig( throw new Error('desktop package: DSH_DESKTOP_UNSIGNED must be 0 or 1') } const unsigned = env.DSH_DESKTOP_UNSIGNED === '1' + const skipNotarization = env.DSH_DESKTOP_MACOS_SKIP_NOTARIZATION === '1' + if (env.DSH_DESKTOP_MACOS_SKIP_NOTARIZATION !== undefined && !['0', '1'].includes(env.DSH_DESKTOP_MACOS_SKIP_NOTARIZATION)) { + throw new Error('desktop package: DSH_DESKTOP_MACOS_SKIP_NOTARIZATION must be 0 or 1') + } + if (skipNotarization && (unsigned || resolvedPlatform !== 'darwin')) throw new Error('Skipping notarization requires a signed macOS build') const packagesMacOS = targetPlatform === 'darwin' || (targetPlatform === undefined && hostPlatform === 'darwin') const packagesWindows = resolvedPlatform === 'win32' const policy = packagesMacOS && unsigned ? undefined : resolveDesktopPolicyEnvironment(env) if (resolvedPlatform === 'win32') installWindowsDirectoryInstaller() const macOSSigning = packagesMacOS && !unsigned ? resolveMacOSSigningEnvironment(env) : undefined - if (packagesMacOS && !unsigned) resolveMacOSNotarizationEnvironment(env) + if (packagesMacOS && !unsigned && !skipNotarization) resolveMacOSNotarizationEnvironment(env) const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) + const output = desktopArtifactOutput(buildPaths, { unsigned, skipNotarization }) let primaryRuntimeDestination let dshDestination let windowsCode = [] @@ -107,8 +113,8 @@ export function createElectronBuilderConfig( }, productName: 'DeepSeek Harness', // Unsigned builds carry their own suffix so a shared file can never pass for a release artifact. - artifactName: `deepseek-harness-\${version}-\${os}-\${arch}${unsigned ? '-unsigned' : ''}.\${ext}`, - directories: { output: unsigned ? buildPaths.unsignedArtifacts : buildPaths.artifacts }, + artifactName: `deepseek-harness-\${version}-\${os}-\${arch}${output.suffix}.\${ext}`, + directories: { output: output.directory }, asar: true, electronDist: buildPaths.electron, electronFuses: { runAsNode: true }, @@ -135,6 +141,8 @@ export function createElectronBuilderConfig( 'lib/preload-welcome.cjs', 'renderer/**/*', 'package.json', + '!node_modules/**/*.{js,cjs,mjs,ts,cts,mts,css}.map', + '!node_modules/**/*.tsbuildinfo', { from: buildPaths.dsh, to: 'dsh', filter: ['**/*'] }, // electron-builder excludes a source directory's root node_modules. { from: join(buildPaths.dsh, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] }, @@ -160,10 +168,11 @@ export function createElectronBuilderConfig( entitlementsInherit: fileURLToPath(new URL('./macos-entitlements.plist', import.meta.url)), // Prepared runtime files retain their signatures; PAK resources are sealed by their enclosing bundle. signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'], - notarize: !unsigned, + notarize: !unsigned && !skipNotarization, target: unsigned ? ['dmg'] : ['dmg', 'zip'], }, dmg: { + format: 'ULMO', sign: !unsigned, writeUpdateInfo: false, }, @@ -214,6 +223,10 @@ export function createElectronBuilderConfig( }, artifactBuildCompleted: artifact => { if (unsigned || !artifact.file.endsWith('.dmg')) return + if (skipNotarization) { + verifyMacOSDiskImageSignature(artifact.file, macOSSigning ?? resolveMacOSSigningEnvironment(env)) + return + } return notarizeMacOSDiskImageArtifact( artifact, env, diff --git a/apps/desktop/scripts/installed-update-builder.ts b/apps/desktop/scripts/installed-update-builder.ts index 2f0cbefe6c..42c665c430 100644 --- a/apps/desktop/scripts/installed-update-builder.ts +++ b/apps/desktop/scripts/installed-update-builder.ts @@ -31,6 +31,7 @@ export async function createInstalledUpdateBuilderConfig(manifest: string, versi files: [ { from: application, to: '.', filter: ['lib/*.js', 'lib/*.cjs', 'renderer/**/*', 'qualification-bootstrap.mjs', 'installed-update-identity.mjs'] }, 'package.json', + ...config.files.filter((file): file is string => typeof file === 'string' && file.startsWith('!')), { from: dsh, to: 'dsh', filter: ['**/*'] }, { from: join(dsh, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] }, ], diff --git a/apps/desktop/scripts/macos-runner-service.sh b/apps/desktop/scripts/macos-runner-service.sh new file mode 100644 index 0000000000..f3fd74eb94 --- /dev/null +++ b/apps/desktop/scripts/macos-runner-service.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# Manage a registered runner in the current user's logged-in macOS session. +set -euo pipefail +if [[ $# -lt 2 ]]; then + echo 'Usage: bash macos-runner-service.sh install RUNNER_ROOT CONFIG_ROOT NODE_BIN | start|stop|status|uninstall RUNNER_ROOT' >&2 + exit 1 +fi +action=$1 +runner_root=$2 +[[ $(uname -s) = Darwin && $EUID -ne 0 ]] || { echo 'Run as the registered macOS runner user, without sudo' >&2; exit 1; } +[[ "$runner_root" = /* && -f "$runner_root/.runner" && -x "$runner_root/svc.sh" ]] || { echo 'Provide an absolute registered runner directory' >&2; exit 1; } +source_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +cd "$runner_root" +case "$action" in + install) + [[ $# = 4 ]] || { echo 'install requires RUNNER_ROOT CONFIG_ROOT NODE_BIN' >&2; exit 1; } + config_root=$3 + node_bin=$4 + [[ ! -f .service ]] || { echo 'Service already installed; stop and uninstall it before reinstalling' >&2; exit 1; } + launchctl print "gui/$(id -u)" >/dev/null || { echo 'Log into the macOS desktop as this user first' >&2; exit 1; } + bash "$source_dir/start-macos-packaging-runner.sh" "$runner_root" "$config_root" "$node_bin" --check + umask 077 + service_dir="$runner_root/dsh-service" + mkdir -p "$service_dir" + cp "$source_dir/start-macos-packaging-runner.sh" "$service_dir/start.sh" + # runsvc.sh reloads .path; replace the interactive PATH saved by registration. + printf '%s\n' "$node_bin:/usr/bin:/bin:/usr/sbin:/sbin" > .path + "$node_bin/node" --input-type=module - "$service_dir" "$runner_root" "$config_root" "$node_bin" <<'NODE' +import { writeFileSync } from 'node:fs' +const [directory, runner, config, node] = process.argv.slice(2) +const xml = value => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''') +// svc.sh substitutes its own placeholders with sed; prohibit template syntax in paths. +for (const value of [directory, runner, config, node]) { + if (/[\r\n]|\{\{/u.test(value)) throw new Error('Service paths cannot contain newlines or template placeholders') +} +const args = ['/bin/bash', `${directory}/start.sh`, runner, config, node, '--service'] +writeFileSync(`${directory}/service.plist.template`, ` + + +Label{{SvcName}} +ProgramArguments${args.map(arg => `${xml(arg)}`).join('')} +WorkingDirectory${xml(runner)} +RunAtLoad +StandardOutPath${xml(directory)}/stdout.log +StandardErrorPath${xml(directory)}/stderr.log +EnvironmentVariablesACTIONS_RUNNER_SVC1 +ProcessTypeInteractive +SessionCreate + +`, { mode: 0o600 }) +NODE + GITHUB_ACTIONS_RUNNER_SERVICE_TEMPLATE="$service_dir/service.plist.template" ./svc.sh install + /usr/bin/plutil -lint "$(cat .service)" + echo 'Installed. Run start, then status. This LaunchAgent requires a desktop login after reboot.' + ;; + start|stop|status|uninstall) + [[ $# = 2 && -f .service ]] || { echo 'Provide an installed runner service directory' >&2; exit 1; } + ./svc.sh "$action" + ;; + *) echo "Unknown action: $action" >&2; exit 1 ;; +esac diff --git a/apps/desktop/scripts/macos-runtime.ts b/apps/desktop/scripts/macos-runtime.ts index 3d5fabdbed..1d4c27c0d5 100644 --- a/apps/desktop/scripts/macos-runtime.ts +++ b/apps/desktop/scripts/macos-runtime.ts @@ -1,8 +1,9 @@ /** Sign final native runtime files before the enclosing Desktop application is signed. */ import { createHash } from 'node:crypto' -import { closeSync, openSync, readSync } from 'node:fs' -import { join } from 'node:path' +import { closeSync, openSync, readdirSync, readSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { primaryRuntimePythonAliases, readPrimaryRuntime } from '../../../packages/skill/tool-workspace-dependencies/src/index.ts' import { inventoryDesktopRuntime } from '../src/runtime-tree.ts' import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs' import { cachedMacOSSignature, pruneMacOSSignatureCache } from './macos-signature-cache.ts' @@ -19,19 +20,34 @@ function magic(path: string): string { } finally { closeSync(descriptor) } } +async function primaryRuntimeFiles(root: string): Promise { + const aliases = await primaryRuntimePythonAliases(root, await readPrimaryRuntime(root)) + const files: string[] = [] + for (const entry of readdirSync(root, { recursive: true, withFileTypes: true })) { + const path = relative(root, join(entry.parentPath, entry.name)).split(sep).join('/') + if (entry.isDirectory() || (entry.isSymbolicLink() && aliases.has(path))) continue + if (!entry.isFile()) throw new Error(`desktop runtime: unsupported filesystem entry ${path}`) + files.push(path) + } + return files.sort() +} + /** * Sign and verify every materialized Mach-O file, awaiting all signers on failure. - * @param root - Self-contained production runtime without symlinks. + * @param root - Self-contained production runtime. * @param appId - Release application identifier. * @param expected - Required signing identity. * @param arch - Target runtime architecture, independent of the signing host. * @param cacheDirectory - Optional content-addressed cache; requires the keychain-owned signing probe. + * @param kind - Primary payloads admit validated Python aliases; Desktop application runtimes reject every link. * @returns Number of signed native files. */ export async function signMacOSRuntime( root: string, appId: string, expected: MacOSSigningEnvironment, arch: 'arm64' | 'x64', cacheDirectory?: string, + kind: 'desktop' | 'primary' = 'desktop', ): Promise { - const files = inventoryDesktopRuntime(root).map(file => file.path).filter(path => MACH_O_MAGICS.has(magic(join(root, path)))) + const inventory = kind === 'primary' ? await primaryRuntimeFiles(root) : inventoryDesktopRuntime(root).map(file => file.path) + const files = inventory.filter(path => MACH_O_MAGICS.has(magic(join(root, path)))) const policy = cacheDirectory === undefined ? undefined : macOSCachePolicy(process.env.DSH_DESKTOP_MACOS_SIGNING_PROBE ?? '') let hits = 0 let misses = 0 diff --git a/apps/desktop/scripts/macos-signature-cache-directory.d.mts b/apps/desktop/scripts/macos-signature-cache-directory.d.mts new file mode 100644 index 0000000000..163905a886 --- /dev/null +++ b/apps/desktop/scripts/macos-signature-cache-directory.d.mts @@ -0,0 +1,15 @@ +/** Optional machine-owned macOS signature storage. */ +/** + * Require existing private storage with no linked ancestors. + * @param path Explicit absolute cache directory. + * @returns Normalized directory; throws on invalid paths, ownership, permissions or access. + */ +export function validateMacOSSignatureCacheDirectory(path: string): string + +/** + * Select machine storage or retain the target-local default when the setting is absent. + * @param environment File-owned packaging configuration. + * @param targetBuildRoot Target build location. + * @returns Validated external directory, or the default directory created later by signing. + */ +export function resolveMacOSSignatureCacheDirectory(environment: NodeJS.ProcessEnv, targetBuildRoot: string): string diff --git a/apps/desktop/scripts/macos-signature-cache-directory.mjs b/apps/desktop/scripts/macos-signature-cache-directory.mjs new file mode 100644 index 0000000000..6a78af8e13 --- /dev/null +++ b/apps/desktop/scripts/macos-signature-cache-directory.mjs @@ -0,0 +1,38 @@ +/** Resolve optional machine-owned macOS signature storage before signing begins. */ +import { accessSync, constants, lstatSync } from 'node:fs' +import { dirname, isAbsolute, join, resolve } from 'node:path' + +/** + * Require existing private storage with no linked ancestors; never create or repair it during packaging. + * @param path Explicit absolute cache directory. + * @returns Normalized directory; invalid paths, ownership, permissions and access fail before signing. + */ +export function validateMacOSSignatureCacheDirectory(path) { + if (!path || path.includes('\0') || !isAbsolute(path)) { + throw new Error('DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR must be a nonempty absolute directory') + } + const root = resolve(path) + for (let ancestor = root; ; ancestor = dirname(ancestor)) { + const info = lstatSync(ancestor) + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error(`macOS signature cache: expected an unlinked directory: ${ancestor}`) + } + if (ancestor === root && (info.uid !== process.getuid() || (info.mode & 0o077) !== 0 || (info.mode & 0o700) !== 0o700)) { + throw new Error(`macOS signature cache: directory must be private, accessible and owned by the current account: ${root}`) + } + if (ancestor === dirname(ancestor)) break + } + accessSync(root, constants.R_OK | constants.W_OK | constants.X_OK) + return root +} + +/** + * Select validated machine storage or retain the target-local cache when the setting is absent. + * @param environment File-owned packaging configuration. + * @param targetBuildRoot Existing target build location used by local packaging. + * @returns Selected cache directory; only the default may be created later by the signer. + */ +export function resolveMacOSSignatureCacheDirectory(environment, targetBuildRoot) { + const selected = environment.DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR + return selected === undefined ? join(targetBuildRoot, 'signature-cache') : validateMacOSSignatureCacheDirectory(selected) +} diff --git a/apps/desktop/scripts/manage-windows-signature-cache.mjs b/apps/desktop/scripts/manage-windows-signature-cache.mjs index de44540c70..f36aaf5925 100644 --- a/apps/desktop/scripts/manage-windows-signature-cache.mjs +++ b/apps/desktop/scripts/manage-windows-signature-cache.mjs @@ -11,7 +11,7 @@ const { values } = parseArgs({ options: { if ([values.from !== undefined, values.usage === true, values.clear === true].filter(Boolean).length !== 1) { throw new Error('Specify exactly one of --from , --usage or --clear') } -const environment = values.directory === undefined ? loadDesktopPackageEnvironment('win32') +const environment = values.directory === undefined ? loadDesktopPackageEnvironment('win32', process.env, undefined, { unsigned: true }) : { DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: values.directory } const root = resolveWindowsSignatureCacheDirectory(environment) const record = event => process.stdout.write(`${JSON.stringify(event)}\n`) diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index a133ccdcf8..99b1a4c5af 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -8,7 +8,7 @@ import { desktopBuildRecordFilename, resolveDesktopAutoUpdateConfig, } from './desktop-auto-update-environment.mjs' -import { desktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { desktopArtifactOutput, desktopTargetBuildPaths } from './desktop-build-paths.mjs' import { packageMacOSArtifacts, type DesktopPrepackagedArtifact } from './package-macos.ts' import { loadDesktopPackageEnvironment, localMacOSPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs' import { createPackagingRun, recordPackagingEvent } from './packaging-run.mjs' @@ -196,6 +196,7 @@ interface DesktopPackageInvocation { readonly directory: boolean readonly prepareOnly: boolean readonly unsigned: boolean + readonly skipNotarization: boolean readonly check: boolean /** Build identifier to publish under, when this build does not publish the product version. */ readonly requestedBuildVersion: string | undefined @@ -228,6 +229,7 @@ export function parseDesktopPackageInvocation( dir: { type: 'boolean', default: false }, 'prepare-only': { type: 'boolean', default: false }, unsigned: { type: 'boolean', default: false }, + 'skip-notarization': { type: 'boolean', default: false }, check: { type: 'boolean', default: false }, 'build-version': { type: 'string' }, }, @@ -235,6 +237,9 @@ export function parseDesktopPackageInvocation( if (positionals.length > 1) throw new Error('desktop package: expected at most one target') const name = positionals[0] ?? hostTargetName(hostPlatform, hostArch) if (values.unsigned && values['prepare-only']) throw new Error('desktop package: --unsigned cannot use --prepare-only') + if (values['skip-notarization'] && (name === 'win-x64' || values.unsigned || values['prepare-only'])) { + throw new Error('desktop package: --skip-notarization requires a signed macOS build, without --prepare-only') + } const requestedBuildVersion = values['build-version']?.trim() if (values['build-version'] !== undefined && (requestedBuildVersion === undefined || requestedBuildVersion === '')) { throw new Error('desktop package: --build-version requires a value') @@ -244,6 +249,7 @@ export function parseDesktopPackageInvocation( directory: values.dir, prepareOnly: values['prepare-only'], unsigned: values.unsigned, + skipNotarization: values['skip-notarization'], check: values.check, requestedBuildVersion, } @@ -322,20 +328,27 @@ async function resolveRequestedBuildVersion( if (requested !== AUTOMATIC_BUILD_VERSION) return validateDesktopBuildVersion(requested, productVersion) const paths = desktopTargetBuildPaths(invocation.target.name) return suggestDesktopBuildVersion({ - productVersion, target: invocation.target.name, environment, + productVersion, target: invocation.target.name, + // Machine presets own packaging, not upload credentials; local auto numbering uses existing artifacts. + environment: environment.DSH_PACKAGING_PRESET === undefined ? environment : {}, // Unsigned builds land beside the signed output, so numbering has to read the directory this run writes. - artifactsRoot: invocation.unsigned ? paths.unsignedArtifacts : paths.artifacts, + artifactsRoot: desktopArtifactOutput(paths, invocation).directory, }) } async function main(): Promise { const invocation = parseDesktopPackageInvocation(process.argv.slice(2)) const { target } = invocation + if (invocation.skipNotarization) { + process.stdout.write('desktop package: skipping notarization and stapling; internal debugging only, not qualified for distribution or update publishing\n') + } const environment = target.platform === 'darwin' && invocation.unsigned ? localMacOSPackageEnvironment() - : loadDesktopPackageEnvironment(target.platform) + : loadDesktopPackageEnvironment(target.platform, process.env, APP_ROOT, { + unsigned: invocation.unsigned, prepareOnly: invocation.prepareOnly, + }) const productVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package') - // Release settings come from the target dotenv file alone, so the version this run publishes is an + // Release settings come from the selected configuration files, so the version this run publishes is an // argument; the environment variable below only carries it to the child processes that build. const buildVersion = await resolveRequestedBuildVersion(invocation, productVersion, environment) environment[DESKTOP_BUILD_VERSION_ENV] = buildVersion @@ -350,7 +363,8 @@ async function main(): Promise { Object.assign(environment, desktopBuildCommitEnvironment(packaged)) const secrets = Object.entries(environment).filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD|APPLE_ID/iu.test(name)).map(([, value]) => value ?? '') const run = createPackagingRun(join(APP_ROOT, '.desktop-build', 'packaging-runs'), { - target: target.name, unsigned: invocation.unsigned, directory: invocation.directory, prepareOnly: invocation.prepareOnly, + target: target.name, unsigned: invocation.unsigned, skipNotarization: invocation.skipNotarization, + directory: invocation.directory, prepareOnly: invocation.prepareOnly, version: buildVersion, productVersion, node: process.version, commit: packaged.commit, dirty: packaged.dirty, @@ -385,8 +399,9 @@ async function main(): Promise { } if (success && target.platform === 'darwin' && !invocation.directory && !invocation.prepareOnly) { const paths = desktopTargetBuildPaths(target.name) - const filename = `deepseek-harness-${buildVersion}-mac-${target.arch}${invocation.unsigned ? '-unsigned' : ''}.dmg` - console.log(`DMG: ${join(invocation.unsigned ? paths.unsignedArtifacts : paths.artifacts, filename)}`) + const output = desktopArtifactOutput(paths, invocation) + const filename = `deepseek-harness-${buildVersion}-mac-${target.arch}${output.suffix}.dmg` + console.log(`DMG: ${join(output.directory, filename)}`) } } @@ -410,14 +425,16 @@ export async function packageTarget( const packArguments = mac === undefined ? [] : ['--concurrency', String(mac.packConcurrency)] const buildPaths = desktopTargetBuildPaths(target.name) const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name)) - if (!invocation.prepareOnly && !invocation.unsigned) { + if (!invocation.prepareOnly && !invocation.unsigned && !invocation.skipNotarization) { rmSync(releaseRecordPath, { force: true }) rmSync(`${releaseRecordPath}.tmp`, { force: true }) } + const artifactsRoot = desktopArtifactOutput(buildPaths, invocation).directory const buildEnv = withoutWindowsSigningEnvironment(withoutDesktopUploadCredentials( desktopElectronBuilderEnvironment(environment, invocation.unsigned))) const targetEnv: NodeJS.ProcessEnv = { ...buildEnv, + DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: invocation.skipNotarization ? '1' : '0', DSH_DESKTOP_TARGET_PLATFORM: target.platform, DSH_DESKTOP_TARGET_ARCH: target.arch, } @@ -481,7 +498,7 @@ export async function packageTarget( await execute(['run', 'prepare:dsh', ...(signPrimaryRuntime ? ['--defer-runtime-smoke'] : [])], downloadEnv) if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime', '--dsh'], electronBuilderEnv) if (invocation.prepareOnly) return - if (target.platform === 'darwin' && !invocation.unsigned && !invocation.directory) { + if (target.platform === 'darwin' && !invocation.unsigned && !invocation.skipNotarization && !invocation.directory) { await execute([ ...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false', @@ -494,7 +511,7 @@ export async function packageTarget( artifactsRoot: buildPaths.artifacts, environment: electronBuilderEnv, }, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv)), undefined, undefined, proxyEvent) - } else if (target.platform === 'darwin' && !invocation.unsigned) { + } 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) const appPath = join(buildPaths.artifacts, target.arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app') @@ -502,10 +519,13 @@ export async function packageTarget( () => notarizeMacOS({ appPath, ...resolveMacOSNotarizationEnvironment(environment) }), undefined, undefined, proxyEvent) } else { await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)) - await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts', ...(invocation.unsigned ? ['--unsigned'] : [])], targetEnv) + await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts', + ...(invocation.unsigned ? ['--unsigned'] : []), ...(invocation.skipNotarization ? ['--skip-notarization'] : [])], targetEnv) } - if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) - if (journal) recordPackagingEvent(journal, { type: 'artifacts', directory: invocation.unsigned ? buildPaths.unsignedArtifacts : buildPaths.artifacts }) + if (!invocation.directory && !invocation.unsigned && !invocation.skipNotarization) { + writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) + } + if (journal) recordPackagingEvent(journal, { type: 'artifacts', directory: artifactsRoot }) } if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main() diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index e40cac98c4..9de39d3ff2 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -1,5 +1,6 @@ /** Materialize the complete production runtime before publishing Desktop resources. */ +import { resolveMacOSSignatureCacheDirectory } from './macos-signature-cache-directory.mjs' import { packagingStep } from './packaging-step.mjs' import { spawn } from 'node:child_process' import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' @@ -43,7 +44,7 @@ const RUNTIME_ROOT = BUILD_PATHS.runtime const PNPM_BUILD_STATE = BUILD_PATHS.dshPnpm const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet const NODE = join(BUILD_PATHS.electron, process.platform === 'win32' ? 'electron.exe' : 'Electron.app/Contents/MacOS/Electron') -const PNPM = join(RUNTIME_ROOT, 'pnpm', 'bin', 'pnpm.mjs') +const PNPM = join(RUNTIME_ROOT, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs') function manifestVersion(path: string, subject: string): string { const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown } @@ -157,8 +158,9 @@ async function main(): Promise { throw new Error(`desktop runtime: missing required LibreOffice engine ${officeEngine}`) } if (process.platform === 'darwin' && process.env.DSH_DESKTOP_UNSIGNED !== '1') { - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache'))) - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache'))) + const cacheDirectory = resolveMacOSSignatureCacheDirectory(process.env, BUILD_PATHS.root) + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, cacheDirectory)) + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, cacheDirectory, 'primary')) } 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'))) diff --git a/apps/desktop/scripts/prepare-macos-ci.mjs b/apps/desktop/scripts/prepare-macos-ci.mjs new file mode 100644 index 0000000000..4aeaef8e8a --- /dev/null +++ b/apps/desktop/scripts/prepare-macos-ci.mjs @@ -0,0 +1,24 @@ +/** Validate machine-owned macOS signing settings and record a manual Actions build version. */ +import { appendFileSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { desktopCiVersion } from './desktop-ci-version.mjs' +import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs' + +const appRoot = resolve(import.meta.dirname, '..') +const product = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8')) +const version = desktopCiVersion(process.env, product.version) +if (!process.env.DSH_PACKAGING_PRESET) throw new Error('Select a machine preset') +const skipNotarization = process.env.PACKAGE_SKIP_NOTARIZATION === 'true' +if (!['true', 'false', undefined].includes(process.env.PACKAGE_SKIP_NOTARIZATION)) throw new Error('Invalid skip notarization selection') +const environment = loadDesktopPackageEnvironment('darwin', process.env, appRoot) +for (const [name, value] of Object.entries(environment)) { + if (/KEY|TOKEN|PASSWORD|SECRET|APPLE_ID/iu.test(name) && value) { + const escaped = value.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A') + process.stdout.write(`::add-mask::${escaped}\n`) + } +} +validateDesktopPackageEnvironment(environment, { platform: 'darwin', arch: 'arm64' }, { skipNotarization }) +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}\nartifacts=${skipNotarization ? 'unnotarized-artifacts' : 'artifacts'}\nmode=${skipNotarization ? 'unnotarized-debug' : 'signed'}\n`) +appendFileSync(process.env.GITHUB_STEP_SUMMARY, `macOS arm64 signed installer\n\nVersion: ${version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`) +if (skipNotarization) appendFileSync(process.env.GITHUB_STEP_SUMMARY, '\nWARNING: signed but NOT notarized or stapled. Internal debugging only; Gatekeeper may block downloaded copies. Not qualified for distribution or update publishing.\n') diff --git a/apps/desktop/scripts/prepare-runtime.ts b/apps/desktop/scripts/prepare-runtime.ts index 1b88f091bc..ba92fef0e6 100644 --- a/apps/desktop/scripts/prepare-runtime.ts +++ b/apps/desktop/scripts/prepare-runtime.ts @@ -1,14 +1,15 @@ -/** Prepare the target Electron distribution and pinned pnpm CLI. */ +/** Prepare Desktop resources and publish their versions after every preparation stage succeeds. */ -import { packagingStep } from './packaging-step.mjs' import { execFileSync } from 'node:child_process' -import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, cpSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { parseArgs } from 'node:util' import { downloadArtifact } from '@electron/get' import extractZip from 'extract-zip' -import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { readPrimaryRuntime } from '../../../packages/skill/tool-workspace-dependencies/src/index.ts' +import { desktopTargetPlatform, resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { packagingStep } from './packaging-step.mjs' import { preparePrimaryRuntime } from './prepare-primary-runtime.ts' import { prepareDesktopCli } from './prepare-cli.ts' import { prepareCommandLink } from './prepare-command-link.ts' @@ -16,23 +17,10 @@ import { prepareCommandLink } from './prepare-command-link.ts' const BUILD_PATHS = resolveDesktopTargetBuildPaths() const RUNTIME_ROOT = BUILD_PATHS.runtime -function preparePnpm(): string { - const require = createRequire(import.meta.url) - const manifestPath = require.resolve('pnpm') - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { version?: unknown } - if (typeof manifest.version !== 'string') throw new Error('desktop runtime: pnpm manifest has no version') - const packageDir = dirname(manifestPath) - const destination = join(RUNTIME_ROOT, 'pnpm') - rmSync(destination, { recursive: true, force: true }) - cpSync(packageDir, destination, { recursive: true }) - return manifest.version -} +type DesktopTarget = ReturnType -async function main(): Promise { - const { values } = parseArgs({ options: { 'defer-primary-runtime-smoke': { type: 'boolean', default: false } } }) - const target = resolveDesktopBuildTarget() - const platform = target.startsWith('mac-') ? 'darwin' : 'win32' - const arch = target.endsWith('arm64') ? 'arm64' : 'x64' +/** Extract the target Electron distribution and return its embedded Node version. */ +async function prepareElectron({ platform, arch }: DesktopTarget): Promise { const require = createRequire(import.meta.url) const { version } = require('electron/package.json') as { version: string } const archive = await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'download:electron', @@ -40,28 +28,42 @@ async function main(): Promise { rmSync(BUILD_PATHS.electron, { recursive: true, force: true }) await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'extract:electron', () => extractZip(archive, { dir: BUILD_PATHS.electron })) const executable = join(BUILD_PATHS.electron, platform === 'win32' ? 'electron.exe' : 'Electron.app/Contents/MacOS/Electron') - const nodeVersion = execFileSync(executable, ['-p', 'process.versions.node'], { + return execFileSync(executable, ['-p', 'process.versions.node'], { encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, }).trim() - const macosMinimumVersion = platform === 'darwin' ? execFileSync('/usr/libexec/PlistBuddy', - ['-c', 'Print LSMinimumSystemVersion', join(BUILD_PATHS.electron, 'Electron.app', 'Contents', 'Info.plist')], { encoding: 'utf8' }).trim() : undefined - rmSync(RUNTIME_ROOT, { recursive: true, force: true }) - mkdirSync(RUNTIME_ROOT, { recursive: true }) - const pnpmVersion = preparePnpm() +} + +function prepareCli({ platform, arch }: DesktopTarget): void { cpSync(join(import.meta.dirname, 'node-bin'), join(RUNTIME_ROOT, 'bin'), { recursive: true }) chmodSync(join(RUNTIME_ROOT, 'bin', 'node'), 0o755) + const cli = join(RUNTIME_ROOT, 'cli') + prepareDesktopCli(cli, platform) + if (platform === 'darwin') { + const minimumVersion = execFileSync('/usr/libexec/PlistBuddy', [ + '-c', 'Print LSMinimumSystemVersion', join(BUILD_PATHS.electron, 'Electron.app', 'Contents', 'Info.plist'), + ], { encoding: 'utf8' }).trim() + prepareCommandLink(cli, arch, minimumVersion) + } + cpSync(join(import.meta.dirname, '..', 'lib', 'command-manager-entry.js'), join(cli, 'command-manager.js')) + cpSync(join(import.meta.dirname, 'command-path.ps1'), join(cli, 'command-path.ps1')) +} + +async function main(): Promise { + const { values } = parseArgs({ options: { 'defer-primary-runtime-smoke': { type: 'boolean', default: false } } }) + 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', + () => preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] })) + 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) }) writeFileSync(join(RUNTIME_ROOT, 'versions.json'), `${JSON.stringify({ schemaVersion: 1, node: nodeVersion, - pnpm: pnpmVersion, + pnpm, }, undefined, 2)}\n`) - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:cli', - async () => prepareDesktopCli(join(RUNTIME_ROOT, 'cli'), platform)) - if (macosMinimumVersion !== undefined) prepareCommandLink(join(RUNTIME_ROOT, 'cli'), arch, macosMinimumVersion) - cpSync(join(import.meta.dirname, '..', 'lib', 'command-manager-entry.js'), join(RUNTIME_ROOT, 'cli', 'command-manager.js')) - cpSync(join(import.meta.dirname, 'command-path.ps1'), join(RUNTIME_ROOT, 'cli', 'command-path.ps1')) - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime', - () => preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] })) } await main() diff --git a/apps/desktop/scripts/prepare-windows-ci.d.mts b/apps/desktop/scripts/prepare-windows-ci.d.mts index 0fa67ecf7f..3b0196dc57 100644 --- a/apps/desktop/scripts/prepare-windows-ci.d.mts +++ b/apps/desktop/scripts/prepare-windows-ci.d.mts @@ -1,14 +1,10 @@ -/** Public settings prepared for a manual Windows package run. */ -export interface WindowsCiSettings { - version: string - settings: string -} +/** Resolve the version used by the manual Windows packaging workflow. */ /** - * Validate manual inputs without local credentials or release storage. - * @param environment Manual inputs and GitHub run identity. + * Resolve an explicit version or a UTC date/run/attempt identifier without release storage access. + * @param environment Manual version and GitHub run identity. * @param productVersion Version declared by the selected checkout. * @param now Time used for the UTC date segment. - * @returns Validated version and public dotenv content. + * @returns Validated build version. */ -export function windowsCiSettings(environment: NodeJS.ProcessEnv, productVersion: string, now?: Date): WindowsCiSettings +export function windowsCiVersion(environment: NodeJS.ProcessEnv, productVersion: string, now?: Date): string diff --git a/apps/desktop/scripts/prepare-windows-ci.mjs b/apps/desktop/scripts/prepare-windows-ci.mjs index c86c303d41..7581d05bab 100644 --- a/apps/desktop/scripts/prepare-windows-ci.mjs +++ b/apps/desktop/scripts/prepare-windows-ci.mjs @@ -1,54 +1,27 @@ -/** Prepare public Windows packaging settings and a version for a manual GitHub run. */ -import { appendFileSync, readFileSync, writeFileSync } from 'node:fs' +/** Resolve a manual Windows build version and validate the selected machine preset. */ +import { appendFileSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { desktopBuildVersionPrefix, validateDesktopBuildVersion } from './desktop-build-version.mjs' -import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' - -/** - * Validate manual inputs without reading local credentials or querying release storage. - * @param {NodeJS.ProcessEnv} environment Manual inputs and GitHub run identity. - * @param {string} productVersion Version declared by the selected checkout. - * @param {Date} now Time used for the UTC date segment. - * @returns {{ version: string, settings: string }} Validated version and public dotenv content. - */ -export function windowsCiSettings(environment, productVersion, now = new Date()) { - const deployment = environment.PACKAGE_DEPLOYMENT - if (deployment !== 'test' && deployment !== 'production') throw new Error('Select test or production deployment') - const loginOrigins = (environment.PACKAGE_LOGIN_ORIGINS ?? '').split(',').map(value => value.trim()).filter(Boolean) - if (deployment === 'production' && loginOrigins.length > 0) throw new Error('Production must not supply login origins') - const originKey = deployment === 'test' ? 'DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN' : 'DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN' - const config = deployment === 'test' ? { allowedAuthOrigins: loginOrigins } : {} - const policy = resolveDesktopPolicyEnvironment({ - DSH_DESKTOP_AUTO_UPDATE_ENV: deployment, - [originKey]: environment.PACKAGE_POLICY_ORIGIN, - DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify(config), - }) - const requested = environment.PACKAGE_BUILD_VERSION?.trim() - if (!requested) throw new Error('Build version is required; use auto or a full version') - let candidate = requested - if (requested === 'auto') { - for (const name of ['GITHUB_RUN_NUMBER', 'GITHUB_RUN_ATTEMPT']) { - if (!/^[1-9]\d*$/u.test(environment[name] ?? '')) throw new Error(`${name} must be a positive integer`) - } - const date = now.toISOString().slice(0, 10).replaceAll('-', '') - candidate = `${desktopBuildVersionPrefix(productVersion)}${date}.${environment.GITHUB_RUN_NUMBER}.${environment.GITHUB_RUN_ATTEMPT}` - } - const version = validateDesktopBuildVersion(candidate, productVersion) - const settings = [ - 'DSH_DESKTOP_APP_ID=com.deepseek.harness', - `DSH_DESKTOP_AUTO_UPDATE_ENV=${deployment}`, - `${originKey}=${policy.origin}`, - `DSH_DESKTOP_MANDATORY_UPDATE_CONFIG=${JSON.stringify(config)}`, - ].join('\n') + '\n' - return { version, settings } -} +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' if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { const appRoot = resolve(import.meta.dirname, '..') const product = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8')) - const result = windowsCiSettings(process.env, product.version) + const version = windowsCiVersion(process.env, product.version) + const mode = process.env.PACKAGE_SIGNING + if (mode !== 'unsigned' && mode !== 'signed') throw new Error('Select unsigned or signed packaging') + if (!process.env.DSH_PACKAGING_PRESET) throw new Error('Select a machine preset') + const environment = loadDesktopPackageEnvironment('win32', process.env, appRoot, { unsigned: mode === 'unsigned' }) + // Machine-owned secrets are not automatically known to the Actions log masker. + for (const [name, value] of Object.entries(environment)) { + if (/TOKEN|PASSWORD|SECRET/iu.test(name) && value) { + const escaped = value.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A') + process.stdout.write(`::add-mask::${escaped}\n`) + } + } + validateDesktopPackageEnvironment(environment, { platform: 'win32', arch: 'x64' }, { unsigned: mode === 'unsigned' }) if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions') - writeFileSync(resolve(appRoot, '.env.windows'), result.settings, { flag: 'wx' }) - appendFileSync(process.env.GITHUB_OUTPUT, `version=${result.version}\n`) - appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 unsigned installer\n\nVersion: ${result.version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nDeployment: ${process.env.PACKAGE_DEPLOYMENT}\n`) + appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`) + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 ${mode} installer\n\nVersion: ${version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`) } diff --git a/apps/desktop/scripts/runtime-file-policy.ts b/apps/desktop/scripts/runtime-file-policy.ts index 1bc16b2a1c..14b0e2a186 100644 --- a/apps/desktop/scripts/runtime-file-policy.ts +++ b/apps/desktop/scripts/runtime-file-policy.ts @@ -1,7 +1,7 @@ /** Desktop-only omissions from already installed production npm packages. */ /** - * Identify build and diagnostic files omitted from the immutable Desktop runtime. + * Identify build, diagnostic and other-platform files omitted from the immutable Desktop runtime. * Unrecognized assets and target runtime binaries are retained. Paths name the copied * node_modules tree, including nested package containers. * @param path - Path relative to the production node_modules directory. @@ -16,7 +16,7 @@ export function desktopRuntimeFileExclusion( if (parts.some(part => ['.bin', '.pnpm', '.modules.yaml', '.pnpm-workspace-state-v1.json'].includes(part))) { return 'package-manager metadata' } - const file = parts.at(-1) ?? '' + const file = parts.at(-1)! if (/\.(?:[cm]?[jt]s|css)\.map$/u.test(file)) return 'source map' if (/\.d\.[cm]?ts$/u.test(file)) return 'TypeScript declaration' if (/\.tsbuildinfo$/u.test(file)) return 'TypeScript build cache' @@ -34,6 +34,10 @@ export function desktopRuntimeFileExclusion( return 'fs-ext build configuration' } if (name === '@mixmark-io/domino' && (entry === 'test' || entry.startsWith('test/'))) return 'Domino test fixtures' + if (name === '@mixmark-io/domino' && (entry === '.yarn' || entry.startsWith('.yarn/'))) return 'Domino development tooling' + if (name === 'node-pty' && target.platform !== 'win32' && /^third_party\/conpty(?:\/|$)/u.test(entry)) { + return 'node-pty Windows ConPTY sources' + } if (name === 'node-pty' && entry.startsWith('prebuilds/')) { const platform = packageParts[nameParts + 1] if (platform !== undefined && platform !== `${target.platform}-${target.arch}`) return 'node-pty other platform' diff --git a/apps/desktop/scripts/runtime-patch-policy.ts b/apps/desktop/scripts/runtime-patch-policy.ts index f688db356d..d2ea174f2c 100644 --- a/apps/desktop/scripts/runtime-patch-policy.ts +++ b/apps/desktop/scripts/runtime-patch-policy.ts @@ -8,17 +8,18 @@ export type DesktopRuntimePatch = /** Every workspace patch requires an explicit Desktop decision; runtime-only entries may add or replace a patch. */ export const DESKTOP_RUNTIME_PATCHES: Readonly> = { - '@earendil-works/pi-ai@1.0.2': { scope: 'shared', reason: 'Runtime providers need the streamed argument parsing fix.' }, + '@earendil-works/pi-ai@1.1.0': { scope: 'shared', reason: 'Runtime providers need the streamed argument parsing fix.' }, '@electron/osx-sign@1.3.3': { scope: 'workspace-only', reason: 'Signing executes on the build host.' }, '@fortune-sheet/core@1.0.4': { scope: 'workspace-only', reason: 'Patched spreadsheet code is embedded in the client bundle.' }, '@fortune-sheet/react@1.0.4': { scope: 'workspace-only', reason: 'Patched spreadsheet code is embedded in the client bundle.' }, '@yao-pkg/pkg@6.21.0': { scope: 'workspace-only', reason: 'The executable packer executes on the build host.' }, + 'app-builder-lib@26.17.0': { scope: 'workspace-only', reason: 'DMG format validation executes on the build host.' }, 'exceljs@4.4.0': { scope: 'workspace-only', reason: 'The patched parser is embedded in the client bundle and worker.' }, 'node-pty@1.2.0-beta.15': { scope: 'shared', reason: 'Runtime terminals need helper resolution and Windows worker cleanup.' }, } /** Reviewed Desktop dependency constraints, independent of patch selection. */ export const DESKTOP_RUNTIME_DEPENDENCY_OVERRIDES: Readonly> = { - // The provider declares ^1.0.2; its patch is qualified against 1.0.2. - '@earendil-works/pi-ai@^1.0.2': '1.0.2', + // The provider declares ^1.1.0; its patch is qualified against 1.1.0. + '@earendil-works/pi-ai@^1.1.0': '1.1.0', } diff --git a/apps/desktop/scripts/setup-macos-packaging.mjs b/apps/desktop/scripts/setup-macos-packaging.mjs new file mode 100644 index 0000000000..d04200f058 --- /dev/null +++ b/apps/desktop/scripts/setup-macos-packaging.mjs @@ -0,0 +1,34 @@ +/** Prepare private machine directories and templates without registering a runner or overwriting configuration. */ +import { readFileSync, writeFileSync, existsSync, lstatSync, mkdirSync } from 'node:fs' +import { dirname, isAbsolute, join, resolve } from 'node:path' + +const [root, mode] = process.argv.slice(2) +if (process.platform !== 'darwin') throw new Error('Run macOS packaging setup on a Mac') +if (!root || !isAbsolute(root) || (mode !== undefined && mode !== '--check') || process.argv.length > 4) { + throw new Error('Usage: node setup-macos-packaging.mjs ABSOLUTE_ROOT [--check]') +} +if (/[\r\n']/u.test(root)) throw new Error('Packaging root cannot contain single quotes or newlines') +for (let path = resolve(root); ; path = dirname(path)) { + if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new Error('Packaging root ancestors cannot be links') + if (path === dirname(path)) break +} +for (const relative of ['', 'packaging', 'packaging/presets', 'packaging/secrets', 'packaging/certificates', 'runner', 'cache', 'cache/macos-signatures']) { + const path = join(root, relative) + if (!existsSync(path) && mode !== '--check') mkdirSync(path, { recursive: true, mode: 0o700 }) + const info = lstatSync(path) + if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== process.getuid() || (info.mode & 0o077) !== 0) { + throw new Error(`Packaging directories must be real, private and owned by the current account: ${path}`) + } +} +if (mode !== '--check') { + for (const [name, directory] of [['test', 'presets'], ['production', 'presets'], ['macos-signing', 'secrets']]) { + const destination = join(root, 'packaging', directory, `${name}.env.example`) + if (!existsSync(destination)) { + const template = readFileSync(resolve(import.meta.dirname, '../config/macos-packaging', `${name}.env.example`), 'utf8') + const cache = join(root, 'cache/macos-signatures') + writeFileSync(destination, template.replace('# DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR=', + `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR='${cache}'`), { flag: 'wx', mode: 0o600 }) + } + } +} +process.stdout.write(`Private packaging directories verified: ${resolve(root)}\n`) diff --git a/apps/desktop/scripts/setup-windows-packaging.ps1 b/apps/desktop/scripts/setup-windows-packaging.ps1 new file mode 100644 index 0000000000..9e686fb2fe --- /dev/null +++ b/apps/desktop/scripts/setup-windows-packaging.ps1 @@ -0,0 +1,49 @@ +# Prepare or inspect machine-owned Windows packaging directories without reading secret values. +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$Root, + [switch]$CheckOnly +) +$ErrorActionPreference = 'Stop' +if ($Root -notmatch '^[A-Za-z]:[\\/]') { throw 'Root must be an absolute local drive directory' } +$rootPath = [IO.Path]::GetFullPath($Root) +$drive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot($rootPath)) +if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw 'Packaging root requires a fixed local drive' } +for ($ancestor = [IO.DirectoryInfo]::new($rootPath); $null -ne $ancestor; $ancestor = $ancestor.Parent) { + if ($ancestor.Exists -and ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Packaging root cannot contain linked directories' } +} +$identity = [Security.Principal.WindowsIdentity]::GetCurrent().User +$allowed = @($identity.Value, 'S-1-5-18', 'S-1-5-32-544') +foreach ($relative in @('packaging', 'packaging/presets', 'packaging/secrets', 'packaging/certificates', 'cache', 'cache/windows-signatures')) { + $path = Join-Path $rootPath $relative + if (-not (Test-Path -LiteralPath $path)) { + if ($CheckOnly) { throw "Missing directory: $path" } + $null = New-Item -ItemType Directory -Path $path + $acl = [Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($identity) + $acl.SetAccessRuleProtection($true, $false) + foreach ($sid in $allowed) { + $rule = [Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid), 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $null = $acl.AddAccessRule($rule) + } + Set-Acl -LiteralPath $path -AclObject $acl + } + $item = Get-Item -LiteralPath $path + if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Not a real directory: $path" } + $acl = Get-Acl -LiteralPath $path + if ($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $identity.Value) { throw "Directory must belong to current account: $path" } + foreach ($rule in $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) { + if ($rule.AccessControlType -eq 'Allow' -and $rule.IdentityReference.Value -notin $allowed) { throw "Directory permits another account: $path" } + } +} +if (-not $CheckOnly) { + $templates = Join-Path $PSScriptRoot '../config/windows-packaging' + foreach ($name in @('test', 'production')) { + $destination = Join-Path $rootPath "packaging/presets/$name.env.example" + if (-not (Test-Path -LiteralPath $destination)) { Copy-Item -LiteralPath (Join-Path $templates "$name.env.example") -Destination $destination } + } + $destination = Join-Path $rootPath 'packaging/secrets/windows-signing.env.example' + if (-not (Test-Path -LiteralPath $destination)) { Copy-Item -LiteralPath (Join-Path $templates 'windows-signing.env.example') -Destination $destination } +} +Write-Output "Packaging directories verified. Configuration root: $(Join-Path $rootPath 'packaging')" +Write-Output 'Existing configuration, caches, runner registration and signing failure records were not overwritten.' diff --git a/apps/desktop/scripts/smoke-packaged-runtime.ts b/apps/desktop/scripts/smoke-packaged-runtime.ts index b91030008b..6c3c12c857 100644 --- a/apps/desktop/scripts/smoke-packaged-runtime.ts +++ b/apps/desktop/scripts/smoke-packaged-runtime.ts @@ -1,17 +1,17 @@ /** Validate the assembled application, including native Office conversion outside ASAR. */ import { join } from 'node:path' import { parseArgs } from 'node:util' -import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { desktopArtifactOutput, resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { readDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts' import { verifyWindowsCode } from './windows-runtime-signature.mjs' import { smokePreparedRuntime } from './smoke-prepared-runtime.ts' import { resolveDesktopPackageTarget } from './package-target.ts' const paths = resolveDesktopTargetBuildPaths() -const { values } = parseArgs({ options: { unsigned: { type: 'boolean', default: false } }, allowPositionals: false }) +const { values } = parseArgs({ options: { unsigned: { type: 'boolean', default: false }, 'skip-notarization': { type: 'boolean', default: false } }, allowPositionals: false }) const target = resolveDesktopBuildTarget() const windows = target === 'win-x64' -const artifacts = values.unsigned ? paths.unsignedArtifacts : paths.artifacts +const artifacts = desktopArtifactOutput(paths, { unsigned: values.unsigned, skipNotarization: values['skip-notarization'] }).directory const application = windows ? join(artifacts, 'win-unpacked') : join(artifacts, target === 'mac-arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app', 'Contents') const resources = join(application, windows ? 'resources' : 'Resources') diff --git a/apps/desktop/scripts/smoke-runtime.ts b/apps/desktop/scripts/smoke-runtime.ts index f15f10a928..bbc2d3e7ae 100644 --- a/apps/desktop/scripts/smoke-runtime.ts +++ b/apps/desktop/scripts/smoke-runtime.ts @@ -1,6 +1,7 @@ /** Boot the materialized target runtime without access to a user's Harness profile. */ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { appendFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { execFile } from 'node:child_process' @@ -12,7 +13,7 @@ import { createPluginProfile } from '../src/project-manager.ts' import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' /** - * Check Host startup, its matching frontend, external plugins and real Office-to-PDF conversion. + * Check CLI package operations, Host startup, its frontend, external plugins and real Office-to-PDF conversion. * @param root - Materialized dsh resources. * @param node - Prepared target Electron executable. * @param runtime - Verified resource descriptor. @@ -25,15 +26,50 @@ export async function smokeDesktopRuntime( ): Promise { const home = mkdtempSync(join(tmpdir(), 'dsh-desktop-smoke-')) const profile = join(home, 'profiles', 'desktop') - const host = new DesktopHostProcess(node, root, profile, undefined, { ...environment, DSH_HOME: home }, + const hostEnvironment = { + ...Object.fromEntries(Object.entries(environment).filter(([name]) => !/^(?:npm|pnpm|corepack)_/iu.test(name))), + DSH_HOME: home, HOME: home, USERPROFILE: home, + XDG_CONFIG_HOME: home, XDG_CACHE_HOME: home, XDG_STATE_HOME: home, + APPDATA: home, LOCALAPPDATA: home, + npm_config_userconfig: join(home, 'npmrc'), npm_config_globalconfig: join(home, 'npmrc'), + npm_config_store_dir: join(home, 'store'), npm_config_enable_global_virtual_store: 'false', + } + const host = new DesktopHostProcess(node, root, profile, undefined, hostEnvironment, undefined, join(resourcesRuntime, 'primary-runtime'), - { pnpm: join(resourcesRuntime, 'pnpm', 'bin', 'pnpm.cjs'), nodeBin: join(resourcesRuntime, 'bin') }) + { pnpm: join(resourcesRuntime, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'), nodeBin: join(resourcesRuntime, 'bin') }) let timer: ReturnType | undefined try { + writeFileSync(join(home, 'npmrc'), '') createPluginProfile(profile) const pluginName = 'desktop-runtime-smoke-plugin' - const plugin = join(profile, 'node_modules', pluginName) + const plugin = join(home, pluginName) mkdirSync(plugin, { recursive: true }) + const installedName = 'desktop-installed-smoke-plugin' + const installedPlugin = join(home, installedName) + mkdirSync(installedPlugin) + writeFileSync(join(installedPlugin, 'package.json'), JSON.stringify({ + name: installedName, version: '1.0.0', type: 'module', exports: './index.js', dsh: { bundle: { patch: './bundle.yml' } }, + })) + writeFileSync(join(installedPlugin, 'index.js'), ` +export function apply(ctx) { + ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-installed', + handler(_request, response) { response.end('installed plugin ready') } })) +} +`) + writeFileSync(join(installedPlugin, 'bundle.yml'), `- insert:\n - id: ${installedName}\n name: ${installedName}\n inject: [webServer]\n`) + appendFileSync(join(profile, 'pnpm-workspace.yaml'), 'offline: true\nupdateNotifier: false\n') + const cli = join(root, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'cli.js') + const runCli = (args: string[]) => promisify(execFile)(node, + ['--expose-internals', cli, 'plugin', '--profile', 'desktop', ...args], + { cwd: home, env: { ...hostEnvironment, ELECTRON_RUN_AS_NODE: '1' }, timeout: 120_000, windowsHide: true }) + await runCli(['add', `file:${installedPlugin}`, '--offline', '--ignore-scripts']) + const installedEntry = join(profile, 'node_modules', installedName, 'index.js') + if (readFileSync(installedEntry, 'utf8') !== readFileSync(join(installedPlugin, 'index.js'), 'utf8')) { + throw new Error('desktop runtime: CLI did not install the local plugin') + } + await runCli(['remove', installedName]) + if (existsSync(installedEntry)) throw new Error('desktop runtime: CLI did not remove the local plugin') + console.log('desktop runtime: CLI offline plugin installation and removal passed') const primary = join(resourcesRuntime, 'primary-runtime') const dependencies = workspaceDependencyPaths(primary, await readPrimaryRuntime(primary)) await promisify(execFile)(dependencies.python, ['-I', '-B', @@ -56,10 +92,19 @@ export function apply(ctx) { if (!(ctx instanceof Context)) throw new Error('desktop runtime: external plugin loaded another Cordis instance') ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke', handler(_request, response) { response.end('plugin route ready') } })) + ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-install', + async handler(_request, response) { + try { + response.end(JSON.stringify(await ctx.pluginManager.installBundle(${JSON.stringify(installedPlugin)}))) + } catch (error) { + response.statusCode = 500 + response.end(inspect(error, { depth: 5 })) + } + } })) ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-office-cli', async handler(_request, response) { try { - const skill = await ctx.skills.get('office-docx') + const skill = await ctx.skills.get('office-export-pdf') const json = skill?.content.match(/\\n(\\{\\n[\\s\\S]+)$/u)?.[1] if (json === undefined) throw new Error('Office skill did not supply CLI paths') const { libreofficeKit: { node, cli } } = JSON.parse(json) @@ -90,12 +135,13 @@ export function apply(ctx) { } } `) - writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf, skills]\n') + writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf, skills, pluginManager]\n') + cpSync(plugin, join(profile, 'node_modules', pluginName), { recursive: true }) const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as { - dependencies: Record + dependencies?: Record dsh: { profile: { bundles: string[] } } } - manifest.dependencies[pluginName] = '1.0.0' + manifest.dependencies = { ...manifest.dependencies, [pluginName]: `file:${plugin}` } manifest.dsh.profile.bundles.push(pluginName) writeFileSync(join(profile, 'package.json'), JSON.stringify(manifest)) writeFileSync(join(profile, 'cordis.patch.yml'), '- id: webserver\n config:\n host: 127.0.0.1\n port: 0\n') @@ -111,6 +157,14 @@ export function apply(ctx) { } const pluginResponse = await fetch(new URL('/desktop-smoke', ready.url), { headers: { cookie } }) if (await pluginResponse.text() !== 'plugin route ready') throw new Error('desktop runtime: plugin HTTP route failed') + const installation = await fetch(new URL('/desktop-smoke-install', ready.url), { + headers: { cookie }, signal: AbortSignal.timeout(120_000), + }) + const installationResult = await installation.text() + const installed = await fetch(new URL('/desktop-smoke-installed', ready.url), { headers: { cookie } }) + if (!installation.ok || !installed.ok || await installed.text() !== 'installed plugin ready') { + throw new Error(`desktop runtime: bundled pnpm plugin installation failed: ${installationResult}`) + } for (const { extension } of inputs) { const converted = await fetch(new URL(`/desktop-smoke-office/${extension}`, ready.url), { headers: { cookie }, signal: AbortSignal.timeout(120_000), @@ -133,7 +187,8 @@ export function apply(ctx) { console.log('desktop runtime: DOCX, XLSX, PPTX to PDF and skill CLI discovery passed') } finally { clearTimeout(timer) - await host.stop() - rmSync(home, { recursive: true, force: true }) + try { await host.stop() } finally { + await rm(home, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }) + } } } diff --git a/apps/desktop/scripts/start-macos-packaging-runner.sh b/apps/desktop/scripts/start-macos-packaging-runner.sh new file mode 100644 index 0000000000..a171001a4a --- /dev/null +++ b/apps/desktop/scripts/start-macos-packaging-runner.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Run an already registered Apple Silicon runner with explicit tools and machine configuration. +set -euo pipefail +if [[ $# -lt 3 || $# -gt 4 || (${4:-} != '' && ${4:-} != '--check' && ${4:-} != '--service') ]]; then + echo 'Usage: bash start-macos-packaging-runner.sh RUNNER_ROOT CONFIG_ROOT NODE_BIN [--check|--service]' >&2 + exit 1 +fi +runner_root=$1 +config_root=$2 +node_bin=$3 +for directory in "$runner_root" "$config_root" "$node_bin"; do + [[ "$directory" = /* && -d "$directory" ]] || { echo 'All directories must exist and be absolute' >&2; exit 1; } +done +[[ $(uname -s) = Darwin && $(uname -m) = arm64 ]] || { echo 'Requires an Apple Silicon Mac' >&2; exit 1; } +[[ -x "$node_bin/node" ]] || { echo 'NODE_BIN must contain Node 24' >&2; exit 1; } +[[ $("$node_bin/node" -p 'process.versions.node.split(".")[0]') = 24 ]] || { echo 'Requires Node 24' >&2; exit 1; } +[[ -f "$config_root/presets/test.env" && -f "$config_root/secrets/macos-signing.env" ]] || { echo 'Configure the test preset and macos-signing.env before starting' >&2; exit 1; } +developer_dir=$(xcode-select -p) +sdk_root=$(DEVELOPER_DIR="$developer_dir" xcrun --sdk macosx --show-sdk-path) +xcrun --find notarytool +xcrun --find stapler +probe_dir=$(mktemp -d "${TMPDIR:-/tmp}/dsh-macos-toolchain.XXXXXX") +trap 'rm -rf "$probe_dir"' EXIT +DEVELOPER_DIR="$developer_dir" SDKROOT="$sdk_root" /usr/bin/cc -x c - -o "$probe_dir/toolchain-probe" <<<'int main(void) { return 0; }' +rm -rf "$probe_dir" +trap - EXIT +if [[ ${4:-} = '--check' ]]; then + echo 'Runner tool and configuration-file checks passed; registration and credentials were not tested.' + exit 0 +fi +[[ -f "$runner_root/.runner" && -x "$runner_root/run.sh" ]] || { echo 'Register the runner before starting' >&2; exit 1; } +entry=./run.sh +if [[ ${4:-} = '--service' ]]; then + [[ -x "$runner_root/runsvc.sh" ]] || { echo 'Install the runner service first' >&2; exit 1; } + entry=./runsvc.sh +fi +cd "$runner_root" +exec /usr/bin/env -i HOME="$HOME" USER="$(id -un)" LOGNAME="$(id -un)" \ + PATH="$node_bin:/usr/bin:/bin:/usr/sbin:/sbin" TMPDIR="${TMPDIR:-/tmp}" \ + DEVELOPER_DIR="$developer_dir" SDKROOT="$sdk_root" \ + ACTIONS_RUNNER_SVC="${ACTIONS_RUNNER_SVC:-0}" LANG=en_US.UTF-8 DSH_PACKAGING_CONFIG_DIR="$config_root" DSH_TELEMETRY_DISABLED=1 \ + /usr/bin/caffeinate -i "$entry" diff --git a/apps/desktop/scripts/start-windows-packaging-runner.ps1 b/apps/desktop/scripts/start-windows-packaging-runner.ps1 new file mode 100644 index 0000000000..13251a110a --- /dev/null +++ b/apps/desktop/scripts/start-windows-packaging-runner.ps1 @@ -0,0 +1,38 @@ +# Start the existing registered runner with explicit machine configuration and a scrubbed environment. +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$RunnerRoot, [Parameter(Mandatory=$true)][string]$ConfigRoot, [switch]$CheckOnly) +$ErrorActionPreference='Stop' +$pwshRoot=Join-Path $RunnerRoot 'tools/pwsh-7.6.6' +$runnerPath = $pwshRoot + ';' + [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User') +$keep=@('SystemRoot','windir','SystemDrive','ComSpec','TEMP','TMP','USERPROFILE','HOMEDRIVE','HOMEPATH','LOCALAPPDATA','APPDATA','ProgramData','ProgramFiles','ProgramFiles(x86)','CommonProgramFiles','CommonProgramFiles(x86)','CommonProgramW6432','ProgramW6432','PROCESSOR_ARCHITECTURE','NUMBER_OF_PROCESSORS','OS','PATHEXT','USERNAME','USERDOMAIN','COMPUTERNAME','PUBLIC','ALLUSERSPROFILE') +foreach($entry in @(Get-ChildItem Env:)) { + if($entry.Name -notin $keep) { [Environment]::SetEnvironmentVariable($entry.Name,$null,'Process') } +} +$env:PATH=$runnerPath +if (-not [IO.Path]::IsPathRooted($ConfigRoot) -or -not (Test-Path -LiteralPath $ConfigRoot -PathType Container)) { throw 'ConfigRoot must identify an existing absolute directory' } +if ($ConfigRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'ConfigRoot requires an absolute local drive directory' } +$drive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot([IO.Path]::GetFullPath($ConfigRoot))) +if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw 'ConfigRoot requires a fixed local drive' } +$env:DSH_PACKAGING_CONFIG_DIR=[IO.Path]::GetFullPath($ConfigRoot) +$env:DSH_TELEMETRY_DISABLED='1' +$env:POWERSHELL_TELEMETRY_OPTOUT='1' +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) +} +$env:PYTHON=(Get-Command python -CommandType Application | Select-Object -First 1).Source +& pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' +if($LASTEXITCODE -ne 0){throw 'PowerShell probe failed'} +& node --version +if($LASTEXITCODE -ne 0){throw 'Node probe failed'} +& python --version +if($LASTEXITCODE -ne 0){throw 'Python probe failed'} +$vswhere=Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/vswhere.exe' +$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.' +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 +& .\run.cmd +exit $LASTEXITCODE diff --git a/apps/desktop/scripts/verify-macos-signature.d.mts b/apps/desktop/scripts/verify-macos-signature.d.mts index a33c3f4213..cf1434b2d2 100644 --- a/apps/desktop/scripts/verify-macos-signature.d.mts +++ b/apps/desktop/scripts/verify-macos-signature.d.mts @@ -80,3 +80,10 @@ export function verifyMacOSSignatureAfterSign( context: MacOSAfterSignContext, expected: MacOSSigningEnvironment, ): void + +/** + * Verify a disk image's code signature and Developer ID without requiring a notarization ticket. + * @param diskImagePath Path to the packaged `.dmg` file. + * @param expected Public signing identity. + */ +export function verifyMacOSDiskImageSignature(diskImagePath: string, expected: MacOSSigningEnvironment): void diff --git a/apps/desktop/scripts/verify-macos-signature.mjs b/apps/desktop/scripts/verify-macos-signature.mjs index 6817f0962f..a906f88ab7 100644 --- a/apps/desktop/scripts/verify-macos-signature.mjs +++ b/apps/desktop/scripts/verify-macos-signature.mjs @@ -172,9 +172,7 @@ export function verifyMacOSNotarizedApplication(appPath, expected) { * @returns {void} */ export function verifyMacOSDiskImage(diskImagePath, expected) { - runCodeSign(['--verify', '--strict', '--verbose=2', diskImagePath]) - const details = runCodeSign(['--display', '--verbose=4', diskImagePath]) - assertMacOSSignatureDetails(details, expected) + verifyMacOSDiskImageSignature(diskImagePath, expected) runAppleCommand('/usr/bin/xcrun', ['stapler', 'validate', diskImagePath], 'stapler validate') runAppleCommand('/usr/sbin/spctl', ['--assess', '--type', 'install', '--verbose=4', diskImagePath], 'spctl') } @@ -202,3 +200,15 @@ if (process.argv[1] !== undefined && import.meta.filename === resolve(process.ar verifyMacOSSignature(resolve(appPath), expected) process.stdout.write(`desktop macOS signing: verified Developer ID Application: ${expected.signingIdentity} (${expected.teamId})\n`) } + +/** + * Verify a disk image's code signature and Developer ID without requiring a notarization ticket. + * @param {string} diskImagePath Path to the packaged `.dmg` file. + * @param {{ signingIdentity: string, teamId: string }} expected Public signing identity. + * @returns {void} + */ +export function verifyMacOSDiskImageSignature(diskImagePath, expected) { + runCodeSign(['--verify', '--strict', '--verbose=2', diskImagePath]) + const details = runCodeSign(['--display', '--verbose=4', diskImagePath]) + assertMacOSSignatureDetails(details, expected) +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 38585675b1..1202670a3e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -155,7 +155,7 @@ function runtimeResources(): RuntimeResources { const nodeBin = development ? join(app.getAppPath(), 'scripts', 'node-bin') : join(process.resourcesPath, 'runtime', 'bin') const pnpm = (development ? process.env.DSH_DESKTOP_PNPM_ENTRY : undefined) ?? (development ? join(app.getAppPath(), 'node_modules', 'pnpm', 'bin', 'pnpm.mjs') - : join(process.resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs')) + : join(process.resourcesPath, 'runtime', 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs')) const dsh = (development ? process.env.DSH_DESKTOP_DSH_DIR : undefined) ?? (development ? join(app.getAppPath(), '.desktop-build', 'development', 'project') : join(app.getAppPath(), 'dsh')) return { node, nodeBin, pnpm, dsh } diff --git a/apps/desktop/src/update-dialog.ts b/apps/desktop/src/update-dialog.ts index 249294f8ae..8f263ca38e 100644 --- a/apps/desktop/src/update-dialog.ts +++ b/apps/desktop/src/update-dialog.ts @@ -136,7 +136,11 @@ export class DesktopUpdateDialog { const window = this.window this.window = undefined this.parent = undefined - if (window !== undefined && !window.isDestroyed()) window.destroy() + if (window !== undefined && !window.isDestroyed()) { + // Release Windows modality before destruction so another application cannot become active. + if (process.platform === 'win32' && window.isVisible()) window.hide() + window.destroy() + } } private owned(event: IpcMainInvokeEvent): void { diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts index a5e9faf296..ede5c5e3d3 100644 --- a/apps/desktop/tests/desktop-build-paths.spec.ts +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -16,6 +16,7 @@ describe('desktop build paths', () => { 'root', 'artifacts', 'unsignedArtifacts', + 'unnotarizedArtifacts', 'runtime', 'packageSet', 'dsh', diff --git a/apps/desktop/tests/desktop-build-version-discovery.spec.ts b/apps/desktop/tests/desktop-build-version-discovery.spec.ts index 8438e076ad..0fe063d1e2 100644 --- a/apps/desktop/tests/desktop-build-version-discovery.spec.ts +++ b/apps/desktop/tests/desktop-build-version-discovery.spec.ts @@ -71,6 +71,16 @@ describe('desktop build version discovery', () => { .resolves.toBe(`${PRERELEASE}.${DATE}.4`) }) + it('numbers after existing unnotarized DMG and ZIP artifacts', async () => { + const artifactsRoot = await artifactsWith([ + `deepseek-harness-${PRERELEASE}.${DATE}.2-mac-arm64-unnotarized.dmg`, + `deepseek-harness-${PRERELEASE}.${DATE}.4-mac-arm64-unnotarized.zip`, + `deepseek-harness-${PRERELEASE}.${DATE}.9-mac-arm64-unnotarized.zip.blockmap`, + ]) + await expect(suggestDesktopBuildVersion({ productVersion: PRERELEASE, target: 'mac-arm64', environment: {}, date: DATE, artifactsRoot })) + .resolves.toBe(`${PRERELEASE}.${DATE}.5`) + }) + it('counts unsigned Windows artifacts written under their own suffix', async () => { const artifactsRoot = await artifactsWith([ `deepseek-harness-${PRERELEASE}.${DATE}.2-win-x64-unsigned.exe`, diff --git a/apps/desktop/tests/desktop-cli.spec.ts b/apps/desktop/tests/desktop-cli.spec.ts new file mode 100644 index 0000000000..e358df8657 --- /dev/null +++ b/apps/desktop/tests/desktop-cli.spec.ts @@ -0,0 +1,37 @@ +/** The installed CLI uses the shared pnpm distribution with Electron's package-operation environment. */ + +import { delimiter, join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { runCli } from '@deepseek-ai/dsh/lib/bin.js' +import { runDesktopCli } from '../../desktop-host/src/cli.ts' +import { installOfficeEngineResolution } from '../../desktop-host/src/office-engine.ts' + +vi.mock('@deepseek-ai/dsh/lib/bin.js', () => ({ runCli: vi.fn() })) +vi.mock('../../desktop-host/src/office-engine.ts', () => ({ installOfficeEngineResolution: vi.fn() })) + +afterEach(() => { + vi.unstubAllEnvs() + vi.clearAllMocks() +}) + +it.each(['system-bin', undefined])('dispatches with bundled pnpm when PATH is %s', async (path) => { + vi.stubEnv('PATH', path) + const runtime = join('Application 中文 with spaces', 'resources', 'app.asar', 'dsh') + const support = join('Application 中文 with spaces', 'resources', 'runtime') + + await runDesktopCli(runtime, support) + + expect(installOfficeEngineResolution).toHaveBeenCalledExactlyOnceWith(runtime) + expect(runCli).toHaveBeenCalledExactlyOnceWith({ + manageDesktopProfile: true, + packageManager: { + command: process.execPath, + args: ['--expose-internals', join(support, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs')], + env: { + ELECTRON_RUN_AS_NODE: '1', + DSH_DESKTOP_NODE_EXECUTABLE: process.execPath, + PATH: `${join(support, 'bin')}${delimiter}${path ?? ''}`, + }, + }, + }) +}) diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index a892a8548a..1c3eb02fc0 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -229,3 +229,27 @@ it('owns macOS tuning in the local file and validates it before signing credenti expect(() =>{ validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: 'socks5://localhost:8080' }, MACOS) }).toThrow('NOTARIZATION_PROXY') }) }) + +it('rejects an explicit invalid macOS cache before signing while leaving unsigned builds independent', () => { + const environment = { ...RELEASE, DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR: '' } + for (const options of [{}, { prepareOnly: true }]) { + expect(() => { validateDesktopPackageEnvironment(environment, MACOS, options) }).toThrow('SIGNATURE_CACHE_DIR') + } + expect(() => { validateDesktopPackageEnvironment(environment, MACOS, { unsigned: true }) }).not.toThrow() +}) + +it('skips Apple credentials but still requires signing credentials for unnotarized packages', async () => { + await withDirectory(async (directory) => { + const certificate = join(directory, 'signing.p12') + await writeFile(certificate, 'fixture') + const environment = { ...RELEASE, ...MAC_IDENTITY, CSC_LINK: certificate, CSC_KEY_PASSWORD: '' } + expect(() => { validateDesktopPackageEnvironment(environment, MACOS, { skipNotarization: true }) }).not.toThrow() + expect(() => { validateDesktopPackageEnvironment(environment, MACOS) }).toThrow() + expect(() => { + validateDesktopPackageEnvironment({ ...environment, CSC_LINK: undefined }, MACOS, { skipNotarization: true }) + }).toThrow(/CSC_LINK/u) + expect(() => { + validateDesktopPackageEnvironment({ ...environment, CSC_KEY_PASSWORD: undefined }, MACOS, { skipNotarization: true }) + }).toThrow(/CSC_KEY_PASSWORD/u) + }) +}) diff --git a/apps/desktop/tests/fixtures/pnpm-smoke.mjs b/apps/desktop/tests/fixtures/pnpm-smoke.mjs new file mode 100644 index 0000000000..5d16769ab4 --- /dev/null +++ b/apps/desktop/tests/fixtures/pnpm-smoke.mjs @@ -0,0 +1,69 @@ +/** Install and use a local dependency with the packaged pnpm and Electron Node launcher. */ + +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' + +/** + * Exercise offline installation and package scripts without a system pnpm or Node installation. + * @param {string} resourcesRuntime - Packaged runtime directory outside ASAR. + * @returns {Promise} Resolves after execution and removal of the private installation. + */ +export async function checkPnpm(resourcesRuntime) { + assert.ok(process.versions.electron, 'Run the package-manager smoke with Electron Node mode') + assert.equal(existsSync(join(resourcesRuntime, 'pnpm')), false, 'Desktop must ship only primary-runtime pnpm') + const primary = join(resourcesRuntime, 'primary-runtime') + const pnpm = join(primary, 'dependencies', 'pnpm', 'bin', 'pnpm.mjs') + const versions = JSON.parse(readFileSync(join(resourcesRuntime, 'versions.json'), 'utf8')) + const manifest = JSON.parse(readFileSync(join(primary, 'runtime.json'), 'utf8')) + assert.equal(versions.pnpm, manifest.pnpm) + const scratch = mkdtempSync(join(tmpdir(), 'desktop-pnpm-smoke-')) + try { + mkdirSync(join(scratch, 'dependency')) + writeFileSync(join(scratch, 'dependency', 'package.json'), JSON.stringify({ + name: 'desktop-pnpm-smoke-dependency', version: '1.0.0', main: 'index.cjs', + })) + writeFileSync(join(scratch, 'dependency', 'index.cjs'), 'exports.answer = 42\n') + writeFileSync(join(scratch, 'package.json'), JSON.stringify({ + name: 'desktop-node-script-smoke', private: true, + dependencies: { 'desktop-pnpm-smoke-dependency': 'file:./dependency' }, scripts: { check: 'node check.cjs' }, + })) + writeFileSync(join(scratch, 'npmrc'), '') + writeFileSync(join(scratch, 'check.cjs'), ` +const assert = require('node:assert/strict') +assert.equal(require('desktop-pnpm-smoke-dependency').answer, 42) +assert.equal(process.execPath, ${JSON.stringify(process.execPath)}) +assert.ok(process.versions.electron) +assert.ok(process.execArgv.includes('--expose-internals')) +assert.equal(typeof require('internal/modules/esm/loader').getOrInitializeCascadedLoader, 'function') +console.log('desktop-node-script-ok') +`) + const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => /^(?:systemroot|windir|comspec|LD_LIBRARY_PATH)$/iu.test(name))) + const systemBin = process.platform === 'win32' ? join(process.env.SystemRoot, 'System32') : '/usr/bin:/bin' + const run = args => execFileSync(process.execPath, ['--expose-internals', pnpm, ...args], { + cwd: scratch, encoding: 'utf8', timeout: 45_000, windowsHide: true, + env: { ...environment, pnpm_config_verify_deps_before_run: 'false', + ELECTRON_RUN_AS_NODE: '1', DSH_DESKTOP_NODE_EXECUTABLE: process.execPath, + PATH: `${join(resourcesRuntime, 'bin')}${delimiter}${systemBin}`, + HOME: scratch, USERPROFILE: scratch, TMP: scratch, TEMP: scratch, TMPDIR: scratch, + XDG_CONFIG_HOME: scratch, XDG_CACHE_HOME: scratch, XDG_STATE_HOME: scratch, + npm_config_userconfig: join(scratch, 'npmrc'), npm_config_globalconfig: join(scratch, 'npmrc'), + npm_config_store_dir: join(scratch, 'store'), npm_config_enable_global_virtual_store: 'false', + npm_config_update_notifier: 'false' }, + }) + assert.equal(run(['--version']).trim(), versions.pnpm) + run(['install', '--offline', '--ignore-scripts']) + assert.match(run(['run', 'check']), /desktop-node-script-ok/u) + } finally { + await rm(scratch, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }) + } +} + +if (import.meta.main) { + assert.ok(process.argv[2], 'Pass the packaged runtime directory') + await checkPnpm(process.argv[2]) + console.log('desktop pnpm: offline installation and Electron Node package script passed') +} diff --git a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs index f9b6ca9279..3cda38c620 100644 --- a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs +++ b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs @@ -8,6 +8,7 @@ import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { delimiter, dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' +import { checkPnpm } from './pnpm-smoke.mjs' const runtime = process.argv[2] assert.ok(runtime, 'Pass the filtered resources/dsh directory') @@ -20,33 +21,6 @@ const resourcesRuntime = process.argv[3] ?? join(dirname(root), 'runtime') const requireRuntime = createRequire(join(root, 'package.json')) const scratch = mkdtempSync(join(tmpdir(), 'dsh-runtime-payload-')) -/** Run a package script with only the shipped node launcher available on PATH. */ -function checkPnpm() { - const bin = join(resourcesRuntime, 'bin') - const pnpm = join(resourcesRuntime, 'pnpm', 'bin', 'pnpm.mjs') - writeFileSync(join(scratch, 'package.json'), JSON.stringify({ - name: 'desktop-node-script-smoke', private: true, scripts: { check: 'node check.cjs' }, - })) - writeFileSync(join(scratch, 'check.cjs'), ` -const assert = require('node:assert/strict') -assert.equal(process.execPath, ${JSON.stringify(process.execPath)}) -assert.ok(process.versions.electron) -assert.ok(process.execArgv.includes('--expose-internals')) -assert.equal(typeof require('internal/modules/esm/loader').getOrInitializeCascadedLoader, 'function') -console.log('desktop-node-script-ok') -`) - const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => /^(?:systemroot|windir|comspec)$/iu.test(name))) - const systemBin = process.platform === 'win32' ? join(process.env.SystemRoot, 'System32') : '/usr/bin:/bin' - // This dependency-free fixture checks script launch, without pnpm's implicit install and update-network check. - const output = execFileSync(process.execPath, ['--expose-internals', pnpm, 'run', 'check'], { - cwd: scratch, encoding: 'utf8', timeout: 45_000, - env: { ...environment, pnpm_config_verify_deps_before_run: 'false', - ELECTRON_RUN_AS_NODE: '1', DSH_DESKTOP_NODE_EXECUTABLE: process.execPath, - PATH: `${bin}${delimiter}${systemBin}`, HOME: scratch, USERPROFILE: scratch, TMP: scratch, TEMP: scratch, TMPDIR: scratch }, - }) - assert.match(output, /desktop-node-script-ok/u) -} - /** Spawn only a fixed Node program and await the terminal's drained exit event. */ async function checkPty() { const pty = requireRuntime('node-pty') @@ -162,7 +136,7 @@ try { const builtin = requireRuntime('node-addon-require-builtin') assert.equal(typeof builtin.requireBuiltin('internal/modules/esm/loader').getOrInitializeCascadedLoader, 'function') console.error('runtime payload: pnpm') - checkPnpm() + await checkPnpm(resourcesRuntime) console.error('runtime payload: koffi') checkKoffi() console.error('runtime payload: sharp') diff --git a/apps/desktop/tests/installed-update-builder.spec.ts b/apps/desktop/tests/installed-update-builder.spec.ts index 089b04deef..12e5a82258 100644 --- a/apps/desktop/tests/installed-update-builder.spec.ts +++ b/apps/desktop/tests/installed-update-builder.spec.ts @@ -88,8 +88,10 @@ describe('installed-update application inputs and builder configuration', () => expect(config.extraMetadata.main).toBe('qualification-bootstrap.mjs') expect(config.directories.output).toBe(join(manifest, '..', versions[index]!, 'installer')) const dsh = join(manifest, '..', versions[index]!, 'dsh') - expect(config.files[2]).toMatchObject({ from: dsh, to: 'dsh' }) - expect(config.files[3]).toMatchObject({ from: join(dsh, 'node_modules'), to: 'dsh/node_modules' }) + expect(config.files.at(-2)).toMatchObject({ from: dsh, to: 'dsh' }) + expect(config.files.at(-1)).toMatchObject({ from: join(dsh, 'node_modules'), to: 'dsh/node_modules' }) + expect(config.files).toContain('!node_modules/**/*.{js,cjs,mjs,ts,cts,mts,css}.map') + expect(config.files).toContain('!node_modules/**/*.tsbuildinfo') expect(config.win.forceCodeSigning).toBe(true) expect(config.win.signtoolOptions.publisherName).toBe('CN=Fixture,O=Fixture,C=CN') expect(typeof config.win.signtoolOptions.sign).toBe('function') diff --git a/apps/desktop/tests/installed-update/README.i18n.yaml b/apps/desktop/tests/installed-update/README.i18n.yaml index 1ac20fbb75..cfd4135420 100644 --- a/apps/desktop/tests/installed-update/README.i18n.yaml +++ b/apps/desktop/tests/installed-update/README.i18n.yaml @@ -15,8 +15,8 @@ en: bd61ff806d57cbb1 zh: 8b09719bc3b247c8 /windows-installed-update-walkthrough/prepare-materials: - en: 1f7d9426be57a23a - zh: 9f2be099d3d1a026 + en: ab2cf13a10671564 + zh: f2c944df56f1e8d7 /windows-installed-update-walkthrough/operator-sequence: en: 8a7f275812dbe11f zh: 24dbf32328f9c0dd diff --git a/apps/desktop/tests/installed-update/README.md b/apps/desktop/tests/installed-update/README.md index b847960862..77c7ae460f 100644 --- a/apps/desktop/tests/installed-update/README.md +++ b/apps/desktop/tests/installed-update/README.md @@ -23,7 +23,7 @@ Prepare a private test application and fresh test distribution namespace, then l From the repository root with dependencies installed, allocate a run with two increasing derived test versions. This writes only an ignored local manifest; it does not build, sign, upload, install, or read credentials. -The examples use base version `0.1.6-alpha.1`. Before creating new material, substitute the actual base, Asia/Shanghai date, and unused index according to the [release version rules](../../README.md#release-versions). +The examples use base version `0.1.6-alpha.1`. Before creating new material, substitute the actual base, Asia/Shanghai date, and unused index according to the [release version rules](../../packaging/README.md#release-versions). ```powershell node --import tsx apps/desktop/scripts/prepare-installed-update.ts init 0.1.6-alpha.1.20260916.1 0.1.6-alpha.1.20260916.2 @@ -44,7 +44,7 @@ Package both files from the generated `bootstrap` directory beside `lib/`, and s The `application` command copies built main/preload modules, renderer files, and the prepared bootstrap into `application/files`, with SHA-256 values in `application/result.json`. It does not copy the app's root `.env.windows` or freeze `node_modules` and build tools. The [qualification builder configuration](../../scripts/installed-update-builder.ts) verifies this inventory and the selected private runtime, selects the shared frozen files and isolated package metadata, and preserves ordinary installer and signing hooks. Its tests substitute the signer and pass the pinned builder's configuration validator; they do not produce an installer. A separately authorized supervised build must still record dependency/tool inputs and verify final package contents and signatures. -The [packaging entry](../../scripts/package-installed-update.ts) defaults to checks. A retained signing interlock rejects before credentials are loaded; it never clears that interlock. With no interlock, checks load `.env.windows`, validate prepared inputs, and launch no child. The shared builder requires a valid `DOWNLOAD_TEST_RELEASE_ID` from the [test release settings](../../README.md#upload-updates); qualification still publishes through the manifest’s separate `qualification/` paths. Run this from the repository root with one exact version: +The [packaging entry](../../scripts/package-installed-update.ts) defaults to checks. A retained signing interlock rejects before credentials are loaded; it never clears that interlock. With no interlock, checks load `.env.windows`, validate prepared inputs, and launch no child. The shared builder requires a valid `DOWNLOAD_TEST_RELEASE_ID` from the [test release settings](../../publishing/README.md#upload-updates); qualification still publishes through the manifest’s separate `qualification/` paths. Run this from the repository root with one exact version: ```powershell node --import tsx apps/desktop/scripts/package-installed-update.ts "" 0.1.6-alpha.1.20260916.1 --check @@ -52,7 +52,7 @@ node --import tsx apps/desktop/scripts/package-installed-update.ts "" Actual packaging remains unverified and requires separate hardware-recovery approval and a present operator. The `--execute` mode requires a terminal and an exact confirmation containing the version and run ID; there is no piped approval option. It rechecks the interlock and exclusively allocates that version's `packaging` directory. The supervised child builds only that version, disables publication, strips unrelated credentials, and stops on failure or a 15-minute overall deadline. This deadline does not bound individual CSP authentication attempts. Records retain source/tool hashes, redacted output, events, and artifact file hashes; an existing attempt or output refuses reuse. `builderCompleted` and a successful supervisor result do not establish package verification, signature acceptance, or installation success; `packageVerification` remains `pending` until those checks are performed independently. -Before the operator starts, supply both verified installers, generated metadata and blockmaps, packaging and signature records, the exact installed executable path, and fixed test feed URL. Both packages must use the same test identity, isolated data directories, and absolute external `DSH_DESKTOP_UPDATE_JOURNAL_DIR` on every launch, including an installer-triggered restart. A variable set only in the original launch terminal is insufficient. The [Windows signing rules](../../README.md#windows-ev-signing) still apply; a manifest never clears a signing interlock. +Before the operator starts, supply both verified installers, generated metadata and blockmaps, packaging and signature records, the exact installed executable path, and fixed test feed URL. Both packages must use the same test identity, isolated data directories, and absolute external `DSH_DESKTOP_UPDATE_JOURNAL_DIR` on every launch, including an installer-triggered restart. A variable set only in the original launch terminal is insufficient. The [Windows signing rules](../../windows-signing/README.md#windows-ev-signing) still apply; a manifest never clears a signing interlock. The Windows [read-only signature helper](../../scripts/installed-update-signature.mjs) uses the real updater verifier with a publisher derived from the trusted public certificate, then requires valid Authenticode and timestamp attributes and unchanged SHA-512. Verification children receive no inherited signing/upload secrets or PowerShell module-path overrides. The helper never loads `.env.windows`, signs a file, or executes the inspected executable. Signed-probe acceptance and unsigned-installer rejection have been observed locally; they do not certify either prepared version, package identity, or installation behavior. diff --git a/apps/desktop/tests/installed-update/README.zh.md b/apps/desktop/tests/installed-update/README.zh.md index ac2bed4e2a..618eb3c578 100644 --- a/apps/desktop/tests/installed-update/README.zh.md +++ b/apps/desktop/tests/installed-update/README.zh.md @@ -23,7 +23,7 @@ description: "Windows 已安装应用更新的人工清单,覆盖下载失败 在已安装依赖的仓库根目录中,用两个递增的派生测试版本创建批次。这只写入已忽略的本地清单,不构建、签名、上传、安装或读取凭据。 -示例以 `0.1.6-alpha.1` 为基础版本。创建新物料前,按[发布版本规则](../../README.zh.md#release-versions)替换为实际基础版本、北京时间日期和未使用的序号。 +示例以 `0.1.6-alpha.1` 为基础版本。创建新物料前,按[发布版本规则](../../packaging/README.zh.md#release-versions)替换为实际基础版本、北京时间日期和未使用的序号。 ```powershell node --import tsx apps/desktop/scripts/prepare-installed-update.ts init 0.1.6-alpha.1.20260916.1 0.1.6-alpha.1.20260916.2 @@ -44,7 +44,7 @@ node --import tsx apps/desktop/scripts/prepare-installed-update.ts application " `application` 命令将已构建的主程序/预加载模块、界面文件和准备好的启动入口复制到 `application/files`,并在 `application/result.json` 中记录 SHA-256。它不复制应用根目录的 `.env.windows`,也不冻结 `node_modules` 与构建工具。[验收打包配置](../../scripts/installed-update-builder.ts)校验该清单与选定的独立运行时,选用共享冻结文件和隔离包元数据,并保留常规安装器与签名钩子。其测试替换签名器,并通过固定版本构建器的配置校验;测试不生成安装器。另行授权的受监督构建仍须记录依赖/工具输入,并验证最终包内容及签名。 -[打包入口](../../scripts/package-installed-update.ts)默认只检查。保留的签名保护锁会在读取凭据前使其拒绝;入口绝不清除此锁。没有保护锁时,检查会加载 `.env.windows`、校验准备输入,不启动子进程。共享构建器要求[测试发布配置](../../README.zh.md#upload-updates)中有效的 `DOWNLOAD_TEST_RELEASE_ID`;资格验收仍使用清单中独立的 `qualification/` 路径发布。在仓库根目录使用一个准确版本执行: +[打包入口](../../scripts/package-installed-update.ts)默认只检查。保留的签名保护锁会在读取凭据前使其拒绝;入口绝不清除此锁。没有保护锁时,检查会加载 `.env.windows`、校验准备输入,不启动子进程。共享构建器要求[测试发布配置](../../publishing/README.zh.md#upload-updates)中有效的 `DOWNLOAD_TEST_RELEASE_ID`;资格验收仍使用清单中独立的 `qualification/` 路径发布。在仓库根目录使用一个准确版本执行: ```powershell node --import tsx apps/desktop/scripts/package-installed-update.ts "" 0.1.6-alpha.1.20260916.1 --check @@ -52,7 +52,7 @@ node --import tsx apps/desktop/scripts/package-installed-update.ts "" 实际打包仍未验证,必须另行获得硬件恢复授权并有操作者在场。`--execute` 模式要求终端和包含版本、批次 ID 的准确确认,没有管道批准选项。它再次检查保护锁,并独占创建该版本的 `packaging` 目录。受监督子进程只构建该版本、禁用发布、移除无关凭据,失败或达到 15 分钟整体期限时停止。该期限不限制单次 CSP 内部认证尝试。记录保留源码/工具哈希、脱敏输出、事件和产物文件哈希;已有尝试或输出拒绝复用。`builderCompleted` 与监督程序成功结果不证明包验证、验签或安装成功;独立完成这些检查前,`packageVerification` 保持 `pending`。 -操作者开始前,提供两个已验证安装包、生成的元数据与 blockmap、打包和签名记录、准确的安装后可执行文件路径与固定 test feed URL。两个安装包必须使用相同的测试身份、隔离数据目录,并在每次启动时设置同一个安装目录外的绝对路径 `DSH_DESKTOP_UPDATE_JOURNAL_DIR`,包括安装器触发的重启。仅在首次启动终端中设置变量是不够的。[Windows 签名规则](../../README.zh.md#windows-ev-signing)仍适用;清单绝不解除签名保护锁。 +操作者开始前,提供两个已验证安装包、生成的元数据与 blockmap、打包和签名记录、准确的安装后可执行文件路径与固定 test feed URL。两个安装包必须使用相同的测试身份、隔离数据目录,并在每次启动时设置同一个安装目录外的绝对路径 `DSH_DESKTOP_UPDATE_JOURNAL_DIR`,包括安装器触发的重启。仅在首次启动终端中设置变量是不够的。[Windows 签名规则](../../windows-signing/README.zh.md#windows-ev-signing)仍适用;清单绝不解除签名保护锁。 Windows [只读签名检查器](../../scripts/installed-update-signature.mjs)使用真实 updater 验签,发布者来自可信公钥证书,随后要求 Authenticode 与时间戳属性有效且 SHA-512 未变。验签子进程不继承签名/上传秘密或 PowerShell 模块路径覆盖。检查器绝不加载 `.env.windows`、签名文件或执行被检查的可执行文件。本地已观察到签名探针通过、未签名安装包被拒绝;这些结果不能证明任一准备版本、包身份或安装行为已验收。 diff --git a/apps/desktop/tests/macos-disk-image-signature.spec.ts b/apps/desktop/tests/macos-disk-image-signature.spec.ts new file mode 100644 index 0000000000..5aaf21f969 --- /dev/null +++ b/apps/desktop/tests/macos-disk-image-signature.spec.ts @@ -0,0 +1,58 @@ +import { spawnSync } from 'node:child_process' +import { afterEach, expect, it, vi } from 'vitest' +import { createElectronBuilderConfig } from '../scripts/electron-builder-config.mjs' +import { verifyMacOSDiskImage } from '../scripts/verify-macos-signature.mjs' + +vi.mock('node:child_process', async importOriginal => ({ + ...await importOriginal(), spawnSync: vi.fn(), +})) +afterEach(() => { vi.resetAllMocks() }) + +const signingIdentity = 'Example Company (TEAMID1234)' +const teamId = 'TEAMID1234' +const environment = { + DSH_DESKTOP_APP_ID: 'com.example.desktop', DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN: 'https://policy.example.com', + DOWNLOAD_PROD_ORIGIN: 'https://updates.example.com', + DSH_DESKTOP_MACOS_SIGNING_IDENTITY: signingIdentity, DSH_DESKTOP_MACOS_TEAM_ID: teamId, + DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: '1', +} + +function toolResult(details = `Authority=Developer ID Application: ${signingIdentity}\nTeamIdentifier=${teamId}`) { + vi.mocked(spawnSync).mockReturnValue({ + pid: 1, output: [null, '', details], stdout: '', stderr: details, status: 0, signal: null, + }) +} + +it('verifies an unnotarized DMG signature and identity without invoking Apple notarization tools', () => { + toolResult() + const config = createElectronBuilderConfig(environment, 'darwin', 'arm64') + expect(config.artifactBuildCompleted({ file: '/fixture/debug.dmg' })).toBeUndefined() + expect(vi.mocked(spawnSync).mock.calls.map(call => call.slice(0, 2))).toEqual([ + ['/usr/bin/codesign', ['--verify', '--strict', '--verbose=2', '/fixture/debug.dmg']], + ['/usr/bin/codesign', ['--display', '--verbose=4', '/fixture/debug.dmg']], + ]) +}) + +it('rejects an unnotarized DMG signed by another team', () => { + toolResult('Authority=Developer ID Application: Other (OTHERID123)\nTeamIdentifier=OTHERID123') + const config = createElectronBuilderConfig(environment, 'darwin', 'arm64') + expect(() => config.artifactBuildCompleted({ file: '/fixture/debug.dmg' })).toThrow(/release identity/u) +}) + +it('rejects a corrupt unnotarized DMG before inspecting its identity', () => { + toolResult() + vi.mocked(spawnSync).mockReturnValueOnce({ pid: 1, output: [null, '', 'invalid signature'], + stdout: '', stderr: 'invalid signature', status: 1, signal: null }) + const config = createElectronBuilderConfig(environment, 'darwin', 'arm64') + expect(() => config.artifactBuildCompleted({ file: '/fixture/debug.dmg' })).toThrow(/invalid signature/u) + expect(spawnSync).toHaveBeenCalledOnce() +}) + +it('still requires a stapled ticket and Gatekeeper acceptance for release DMGs', () => { + toolResult() + verifyMacOSDiskImage('/fixture/release.dmg', { signingIdentity, teamId }) + expect(vi.mocked(spawnSync).mock.calls.map(call => call[0])).toEqual([ + '/usr/bin/codesign', '/usr/bin/codesign', '/usr/bin/xcrun', '/usr/sbin/spctl', + ]) +}) diff --git a/apps/desktop/tests/macos-package-preset.spec.ts b/apps/desktop/tests/macos-package-preset.spec.ts new file mode 100644 index 0000000000..a4ab457ec0 --- /dev/null +++ b/apps/desktop/tests/macos-package-preset.spec.ts @@ -0,0 +1,101 @@ +import { spawnSync } from 'node:child_process' +import { mkdir, mkdtemp, realpath, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { parseEnv } from 'node:util' +import { expect, it } from 'vitest' +import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' + +it('loads machine signing credentials independently of the checkout and rejects misplaced settings', async () => { + const root = await mkdtemp(join(tmpdir(), 'macos-preset-')) + try { + await mkdir(join(root, 'presets')) + await mkdir(join(root, 'secrets')) + const preset = join(root, 'presets/test.env') + const secrets = join(root, 'secrets/macos-signing.env') + await writeFile(preset, 'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDSH_DESKTOP_MACOS_PACK_CONCURRENCY=2\nDSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR=/machine/cache\n') + await writeFile(secrets, 'CSC_LINK=../certificates/app.p12\nCSC_KEY_PASSWORD=machine-secret\nAPPLE_API_KEY=../certificates/key.p8\n') + const env = { DSH_PACKAGING_PRESET: 'test', DSH_PACKAGING_CONFIG_DIR: root, CSC_KEY_PASSWORD: 'ambient-secret' } + for (const options of [{}, { prepareOnly: true }]) { + const selected = loadDesktopPackageEnvironment('darwin', env, join(root, 'absent-checkout'), options) + expect(selected.CSC_LINK).toBe(join(root, 'certificates/app.p12')) + expect(selected.APPLE_API_KEY).toBe(join(root, 'certificates/key.p8')) + expect(selected.CSC_KEY_PASSWORD).toBe('machine-secret') + expect(selected.DSH_DESKTOP_MACOS_PACK_CONCURRENCY).toBe('2') + expect(selected.DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR).toBe('/machine/cache') + } + await rm(secrets) + expect(loadDesktopPackageEnvironment('darwin', env, root, { unsigned: true }).CSC_KEY_PASSWORD).toBeUndefined() + expect(() => loadDesktopPackageEnvironment('darwin', env)).toThrow(/cannot read/u) + await writeFile(secrets, 'DSH_DESKTOP_MACOS_DOWNLOAD_PROXY=http://localhost:1234\n') + expect(() => loadDesktopPackageEnvironment('darwin', env)).toThrow(/unsupported setting/u) + await writeFile(preset, 'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nCSC_KEY_PASSWORD=private-value\n') + expect(() => loadDesktopPackageEnvironment('darwin', env)).toThrow(/unsupported setting CSC_KEY_PASSWORD/u) + expect(() => loadDesktopPackageEnvironment('darwin', env)).not.toThrow(/private-value/u) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +it.skipIf(process.platform !== 'darwin')('prepares private machine directories without overwriting existing files', async () => { + const root = await mkdtemp(join(tmpdir(), 'macos-setup-')) + try { + const machine = join(await realpath(root), 'machine') + const run = (...args: string[]) => spawnSync(process.execPath, + [join(import.meta.dirname, '../scripts/setup-macos-packaging.mjs'), machine, ...args], { encoding: 'utf8' }) + expect(run().status).toBe(0) + const config = join(machine, 'packaging/presets/test.env.example') + expect(parseEnv(await readFile(config, 'utf8')).DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR).toBe(join(machine, 'cache/macos-signatures')) + await writeFile(config, 'keep existing configuration\n') + expect(run().status).toBe(0) + expect(await readFile(config, 'utf8')).toBe('keep existing configuration\n') + expect(run('--check').status).toBe(0) + await rm(join(machine, 'packaging/secrets'), { recursive: true }) + const missing = run('--check') + expect(missing.error).toBeUndefined() + expect(missing.signal).toBeNull() + expect(missing.status).toBe(1) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +it('records debugging artifact paths only for an explicit CI selection without notarization credentials', async () => { + const root = await mkdtemp(join(tmpdir(), 'macos-ci-settings-')) + try { + await mkdir(join(root, 'presets')) + await mkdir(join(root, 'secrets')) + await writeFile(join(root, 'certificate.p12'), 'fixture') + await writeFile(join(root, 'presets/test.env'), [ + 'DSH_DESKTOP_APP_ID=com.example.fixture', 'DSH_DESKTOP_AUTO_UPDATE_ENV=test', + 'DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=https://policy.example.com', + 'DSH_DESKTOP_MANDATORY_UPDATE_CONFIG={"allowedAuthOrigins":["https://login.example.com"]}', + 'DOWNLOAD_TEST_ORIGIN=https://updates.example.com', 'DOWNLOAD_TEST_RELEASE_ID=0123456789abcdef0123456789abcdef', + ].join('\n')) + await writeFile(join(root, 'secrets/macos-signing.env'), [ + 'CSC_LINK=../certificate.p12', 'CSC_KEY_PASSWORD=fixture-password', + 'DSH_DESKTOP_MACOS_SIGNING_IDENTITY=Example Company (TEAMID1234)', 'DSH_DESKTOP_MACOS_TEAM_ID=TEAMID1234', + ].join('\n')) + const output = join(root, 'output') + const summary = join(root, 'summary') + const run = (skip: string) => spawnSync(process.execPath, + [join(import.meta.dirname, '../scripts/prepare-macos-ci.mjs')], { encoding: 'utf8', env: { + ...process.env, DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test', + PACKAGE_SKIP_NOTARIZATION: skip, PACKAGE_BUILD_VERSION: 'auto', + GITHUB_RUN_NUMBER: '1', GITHUB_RUN_ATTEMPT: '1', GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, + } }) + const missingCredentials = run('false') + expect(missingCredentials.status).toBe(1) + expect(missingCredentials.stderr).toContain('macOS packaging requires APPLE_API_KEY') + const invalidSelection = run('yes') + expect(invalidSelection.status).toBe(1) + expect(invalidSelection.stderr).toContain('Invalid skip notarization selection') + const debugging = run('true') + expect(debugging.stderr).toBe('') + expect(debugging.status).toBe(0) + expect(await readFile(output, 'utf8')).toContain('artifacts=unnotarized-artifacts\nmode=unnotarized-debug\n') + expect(await readFile(summary, 'utf8')).toContain('Internal debugging only') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/tests/macos-runtime.spec.ts b/apps/desktop/tests/macos-runtime.spec.ts index fdcb61600a..4ad63e41fd 100644 --- a/apps/desktop/tests/macos-runtime.spec.ts +++ b/apps/desktop/tests/macos-runtime.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' @@ -25,6 +25,41 @@ it('signs Mach-O files in their final locations and verifies each signature', as expect(signMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), expect.stringMatching(/^com\.example\.app\.runtime\.[a-f0-9]{64}$/u), identity, undefined) expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), identity) }) + +// File symlinks in these macOS fixtures require privileges unavailable to ordinary Windows processes. +it.skipIf(process.platform === 'win32')('signs a shared Python interpreter once and retains both executable aliases', async () => { + const path = root() + const bin = join(path, 'dependencies/python/bin') + mkdirSync(bin, { recursive: true }) + writeFileSync(join(path, 'runtime.json'), JSON.stringify({ desktopVersion: '1', platform: 'darwin', arch: 'arm64', python: '3.12.14', pythonPackages: {} })) + writeFileSync(join(bin, 'python3.12'), Buffer.from('cffaedfe00000000', 'hex')) + for (const name of ['python', 'python3']) symlinkSync('python3.12', join(bin, name)) + await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64', undefined, 'primary')).resolves.toBe(1) + expect(signMacOSRuntimeCode).toHaveBeenCalledExactlyOnceWith(join(bin, 'python3.12'), expect.any(String), identity, undefined) + for (const name of ['python', 'python3']) expect(readlinkSync(join(bin, name))).toBe('python3.12') + await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64')).rejects.toThrow('unsupported filesystem entry') +}) + +// The signer must inspect real file symlinks, including dangling and cyclic ones. +it.skipIf(process.platform === 'win32').each(['unknown', 'absolute', 'escaping', 'dangling', 'chained', 'directory'])('rejects a %s runtime link before signing any file', async (kind) => { + const path = root() + const bin = join(path, 'dependencies/python/bin') + mkdirSync(bin, { recursive: true }) + writeFileSync(join(path, 'runtime.json'), JSON.stringify({ desktopVersion: '1', platform: 'darwin', arch: 'arm64', python: '3.12.14', pythonPackages: {} })) + const canonical = join(bin, 'python3.12') + if (kind === 'directory') mkdirSync(canonical) + else if (kind !== 'dangling') writeFileSync(canonical, Buffer.from('cffaedfe00000000', 'hex')) + if (kind === 'chained') { + rmSync(canonical) + symlinkSync('python3', canonical) + } + const target = kind === 'absolute' ? canonical : kind === 'escaping' ? '../outside' : 'python3.12' + symlinkSync(target, join(bin, kind === 'unknown' ? 'unlisted' : 'python3')) + const result = signMacOSRuntime(path, 'com.example.app', identity, 'arm64', undefined, 'primary') + if (kind === 'dangling') await expect(result).rejects.toMatchObject({ code: 'ENOENT', path: canonical }) + else await expect(result).rejects.toThrow(kind === 'unknown' ? 'unsupported filesystem entry' : 'invalid Python alias') + expect(signMacOSRuntimeCode).not.toHaveBeenCalled() +}) it('awaits other signers before rejecting and permitting output cleanup', async () => { const path = root() for (const name of ['a.node', 'b.node']) writeFileSync(join(path, name), Buffer.from('cffaedfe00000000', 'hex')) diff --git a/apps/desktop/tests/macos-signature-cache-directory.spec.ts b/apps/desktop/tests/macos-signature-cache-directory.spec.ts new file mode 100644 index 0000000000..adefc7a060 --- /dev/null +++ b/apps/desktop/tests/macos-signature-cache-directory.spec.ts @@ -0,0 +1,63 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveMacOSSignatureCacheDirectory, validateMacOSSignatureCacheDirectory } from '../scripts/macos-signature-cache-directory.mjs' +import { cachedMacOSSignature } from '../scripts/macos-signature-cache.ts' + +const roots: string[] = [] +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) +function fixture(): string { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'mac-cache-directory-'))) + roots.push(root) + return root +} + +it('retains target-local storage only when the setting is absent', () => { + expect(resolveMacOSSignatureCacheDirectory({}, '/build/mac-arm64')).toBe(join('/build/mac-arm64', 'signature-cache')) + for (const path of ['', 'relative/cache', '~/ci/cache', '/cache\0invalid']) { + expect(() => resolveMacOSSignatureCacheDirectory({ DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR: path }, '/build')).toThrow(/absolute directory/u) + } +}) + +// Machine storage requires POSIX ownership and permissions, which Windows cannot represent. +describe.skipIf(process.platform === 'win32')('private external macOS storage', () => { + it('rejects missing, non-directory, linked and non-private storage without repairing it', () => { + const root = fixture() + expect(() => validateMacOSSignatureCacheDirectory(join(root, 'absent'))).toThrow(/ENOENT/u) + const file = join(root, 'file') + writeFileSync(file, '') + expect(() => validateMacOSSignatureCacheDirectory(file)).toThrow(/unlinked directory/u) + const cache = join(root, 'cache') + mkdirSync(cache, { mode: 0o700 }) + const link = join(root, 'link') + symlinkSync(root, link) + expect(() => validateMacOSSignatureCacheDirectory(join(link, 'cache'))).toThrow(/unlinked directory/u) + for (const mode of [0o755, 0o500]) { + chmodSync(cache, mode) + expect(() => validateMacOSSignatureCacheDirectory(cache)).toThrow(/private, accessible/u) + } + chmodSync(cache, 0o700) + expect(validateMacOSSignatureCacheDirectory(cache)).toBe(cache) + }) + + it('restores a verified signature after removing and recreating the checkout', async () => { + const root = fixture() + const cache = join(root, 'machine-cache') + mkdirSync(cache, { mode: 0o700 }) + const checkout = join(root, 'checkout') + const sign = vi.fn(async (path: string) => { writeFileSync(path, 'signed bytes') }) + const verify = vi.fn((path: string) => { expect(readFileSync(path, 'utf8')).toBe('signed bytes') }) + for (const hit of [false, true]) { + mkdirSync(checkout) + const file = join(checkout, 'runtime') + writeFileSync(file, 'unsigned bytes', { mode: 0o755 }) + const selected = resolveMacOSSignatureCacheDirectory({ DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR: cache }, checkout) + expect(await cachedMacOSSignature(file, selected, { policy: 'same certificate and tools', sign, verify })).toBe(hit) + expect(readFileSync(file, 'utf8')).toBe('signed bytes') + rmSync(checkout, { recursive: true }) + } + expect(sign).toHaveBeenCalledTimes(1) + expect(verify).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index d39ee99cb9..0d9f486bdd 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' import type { NotarizeOptions } from '@electron/notarize' import { resolveDesktopAppId, @@ -12,6 +13,11 @@ import { assertMacOSSignatureDetails, } from '../scripts/verify-macos-signature.mjs' +const require = createRequire(import.meta.url) +const { validateConfiguration } = require('app-builder-lib/out/util/config/config.js') as { + validateConfiguration: (config: object, logger: { isEnabled: false }) => Promise +} + const RELEASE_ENVIRONMENT = { DSH_DESKTOP_APP_ID: 'com.example.desktop', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', @@ -75,6 +81,7 @@ describe('desktop macOS release signature', () => { signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'], }, dmg: { + format: 'ULMO', sign: true, writeUpdateInfo: false, }, @@ -87,15 +94,32 @@ describe('desktop macOS release signature', () => { expect(typeof config.artifactBuildCompleted).toBe('function') }) + it('accepts LZMA DMGs through builder validation while rejecting unknown formats', async () => { + const { createElectronBuilderConfig } = await import('../scripts/electron-builder-config.mjs') + for (const arch of ['arm64', 'x64']) { + for (const unsigned of ['0', '1']) { + const config = createElectronBuilderConfig({ + ...RELEASE_ENVIRONMENT, + DSH_DESKTOP_TARGET_ARCH: arch, + DSH_DESKTOP_UNSIGNED: unsigned, + }, 'darwin', arch) + expect(config.dmg.format).toBe('ULMO') + await expect(validateConfiguration(config, { isEnabled: false })).resolves.toBeUndefined() + } + } + await expect(validateConfiguration({ dmg: { format: 'invalid' } }, { isEnabled: false })) + .rejects.toThrow('configuration.dmg.format') + }) + it('seals PAK resources with their enclosing bundle while signing executable code', async () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') const ignored = (path: string): boolean => config.mac.signIgnore.some(pattern => new RegExp(pattern).test(path)) expect(ignored('/App.app/Contents/Frameworks/Electron.framework/Versions/A/Resources/en.lproj/locale.pak')).toBe(true) expect(ignored('/App.app/Contents/Frameworks/Electron.framework/Versions/A/Resources/resources.pak')).toBe(true) + expect(ignored('/App.app/Contents/Resources/runtime/primary-runtime/dependencies/pnpm/addon.node')).toBe(true) for (const path of [ '/App.app/Contents/Resources/runtime/node/node', - '/App.app/Contents/Resources/runtime/pnpm/addon.node', '/App.app/Contents/Frameworks/Electron.framework/Versions/A/library.dylib', '/App.app/Contents/Frameworks/Electron.framework', '/App.app', @@ -255,3 +279,19 @@ describe('desktop macOS release signature', () => { expect(verified).toEqual(['/tmp/release.dmg']) }) }) + +it('signs debugging artifacts without Apple credentials and isolates them from releases', async () => { + const { createElectronBuilderConfig } = await import('../scripts/electron-builder-config.mjs') + const config = createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, + APPLE_API_KEY: undefined, APPLE_API_KEY_ID: undefined, APPLE_API_ISSUER: undefined, + DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: '1', + }, 'darwin', 'arm64') + expect(config.mac.forceCodeSigning).toBe(true) + expect(config.mac.notarize).toBe(false) + expect(config.dmg.sign).toBe(true) + expect(config.mac).toMatchObject({ target: ['dmg', 'zip'] }) + expect(config.artifactName).toContain('-unnotarized.') + expect(portablePath(config.directories.output)).toContain('/unnotarized-artifacts') + expect(() => createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: 'yes' }, 'darwin', 'arm64')).toThrow(/must be 0 or 1/u) + expect(() => createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: '1', DSH_DESKTOP_UNSIGNED: '1' }, 'darwin', 'arm64')).toThrow(/requires a signed macOS build/u) +}) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index fff39c38ef..d9a8f3103b 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -443,6 +443,21 @@ afterEach(async () => { }) describe('desktop main startup', () => { + it.each([true, false])('selects the package manager before Host startup (packaged=%s)', async (packaged) => { + harness.app.isPackaged = packaged + await readyForUpdate() + const host = harness.hosts[0]! + expect(host.node).toBe(process.execPath) + expect(host.primaryRuntime).toBe(packaged + ? join('desktop-test-resources', 'runtime', 'primary-runtime') : 'test-primary-runtime') + expect(host.packageManager).toMatchObject({ + pnpm: packaged + ? join('desktop-test-resources', 'runtime', 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs') + : 'test-pnpm', + nodeBin: packaged ? join('desktop-test-resources', 'runtime', 'bin') : join('desktop-test-app', 'scripts', 'node-bin'), + }) + }) + it('starts the Host with authentication popups disabled when local settings are invalid', async () => { configureTestAuthPopup(false) const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index cde1b0079d..1d8b067390 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -197,3 +197,27 @@ it('does not write a release completion record when Apple proxy cleanup fails', .rejects.toThrow('proxy restoration failed') expect(writeFileSync).not.toHaveBeenCalled() }) + +it.each([false, true])('keeps signing and runtime verification when notarization is skipped (directory=%s)', async (directory) => { + const { run, stages } = supervisor() + 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.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', + }) + expect(withMacOSNotarizationProxy).not.toHaveBeenCalled() + expect(packageMacOSArtifacts).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) + await expect(packageTarget(parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization'], 'darwin', 'arm64'), environment, run)) + .rejects.toThrow('stage refused') + expect(stages.at(-1)).toBe(failure) + expect(writeFileSync).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/package-target.spec.ts b/apps/desktop/tests/package-target.spec.ts index 5019841e84..ba647d76ac 100644 --- a/apps/desktop/tests/package-target.spec.ts +++ b/apps/desktop/tests/package-target.spec.ts @@ -148,3 +148,12 @@ describe('desktop package target', () => { }) }) }) + +it('allows skipping notarization only for signed macOS packages', () => { + expect(parseDesktopPackageInvocation(['mac-arm64'], 'darwin', 'arm64').skipNotarization).toBe(false) + expect(parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization'], 'darwin', 'arm64').skipNotarization).toBe(true) + for (const flag of ['--unsigned', '--prepare-only']) { + expect(() => parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization', flag], 'darwin', 'arm64')).toThrow(/requires a signed macOS build/u) + } + expect(() => parseDesktopPackageInvocation(['win-x64', '--skip-notarization'], 'win32', 'x64')).toThrow(/requires a signed macOS build/u) +}) diff --git a/apps/desktop/tests/prepare-runtime-patches.spec.ts b/apps/desktop/tests/prepare-runtime-patches.spec.ts index 951724e114..84c71a1884 100644 --- a/apps/desktop/tests/prepare-runtime-patches.spec.ts +++ b/apps/desktop/tests/prepare-runtime-patches.spec.ts @@ -42,11 +42,11 @@ it('classifies the current workspace and shares only runtime patch bytes', () => const repository = resolve(import.meta.dirname, '../../..') const before = readFileSync(join(repository, 'pnpm-lock.yaml')) const plan = prepareRuntimePatches(project, repository) - expect(plan.patches.map(entry => entry.spec)).toEqual(['@earendil-works/pi-ai@1.0.2', 'node-pty@1.2.0-beta.15']) - expect(plan.workspaceOnly).toHaveLength(5) + expect(plan.patches.map(entry => entry.spec)).toEqual(['@earendil-works/pi-ai@1.1.0', 'node-pty@1.2.0-beta.15']) + expect(plan.workspaceOnly).toHaveLength(6) const settings = yaml.load(readFileSync(join(project, 'pnpm-workspace.yaml'), 'utf8')) as { overrides: Record } expect(settings.overrides['@earendil-works/pi-ai']).toBeUndefined() - expect(settings.overrides['@earendil-works/pi-ai@^1.0.2']).toBe('1.0.2') + expect(settings.overrides['@earendil-works/pi-ai@^1.1.0']).toBe('1.1.0') expect(readFileSync(join(repository, 'pnpm-lock.yaml'))).toEqual(before) }) diff --git a/apps/desktop/tests/prepare-windows-ci.spec.ts b/apps/desktop/tests/prepare-windows-ci.spec.ts index 4633a77a12..67d62bbe19 100644 --- a/apps/desktop/tests/prepare-windows-ci.spec.ts +++ b/apps/desktop/tests/prepare-windows-ci.spec.ts @@ -1,56 +1,22 @@ -import { parseEnv } from 'node:util' import { describe, expect, it } from 'vitest' -import { windowsCiSettings } from '../scripts/prepare-windows-ci.mjs' -import { validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' +import { windowsCiVersion } from '../scripts/prepare-windows-ci.mjs' -const inputs = { - PACKAGE_BUILD_VERSION: 'auto', - PACKAGE_DEPLOYMENT: 'test', - PACKAGE_POLICY_ORIGIN: 'https://policy.example.com', - PACKAGE_LOGIN_ORIGINS: 'https://login.example.com, https://sso.example.com', - GITHUB_RUN_NUMBER: '42', - GITHUB_RUN_ATTEMPT: '2', -} -const productVersion = '0.2.1-alpha.1' -const now = new Date('2026-10-08T23:59:59Z') +const inputs = { PACKAGE_BUILD_VERSION: 'auto', GITHUB_RUN_NUMBER: '42', GITHUB_RUN_ATTEMPT: '2' } +const now = new Date('2026-10-09T01:02:03Z') -describe('manual Windows package inputs', () => { - it('numbers fresh runners and reruns without storage credentials', () => { - const result = windowsCiSettings(inputs, productVersion, now) - expect(result.version).toBe('0.2.1-alpha.1.20261008.42.2') - const settings = parseEnv(result.settings) - expect(() => { validateDesktopPackageEnvironment(settings, { platform: 'win32', arch: 'x64' }, { unsigned: true }) }).not.toThrow() - expect(Object.keys(settings).sort()).toEqual([ - 'DSH_DESKTOP_APP_ID', 'DSH_DESKTOP_AUTO_UPDATE_ENV', - 'DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN', 'DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', - ].sort()) +describe('manual Windows CI version', () => { + it('uses the selected product prefix and UTC run identity', () => { + expect(windowsCiVersion(inputs, '0.2.1-alpha.1', now)).toBe('0.2.1-alpha.1.20261009.42.2') + expect(windowsCiVersion(inputs, '0.2.1', now)).toBe('0.2.1-test.20261009.42.2') }) - - it('accepts an explicit version without a GitHub run identity', () => { - const result = windowsCiSettings({ ...inputs, PACKAGE_BUILD_VERSION: '0.2.1-alpha.1.20261008.7', - GITHUB_RUN_NUMBER: undefined, GITHUB_RUN_ATTEMPT: undefined }, productVersion, now) - expect(result.version).toBe('0.2.1-alpha.1.20261008.7') + it.each(['0.2.1-alpha.1', '0.2.1-alpha.1.20261009.7'])('accepts explicit version %s without run identity', (version) => { + expect(windowsCiVersion({ PACKAGE_BUILD_VERSION: version }, '0.2.1-alpha.1', now)).toBe(version) }) - - it('uses the stable product test prefix and anonymous production policy', () => { - const result = windowsCiSettings({ ...inputs, PACKAGE_DEPLOYMENT: 'production', PACKAGE_LOGIN_ORIGINS: '' }, '0.2.1', now) - expect(result.version).toBe('0.2.1-test.20261008.42.2') - expect(() => { validateDesktopPackageEnvironment(parseEnv(result.settings), { platform: 'win32', arch: 'x64' }, { unsigned: true }) }).not.toThrow() + it.each(['', 'bad', '0.3.0'])('reports checkout-specific guidance for %s', (version) => { + expect(() => windowsCiVersion({ PACKAGE_BUILD_VERSION: version }, '0.2.1-alpha.1', now)) + .toThrow(/auto, 0\.2\.1-alpha\.1, or e\.g\. 0\.2\.1-alpha\.1\.20261009\.1/u) }) - - it.each([ - { PACKAGE_BUILD_VERSION: '' }, - { PACKAGE_BUILD_VERSION: '0.3.0' }, - { PACKAGE_DEPLOYMENT: 'staging' }, - { PACKAGE_POLICY_ORIGIN: '' }, - { PACKAGE_POLICY_ORIGIN: 'http://policy.example.com' }, - { PACKAGE_POLICY_ORIGIN: 'https://policy.example.com/path' }, - { PACKAGE_LOGIN_ORIGINS: '' }, - { PACKAGE_LOGIN_ORIGINS: 'https://user:password@login.example.com' }, - { PACKAGE_DEPLOYMENT: 'production' }, - { GITHUB_RUN_NUMBER: '0' }, - { GITHUB_RUN_ATTEMPT: '1\nINJECTED=value' }, - ])('rejects invalid manual settings %j', (override) => { - expect(() => windowsCiSettings({ ...inputs, ...override }, productVersion, now)).toThrow() + it.each([{ GITHUB_RUN_NUMBER: '0' }, { GITHUB_RUN_ATTEMPT: '1\nINJECTED=value' }])('rejects invalid run identity %j', (override) => { + expect(() => windowsCiVersion({ ...inputs, ...override }, '0.2.1', now)).toThrow(/positive integer/u) }) }) diff --git a/apps/desktop/tests/ptc-runtime.spec.ts b/apps/desktop/tests/ptc-runtime.spec.ts index e2f8fca009..0766da0e67 100644 --- a/apps/desktop/tests/ptc-runtime.spec.ts +++ b/apps/desktop/tests/ptc-runtime.spec.ts @@ -30,7 +30,7 @@ it.skipIf(process.platform !== 'darwin' || !electronInstalled).each(['workspace- await mkdir(cwd) vi.stubEnv('ELECTRON_RUN_AS_NODE', '1') vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit') - const runtime = await mountRuntime(ctx, { launch: { kind: 'node-script', executable: electron } }, { mode, workspaceRoot: cwd }) + const { runtime } = await mountRuntime(ctx, { launch: { kind: 'node-script', executable: electron } }, { mode, workspaceRoot: cwd }) const result = await runtime.run(runtime.resolve({ // Bound startup failure before the outer test deadline, leaving time for managed cleanup. timeoutMs: 30_000, diff --git a/apps/desktop/tests/runtime-file-policy.spec.ts b/apps/desktop/tests/runtime-file-policy.spec.ts index 76f5c8d95e..ed478b42f6 100644 --- a/apps/desktop/tests/runtime-file-policy.spec.ts +++ b/apps/desktop/tests/runtime-file-policy.spec.ts @@ -27,6 +27,8 @@ it('omits development artifacts while preserving executable modules, assets and 'node-pty/prebuilds/win32-x64/conpty.pdb', '@koromix/koffi-win32-x64/win32_x64/koffi.lib', '@mixmark-io/domino/test/entities.html', + '@mixmark-io/domino/.yarn/plugins/@yarnpkg/plugin-version.cjs', + '@mixmark-io/domino/.yarn/versions/release.yml', '@deepseek-ai/libreoffice-kit-wasm/assets/soffice.data', '@deepseek-ai/libreoffice-kit-darwin-arm64/bin/libreoffice-kit', '.modules.yaml', '.pnpm-workspace-state-v1.json', '.bin/tool', '.pnpm/cache', @@ -45,6 +47,8 @@ it('omits development artifacts while preserving executable modules, assets and 'node-pty/third_party/conpty/win10-x64/OpenConsole.exe', '@koromix/koffi-win32-x64/win32_x64/koffi.node', '@mixmark-io/domino/lib/HTMLParser.js', '@mixmark-io/domino/lib/EntityParser.js', + '@mixmark-io/domino/LICENSE', '@mixmark-io/domino/package.json', + 'example/.yarn/runtime.cjs', '@mixmark-io/domino/.yarn-data.json', '@img/sharp-win32-x64/lib/libvips-42.dll', '@deepseek-ai/libreoffice-kit-win32-x64/bin/libreoffice-kit.exe', '@deepseek-ai/libreoffice-kit-win32-x64/sources/core-source.json', @@ -73,6 +77,8 @@ it('omits development artifacts while preserving executable modules, assets and it('applies package-specific rules inside scoped and nested dependency containers', () => { expect(desktopRuntimeFileExclusion('outer/node_modules/@mixmark-io/domino/test/data.html', windows, 'win32-x64')).toBeDefined() + expect(desktopRuntimeFileExclusion('outer/node_modules/@mixmark-io/domino/.yarn/plugin.cjs', windows, 'win32-x64')).toBeDefined() + expect(desktopRuntimeFileExclusion('@mixmark-io/domino/.yarn', windows, 'win32-x64')).toBeDefined() expect(desktopRuntimeFileExclusion('outer\\node_modules\\node-pty\\prebuilds\\win32-arm64\\conpty.node', windows, 'win32-x64')).toBeDefined() expect(desktopRuntimeFileExclusion('outer/node_modules/unrelated/test/data.html', windows, 'win32-x64')).toBeUndefined() expect(desktopRuntimeFileExclusion('outer/fs-ext/build/Release/fs_ext.lib', windows, 'win32-x64')).toBeUndefined() @@ -85,6 +91,62 @@ it('retains native prebuilds for the selected macOS architecture', () => { expect(desktopRuntimeFileExclusion('node-pty/prebuilds/win32-x64/conpty.node', mac, 'darwin-arm64')).toBeDefined() }) +it.each([ + ['darwin', 'arm64'], ['darwin', 'x64'], + ['linux', 'arm64'], ['linux', 'x64'], + ['win32', 'arm64'], ['win32', 'x64'], +] as const)('copies ConPTY sources only for Windows targets (%s/%s)', (platform, arch) => { + const root = mkdtempSync(join(tmpdir(), 'desktop-conpty-policy-')) + try { + const source = join(root, 'source') + const output = join(root, 'output') + const prefixes = ['', '@example/outer/node_modules/'] + const conpty = ['arm64', 'x64'].flatMap(cpu => ['conpty.dll', 'OpenConsole.exe'].map( + file => `node-pty/third_party/conpty/1.25.260303002/win10-${cpu}/${file}`, + )) + const native = platform === 'win32' + ? ['conpty.node', 'conpty_console_list.node', 'conpty/conpty.dll', 'conpty/OpenConsole.exe'] + : ['pty.node', 'spawn-helper'] + const retained = [ + ...native.map(file => `node-pty/prebuilds/${platform}-${arch}/${file}`), + ...native.map(file => `node-pty/build/Release/${file}`), + 'node-pty/lib/index.js', 'node-pty/LICENSE', + 'node-pty/third_party/conpty-notes/README.md', 'node-pty/third_party/conpty.dll', + 'other/third_party/conpty/conpty.dll', + ] + for (const prefix of prefixes) { + for (const path of [...conpty, ...retained]) { + mkdirSync(join(source, prefix, path, '..'), { recursive: true }) + writeFileSync(join(source, prefix, path), `payload:${prefix}${path}`) + } + } + cpSync(source, output, { + recursive: true, dereference: true, + filter: (path) => { + const entry = relative(source, path) + return desktopRuntimeFileExclusion( + platform === 'win32' ? entry.replaceAll('/', '\\') : entry, + { platform, arch }, `${platform}-${arch}`, + ) === undefined + }, + }) + for (const prefix of prefixes) { + expect(existsSync(join(output, prefix, 'node-pty/third_party/conpty'))).toBe(platform === 'win32') + for (const path of conpty) { + expect(existsSync(join(output, prefix, path)), `${prefix}${path}`).toBe(platform === 'win32') + } + for (const path of [...retained, ...(platform === 'win32' ? conpty : [])]) { + expect(readFileSync(join(output, prefix, path), 'utf8')).toBe(`payload:${prefix}${path}`) + } + for (const path of conpty) { + expect(readFileSync(join(source, prefix, path), 'utf8')).toBe(`payload:${prefix}${path}`) + } + } + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + it.each([ ['linux', 'x64', 'linux-x64'], ['linux', 'arm64', 'wasm'], diff --git a/apps/desktop/tests/runtime-preparation.spec.ts b/apps/desktop/tests/runtime-preparation.spec.ts new file mode 100644 index 0000000000..d16dd592c2 --- /dev/null +++ b/apps/desktop/tests/runtime-preparation.spec.ts @@ -0,0 +1,120 @@ +/** Desktop resource preparation publishes versions only for complete resources. */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + root: '', + target: 'mac-arm64' as 'mac-arm64' | 'mac-x64' | 'win-x64', + pnpm: '11.7.0' as string | undefined, + failure: undefined as 'electron' | 'primary' | 'cli' | undefined, + preparePrimary: vi.fn(), + commandLink: vi.fn(), +})) +vi.mock('../scripts/desktop-build-paths.mjs', async importOriginal => ({ + ...await importOriginal(), + resolveDesktopBuildTarget: () => state.target, + resolveDesktopTargetBuildPaths: () => ({ + runtime: join(state.root, 'runtime'), electron: join(state.root, 'electron'), downloads: join(state.root, 'downloads'), + }), +})) +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('node:child_process', async importOriginal => ({ + ...await importOriginal(), + execFileSync: (file: string) => file === '/usr/libexec/PlistBuddy' ? '12.0\n' : '24.18.1\n', +})) +vi.mock('../scripts/prepare-command-link.ts', () => ({ prepareCommandLink: state.commandLink })) +// The compiled command manager is a build input; all source resource copies use real files. +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, cpSync: (...args: Parameters) => { + if (String(args[0]).endsWith(join('lib', 'command-manager-entry.js'))) { + if (state.failure === 'cli') throw new Error('command manager copy failed') + actual.writeFileSync(args[1], 'command manager fixture') + } else actual.cpSync(...args) + } } +}) +vi.mock('../scripts/prepare-primary-runtime.ts', () => ({ + preparePrimaryRuntime: state.preparePrimary, +})) + +const originalArgv = process.argv +beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + state.root = mkdtempSync(join(tmpdir(), 'desktop-runtime-preparation-')) + state.target = 'mac-arm64' + state.pnpm = '11.7.0' + state.failure = undefined + process.argv = [process.execPath, 'prepare-runtime.ts'] + vi.stubEnv('DSH_DESKTOP_PACKAGING_RUN_DIR', undefined) + state.preparePrimary.mockImplementation(async () => { + if (state.failure === 'primary') throw new Error('primary preparation failed') + const root = join(state.root, 'runtime', 'primary-runtime') + const pnpm = join(root, 'dependencies', 'pnpm', 'bin') + mkdirSync(pnpm, { recursive: true }) + writeFileSync(join(pnpm, 'pnpm.mjs'), '') + writeFileSync(join(root, 'runtime.json'), JSON.stringify({ + desktopVersion: '1.0.0', platform: state.target === 'win-x64' ? 'win32' : 'darwin', + arch: state.target === 'mac-arm64' ? 'arm64' : 'x64', + python: '3.12.14', node: '24.17.0', pnpm: state.pnpm, pythonPackages: { numpy: '2.3.5' }, + })) + }) +}) + +afterEach(async () => { + process.argv = originalArgv + vi.unstubAllEnvs() + await rm(state.root, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }) +}) + +it.each(['mac-arm64', 'mac-x64', 'win-x64'] as const)('prepares %s with one pnpm distribution and its CLI resources', async (target) => { + state.target = target + const windows = target === 'win-x64' + if (windows) process.argv.push('--defer-primary-runtime-smoke') + const runtime = join(state.root, 'runtime') + mkdirSync(join(runtime, 'pnpm'), { recursive: true }) + await import('../scripts/prepare-runtime.ts') + expect(existsSync(join(runtime, 'pnpm'))).toBe(false) + expect(existsSync(join(runtime, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'))).toBe(true) + expect(existsSync(join(runtime, 'bin', 'node'))).toBe(true) + expect(existsSync(join(runtime, 'cli', 'bin', windows ? 'dsh.cmd' : 'dsh'))).toBe(true) + expect(readFileSync(join(runtime, 'cli', 'command-manager.js'), 'utf8')).toBe('command manager fixture') + expect(existsSync(join(runtime, 'cli', 'command-path.ps1'))).toBe(true) + expect(JSON.parse(readFileSync(join(runtime, 'versions.json'), 'utf8'))).toEqual({ + schemaVersion: 1, node: '24.18.1', pnpm: '11.7.0', + }) + expect(state.preparePrimary).toHaveBeenCalledWith({ deferSmoke: windows }) + if (windows) expect(state.commandLink).not.toHaveBeenCalled() + else expect(state.commandLink).toHaveBeenCalledWith(join(runtime, 'cli'), target === 'mac-arm64' ? 'arm64' : 'x64', '12.0') +}) + +it.each([ + ['electron', 'Electron extraction failed'], + ['primary', 'primary preparation failed'], + ['cli', 'command manager copy failed'], +] as const)('does not publish versions when %s preparation fails', async (failure, error) => { + state.failure = failure + const runtime = join(state.root, 'runtime') + mkdirSync(runtime) + writeFileSync(join(runtime, 'versions.json'), 'stale versions') + await expect(import('../scripts/prepare-runtime.ts')).rejects.toThrow(error) + expect(existsSync(join(runtime, 'versions.json'))).toBe(false) +}) + +it.each([ + ['not-a-version', 'primary runtime: invalid metadata'], + [undefined, 'primary-runtime manifest has no pnpm version'], +])('rejects pnpm metadata %s even when Windows smoke is deferred', async (pnpm, error) => { + state.target = 'win-x64' + process.argv.push('--defer-primary-runtime-smoke') + state.pnpm = pnpm + await expect(import('../scripts/prepare-runtime.ts')).rejects.toThrow(error) + expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false) +}) diff --git a/apps/desktop/tests/shell-file-selection.spec.ts b/apps/desktop/tests/shell-file-selection.spec.ts new file mode 100644 index 0000000000..115d75392a --- /dev/null +++ b/apps/desktop/tests/shell-file-selection.spec.ts @@ -0,0 +1,110 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, relative } from 'node:path' +import { Packager, type Configuration, type PlatformSpecificBuildOptions } from 'app-builder-lib' +import { readAsar } from 'app-builder-lib/out/asar/asar.js' +import type { FileMatcher } from 'app-builder-lib/out/fileMatcher.js' +import { MacPackager } from 'app-builder-lib/out/macPackager.js' +import { PM } from 'app-builder-lib/out/node-module-collector/index.js' +import type { PlatformPackager } from 'app-builder-lib/out/platformPackager.js' +import type { ResolvedFileSet } from 'app-builder-lib/out/util/appFileCopier.js' +import { WinPackager } from 'app-builder-lib/out/winPackager.js' +import { expect, it, onTestFinished, vi } from 'vitest' +import { createElectronBuilderConfig } from '../scripts/electron-builder-config.mjs' + +const require = createRequire(import.meta.url) +// The locked builder exports these packaging operations without declarations. +const { getNodeModuleFileMatcher } = require('app-builder-lib/out/fileMatcher.js') as { + getNodeModuleFileMatcher: (app: string, destination: string, expand: (value: string) => string, + options: PlatformSpecificBuildOptions, packager: Packager) => FileMatcher +} +const { computeNodeModuleFileSets } = require('app-builder-lib/out/util/appFileCopier.js') as { + computeNodeModuleFileSets: (packager: PlatformPackager, matcher: FileMatcher) => Promise +} +const { AsarPackager } = require('app-builder-lib/out/asar/asarUtil.js') as { + AsarPackager: new (packager: object, configuration: object) => { pack(files: ResolvedFileSet[]): Promise } +} + +it.each([ + ['darwin', 'arm64'], ['darwin', 'x64'], ['win32', 'x64'], +] as const)('omits shell build files from %s/%s ASAR while retaining runtime assets', async (platform, arch) => { + const root = await mkdtemp(join(tmpdir(), 'desktop-shell-files-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) + const appDir = join(root, 'app') + const resources = join(root, 'resources') + const destination = join(resources, 'app') + await mkdir(resources, { recursive: true }) + const manifest = { name: 'fixture', version: '1.0.0', dependencies: { shell: '1.0.0', '@fixture/shared': '2.0.0' } } + const packages = [ + ['node_modules/shell', { name: 'shell', version: '1.0.0', dependencies: { '@fixture/shared': '1.0.0' } }], + ['node_modules/@fixture/shared', { name: '@fixture/shared', version: '2.0.0' }], + ['node_modules/shell/node_modules/@fixture/shared', { name: '@fixture/shared', version: '1.0.0' }], + ] as const + const omitted = [ + 'index.js.map', 'lib/index.cjs.map', 'lib/index.mjs.map', 'lib/index.ts.map', + 'lib/index.cts.map', 'lib/index.mts.map', 'lib/style.css.map', 'tsconfig.tsbuildinfo', 'lib/types/tsconfig.tsbuildinfo', + ] + const retained = [ + 'index.js', 'lib/index.cjs', 'lib/index.mjs', 'lib/index.ts', 'lib/index.cts', 'lib/index.mts', + 'lib/style.css', 'lib/types/runtime.js', 'data.map', 'lib/data.map', 'lib/addon.node', 'lib/engine.wasm', 'LICENSE', 'NOTICE', + ] + const originals = new Map() + for (const [directory, metadata] of packages) { + for (const name of [...retained, ...omitted, 'package.json']) { + const file = join(appDir, directory, name) + const contents = Buffer.from(name === 'package.json' ? JSON.stringify(metadata) : `${directory}/${name}\n`) + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, contents) + originals.set(file, contents) + } + } + await writeFile(join(appDir, 'package.json'), JSON.stringify(manifest)) + const desktopConfig = createElectronBuilderConfig({ + DSH_DESKTOP_APP_ID: 'com.example.shell-files', + DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', + DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }), + DSH_DESKTOP_UNSIGNED: platform === 'win32' ? '1' : '0', + DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', + DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234', + APPLE_KEYCHAIN_PROFILE: 'shell-files-test', + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', + }, platform, arch) + const config: Configuration = { + files: desktopConfig.files.map(file => typeof file === 'string' ? file : { ...file, filter: [...file.filter] }), + } + const info = new Packager({ projectDir: appDir }) + onTestFinished(() => info.tempDirManager.cleanup()) + Object.defineProperties(info, { + appDir: { value: appDir }, config: { value: config }, + metadata: { value: manifest }, originalMetadata: { value: manifest }, + appInfo: { value: { type: 'commonjs' } }, + framework: { value: { name: 'electron' } }, + }) + // Traverse the installed fixture without invoking a host package manager. + vi.spyOn(info, 'getPackageManager').mockResolvedValue(PM.TRAVERSAL) + vi.spyOn(info, 'getWorkspaceRoot').mockResolvedValue(appDir) + const packager = platform === 'darwin' ? new MacPackager(info) : new WinPackager(info) + const matcher = getNodeModuleFileMatcher(appDir, destination, value => value, packager.platformSpecificBuildOptions, info) + const fileSets = await computeNodeModuleFileSets(packager, matcher) + expect(fileSets.map(set => relative(destination, set.destination).replaceAll('\\', '/')).sort()) + .toEqual(packages.map(([directory]) => directory).sort()) + const consoleLog = console.log + try { + await new AsarPackager(packager, { + defaultDestination: destination, resourcePath: resources, options: { smartUnpack: false }, + }).pack(fileSets) + } finally { + // Builder temporarily intercepts console.log while writing ASAR. + console.log = consoleLog + } + const archive = await readAsar(join(resources, 'app.asar')) + for (const [directory] of packages) { + for (const name of [...retained, 'package.json']) { + expect(await archive.readFile(join(directory, name))).toEqual(originals.get(join(appDir, directory, name))) + } + for (const name of omitted) expect(archive.getNode(join(directory, name))).toBeUndefined() + } + for (const [file, contents] of originals) expect(await readFile(file)).toEqual(contents) +}) diff --git a/apps/desktop/tests/update-dialog.spec.ts b/apps/desktop/tests/update-dialog.spec.ts index 5e9f26e716..16b5888e53 100644 --- a/apps/desktop/tests/update-dialog.spec.ts +++ b/apps/desktop/tests/update-dialog.spec.ts @@ -19,6 +19,7 @@ const fixture = await vi.hoisted(async () => { }) readonly focus = vi.fn() readonly show = vi.fn() + readonly hide = vi.fn() readonly setBounds = vi.fn() readonly loadURL = vi.fn(async () => {}) constructor(readonly options: unknown) { super(); windows.push(this) } @@ -79,6 +80,23 @@ it('accepts only a displayed choice from its own main frame and retains cancella expect((await next).response).toBe(0) }) +it('releases visible Windows modality before destroying a dismissed dialog', async () => { + vi.useFakeTimers() + const f = setup() + const pending = f.show() + const window = fixture.windows.at(-1)! + const destroy = vi.spyOn(window, 'destroy').mockImplementation(() => { + expect(window.hide).toHaveBeenCalledTimes(process.platform === 'win32' ? 1 : 0) + window.destroyed = true + window.emit('closed') + }) + f.invoke(UPDATE_DIALOG_IPC.respond, 0) + await pending + expect(destroy).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(150) + expect(destroy).toHaveBeenCalledOnce() +}) + it('follows the parent geometry and removes listeners when closed or replaced', async () => { const f = setup() const first = f.show() diff --git a/apps/desktop/tests/windows-package-preset.spec.ts b/apps/desktop/tests/windows-package-preset.spec.ts new file mode 100644 index 0000000000..d8d6ad6ce7 --- /dev/null +++ b/apps/desktop/tests/windows-package-preset.spec.ts @@ -0,0 +1,92 @@ +import { spawnSync } from 'node:child_process' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' + +async function withPreset(run: (root: string) => Promise) { + const root = await mkdtemp(join(tmpdir(), 'dsh-windows-preset-')) + try { + await mkdir(join(root, 'presets')) + await mkdir(join(root, 'secrets')) + await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDSH_DESKTOP_APP_ID=com.example.desktop\n') + await run(root) + } finally { + await rm(root, { recursive: true, force: true }) + } +} + +it('loads unsigned public settings without requiring the signing secret file or personal dotenv', async () => { + await withPreset(async (root) => { + const environment = loadDesktopPackageEnvironment('win32', { + DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'ambient-secret', + }, join(root, 'missing-checkout'), { unsigned: true }) + expect(environment.DSH_DESKTOP_APP_ID).toBe('com.example.desktop') + expect(environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBeUndefined() + const prepared = loadDesktopPackageEnvironment('win32', { + DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test', + }, root, { prepareOnly: true }) + expect(prepared.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBeUndefined() + expect(prepared.DSH_PACKAGING_PRESET).toBe('test') + expect(() => loadDesktopPackageEnvironment('win32', { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' })) + .toThrow(/cannot read/u) + }) +}) + +it('resolves signing file paths relative to the secret file and rejects public overrides there', async () => { + await withPreset(async (root) => { + const env = { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' } + const file = join(root, 'secrets/windows-signing.env') + await writeFile(file, 'DSH_DESKTOP_WINDOWS_CER_FILE=../certificates/ev.cer\nDSH_DESKTOP_WINDOWS_TOKEN_PIN="private-value"\n') + const result = loadDesktopPackageEnvironment('win32', env) + expect(result.DSH_DESKTOP_WINDOWS_CER_FILE).toBe(join(root, 'certificates/ev.cer')) + expect(result.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBe('private-value') + await writeFile(file, 'DSH_DESKTOP_AUTO_UPDATE_ENV=production\n') + expect(() => loadDesktopPackageEnvironment('win32', env)).toThrow(/unsupported setting/u) + }) +}) + +it('preserves the selected preset when Windows environment enumeration uses lowercase keys', async () => { + await withPreset(async (root) => { + const values: NodeJS.ProcessEnv = { dsh_packaging_config_dir: root, dsh_packaging_preset: 'test' } + const environment = new Proxy(values, { + get: (target, name) => typeof name === 'string' ? target[name.toLowerCase()] : undefined, + }) + const selected = loadDesktopPackageEnvironment('win32', environment, root, { unsigned: true }) + expect(selected.DSH_PACKAGING_PRESET).toBe('test') + }) +}) + +it('rejects missing presets, traversal, deployment mismatches and secrets in public configuration', async () => { + await withPreset(async (root) => { + const env = { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' } + const load = (override = {}) => loadDesktopPackageEnvironment('win32', { ...env, ...override }, root, { unsigned: true }) + expect(() => load({ DSH_PACKAGING_CONFIG_DIR: 'relative' })).toThrow(/absolute/u) + expect(() => load({ DSH_PACKAGING_CONFIG_DIR: undefined })).toThrow(/absolute/u) + expect(() => load({ DSH_PACKAGING_PRESET: '../secrets' })).toThrow(/preset must/u) + expect(() => load({ DSH_PACKAGING_PRESET: 'production' })).toThrow(/cannot read/u) + await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_AUTO_UPDATE_ENV=production\n') + expect(() => load()).toThrow(/must match/u) + await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_WINDOWS_TOKEN_PIN=private-value\n') + expect(() => load()).toThrow(/unsupported setting DSH_DESKTOP_WINDOWS_TOKEN_PIN/u) + expect(() => load()).not.toThrow(/private-value/u) + }) +}) + +it('cache maintenance reaches cache path validation without a signing file', async () => { + await withPreset(async (root) => { + await writeFile(join(root, 'presets/test.env'), + 'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=relative-cache\n') + const result = spawnSync(process.execPath, + [join(import.meta.dirname, '../scripts/manage-windows-signature-cache.mjs'), '--usage'], { + env: { ...process.env, DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' }, + encoding: 'utf8', timeout: 10_000, windowsHide: true, + }) + expect(result.error).toBeUndefined() + expect(result.signal).toBeNull() + expect(result.status).toBe(1) + expect(result.stderr).toContain('absolute local') + expect(result.stderr).not.toContain('cannot read') + }) +}) diff --git a/apps/desktop/windows-signing/README.i18n.yaml b/apps/desktop/windows-signing/README.i18n.yaml new file mode 100644 index 0000000000..c063a69c88 --- /dev/null +++ b/apps/desktop/windows-signing/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 apps/desktop/windows-signing/README.md +/: + en: 5b2af461afadb867 + zh: c7558c795a752944 +/windows-signing-and-runner-operations: + en: 65f1ac70a69830fd + zh: cd5825b41e9e30f4 +/windows-signing-and-runner-operations/summary: + en: 3eec637b807a2d4c + zh: e042992b9ea83345 +/windows-signing-and-runner-operations/table-of-contents: + en: 3a812cc5ad554fd3 + zh: 0401b5764c155c0b +/windows-signing-and-runner-operations/trust-and-isolation-limits: + en: 0c261f3948178d55 + zh: 1086cd882468ad8f +/windows-signing-and-runner-operations/machine-setup-and-runner-startup: + en: ba3de07cc9553056 + zh: 887e652c40d57091 +/windows-signing-and-runner-operations/signing-configuration: + en: 4b5548818375eb9a + zh: 8c975adbb1dca9c8 +/windows-signing-and-runner-operations/signing-and-timestamp-completion: + en: e42565e00283e3f6 + zh: 584a33f07b50fd5b +/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: 24032af67e83a003 + zh: 76894f57a9dfed52 +/windows-signing-and-runner-operations/verification-limits: + en: 94a471410c84a1c7 + zh: 115228e4ca36581d +/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 new file mode 100644 index 0000000000..eb3fd4a76d --- /dev/null +++ b/apps/desktop/windows-signing/README.md @@ -0,0 +1,93 @@ +--- +description: "Configure and operate the Windows packaging runner, signing credentials, EV token and signature cache. Read the accepted isolation risks before running branch code or accessing the token. Hardware failures require operator inspection; packaging success does not establish installation or production readiness." +--- + +# Windows signing and runner operations + +English | [中文](README.zh.md) + +## Summary + +Configure and operate the Windows packaging runner, signing credentials, EV token and signature cache. Read the accepted isolation risks before running branch code or accessing the token. Hardware failures require operator inspection; packaging success does not establish installation or production readiness. + +## Table of Contents + +- [Trust and isolation limits](#trust-and-isolation) +- [Machine setup and runner startup](#machine-setup) +- [Signing configuration](#signing-configuration) +- [Signing and timestamp completion](#windows-ev-signing) +- [Failure records and operator recovery](#failure-and-recovery) +- [Signature cache and concurrent stages](#signature-cache) +- [Verification limits](#verification) + + + +## Trust and isolation limits + +The shared packaging runner assumes trusted internal-team code and operators. Under this assumption, credential and device isolation are deferred and the residual risk is accepted (tracked in #5973); signed/master-only access is not enforced. Unsigned mode skips signing-file loading but cannot prevent branch code running as the same Windows account from reading those files or accessing the token. SafeNet Single Logon may reuse authentication. Direct device calls can bypass the signing scripts and exhaust PIN retries; the persistent failure interlock only protects calls through those scripts. Cross-account token isolation and remaining retry counts are unverified. Reassess runner/account/device isolation before expanding access, executing untrusted contributions, changing runner sharing, or after suspicious activity. + + + +## Machine setup and runner startup + +Use a runner-owned checkout and keep configuration, certificates and caches outside it. Registration and tool installation remain operator tasks. Packaging inputs and artifact retrieval are described in the [packaging guide](../packaging/README.md#windows-actions). + +Run `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Root ` to prepare private `packaging/{presets,secrets,certificates}` and `cache/windows-signatures` directories and copy templates without overwriting existing files. Add `-CheckOnly` to inspect without writes. Fill the [preset templates](../config/windows-packaging/test.env.example) and [signing template](../config/windows-packaging/windows-signing.env.example) on the machine. Run `start-windows-packaging-runner.ps1 -RunnerRoot -ConfigRoot /packaging -CheckOnly` from the same scripts directory to inspect PowerShell 7.6.6 under the runner's `tools` directory, Node, Python, Git, tar and Visual C++ tools; packaging `--check` performs the remaining toolchain validation. Omit `-CheckOnly` to start the existing runner with an explicit config root and scrubbed inherited environment. Registration and tool installation are separate; these scripts never create tokens, alter system security settings or clear signing failure records. Use the signing cache maintenance command below to migrate existing entries before changing the preset's cache path. + + + +## Signing configuration + +The [machine preset configuration](../packaging/README.md#configuration-and-local-builds) selects public settings and the signing file. Without a preset, use the target dotenv file. + +Fill in `.env.windows` with `DSH_DESKTOP_WINDOWS_CER_FILE` (public EV leaf certificate), `DSH_DESKTOP_WINDOWS_SIGNTOOL` (SafeNet-compatible SignTool), `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` (matching private-key container), and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` (Token Password). The private key stays on the USB token; keep the certificate and local credential file out of Git. + +The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives signing preflight, the dedicated runtime signing stages and electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` or machine-owned `secrets/windows-signing.env` stores the PIN in plaintext and needs restricted file access. CI reads the machine-owned file outside the checkout and retains it across jobs. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure. + +Signed Windows configuration derives the updater's `publisherName` from the same public certificate's `CN`, `O`, and `C` attributes. Each must be present, nonempty, and single-valued. These identity attributes allow certificate renewal without pinning a leaf thumbprint. The installed application's `app-update.yml` carries the expected publisher; the downloaded feed does not choose it. Unsigned test builds omit updater configuration. See the [signature qualification record](../tests/README.md) for real-file verification and its limits. + + + +## Signing and timestamp completion + +Follow the [version and build instructions](../packaging/README.md#release-versions) before starting a signed build. Configuration checks do not establish token authentication. + +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. + +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). + + + +## Failure records and operator recovery + +Treat `SignTool Error: No private key is available.` as a signing failure: stop all signing attempts and have the operator inspect the signing configuration and token state before continuing. The device-specific PIN retry limit and remaining attempts are unverified; do not probe them with authentication attempts. Do not retry packaging or signing probes after this error. The signer serializes token operations and rejects all queued tasks after the first failure. + +Windows package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `.desktop-build/packaging-runs/`. Each run retains `run.json`, timestamped `events.jsonl`, redacted `stdout.log` and `stderr.log`, and `result.json`. A signing failure also writes `fatal.json` and notifies the parent through stderr; the supervisor immediately requests termination of its stage process tree and waits for exit. Failed stages cannot start subsequent stages or create a release completion record. Journal failures also stop the run. Termination errors remain failures and require operator inspection; a missing final record means completion was not established. + +Hardware signing requires a supervised run. Before invoking the command interpreter, the signer atomically acquires `%USERPROFILE%/.dsh-desktop-signing/attempt.json` and records the attempt. Only successful signing followed by verification of the configured primary signature releases that file; timestamps are completed afterward without hardware access. Failure, interruption, an existing interlock, or unavailable audit storage prevents further hardware access, including from another signer instance, process, or checkout under the same Windows account. There is no timed recovery or automatic retry. An administrator must inspect the retained evidence and token state before explicitly authorizing interlock recovery; logging into the token or replacing a PIN file does not clear it. The records distinguish signing intent, command-interpreter PID, and completion; they do not measure internal CSP/token authentication attempts. Command arguments, PINs, and credential environments are not recorded. Independent Windows accounts and unrelated signing programs are outside this interlock. + + + +## Signature cache and concurrent stages + +Runtime signing shares complete signed files across the current Windows account's worktrees. `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` in `.env.windows` selects an absolute fixed-drive directory; the default is `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`. Cache directories must belong to the current account and exclude other ordinary accounts from their access permissions; linked paths are rejected. Entries identify the original bytes, public certificate and signing toolchain. Every restoration checks the digest, Windows trust, timestamp and certificate before replacing the unsigned file; invalid entries stop packaging without hardware fallback. Cache age alone does not trigger signing. The cache trusts programs running as the same account and does not defend against administrators. The [runtime signature cache decision](../../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md) owns qualification and design limits. + +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. + +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. + +Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect structurally complete-entry bytes and counts, or `--from ` to import an explicitly selected cache owned by the same account. Migration leaves the source unchanged, skips staging names, rejects corrupt entries and retains valid existing entries even when their timestamp bytes differ. `--clear` explicitly removes complete entries under the stage lock; no automatic capacity eviction runs. `--directory ` selects a maintenance destination without loading release credentials. Incomplete staging entries remain untouched and are counted separately; inspect them only after all builds have stopped. These commands never clear hardware failure evidence. Default storage is outside AppData so MSIX launcher virtualization does not split the account's cache; redirected overrides fail explicitly. + + + +## 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. + +## Dev Note + +None. diff --git a/apps/desktop/windows-signing/README.zh.md b/apps/desktop/windows-signing/README.zh.md new file mode 100644 index 0000000000..70718a8bf7 --- /dev/null +++ b/apps/desktop/windows-signing/README.zh.md @@ -0,0 +1,93 @@ +--- +description: "配置和维护 Windows 打包 runner、签名凭据、EV Token 与签名缓存。运行分支代码或访问 Token 前,先阅读已接受的隔离风险。硬件失败需要操作者检查;打包成功不代表安装或生产发布已通过验收。" +--- + +# Windows 签名与 runner 运维 + +[English](README.md) | 中文 + +## 概述 + +配置和维护 Windows 打包 runner、签名凭据、EV Token 与签名缓存。运行分支代码或访问 Token 前,先阅读已接受的隔离风险。硬件失败需要操作者检查;打包成功不代表安装或生产发布已通过验收。 + +## 目录 + +- [信任前提与隔离缺陷](#trust-and-isolation) +- [机器配置与 runner 启动](#machine-setup) +- [签名配置](#signing-configuration) +- [签名与时间戳处理](#windows-ev-signing) +- [失败记录与人工恢复](#failure-and-recovery) +- [签名缓存与并发阶段](#signature-cache) +- [验证范围](#verification) + + + +## 信任前提与隔离缺陷 + +共享打包 runner 以内部团队代码和操作人员可信为前提。在此前提下,凭据和设备隔离暂缓实施,剩余风险按已知风险接受(由 #5973 跟踪);尚未强制限制仅 signed/master 可访问。未签名模式跳过签名文件加载,但无法阻止同一 Windows 账户下的分支代码读取这些文件或访问 Token。SafeNet Single Logon 可能复用认证状态。直接调用设备可以绕过签名脚本并耗尽 PIN 重试次数;持久失败 interlock 只保护经过这些脚本的调用。跨账户 Token 隔离和剩余重试次数尚未验证。扩大访问范围、执行不可信贡献、改变 runner 共享方式前,或出现可疑活动后,重新评估 runner、账户和设备隔离。 + + + +## 机器配置与 runner 启动 + +使用 runner 专用 checkout,并将配置、证书和缓存放在其外部。注册和工具安装由操作者负责。打包输入与产物获取见[打包指南](../packaging/README.zh.md#windows-actions)。 + +运行 `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Root ` 创建私有 `packaging/{presets,secrets,certificates}` 和 `cache/windows-signatures` 目录并复制模板,不覆盖现有文件。添加 `-CheckOnly` 只检查不写入。在机器上填写 [preset 模板](../config/windows-packaging/test.env.example)和[签名模板](../config/windows-packaging/windows-signing.env.example)。从同一 scripts 目录运行 `start-windows-packaging-runner.ps1 -RunnerRoot -ConfigRoot /packaging -CheckOnly`,检查 runner 的 `tools` 目录下 PowerShell 7.6.6、Node、Python、Git、tar 和 Visual C++ 工具;打包 `--check` 完成其余工具链检查。省略 `-CheckOnly`,以显式配置目录和清理后的继承环境启动现有 runner。注册和工具安装单独进行;这些脚本不创建 token、不改系统安全设置、不清除签名失败记录。修改 preset 缓存路径前,使用下文签名缓存维护命令迁移已有条目。 + + + +## 签名配置 + +通过[机器 preset 配置](../packaging/README.zh.md#configuration-and-local-builds)选择公开设置与签名文件;不选择 preset 时使用目标 dotenv 文件。 + +在 `.env.windows` 中填写 `DSH_DESKTOP_WINDOWS_CER_FILE`(公开 EV 叶证书)、`DSH_DESKTOP_WINDOWS_SIGNTOOL`(SafeNet 兼容的 SignTool)、`DSH_DESKTOP_WINDOWS_KEY_CONTAINER`(匹配的私钥容器)和 `DSH_DESKTOP_WINDOWS_TOKEN_PIN`(Token Password)。私钥仍保留在 USB Token;不要把证书或本地凭据文件提交到 Git。 + +PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向签名预检、独立的第一方运行时签名阶段与 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 或机器管理的 `secrets/windows-signing.env` 明文保存 PIN,应限制文件访问权限。CI 读取 checkout 外的机器签名文件,并在任务结束后保留。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。 + +签名 Windows 配置从同一份公开证书的 `CN`、`O` 和 `C` 属性生成 updater 的 `publisherName`。每个属性都必须存在、非空且只有一个值。这些身份属性允许证书续期,无需固定叶证书指纹。已安装应用的 `app-update.yml` 保存预期发布者,下载的清单不能选择该身份。未签名测试构建省略 updater 配置。真实文件验证及其限制见[签名验收记录](../tests/README.zh.md)。 + + + +## 签名与时间戳处理 + +启动签名构建前,先按[版本与打包说明](../packaging/README.zh.md#release-versions)确认输入。配置检查不代表 Token 认证成功。 + +Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检的整体 60 秒期限包含时间戳尝试;超时、硬件签名报错或验签失败都会停止本轮流程,不再次调用硬件。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。 + +打包前插入并解锁 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)。 + + + +## 失败记录与人工恢复 + +将 `SignTool Error: No private key is available.` 视为签名失败:立即停止所有签名尝试,由操作者检查签名配置和 Token 状态后再继续。设备具体的 PIN 重试上限和剩余次数尚未验证,不得通过认证尝试探测。出现此错误后不要重试打包或签名探针。签名器串行执行 Token 操作,并在首次失败后拒绝所有排队任务。 + +Windows 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `.desktop-build/packaging-runs/` 下的唯一目录。每次运行保留 `run.json`、带时间戳的 `events.jsonl`、脱敏后的 `stdout.log` 和 `stderr.log`,以及 `result.json`。签名失败还会写入 `fatal.json` 并通过 stderr 通知父进程;监督程序立即请求终止当前阶段的进程树并等待退出。失败阶段不能启动后续阶段或生成发布完成记录。日志写入失败也会停止运行。终止错误仍按失败处理,需要操作者检查;缺少最终记录表示尚未确认完成。 + +硬件签名必须属于受监督的打包运行。调用命令解释器前,签名器原子获取 `%USERPROFILE%/.dsh-desktop-signing/attempt.json` 并记录本次尝试。只有签名成功且配置证书的主签名通过验证后才释放该文件;随后完成时间戳,不再访问硬件。失败、中断、已有锁定文件或审计存储不可用都会阻止再次访问硬件,包括同一 Windows 账户下的另一个签名器实例、进程或代码检出目录。没有定时恢复或自动重试。管理员必须检查保留的证据及令牌状态,再明确授权恢复锁定状态;登录令牌或替换 PIN 文件不会清除它。记录区分签名意图、命令解释器 PID 和完成结果,不计量 CSP/令牌内部的认证次数。不记录命令参数、PIN 或凭据环境。其他 Windows 账户及无关签名程序不在此锁定机制的保护范围内。 + + + +## 签名缓存与并发阶段 + +运行时签名在当前 Windows 账户的各 worktree 间共享完整的已签名文件。`.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` 指定固定本地磁盘上的绝对目录;默认值为 `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`。缓存目录必须属于当前账户,访问权限不得向其他普通账户开放;带链接的路径会被拒绝。缓存项标识原始字节、公钥证书和签名工具链。每次恢复都检查摘要、Windows 信任状态、时间戳和证书,再替换未签名文件;缓存项无效会停止打包,不回退到硬件签名。不会仅因缓存较旧而重新签名。缓存信任同账户运行的程序,不防御管理员。[运行时签名缓存决策](../../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md)定义验收要求和设计限制。 + +预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。 + +缓存命中的复制、摘要和逐文件信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复及后置验签完成后,未命中项才进入串行硬件签名。恢复或验签失败会停止派发新任务,并在释放阶段锁前等待在途工作结束;硬件签名、运行时 smoke 检查和最终完整性校验仍须执行。 + +每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。 + +使用 `pnpm --dir apps/desktop run cache:windows-signatures --usage` 查看结构完整的缓存项字节数和数量,或用 `--from ` 导入显式指定、属于同账户的旧缓存。迁移不修改源目录,跳过暂存名称,拒绝损坏项,并保留已有有效项,即使它们的时间戳字节不同。`--clear` 在阶段锁保护下显式删除完整缓存项;不执行自动容量淘汰。`--directory ` 指定维护目标目录,无需加载发布凭据。不完整暂存项保持原状并单独计数;仅在所有构建停止后检查它们。这些命令绝不清除硬件失败证据。默认存储位于 AppData 之外,避免 MSIX 启动器虚拟化将账户缓存拆开;被重定向的覆盖目录会明确失败。 + + + +## 验证范围 + +真实硬件与安装证据由[Desktop 验证记录](../tests/README.zh.md)和[已安装更新流程](../tests/installed-update/README.zh.md)维护。本文档不授权 PIN 重试、自动删除 interlock、设备初始化或改变已接受的隔离策略。 + +## 开发备注 + +无。 diff --git a/apps/web/katex-fonts.ts b/apps/web/katex-fonts.ts new file mode 100644 index 0000000000..702437384b --- /dev/null +++ b/apps/web/katex-fonts.ts @@ -0,0 +1,33 @@ +/** WOFF2-only KaTeX font sources for the Web and Desktop browser bundle. */ + +import { transform } from 'lightningcss' +import type { Plugin } from 'vite' + +/** + * Select WOFF2 before Vite collects KaTeX font assets, preserving every face and layout rule. + * A face without WOFF2 rejects the build. Other packages' stylesheets are unchanged. + * @returns Vite stylesheet transform for the installed KaTeX distribution. + */ +export function katexWoff2Fonts(): Plugin { + return { + name: 'dsh-katex-woff2-fonts', + enforce: 'pre', + transform(code, id) { + if (!/\/node_modules\/katex\/dist\/katex(?:\.min)?\.css(?:\?|$)/u.test(id.replaceAll('\\', '/'))) return + const result = transform({ + filename: id, code: Buffer.from(code), sourceMap: true, + visitor: { Rule: { 'font-face'(rule) { + const descriptors = rule.value.properties.filter(property => property.type === 'source') + if (descriptors.length === 0) throw new Error('KaTeX font face requires a WOFF2 source') + for (const property of descriptors) { + const sources = property.value.filter(source => source.type === 'url' && source.value.format?.type === 'woff2') + if (sources.length === 0) throw new Error('KaTeX font face requires a WOFF2 source') + property.value = sources + } + return rule + } } }, + }) + return { code: result.code.toString(), map: result.map?.toString() } + }, + } +} diff --git a/apps/web/package.json b/apps/web/package.json index f44baf5c4c..1684c9a9d8 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -49,8 +49,6 @@ "@deepseek-ai/dsh-session-projection-cache": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@types/node": "^22.20.1", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "@types/ws": "8.18.1", "@vitejs/plugin-react": "^4.7.0", "http-server": "^14.1.1", diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index a47cad63f6..f97978d346 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -64,7 +64,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { const currentMenu = page.getByRole('menu') await currentMenu.waitFor({ timeout: 10_000 }) expect(await currentMenu.getByRole('menuitem').allTextContents()) - .toEqual(['仅可查看', '工作区内修改', '完全权限', 'Auto reviewEXP']) + .toEqual(['仅可查看', '工作区内修改', '完全权限', '自动审查EXP']) await captureAutoReviewState(page, 'experimental-current-session-picker') const currentSnapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd) await compareOrRefreshGolden(CURRENT_SESSION_PICKER_EXPECTED, currentSnapshot, MODE) @@ -77,10 +77,10 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { expect(currentMenuBox!.width).toBeGreaterThan(currentTriggerBox!.width) expect(Math.abs(currentTriggerBox!.y - currentMenuBox!.y - currentMenuBox!.height - 4)).toBeLessThan(1) - await currentMenu.getByRole('menuitem', { name: 'Auto review EXP' }).click() - const autoDialog = page.getByRole('dialog', { name: '确认启用 Auto review(实验)?' }) + await currentMenu.getByRole('menuitem', { name: '自动审查 EXP' }).click() + const autoDialog = page.getByRole('dialog', { name: '确认启用自动审查(实验)?' }) await autoDialog.waitFor({ timeout: 10_000 }) - const autoEnable = autoDialog.getByRole('button', { name: '启用 Auto review' }) + const autoEnable = autoDialog.getByRole('button', { name: '启用自动审查' }) expect(await autoEnable.isDisabled()).toBe(true) expect(await autoDialog.getByText(/不使用沙箱/).count()).toBe(1) expect(await autoDialog.getByText(/误放行或误拒绝/).count()).toBe(1) @@ -105,7 +105,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { await slashPicker.waitFor({ timeout: 10_000 }) const slashRows = slashPicker.getByRole('option') expect(await slashRows.count()).toBe(4) - expect(await slashPicker.getByRole('option', { name: 'Auto review EXP' }).count()).toBe(1) + expect(await slashPicker.getByRole('option', { name: '自动审查 EXP' }).count()).toBe(1) await captureAutoReviewState(page, 'experimental-slash-picker') const slashSnapshot = await captureStableAria(page, '[aria-label="/permission 选项"]', scaffold.workspaceCwd) await compareOrRefreshGolden(SLASH_PICKER_EXPECTED, slashSnapshot, MODE) @@ -119,15 +119,15 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { expect(slashPickerBox!.x).toBeCloseTo(composerBox!.x) expect(Math.abs(composerBox!.y - slashPickerBox!.y - slashPickerBox!.height - 4)).toBeLessThan(1) - await slashPicker.getByRole('option', { name: 'Auto review EXP' }).click() - const slashDialog = page.getByRole('dialog', { name: '确认启用 Auto review(实验)?' }) + await slashPicker.getByRole('option', { name: '自动审查 EXP' }).click() + const slashDialog = page.getByRole('dialog', { name: '确认启用自动审查(实验)?' }) await slashDialog.waitFor({ timeout: 10_000 }) expect(await slashDialog.count()).toBe(1) expect(await slashPicker.count()).toBe(0) await slashDialog.getByRole('checkbox', { name: '我已了解这些风险,并愿意继续' }).check() - await slashDialog.getByRole('button', { name: '启用 Auto review' }).click() + await slashDialog.getByRole('button', { name: '启用自动审查' }).click() await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 }) - .toBe('访问模式,当前:Auto review EXP') + .toBe('访问模式,当前:自动审查') expect(await slashDialog.count()).toBe(0) expect(await input.textContent()).toBe('') await captureAutoReviewState(page, 'experimental-current-session') @@ -188,12 +188,18 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { expect(menuBox.width).toBeLessThanOrEqual(360) expect(menuBox.x).toBeGreaterThanOrEqual(0) expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(420) - for (const name of ['仅可查看', '工作区内修改', '完全权限', 'Auto review']) { + for (const name of ['仅可查看', '工作区内修改', '完全权限', '自动审查']) { expect(await menu.getByText(name, { exact: true }).evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true) } await page.keyboard.press('Escape') const input = page.locator('[data-composer-input]').first() + await writeComposerDraft(page, input, '/permission auto') + await input.press('Enter') + await expect.poll(() => access.getAttribute('aria-label')).toBe('访问模式,当前:自动审查') + expect(await access.getByText('自动审查', { exact: true }).isVisible()).toBe(false) + expect(await access.getByText('EXP', { exact: true }).count()).toBe(0) + expect(await access.getAttribute('title')).toBe('无沙箱运行;每次原生工具调用和 PTC 内层调用前由同一模型进行实验性审查。') await writeComposerDraft(page, input, '/permission') await input.press('Escape') await input.press('Enter') @@ -206,10 +212,13 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { expect(slashBox.x + slashBox.width).toBeLessThanOrEqual(420) expect(await slash.evaluate(node => getComputedStyle(node).minWidth)).toBe('min(220px, 100%)') expect(await slash.evaluate(node => getComputedStyle(node).maxWidth)).toBe('100%') - for (const name of ['仅可查看', '工作区内修改', '完全权限', 'Auto review']) { + for (const name of ['仅可查看', '工作区内修改', '完全权限', '自动审查']) { expect(await slash.getByText(name, { exact: true }).evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true) } await page.keyboard.press('Escape') + await writeComposerDraft(page, input, '/permission danger-full-access') + await input.press('Enter') + await expect.poll(() => access.getAttribute('aria-label')).toBe('访问模式,当前:完全权限') expect(tripwire.pageErrors).toEqual([]) }) @@ -219,7 +228,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { const access = page.locator('button[aria-label^="访问模式"]').first() await writeComposerDraft(page, input, '/permission auto') await input.press('Enter') - await expect.poll(() => access.getAttribute('aria-label')).toBe('访问模式,当前:Auto review EXP') + await expect.poll(() => access.getAttribute('aria-label')).toBe('访问模式,当前:自动审查') expect(await page.getByRole('dialog').count()).toBe(0) await writeComposerDraft(page, input, '/permission') await input.press('Escape') @@ -237,7 +246,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { await input.press('Enter') await slash.waitFor() expect(await slash.getByRole('option').count()).toBe(3) - expect(await slash.getByText('Auto review', { exact: true }).count()).toBe(0) + expect(await slash.getByText('自动审查', { exact: true }).count()).toBe(0) await page.keyboard.press('Escape') await access.click() await expect.poll(() => page.getByRole('menuitem').allTextContents()) @@ -248,7 +257,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { await scaffold.ctx.loader.await() await access.click() await expect.poll(() => page.getByRole('menuitem').allTextContents()) - .toEqual(['仅可查看', '工作区内修改', '完全权限', 'Auto reviewEXP']) + .toEqual(['仅可查看', '工作区内修改', '完全权限', '自动审查EXP']) expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:完全权限') await captureAutoReviewState(page, 'reinstalled-current-session-picker') await page.keyboard.press('Escape') @@ -302,7 +311,7 @@ describe('web e2e: default permission choices', () => { const slash = page.locator('[aria-label="/permission 选项"]') await slash.waitFor() expect(await slash.getByRole('option').count()).toBe(3) - expect(await slash.getByText('Auto review', { exact: true }).count()).toBe(0) + expect(await slash.getByText('自动审查', { exact: true }).count()).toBe(0) await captureAutoReviewState(page, 'default-slash-picker') }) }) diff --git a/apps/web/tests/account-login.e2e.ts b/apps/web/tests/account-login.e2e.ts new file mode 100644 index 0000000000..e20a18c94b --- /dev/null +++ b/apps/web/tests/account-login.e2e.ts @@ -0,0 +1,136 @@ +// Real-composition acceptance for DeepSeek account sign-in from a plain browser. The +// scaffold boots the shipped Web Loader tree, so the real account provider drives +// auth_init, the loopback OAuth callback, and auth_exchange against a loopback Platform +// double, while a real Chromium follows the tab the Web UI preopens. +import { createServer, type Server } from 'node:http' +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 { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +const OVERLAY_TEMPLATE = fileURLToPath(new URL('./fixtures/account-login/cordis.patch.yml', import.meta.url)) +const MODE = webSnapshotMode() +const TOKEN = 'dsh_web_login_test' +const CODE = 'web-login-code' + +/** Loopback Platform double: authorization handoff, code exchange, and the signed-in profile reads. */ +async function mockPlatform() { + const inits: Record[] = [] + const pages: string[] = [] + let state: string | undefined + let redirectUri: string | undefined + const server: Server = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1') + const reply = (payload: unknown): void => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ code: 0, data: { biz_code: 0, biz_data: payload } })) + } + if (url.pathname === '/dsh/authorize') { + pages.push(url.href) + // The user approves at once: Platform returns the browser to the Host callback. + const callback = new URL(redirectUri ?? '/') + callback.searchParams.set('code', CODE) + callback.searchParams.set('state', state ?? '') + res.writeHead(302, { location: callback.href }).end() + return + } + if (url.pathname === '/dsh/authorized') { + pages.push(url.href) + res.writeHead(200, { 'content-type': 'text/html' }).end('Authorized') + return + } + if (req.method === 'POST' && url.pathname.startsWith('/auth-api/v0/dsh/')) { + let body = '' + req.setEncoding('utf8') + req.on('data', (chunk: string) => { body += chunk }) + req.on('end', () => { + const payload = JSON.parse(body) as Record + if (url.pathname === '/auth-api/v0/dsh/auth_init') { + inits.push(payload) + state = String(payload.state) + redirectUri = String(payload.redirect_uri) + reply({ authorize_url: `${origin}/dsh/authorize?authorize_id=web`, authorize_id: 'web', expires_in: 300 }) + return + } + if (url.pathname === '/auth-api/v0/dsh/auth_exchange' && payload.code === CODE) { + reply({ token: TOKEN, authorized_url: `${origin}/dsh/authorized` }) + return + } + res.writeHead(404).end() + }) + return + } + if (req.headers['x-dsh-auth-token'] !== TOKEN) { res.writeHead(401).end(); return } + if (url.pathname === '/auth-api/v0/users/current') { + // `email` is required by the provider's profile schema even when the account has none. + reply({ id: 'web-user', email: '', id_profile: { name: 'Web User', picture: null } }) + return + } + if (url.pathname === '/api/v0/users/get_user_summary') { + reply({ normal_wallets: [{ currency: 'CNY', balance: '1.00' }], bonus_wallets: [] }) + return + } + if (url.pathname === '/api/v0/users/get_unnotified_bonuses') { reply([]); return } + res.writeHead(404).end() + }) + await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve) }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('account login: missing mock listener') + const origin = `http://127.0.0.1:${String(address.port)}` + return { + origin, inits, pages, + close: () => new Promise((resolve) => { server.close(() => { resolve() }); server.closeAllConnections() }), + } +} + +describe.skipIf(MODE === 'record')('web e2e: DeepSeek account sign-in in a plain browser', () => { + let root: string | undefined + let scaffold: WebScaffold + let browser: Browser + let platform: Awaited> + + beforeAll(async () => { + platform = await mockPlatform() + root = await mkdtemp(join(tmpdir(), 'dsh-web-account-login-')) + const overlay = join(root, 'account-login.overlay.yml') + await writeFile(overlay, (await readFile(OVERLAY_TEMPLATE, 'utf8')).replaceAll('{{origin}}', platform.origin)) + scaffold = await launchWebScaffold({ extraOverlayPath: overlay }) + browser = await chromium.launch() + }) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + await platform?.close() + if (root !== undefined) await rm(root, { recursive: true, force: true }) + }) + + it('signs in through a preopened themed tab and reports the Web login source', async () => { + const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: 'en-US', colorScheme: 'dark' }) + const page = await context.newPage() + const console = watchConsole(page) + onTestFailed(() => saveFailureShot(page, 'web-e2e-account-login')) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const menu = page.getByRole('button', { name: 'Account menu', exact: true }) + await menu.click() + const tab = context.waitForEvent('page') + await page.getByRole('menuitem', { name: 'Sign in', exact: true }).click() + const authorization = await tab + await authorization.waitForURL(url => url.pathname === '/dsh/authorized', { timeout: 30_000 }) + expect(await authorization.evaluate(() => window.opener === null)).toBe(true) + await expect.poll(() => menu.textContent(), { timeout: 30_000 }).toBe('Web User') + + const pageOrigin = new URL(scaffold.authenticatedUrl).origin + expect(platform.inits).toHaveLength(1) + expect(platform.inits[0]).toMatchObject({ login_source: 'web', redirect_uri: `${pageOrigin}/oauth/callback` }) + const [authorize, authorized] = platform.pages.map(href => new URL(href)) + expect(authorize?.searchParams.get('theme')).toBe('dark') + expect(authorized?.searchParams.get('login_source')).toBe('web') + expect(console.pageErrors).toEqual([]) + await context.close() + }, 90_000) +}) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 6fc12aba02..094047c701 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -323,7 +323,7 @@ describe('web e2e: agent-preset selection', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog')) const composer = page.locator('[data-composer-input][contenteditable="true"]').last() - // `minimal` mounts neither the compaction group nor plan mode nor local + // `minimal` mounts compaction, but neither plan mode nor local // skill discovery, so the catalog the composer warmed under the // deployment default must not survive the switch. await writeComposerDraft(page, composer, '/') @@ -331,7 +331,7 @@ describe('web e2e: agent-preset selection', () => { .not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)])) // Rows read as `Title Description`; the title is the capitalized command name. const onMinimal = (await menuOptions(page)).map(option => option.toLowerCase()) - expect(onMinimal.some(option => option.startsWith('compact'))).toBe(false) + expect(onMinimal.some(option => option.startsWith('compact'))).toBe(true) expect(onMinimal.some(option => option.startsWith('plan'))).toBe(false) // Preset-scoped commands follow the switch; the client's own model command // remains outside every preset. @@ -422,7 +422,7 @@ describe('web e2e: agent-preset selection', () => { await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE) expect(snapshot).toContain('Minimal mode') expect(snapshot).toContain('button "1 subagent"') - expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('Minimal mode')) + expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"')) expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('button "More actions"')) // Static chrome, not a control: the header can only report a composition // the host would refuse to change. @@ -455,8 +455,9 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without await page.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click() await page.getByRole('button', { name: 'Choose how to add a plugin', exact: true }).click() + // The Creator entry reuses the draft's blank Session and names its preset in the creation request. await Promise.all([ - page.waitForResponse('**/api/agentPresets/select'), + page.waitForResponse('**/api/session/create'), page.getByRole('menuitem', { name: /^Let the agent create a plugin/ }).click(), ]) const picker = page.getByTitle('Choose the agent preset for your new task', { exact: true }).filter({ hasText: 'Creator mode' }) @@ -469,8 +470,9 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('cordis') expect(scaffold.ctx.tools.schemas(creator).map(tool => tool.name)) .toEqual(expect.arrayContaining(['cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager'])) - expect(creator.session.snapshotEvents().some(event => event.type === 'agent-preset/selected' - && event.data.agentPreset === 'cordis')).toBe(true) + // The chip may also select Creator once the list updates; the registry records the switch only once. + expect(creator.session.snapshotEvents().filter(event => event.type === 'agent-preset/selected' + && event.data.agentPreset === 'cordis')).toHaveLength(1) expect(creator.session.snapshotEvents().some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false) expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: developerTools }) expect(scaffold.ctx.agentPresets.defaultId).toBe('standard') diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index 8358ef1a20..bd7dcff720 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -150,7 +150,7 @@ describe('web e2e: Agent Teams panel', () => { expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) await page.keyboard.press('Enter') await panel.waitFor() - const outside = page.getByRole('button', { name: 'Settings', exact: true }) + const outside = page.getByRole('button', { name: 'Account menu', exact: true }) await outside.focus() expect(await panel.count()).toBe(1) await page.keyboard.press('Escape') diff --git a/apps/web/tests/auto-review-approval.e2e.ts b/apps/web/tests/auto-review-approval.e2e.ts index b430de35f4..890ed25e0e 100644 --- a/apps/web/tests/auto-review-approval.e2e.ts +++ b/apps/web/tests/auto-review-approval.e2e.ts @@ -63,7 +63,7 @@ describe.skipIf(MODE === 'record')('web e2e: cold Auto-review user rejection and const access = page.locator('button[aria-label^="Access mode"]').first() await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 }) - .toBe('Access mode, current: Auto review EXP') + .toBe('Access mode, current: Auto review') const collapsed = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') diff --git a/apps/web/tests/auto-review-denial.e2e.ts b/apps/web/tests/auto-review-denial.e2e.ts index b792361be5..58371631a9 100644 --- a/apps/web/tests/auto-review-denial.e2e.ts +++ b/apps/web/tests/auto-review-denial.e2e.ts @@ -92,7 +92,7 @@ describe.skipIf(MODE === 'record')('web e2e: cold Auto-review denial', () => { const access = page.locator('button[aria-label^="Access mode"]').first() await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 }) - .toBe('Access mode, current: Auto review EXP') + .toBe('Access mode, current: Auto review') await captureAutoReviewState(page, 'deny-collapsed') const collapsed = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index 673270ae86..f407bdb53e 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -95,7 +95,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { agent, }) const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') - const matched = /\bbash-\d+\b/.exec(reported) + const matched = /\bbash-[0-9a-f]{16}\b/.exec(reported) if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) const jobId = JobId(matched[0]) diff --git a/apps/web/tests/blank-preset-reuse.e2e.ts b/apps/web/tests/blank-preset-reuse.e2e.ts new file mode 100644 index 0000000000..5f9958ab5f --- /dev/null +++ b/apps/web/tests/blank-preset-reuse.e2e.ts @@ -0,0 +1,131 @@ +/** Blank Session reuse under the requested or default Agent preset through the shipped Web composition. */ + +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage } from './support.ts' + +/** A preset these blank Sessions recorded that the deployment no longer declares. */ +const UNDECLARED = 'liangshen' + +describe('web e2e: blank Session reuse under the requested preset', () => { + let scaffold: WebScaffold + let browser: Browser + const tripwires: ReturnType[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + browser = await chromium.launch() + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + /** Store a blank Session recorded under `agentPreset`, with a plan-mode switch, as the only member of a new Workspace. */ + async function storeBlank(sessionId: SessionId, folder: string, agentPreset: string): Promise { + const path = join(scaffold.workspaceCwd, folder) + await mkdir(path) + const workspace = await scaffold.ctx.workspaceRegistry.create(path) + const handle = await scaffold.ctx.agents.create({ + sessionId, + meta: { cwd: path, agentPreset }, + agentOptions: scaffold.ctx.agentDefaultModel.currentSelection(), + }) + try { + handle.agent.session.append('plan/mode', { active: true }) + await workspace.attachSession(sessionId) + } finally { + await handle.dispose() + } + } + + async function openPage(saved?: SessionId): Promise { + const page = await newEnglishPage(browser) + tripwires.push(watchConsole(page)) + if (saved !== undefined) { + await page.addInitScript((id) => { + localStorage.setItem('dsh.sessions.current', JSON.stringify({ sessionId: id })) + }, saved) + } + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[data-composer-input]').first().waitFor({ timeout: 30_000 }) + return page + } + + async function selected(page: Page): Promise { + return page.evaluate(() => { + const value = localStorage.getItem('dsh.sessions.current') + return value === null ? undefined : (JSON.parse(value) as { sessionId?: string }).sessionId + }) + } + + /** The live Agent's mounted preset, its projected preset, and its plan-mode state. */ + function liveComposition(sessionId: SessionId) { + const agent = scaffold.ctx.agents.get(sessionId) + if (agent === undefined) return undefined + return { + mounted: scaffold.ctx.agentPresets.composedPreset(agent.ctx), + projected: scaffold.ctx.sessionProjections.stateOf(agent.session, 'agentPreset'), + plan: scaffold.ctx.sessionProjections.stateOf(agent.session, 'plan'), + } + } + + async function storedSelections(sessionId: SessionId): Promise { + const handle = await scaffold.ctx.sessionPersistence.open(sessionId, 'read') + try { + const { events } = await handle.read() + return events + .filter((event: SessionEvent) => event.type === 'agent-preset/selected') + .map((event: SessionEvent) => event.data) + } finally { + await handle.close() + } + } + + it('reuses a blank recorded under an undeclared preset under the default when startup connects its Workspace', async () => { + const stale = SessionId('blank-undeclared') + await storeBlank(stale, 'undeclared', UNDECLARED) + + const page = await openPage() + await expect.poll(() => selected(page)).toBe(stale) + await expect.poll(() => liveComposition(stale)).toMatchObject({ + mounted: 'standard', projected: 'standard', plan: { active: true }, + }) + expect(await storedSelections(stale)).toEqual([{ agentPreset: 'standard' }]) + expect(scaffold.ctx.workspaceRegistry.list().find(item => item.sessionIds.includes(stale))?.sessionIds).toEqual([stale]) + expect(await page.getByRole('alert').count()).toBe(0) + }) + + it('restores a saved blank under the preset it recorded', async () => { + const picked = SessionId('blank-picked') + await storeBlank(picked, 'picked', 'minimal') + + const page = await openPage(picked) + await expect.poll(() => selected(page)).toBe(picked) + await expect.poll(() => liveComposition(picked)).toMatchObject({ mounted: 'minimal', projected: 'minimal' }) + expect(await storedSelections(picked)).toEqual([]) + }) + + it('restores a saved blank under the default once its recorded preset is undeclared', async () => { + const saved = SessionId('saved-undeclared') + await storeBlank(saved, 'saved-undeclared', UNDECLARED) + + const page = await openPage(saved) + await expect.poll(() => selected(page)).toBe(saved) + await expect.poll(() => liveComposition(saved)).toMatchObject({ + mounted: 'standard', projected: 'standard', plan: { active: true }, + }) + expect(await storedSelections(saved)).toEqual([{ agentPreset: 'standard' }]) + expect(await page.getByRole('alert').count()).toBe(0) + for (const tripwire of tripwires) { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } + }) +}) diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index a7b5bc4b7a..955d251ab7 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -6,15 +6,15 @@ import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' +import type { Browser, Page, Route } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type { StreamChunk } from '@deepseek-ai/dsh-llm' -import { ToolCallId } from '@deepseek-ai/dsh-llm' +import { ToolCallId, createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent' import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts' import { launchWebScaffold, @@ -28,7 +28,7 @@ import { expandOwningTurnProcess, newEnglishPage, openSettings, saveFailureShot const MODE = webSnapshotMode() const HISTORY_SESSION_ID = 'chat-scroll-history-e2e' -const TOOL_SESSION_ID = 'chat-scroll-tool-e2e' +const TOOL_SESSION_ID = SessionId('chat-scroll-tool-e2e') const RESTORE_SESSION_A_ID = 'chat-scroll-restore-a-e2e' const RESTORE_SESSION_B_ID = 'chat-scroll-restore-b-e2e' const REPLAY_CONTEXT_WINDOW = 10_000_000 @@ -47,7 +47,7 @@ const TOOL_READY_FILE = '.chat-scroll-tool-ready' const TOOL_RELEASE_FILE = '.chat-scroll-tool-release' const INPUTS_SESSION_ID = 'chat-scroll-inputs-e2e' const RAIL_SESSION_ID = 'chat-scroll-rail-e2e' -const FLING_SESSION_ID = 'chat-scroll-fling-e2e' +const FLING_SESSION_ID = SessionId('chat-scroll-fling-e2e') const LIVE_FLING_PROMPT = 'CHAT_SCROLL_FLING_USER Keep streaming while I fling back through older output.' const LIVE_FLING_FIRST = 'CHAT_SCROLL_FLING_STREAM_FIRST' const LIVE_FLING_DONE = 'CHAT_SCROLL_FLING_STREAM_DONE' @@ -74,6 +74,22 @@ const INPUTS_FIXTURE = createChatScrollFixture({ title: 'CHAT_SCROLL_INPUTS non-wheel reader input session', }) +interface FoldMotionSample { + readonly t: number + readonly ordinaryGrowth: boolean + readonly top: number + readonly bodyY: number + readonly earlierY: number + readonly messageY: number | null + readonly stepY: number | null + readonly flowHeight: number + readonly height: number + readonly spacer: number + readonly folding: boolean + readonly raceApplied: boolean + readonly composerTop: number +} + interface ScrollGeometry { readonly distanceFromBottom: number readonly scrollTop: number @@ -95,6 +111,8 @@ interface ScrollWorld { interface ScrollWorldOptions { readonly failureShot: string + /** Install a controllable browser clock before boot; omitted worlds use real time. */ + readonly controlledClock?: boolean readonly paceMs?: number readonly replay?: ReplayOverrideDoc readonly seeds: readonly { fixture: ChatScrollFixture; id: string }[] @@ -190,6 +208,7 @@ async function launchScrollWorld(options: ScrollWorldOptions): Promise { events.push(event) }) scaffold.ctx.on('agent/assistant-stream', ({ frame }) => { assistantFrames.push(frame) }) page = await newEnglishPage(browser, 900) + if (options.controlledClock) await page.clock.install() const tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -525,115 +544,674 @@ describe('web e2e: long Chat scroll contract', () => { await browser?.close() }) - it.skipIf(MODE === 'record')('merges a large process fold with new-input following without returning downward', async () => { - const fixture = createChatScrollFixture({ markerPrefix: 'FOLD', title: 'CHAT_FOLD_INPUT', turns: 8 }) - const input = 'CHAT_FOLD_NEXT_INPUT' - const artifactRoot = fileURLToPath(new URL('../../../.artifacts/fold-input-e2e/', import.meta.url)) - await mkdir(artifactRoot, { recursive: true }) - const evidence = await mkdtemp(join(artifactRoot, 'run-')) + for (const scenario of [ + { name: 'body-only', bodyParagraphs: 32, inputLines: 1, reduced: false }, + { name: 'visible-steps', bodyParagraphs: 2, inputLines: 1, reduced: false }, + { name: 'large-input', bodyParagraphs: 2, inputLines: 18, reduced: false }, + { name: 'reduced-motion', bodyParagraphs: 2, inputLines: 1, reduced: true }, + { name: 'cancel-all-during-fold', bodyParagraphs: 2, inputLines: 1, reduced: false }, + { name: 'content-growth-during-fold', bodyParagraphs: 32, inputLines: 1, reduced: false }, + ]) { + it.skipIf(MODE === 'record')(`uses only visible fold room in one upward movement: ${scenario.name}`, async () => { + const base = createChatScrollFixture({ markerPrefix: 'VISIBLE_FOLD', title: `CHAT_VISIBLE_FOLD_${scenario.name}`, turns: 8 }) + const parsed = parseSeedFixture(base.log) + const boundary = parsed.events.findIndex(event => event.type === 'turn/start' && event.data.turn === 8) + const drafts: (SessionEvent | { type: string; data: object; surfaceOp?: 'append' })[] = [ + ...parsed.events.slice(0, boundary), + { type: 'turn/start', data: { turn: 8 } }, + { type: 'user/message', data: createUserMessage({ + content: [{ type: 'text', text: 'VISIBLE_FOLD_EARLIER_ANCHOR' }], source: { kind: 'user' }, + }), surfaceOp: 'append' }, + ] + for (let step = 1; step <= 6; step++) { + drafts.push({ type: 'step/start', data: { turn: 8, step } }) + drafts.push({ type: 'assistant/message', surfaceOp: 'append', data: { + turn: 8, step, stream: [], message: createAssistantMessage({ + content: [ + { type: 'reasoning', text: `VISIBLE_FOLD_STEP_${step}\n\n${Array.from({ length: 18 }, (_, line) => + `Reasoning stage ${step}, paragraph ${line}: retained work details explain this intermediate decision.`).join('\n\n')}` }, + { type: 'text', text: `VISIBLE_FOLD_PROGRESS_${step}` }, + ], source: { provider: 'fixture', model: 'fixture' }, + }), + } }) + drafts.push({ type: 'step/end', data: { turn: 8, step } }) + } + const bodyMarker = 'VISIBLE_FOLD_BODY' + drafts.push({ type: 'step/start', data: { turn: 8, step: 7 } }) + drafts.push({ type: 'assistant/message', surfaceOp: 'append', data: { + turn: 8, step: 7, stream: [], message: createAssistantMessage({ + content: [{ type: 'text', text: `${bodyMarker}\n\n${Array.from({ length: scenario.bodyParagraphs }, (_, line) => + `Final answer paragraph ${line}: this content remains after the preceding work details fold.`).join('\n\n')}` }], + source: { provider: 'fixture', model: 'fixture' }, + }), + } }) + drafts.push({ type: 'step/end', data: { turn: 8, step: 7 } }, + { type: 'turn/end', data: { turn: 8, reason: { kind: 'completed' } } }) + const fixture: ChatScrollFixture = { ...base, log: [parsed.headerLine, + ...drafts.map((event, seq) => JSON.stringify({ ...event, seq, time: seq + 1 })), ''].join('\n') } + const input = Array.from({ length: scenario.inputLines }, (_, index) => `VISIBLE_FOLD_NEXT_${index}`).join('\n') + const artifactRoot = fileURLToPath(new URL('../../../.artifacts/screenshots/visible-fold/', import.meta.url)) + await mkdir(artifactRoot, { recursive: true }) + const evidence = await mkdtemp(join(artifactRoot, `${scenario.name}-`)) + const controlledClock = scenario.name === 'content-growth-during-fold' || scenario.name === 'cancel-all-during-fold' + await withScrollWorld({ + failureShot: `visible-fold-${scenario.name}`, + controlledClock, + replay: [replayEntry(textStream('VISIBLE_FOLD_REPLY', 'VISIBLE_FOLD_DONE', 2))], + seeds: [{ fixture, id: `visible-fold-${scenario.name}` }], + }, async (world) => { + const { page } = world + await page.setViewportSize({ width: 1280, height: 720 }) + await page.emulateMedia({ reducedMotion: scenario.reduced ? 'reduce' : 'no-preference' }) + await openSeed(page, fixture, bodyMarker) + await openSettings(page, 'en') + const dialog = page.getByRole('dialog', { name: 'Settings', exact: true }) + const timing = dialog.getByText('When to Collapse Work Details', { exact: true }).locator('../..') + await timing.getByRole('button').click() + await page.getByRole('menuitem', { name: 'On next message', exact: true }).click() + const details = dialog.getByText('Work details', { exact: true }).locator('../..') + await details.getByRole('button').click() + await page.getByRole('menuitem', { name: 'Detailed', exact: true }).click() + await dialog.getByRole('button', { name: 'Close', exact: true }).click() + const thoughts = page.locator('[data-chat-turn="8"] [data-variant="think"]') + await expect.poll(() => thoughts.count()).toBe(6) + for (let index = 0; index < 6; index++) { + const button = thoughts.nth(index).getByRole('button').first() + if (await button.getAttribute('aria-expanded') !== 'true') await button.click() + } + await wheelTranscript(page, -600) + await page.getByRole('button', { name: 'Back to bottom', exact: true }).click() + await expectBottom(page) + await nextPaint(page) + await page.locator('[data-composer-input][contenteditable="true"]').last().fill(input) + await nextPaint(page) + if (scenario.name === 'visible-steps') { + const offset = 20 + await thoughts.last().getByRole('button').first().evaluate((button, offset) => { + const host = button.closest('[data-conversation-scroll]')! + const group = button.closest('[data-chat-group-key]')! + host.scrollTop += group.getBoundingClientRect().top - host.getBoundingClientRect().top - offset + host.dispatchEvent(new Event('scroll')) + }, offset) + await nextPaint(page) + } + const before = await page.locator('[data-conversation-scroll]').evaluate((host) => { + const viewport = host.getBoundingClientRect() + const composer = host.querySelector('[data-composer-seat]')! + const body = [...host.querySelectorAll('[data-chat-turn="8"][data-chat-group-part="response"]')].at(-1)! + const groups = [...host.querySelectorAll('[data-chat-turn="8"][data-chat-group-key]')] + const candidates = [...host.querySelectorAll('[data-chat-turn="8"][data-chat-group-key], [data-chat-turn="8"][data-turn-process-member="true"]')] + .filter(element => !element.parentElement?.closest('[data-chat-turn="8"][data-chat-group-key], [data-chat-turn="8"][data-turn-process-member="true"]')) + return { top: host.scrollTop, height: host.scrollHeight, viewportTop: viewport.top, + removed: candidates.map((element) => { + const rect = element.getBoundingClientRect() + const margin = Number.parseFloat(getComputedStyle(element).marginTop) || 0 + return { top: rect.top - viewport.top - margin, height: rect.height + margin } + }), + floor: Math.max(0, host.scrollHeight - host.clientHeight), + viewport: host.clientHeight, visibleHeight: composer.getBoundingClientRect().top - viewport.top, + bodyTop: body.getBoundingClientRect().top, bodyHeight: body.getBoundingClientRect().height, + flowHeight: host.querySelector('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')!.getBoundingClientRect().height, + groupHeight: groups.reduce((sum, group) => sum + group.getBoundingClientRect().height, 0), + visibleSteps: groups.reduce((sum, group) => { + const rect = group.getBoundingClientRect() + return sum + Math.max(0, Math.min(rect.bottom, composer.getBoundingClientRect().top) - Math.max(rect.top, viewport.top)) + }, 0) } + }) + expect(before.groupHeight).toBeGreaterThan(before.visibleHeight * 3) + if (scenario.bodyParagraphs === 32) expect(before.visibleSteps).toBe(0) + else expect(before.visibleSteps).toBeGreaterThan(0) + if (scenario.name === 'cancel-all-during-fold') { + await openSettings(page, 'en') + const settings = page.getByRole('dialog', { name: 'Settings', exact: true }) + await settings.getByText('Work details', { exact: true }).locator('../..').getByRole('button').click() + await page.getByRole('menuitem', { name: 'Verbose', exact: true }).waitFor() + } + const release = holdTextAfter(world, 0) + const finishGate = Promise.withResolvers() + const finishBlocked = Promise.withResolvers() + const holdFinish = world.scaffold.ctx.on('llm/stream', async function* (_options, next) { + for await (const chunk of next()) { + if (chunk.type === 'finish') { + finishBlocked.resolve(undefined) + await finishGate.promise + } + yield chunk + } + }) + const promptGate = Promise.withResolvers() + const promptBlocked = Promise.withResolvers() + const holdPrompt = async (route: Route): Promise => { + promptBlocked.resolve(undefined) + await promptGate.promise + await route.continue() + } + await page.route('**/api/session/prompt', holdPrompt) + const trace = page.evaluate(({ marker, race }) => new Promise((resolveTrace) => { + const host = document.querySelector('[data-conversation-scroll]')! + const column = host.querySelector('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')! + const body = [...column.querySelectorAll('[data-chat-turn="8"][data-chat-group-part="response"]')].at(-1)! + const earlier = [...column.querySelectorAll('*')].find(element => + element.children.length === 0 && element.textContent === 'VISIBLE_FOLD_EARLIER_ANCHOR')! + const samples: FoldMotionSample[] = [] + const visibleStep = [...column.querySelectorAll('[data-chat-turn="8"] [data-variant="think"] [role="button"]')].at(-1) + let frame = 0 + let ordinaryGrowth = false + let raceApplied = false + let closingHeight: number | undefined + const markGrowth = (): void => { ordinaryGrowth = true } + host.addEventListener('dsh-test-fold-growth', markGrowth) + const stepPosition = (): number | null => { + if (visibleStep === undefined || !visibleStep.isConnected || visibleStep.closest('[hidden]') !== null) return null + const rect = visibleStep.getBoundingClientRect() + if (rect.height === 0 || rect.width === 0) return null + const walker = document.createTreeWalker(visibleStep, NodeFilter.SHOW_TEXT) + let textNode = walker.nextNode() + while (textNode !== null && !(textNode.textContent ?? '').trim()) textNode = walker.nextNode() + if (textNode === null) return null + const range = document.createRange() + range.selectNodeContents(textNode) + const glyph = range.getBoundingClientRect() + return glyph.height === 0 ? null : glyph.top + } + const sample = (): void => { + const closing = [...column.querySelectorAll('[data-chat-motion="collapse"]')] + .filter(element => element.parentElement?.closest('[data-chat-motion="collapse"]') === null) + const height = closing.reduce((sum, element) => sum + element.getBoundingClientRect().height, 0) + if (closing.length > 0) closingHeight ??= height + if (!raceApplied && closingHeight !== undefined && height > 0 && height < closingHeight - 1) { + if (race === 'cancel-all-during-fold') { + const verbose = [...document.querySelectorAll('[role="menuitem"]')] + .find(element => element.textContent?.trim() === 'Verbose') + if (verbose !== undefined) { + raceApplied = true + verbose.click() + } + } else if (race === 'content-growth-during-fold') { + // Controlled layout growth exercises ResizeObserver overlap, not Session-event delivery. + raceApplied = true + column.style.paddingBottom = '320px' + } + if (raceApplied) host.setAttribute('data-fold-race-applied', '') + } + const message = [...column.querySelectorAll('[data-submission-echo], [data-chat-flow-kind="user"]')] + .find(element => element.textContent?.includes(marker)) + const step = stepPosition() + samples.push({ t: performance.now(), ordinaryGrowth, top: host.scrollTop, bodyY: body.getBoundingClientRect().top, + earlierY: earlier.getBoundingClientRect().top, messageY: message?.getBoundingClientRect().top ?? null, + stepY: step, + flowHeight: column.getBoundingClientRect().height, height: host.scrollHeight, + spacer: host.querySelector('[data-chat-turn-spacer]')!.getBoundingClientRect().height, + folding: column.querySelector('[data-chat-motion="collapse"]') !== null, raceApplied, + composerTop: host.querySelector('[data-composer-seat]')!.getBoundingClientRect().top }) + } + const tick = (): void => { sample(); frame = requestAnimationFrame(tick) } + host.addEventListener('dsh-test-fold-stop', () => { + cancelAnimationFrame(frame); sample() + host.removeEventListener('dsh-test-fold-growth', markGrowth) + host.removeAttribute('data-fold-race-applied') + host.removeAttribute('data-fold-probe-ready'); resolveTrace(samples) + }, { once: true }) + sample(); host.setAttribute('data-fold-probe-ready', ''); frame = requestAnimationFrame(tick) + }), { marker: 'VISIBLE_FOLD_NEXT_0', race: scenario.name }) + await page.locator('[data-fold-probe-ready]').waitFor() + let settled: ReturnType | undefined + let samples: Awaited = [] + try { + settled = world.scaffold.whenTurnSettled(30_000) + if (controlledClock) { + // Advance every animation frame so the race cannot miss the intermediate fold height. + await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)) + try { + await page.getByRole('button', { name: 'Send message', exact: true }).evaluate((button) => { + if (!(button instanceof HTMLButtonElement)) throw new Error('Send control is not a button') + button.click() + }) + await expect.poll(async () => { + await page.clock.runFor(16) + return page.locator('[data-fold-race-applied]').count() + }, { timeout: 10_000 }).toBe(1) + await expect.poll(async () => { + await page.clock.runFor(16) + return page.locator('[data-chat-motion="collapse"]').count() + }, { timeout: 10_000 }).toBe(0) + } finally { + await page.clock.resume() + } + } else await page.getByRole('button', { name: 'Send message', exact: true }).click() + await promptBlocked.promise + await page.getByText(input, { exact: true }).waitFor() + await expect.poll(() => page.locator('[data-chat-motion="collapse"]').count()).toBe(0) + if (scenario.name !== 'cancel-all-during-fold') await expectBottom(page) + await page.locator('[data-conversation-scroll]').evaluate(async (host) => { + let previous = host.scrollTop + let stableSince = performance.now() + await new Promise((resolveStable) => { + const check = (): void => { + const now = performance.now() + if (Math.abs(host.scrollTop - previous) > 0.25) stableSince = now + previous = host.scrollTop + if (now - stableSince >= 240) resolveStable() + else requestAnimationFrame(check) + } + requestAnimationFrame(check) + }) + }) + await page.locator('[data-conversation-scroll]').evaluate(host => host.dispatchEvent(new Event('dsh-test-fold-growth'))) + promptGate.resolve(undefined) + release() + await finishBlocked.promise + await page.getByText('VISIBLE_FOLD_DONE', { exact: false }).waitFor() + await expectBottom(page) + await nextPaint(page) + } finally { + promptGate.resolve(undefined) + release() + await page.unroute('**/api/session/prompt', holdPrompt) + await page.locator('[data-conversation-scroll]').evaluate(host => host.dispatchEvent(new Event('dsh-test-fold-stop'))) + samples = await trace + finishGate.resolve(undefined) + holdFinish() + await settled + } + const allSamples = samples + const ordinarySamples = samples.filter(sample => sample.ordinaryGrowth) + samples = samples.filter(sample => !sample.ordinaryGrowth) + const first = samples[0]! + const last = samples.at(-1)! + const closedBoxesHeight = before.removed.reduce((sum, box) => sum + box.height, 0) + const removedHeight = first.bodyY + first.top - last.bodyY - last.top + const boundaryGrowth = closedBoxesHeight - removedHeight + const aboveRemoved = Math.min(removedHeight, + before.removed.reduce((sum, box) => sum + Math.min(box.height, Math.max(0, -box.top)), 0)) + const remainingHeight = last.height - last.spacer + const newHeight = remainingHeight - before.height + removedHeight + const baseTop = Math.max(0, before.top - aboveRemoved) + const expectedTop = Math.max(baseTop, remainingHeight - before.viewport, 0) + const expectedSpacer = expectedTop === 0 ? 0 : Math.max(0, expectedTop + before.viewport - remainingHeight) + const ordinaryFollow = expectedTop - baseTop + const expectedBodyShift = removedHeight - aboveRemoved + ordinaryFollow + const bodyShift = first.bodyY - last.bodyY + const reversalOf = (values: readonly number[]): number => { + let lowest = values[0] ?? 0 + let reversal = 0 + for (const value of values) { + lowest = Math.min(lowest, value) + reversal = Math.max(reversal, value - lowest) + } + return reversal + } + const reversal = reversalOf(samples.map(sample => sample.bodyY)) + const inputReversal = reversalOf(samples.flatMap(sample => sample.messageY === null ? [] : [sample.messageY])) + const ordinaryBodyReversal = reversalOf([samples.at(-1)!.bodyY, ...ordinarySamples.map(sample => sample.bodyY)]) + const ordinaryInputReversal = reversalOf(ordinarySamples.flatMap(sample => sample.messageY === null ? [] : [sample.messageY])) + const stepReversal = reversalOf(samples.flatMap(sample => sample.stepY === null ? [] : [sample.stepY])) + const firstFoldIdle = samples.findIndex((sample, index) => index > 0 && !sample.folding && samples[index - 1]!.folding) + const afterFold = firstFoldIdle < 0 ? [] : samples.slice(firstFoldIdle) + const range = (values: number[]): number => values.length === 0 ? 0 : Math.max(...values) - Math.min(...values) + const foldBoundaryMovement = firstFoldIdle > 0 + ? Math.abs(samples[firstFoldIdle]!.bodyY - samples[firstFoldIdle - 1]!.bodyY) : 0 + const priorFrameMovements = samples.slice(1, Math.max(1, firstFoldIdle - 1)) + .map((sample, index) => Math.abs(sample.bodyY - samples[index]!.bodyY)) + const secondMovement = range(afterFold.map(sample => sample.bodyY)) + const secondInputMovement = range(afterFold.flatMap(sample => sample.messageY === null ? [] : [sample.messageY])) + const earlierShift = first.earlierY - last.earlierY + await writeFile(join(evidence, 'motion.json'), JSON.stringify({ scenario, before, closedBoxesHeight, removedHeight, boundaryGrowth, aboveRemoved, + remainingHeight, newHeight, expectedTop, expectedSpacer, expectedBodyShift, bodyShift, ordinaryFollow, + earlierShift, reversal, inputReversal, stepReversal, secondMovement, secondInputMovement, + ordinaryBodyReversal, ordinaryInputReversal, samples: allSamples }, null, 2)) + console.log(`visible-fold ${scenario.name}: spacer=${last.spacer}/${expectedSpacer}, top=${last.top}/${expectedTop}, reversal=${reversal}, secondMovement=${secondMovement}, evidence=${evidence}`) + if (scenario.name === 'cancel-all-during-fold') { + expect(samples.some(sample => sample.raceApplied)).toBe(true) + expect(last.folding).toBe(false) + expect(last.spacer).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect(await page.getByRole('dialog', { name: 'Settings', exact: true }) + .getByText('Work details', { exact: true }).locator('../..') + .getByRole('button', { name: 'Verbose', exact: true }).count()).toBe(1) + expect(await page.locator('[data-input-fold]').count()).toBe(0) + assertClean(world) + return + } + if (scenario.name === 'content-growth-during-fold') { + expect(samples.some(sample => sample.raceApplied && sample.folding)).toBe(true) + expect.soft(foldBoundaryMovement).toBeLessThanOrEqual(Math.max(...priorFrameMovements) + GEOMETRY_TOLERANCE) + } + expect.soft(removedHeight).toBeGreaterThan(before.visibleHeight * 3) + if (!scenario.reduced) { + expect.soft(samples.some(sample => sample.folding)).toBe(true) + const movingSamples = samples.filter((sample, index) => index > 0 + && Math.abs(sample.bodyY - samples[index - 1]!.bodyY) > 0.25) + expect.soft(movingSamples.length).toBeGreaterThan(1) + expect.soft(firstFoldIdle).toBeGreaterThan(0) + } + if (scenario.name === 'visible-steps') { + expect.soft(first.stepY).not.toBeNull() + expect.soft(first.stepY!).toBeGreaterThanOrEqual(before.viewportTop) + expect.soft(first.stepY!).toBeLessThan(first.composerTop) + const initial = samples.filter(sample => sample.stepY !== null && sample.t <= samples[firstFoldIdle - 1]!.t) + expect.soft(Math.max(...initial.map(sample => sample.stepY!)) - first.stepY!).toBeLessThanOrEqual(0.5) + } + expect.soft(Math.abs(last.top - expectedTop)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(Math.abs(last.spacer - expectedSpacer)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(Math.abs(bodyShift - expectedBodyShift)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(reversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(inputReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(stepReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(ordinarySamples.length).toBeGreaterThan(0) + expect.soft(ordinaryBodyReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(ordinaryInputReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(secondMovement).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(secondInputMovement).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + if (first.earlierY >= before.viewportTop) { + expect.soft(Math.abs(earlierShift - ordinaryFollow)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + } + if (scenario.name === 'body-only' || scenario.name === 'large-input') expect.soft(last.spacer).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + expect.soft(last.messageY).not.toBeNull() + expect.soft(last.messageY!).toBeLessThan(last.composerTop) + assertClean(world) + }) + }, 180_000) + } + + it.skipIf(MODE === 'record')('resets transcript height when switching from Verbose back to Standard during a reply', async () => { + const fixture = createChatScrollFixture({ markerPrefix: 'MODE', title: 'CHAT_MODE_HEIGHT', turns: 8 }) + await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/chat-mode-height/', import.meta.url)), { recursive: true }) await withScrollWorld({ - failureShot: 'web-e2e-fold-input-motion', - replay: [replayEntry(textStream('CHAT_FOLD_REPLY', 'CHAT_FOLD_DONE', 2))], - seeds: [{ fixture, id: 'chat-fold-input-e2e' }], + failureShot: 'screenshots/chat-mode-height/web-e2e-chat-mode-height', + replay: [replayEntry(textStream('CHAT_MODE_REPLY', 'CHAT_MODE_DONE', 2))], + seeds: [{ fixture, id: 'chat-mode-height-e2e' }], }, async (world) => { const { page } = world await page.setViewportSize({ width: 1280, height: 720 }) await page.emulateMedia({ reducedMotion: 'no-preference' }) - await openSeed(page, fixture, fixture.markers.assistant(8)) - await openSettings(page, 'en') - await page.getByRole('dialog', { name: 'Settings', exact: true }) - .getByText('When to Collapse Work Details', { exact: true }).locator('../..') - .getByRole('button', { name: 'On completion', exact: true }).click() - await page.getByRole('menuitem', { name: 'On next message', exact: true }).click() - await page.keyboard.press('Escape') - for (const index of [1, 2]) { - const row = page.locator(`[data-chat-call-id="chat-scroll-008-${index}"] [data-sample="bash"]`) - await row.click() - await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true') - } - await wheelTranscript(page, -600) - await page.getByRole('button', { name: 'Back to bottom', exact: true }).click() - await expectBottom(page) - const beforeHeight = await page.locator('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])') - .evaluate(element => element.getBoundingClientRect().height) - const release = holdTextAfter(world, 0) - const trace = page.evaluate(marker => new Promise<{ - readonly t: number - readonly top: number - readonly messageY: number - readonly columnHeight: number - readonly spacer: number - readonly folding: boolean - }[]>((resolve) => { - const host = document.querySelector('[data-conversation-scroll]')! - const column = host.querySelector('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')! - const samples: { t: number; top: number; messageY: number; columnHeight: number; spacer: number; folding: boolean }[] = [] - let frame = 0 - let timer = 0 - const sample = (): void => { - const message = Array.from(column.querySelectorAll('*')) - .find(element => element.children.length === 0 && element.textContent === marker) - if (message === undefined) return - samples.push({ t: performance.now(), top: host.scrollTop, messageY: message.getBoundingClientRect().top, - columnHeight: column.getBoundingClientRect().height, - spacer: Number.parseFloat(host.querySelector('[data-chat-turn-spacer]')?.style.height ?? '') || 0, - folding: column.querySelector('[data-chat-motion="collapse"]') !== null }) + const selectPreference = async (title: string, choice: string): Promise => { + await openSettings(page, 'en') + const dialog = page.getByRole('dialog', { name: 'Settings', exact: true }) + const row = dialog.getByText(title, { exact: true }).locator('../..') + await row.getByRole('button').click() + await page.getByRole('menuitem', { name: choice, exact: true }).click() + await row.getByRole('button', { name: choice, exact: true }).waitFor() + if (title === 'Work details') { + await expect.poll(() => world.scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value) + .toMatchObject({ transcriptView: choice.toLowerCase() }) } - const tick = (): void => { - timer = window.setTimeout(() => { sample(); frame = requestAnimationFrame(tick) }, 0) - } - host.addEventListener('dsh-test-fold-stop', () => { - cancelAnimationFrame(frame) - window.clearTimeout(timer) - sample() - host.removeAttribute('data-fold-probe-ready') - resolve(samples) - }, { once: true }) - host.setAttribute('data-fold-probe-ready', '') - frame = requestAnimationFrame(tick) - }), input) - await page.locator('[data-fold-probe-ready]').waitFor() - let settled: ReturnType | undefined - try { - await page.locator('[data-composer-input][contenteditable="true"]').last().fill(input) - await page.getByRole('button', { name: 'Send message', exact: true }).click() - settled = world.scaffold.whenTurnSettled(30_000) - await page.getByText(input, { exact: true }).waitFor() - await expect.poll(() => page.locator('[data-chat-motion="collapse"]').count()).toBe(0) - await expectBottom(page) + await dialog.getByRole('button', { name: 'Close', exact: true }).click() + await expect.poll(() => page.locator('[data-chat-motion="collapse"], [data-chat-motion="reveal"]').count()).toBe(0) await nextPaint(page) + } + const returnToBottom = async (): Promise => { + await wheelTranscript(page, -600) + await page.getByRole('button', { name: 'Back to bottom', exact: true }).click() + await expectBottom(page) + } + const geometry = () => page.locator('[data-conversation-scroll]').evaluate(host => ({ + height: host.scrollHeight, + viewport: host.clientHeight, + spacer: host.querySelector('[data-chat-turn-spacer]')!.getBoundingClientRect().height, + })) + await selectPreference('Work details', 'Standard') + await openSeed(page, fixture, fixture.markers.assistant(8)) + // Hold a live reply so preference changes exercise the same fold animation as incoming content. + const release = holdTextAfter(world, 1) + let replyStarted = false + try { + await page.locator('[data-composer-input][contenteditable="true"]').last().fill('CHAT_MODE_NEXT_INPUT') + await page.getByRole('button', { name: 'Send message', exact: true }).click() + await page.getByText('CHAT_MODE_REPLY', { exact: false }).waitFor() + replyStarted = true + await selectPreference('When to Collapse Work Details', 'On next message') + await page.locator('[data-chat-flow][data-chat-motion=""]').waitFor() + await returnToBottom() + const standard = await geometry() + expect(standard.height).toBeGreaterThan(standard.viewport) + expect(standard.spacer).toBe(0) + for (let cycle = 0; cycle < 2; cycle += 1) { + await selectPreference('Work details', 'Verbose') + await expect.poll(async () => (await geometry()).height) + .toBeGreaterThan(standard.height + GEOMETRY_TOLERANCE) + await returnToBottom() + await selectPreference('Work details', 'Standard') + await expect.poll(async () => (await geometry()).spacer).toBe(0) + await expect.poll(async () => Math.abs((await geometry()).height - standard.height)) + .toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + await expectBottom(page) + await expectMarkerAboveComposer(page, 'CHAT_MODE_REPLY') + } } finally { - await page.locator('[data-conversation-scroll]').evaluate(host => host.dispatchEvent(new Event('dsh-test-fold-stop'))) + const settled = replyStarted ? world.scaffold.whenTurnSettled() : undefined release() await settled } - const samples = await trace - expect(samples.length).toBeGreaterThan(4) - const first = samples[0]! - let minimum = first.messageY - let reversal = 0 - let maximumScroll = first.top - let viewportBacktrack = 0 - for (const sample of samples) { - minimum = Math.min(minimum, sample.messageY) - if (first.messageY - minimum > 4) reversal = Math.max(reversal, sample.messageY - minimum) - maximumScroll = Math.max(maximumScroll, sample.top) - viewportBacktrack = Math.max(viewportBacktrack, maximumScroll - sample.top) - } - const last = samples.at(-1)! - const afterHeight = last.columnHeight - await writeFile(join(evidence, 'motion.json'), JSON.stringify({ beforeHeight, afterHeight, reversal, viewportBacktrack, samples }, null, 2)) - await page.screenshot({ path: join(evidence, 'settled.png') }) - console.log(`fold-input e2e: message reversal=${reversal}px, viewport backtrack=${viewportBacktrack}px, spacer=${last.spacer}px, evidence=${evidence}`) - expect(beforeHeight - afterHeight).toBeGreaterThan(250) - expect(first.messageY - minimum).toBeGreaterThan(40) - expect(reversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) - expect(viewportBacktrack).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) - expect(Math.abs(last.spacer - (beforeHeight - afterHeight))).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) - await expectBottom(page) assertClean(world) }) - }, 180_000) + }) + + it.skipIf(MODE === 'record')('keeps a running Session stationary when returning with output held', async () => { + const fixtureA = createChatScrollFixture({ markerPrefix: 'RETURN_A', title: 'CHAT_RUNNING_RETURN_A' }) + const fixtureB = createChatScrollFixture({ markerPrefix: 'RETURN_B', title: 'CHAT_RUNNING_RETURN_B', turns: 32 }) + const heldMarker = 'CHAT_RUNNING_RETURN_HELD' + const grownMarker = 'CHAT_RUNNING_RETURN_GROWN' + const doneMarker = 'CHAT_RUNNING_RETURN_DONE' + const paragraphs = (count: number, prefix: string): string => Array.from({ length: count }, (_, index) => + `${prefix} paragraph ${index + 1}: ${'The recorded response stays readable while this Session is open. '.repeat(3)}`, + ).join('\n\n') + const parts = [ + `${paragraphs(32, 'Initial output')}\n\n${heldMarker}\n\n`, + `${paragraphs(12, 'Resumed output')}\n\n${grownMarker}\n\n`, + doneMarker, + ] + const chunks: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'text' }, + ...parts.map((text): StreamChunk => ({ type: 'text-delta', index: 0, text })), + { type: 'block-end', index: 0, block: { type: 'text', text: parts.join('') } }, + { type: 'usage', usage: { inputTokens: 512, outputTokens: 2_500 } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] + const artifactRoot = fileURLToPath(new URL('../../../.artifacts/screenshots/chat-running-return/', import.meta.url)) + await mkdir(artifactRoot, { recursive: true }) + const evidence = await mkdtemp(join(artifactRoot, 'run-')) + await withScrollWorld({ + failureShot: 'screenshots/chat-running-return/web-e2e-chat-running-return', + replay: [replayEntry(chunks)], + seeds: [ + { fixture: fixtureA, id: 'chat-running-return-a-e2e' }, + { fixture: fixtureB, id: 'chat-running-return-b-e2e' }, + ], + }, async (world) => { + const { page } = world + await page.setViewportSize({ width: 1280, height: 720 }) + await page.emulateMedia({ reducedMotion: 'no-preference' }) + await openSettings(page, 'en') + const dialog = page.getByRole('dialog', { name: 'Settings', exact: true }) + for (const [title, choice] of [ + ['Work details', 'Standard'], + ['When to Collapse Work Details', 'On next message'], + ] as const) { + await dialog.getByText(title, { exact: true }).locator('../..').getByRole('button').click() + await page.getByRole('menuitem', { name: choice, exact: true }).click() + } + await expect.poll(() => world.scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value) + .toMatchObject({ transcriptView: 'standard' }) + await dialog.getByRole('button', { name: 'Close', exact: true }).click() + await openSeed(page, fixtureA, fixtureA.markers.assistant(fixtureA.turns)) + await expectBottom(page) + + interface ReturnFrame { + frame: number + t: number + sessionA: boolean + markerY: number | null + markerVisible: boolean + scrollTop: number + floor: number + viewportHeight: number + columnHeight: number + spacer: number + motionEnabled: boolean + motion: string[] + following: boolean + running: boolean + responseLength: number | null + } + const sampleReturn = async (marker: string): Promise => { + await openSeed(page, fixtureB, fixtureB.markers.assistant(fixtureB.turns)) + await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixtureA.markers.user(1)) + const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => result.count()).toBe(1) + // Start before the click; the first visible A frame is part of the assertion, not a setup wait. + const trace = page.evaluate(({ marker, identity }) => new Promise((resolve) => { + const samples: ReturnFrame[] = [] + let frame = 0 + let timer = 0 + let visibleFrames = 0 + let ordinal = 0 + const finish = (): void => { + cancelAnimationFrame(frame) + window.clearTimeout(timer) + document.removeEventListener('dsh-test-return-stop', finish) + document.documentElement.dataset.returnProbe = 'done' + resolve(samples) + } + const sample = (): void => { + const host = [...document.querySelectorAll('[data-conversation-scroll]')] + .find(element => !element.closest('[inert], [hidden]') && element.checkVisibility()) + const column = host?.querySelector('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])') + if (host !== undefined && column !== null && column !== undefined) { + const viewport = host.getBoundingClientRect() + const composerTop = host.querySelector('[data-composer-seat]')?.getBoundingClientRect().top ?? viewport.bottom + const node = [...column.querySelectorAll('p')] + .find(element => element.textContent?.trim() === marker) + const rect = node?.getBoundingClientRect() + const sessionA = column.textContent.includes(identity) + samples.push({ + frame: ordinal++, t: performance.now(), sessionA, + markerY: rect === undefined ? null : rect.top - viewport.top, + markerVisible: rect !== undefined && rect.bottom > viewport.top && rect.top < composerTop, + scrollTop: host.scrollTop, floor: host.scrollHeight - host.clientHeight, + viewportHeight: host.clientHeight, columnHeight: column.getBoundingClientRect().height, + spacer: host.querySelector('[data-chat-turn-spacer]')?.getBoundingClientRect().height ?? 0, + motionEnabled: column.hasAttribute('data-chat-motion'), + motion: [...column.querySelectorAll('[data-chat-motion]')] + .map(element => element.dataset.chatMotion ?? ''), + following: host.querySelector('[data-chat-following-tail]') !== null, + running: host.querySelector('[data-chat-running]') !== null, + responseLength: node?.closest('[data-chat-flow-key]')?.textContent?.length ?? null, + }) + if (sessionA) visibleFrames += 1 + } + if (visibleFrames >= 60) finish() + else frame = requestAnimationFrame(tick) + } + // A zero-delay task samples after this frame's animation callbacks, including native scrolling. + const tick = (): void => { timer = window.setTimeout(sample, 0) } + document.addEventListener('dsh-test-return-stop', finish, { once: true }) + document.documentElement.dataset.returnProbe = 'ready' + frame = requestAnimationFrame(tick) + }), { marker, identity: 'CHAT_SCROLL_RETURN_A_' }) + try { + await page.locator('html[data-return-probe="ready"]').waitFor() + await result.click() + await page.locator('html[data-return-probe="done"]').waitFor({ timeout: 15_000 }) + } finally { + await page.evaluate(() => { document.dispatchEvent(new Event('dsh-test-return-stop')) }) + await trace + await page.evaluate(() => { delete document.documentElement.dataset.returnProbe }) + } + return trace + } + const gates = [Promise.withResolvers(), Promise.withResolvers()] + const held = [false, false] + const dispose = world.scaffold.ctx.on('llm/stream', async function* (_options, next) { + let index = -1 + for await (const chunk of next()) { + if (chunk.type === 'text-delta') { + const gate = gates[index++] + if (gate !== undefined) { + held[index - 1] = true + await gate.promise + } + } + yield chunk + } + }) + let settled: ReturnType | undefined + await page.context().tracing.start({ screenshots: true, snapshots: true }) + try { + await page.locator('[data-composer-input][contenteditable="true"]').last().fill('CHAT_RUNNING_RETURN_NEXT_INPUT') + await page.getByRole('button', { name: 'Send message', exact: true }).click() + await expect.poll(() => held[0]).toBe(true) + await page.getByText(heldMarker, { exact: true }).waitFor() + await page.locator('[data-chat-running]').waitFor() + await page.locator('[data-chat-flow][data-chat-motion=""]').waitFor() + await expect.poll(() => page.locator('[data-chat-motion="collapse"], [data-chat-motion="reveal"]').count()).toBe(0) + await expectBottom(page) + const before = await scrollGeometry(page) + const chunkCount = () => world.assistantFrames.filter(frame => frame.type === 'chunk').length + const heldChunks = chunkCount() + const running = await sampleReturn(heldMarker) + const chunksAfterReturn = chunkCount() + await writeFile(join(evidence, 'running-frames.json'), JSON.stringify({ before, heldChunks, chunksAfterReturn, samples: running }, null, 2)) + await page.screenshot({ path: join(evidence, 'running-held.png') }) + expect(chunksAfterReturn).toBe(heldChunks) + expect(world.events.some(event => event.type === 'turn/end')).toBe(false) + await page.getByRole('button', { name: 'Stop generating', exact: true }).waitFor() + + const beforeGrowth = await scrollGeometry(page) + gates[0]!.resolve(undefined) + await expect.poll(() => held[1]).toBe(true) + await page.getByText(grownMarker, { exact: true }).waitFor() + await expectBottom(page) + const afterGrowth = await scrollGeometry(page) + expect(afterGrowth.scrollTop - beforeGrowth.scrollTop).toBeGreaterThan(200) + expect(await page.locator('[data-chat-following-tail]').count()).toBe(1) + await page.getByRole('button', { name: 'Stop generating', exact: true }).waitFor() + await expectMarkerAboveComposer(page, grownMarker) + settled = world.scaffold.whenTurnSettled() + gates[1]!.resolve(undefined) + await settled + await page.getByText(doneMarker, { exact: true }).waitFor() + await expect.poll(() => page.locator('[data-chat-running], [data-streaming="true"]').count()).toBe(0) + await expectBottom(page) + const completed = await sampleReturn(doneMarker) + await page.screenshot({ path: join(evidence, 'completed.png') }) + const summarize = (samples: ReturnFrame[]) => { + const visible = samples.filter(sample => sample.sessionA) + const first = visible[0]! + const markerMovement = Math.max(...visible.map(sample => + sample.markerY === null || first.markerY === null ? Infinity : Math.abs(sample.markerY - first.markerY))) + return { frames: visible.length, markerMovement, first, last: visible.at(-1)! } + } + const summary = { running: summarize(running), completed: summarize(completed) } + await writeFile(join(evidence, 'return-trace.json'), JSON.stringify({ + viewport: { width: 1280, height: 720 }, mode: 'standard', collapseTiming: 'next-input', + historyTurns: { a: fixtureA.turns, b: fixtureB.turns }, initialTextLength: parts[0]!.length, + before, heldChunks, chunksAfterReturn, beforeGrowth, afterGrowth, summary, running, completed, + }, null, 2)) + console.log(`running-return e2e: ${JSON.stringify(summary)}; evidence=${evidence}`) + for (const [name, samples] of [['completed', completed], ['running', running]] as const) { + const visible = samples.filter(sample => sample.sessionA) + expect(visible.length, `${name} return did not capture its first 60 visible frames`).toBe(60) + expect(visible.every(sample => sample.markerY !== null), `${name} return omitted the response in a visible frame`).toBe(true) + expect(visible.every(sample => sample.running === (name === 'running'))).toBe(true) + expect(new Set(visible.map(sample => sample.responseLength)).size, `${name} response changed while output was held`).toBe(1) + expect(summary[name].markerMovement, `${name} Session moved after its first visible frame; trace=${evidence}`) + .toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + } + await expectBottom(page) + assertClean(world) + } finally { + settled ??= world.events.some(event => event.type === 'turn/start') + && !world.events.some(event => event.type === 'turn/end') ? world.scaffold.whenTurnSettled() : undefined + for (const gate of gates) gate.resolve(undefined) + dispose() + try { + await settled + } finally { + await page.context().tracing.stop({ path: join(evidence, 'browser-trace.zip') }) + } + } + }) + }) it.skipIf(MODE === 'record')('keeps floating controls anchored outside the clipped transcript', async () => { await withScrollWorld({ @@ -1017,6 +1595,9 @@ describe('web e2e: long Chat scroll contract', () => { const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE) const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE) await openSeed(world.page, TOOL_FIXTURE, TOOL_FIXTURE.markers.assistant(TOOL_FIXTURE.turns)) + const resolved = await world.scaffold.ctx.sessionController.resolveAgent(TOOL_SESSION_ID) + if ('error' in resolved) throw resolved.error + world.scaffold.ctx.permissionPresets.set(resolved.agent.session, 'danger-full-access') const settled = world.scaffold.whenTurnSettled(60_000) let released = false try { @@ -1249,6 +1830,9 @@ describe('web e2e: long Chat scroll contract', () => { const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE) const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE) await openSeed(world.page, INPUTS_FIXTURE, INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns)) + const resolved = await world.scaffold.ctx.sessionController.resolveAgent(FLING_SESSION_ID) + if ('error' in resolved) throw resolved.error + world.scaffold.ctx.permissionPresets.set(resolved.agent.session, 'danger-full-access') const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) const settled = world.scaffold.whenTurnSettled(60_000) const releaseText = holdTextAfter(world, 16) diff --git a/apps/web/tests/client-plugin-live.e2e.ts b/apps/web/tests/client-plugin-live.e2e.ts index 21c131a764..db35e15a1f 100644 --- a/apps/web/tests/client-plugin-live.e2e.ts +++ b/apps/web/tests/client-plugin-live.e2e.ts @@ -30,7 +30,7 @@ it('places dynamic Session menu rows by order among the shipped ones and removes const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')] }) onTestFinished(() => scaffold.close()) const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) - const sessionId = await seedSession(scaffold, await readFile(SESSION_SEED, 'utf8'), 'session-menu-actions-web-e2e') + const sessionId = await seedSession(scaffold, await readFile(SESSION_SEED, 'utf8'), 'session-c4421b66-252e-44c0-80ed-6e320abeba25') await workspace.attachSession(sessionId) await scaffold.ctx.sessionController.rename({ sessionId, title: SESSION_TITLE }) const entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' }) @@ -71,13 +71,18 @@ it('places dynamic Session menu rows by order among the shipped ones and removes await row.hover() await trigger.click() await menu.waitFor() - const remainingNames = ['Pin session', 'Rename', 'Fork session', 'Archive session'] + const remainingNames = ['Pin session', 'Rename', 'Fork session', 'Archive session', 'Copy Session ID'] const remainingItems = menu.getByRole('menuitem') expect(await remainingItems.count()).toBe(remainingNames.length) for (const [index, name] of remainingNames.entries()) { expect(await remainingItems.nth(index).and(menu.getByRole('menuitem', { name, exact: true })).count()).toBe(1) } expect(await menu.getByRole('separator').count()).toBe(0) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + await menu.getByRole('menuitem', { name: 'Copy Session ID', exact: true }).click() + await expect.poll(() => menu.count()).toBe(0) + await page.getByRole('alert').filter({ hasText: 'Session ID copied' }).waitFor() + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(sessionId) expect(console.pageErrors).toEqual([]) await assertFixtureInventory(EXPECTED, [ 'bootstrap-rebuild.expected.md', 'enabled.expected.md', 'recovered.expected.md', diff --git a/apps/web/tests/collapse-timing.e2e.ts b/apps/web/tests/collapse-timing.e2e.ts index a33867b404..0c6a2ecf02 100644 --- a/apps/web/tests/collapse-timing.e2e.ts +++ b/apps/web/tests/collapse-timing.e2e.ts @@ -61,7 +61,7 @@ describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => { page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.getByRole('button', { name: 'Settings', exact: true }).waitFor() + await page.getByRole('button', { name: 'Account menu', exact: true }).waitFor() }) afterAll(async () => { diff --git a/apps/web/tests/composer-focus.e2e.ts b/apps/web/tests/composer-focus.e2e.ts new file mode 100644 index 0000000000..39c73a4937 --- /dev/null +++ b/apps/web/tests/composer-focus.e2e.ts @@ -0,0 +1,151 @@ +/** Browser selection and focus after presses in the composer's unused space. */ +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { chromium, type Page } from 'playwright' +import { expect, it, onTestFailed, onTestFinished } from 'vitest' +import { + assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, +} from './scaffold.ts' +import { createChatScrollFixture } from './chat-scroll-fixture.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/composer-focus', import.meta.url)) +const EXPECTED = join(SNAPSHOT_DIR, 'focus.expected.md') + +/** + * Observe focus and both ends of the browser selection, including its direction. + * @param page - Page containing the active composer. + * @returns The editor's focus, draft and DOM selection. + */ +async function selectionState(page: Page) { + return await page.locator('[data-composer-input]').evaluate((input) => { + const selection = window.getSelection() + return { + focused: document.activeElement === input, + draft: input.textContent, + text: selection?.toString(), + anchor: selection?.anchorOffset, + focus: selection?.focusOffset, + } + }) +} + +/** + * Format observed browser focus and selection for the golden file. + * @param state - Current browser selection. + * @returns Focus, selected text and directed offsets. + */ +function describeSelection(state: Awaited>): string { + return `${state.focused ? 'focused' : 'unfocused'}; selection ${JSON.stringify(state.text)} (${state.anchor} → ${state.focus})` +} + +/** + * Exercise the card's top, inter-row gap, toolbar gap and bottom padding. + * @param page - Page containing an editable composer. + * @returns Stable behavior observations for the expected-output file. + */ +async function exerciseComposer(page: Page): Promise { + const input = page.locator('[data-composer-input][contenteditable="true"]') + await input.click() + // Let the editor observe its insertText selection before native arrow moves; + // a coalesced first notification can otherwise be treated as its own update. + await input.evaluate((el) => { + const onSelectionChange = (): void => { + const selection = el.ownerDocument.getSelection() + if (selection === null || !el.contains(selection.anchorNode) || !el.contains(selection.focusNode)) return + if (selection.isCollapsed && selection.anchorOffset === 16 && el.textContent === 'alpha beta gamma') { + el.dataset.selectionReady = 'inserted' + return + } + if (selection.toString() !== 'beta' || selection.anchorOffset !== 10 || selection.focusOffset !== 6) return + el.dataset.selectionReady = 'selected' + el.ownerDocument.removeEventListener('selectionchange', onSelectionChange) + } + el.ownerDocument.addEventListener('selectionchange', onSelectionChange) + }) + await page.keyboard.insertText('alpha beta gamma') + await expect.poll(() => input.getAttribute('data-selection-ready')).toBe('inserted') + await page.keyboard.press('Home') + for (let index = 0; index < 10; index++) await page.keyboard.press('ArrowRight') + for (let index = 0; index < 4; index++) await page.keyboard.press('Shift+ArrowLeft') + const selected = { focused: true, draft: 'alpha beta gamma', text: 'beta', anchor: 10, focus: 6 } + await expect.poll(() => selectionState(page)).toEqual(selected) + + const points = await page.locator('[data-composer-card]').evaluate((card) => { + const box = card.getBoundingClientRect() + const scroll = card.querySelector('[data-input-scroll]')!.getBoundingClientRect() + const rowElement = card.querySelector('[data-input-scroll]')!.nextElementSibling! + const row = rowElement.getBoundingClientRect() + const tools = rowElement.firstElementChild!.getBoundingClientRect() + const trailing = rowElement.lastElementChild!.getBoundingClientRect() + if (trailing.left <= tools.right) throw new Error('Composer toolbar has no unused gap') + return [ + { name: 'top padding', x: box.x + box.width / 2, y: (box.top + scroll.top) / 2 }, + { name: 'row gap', x: box.x + box.width / 2, y: (scroll.bottom + row.top) / 2 }, + { name: 'toolbar gap', x: (tools.right + trailing.left) / 2, y: row.top + row.height / 2 }, + { name: 'bottom padding', x: box.x + box.width / 2, y: box.bottom - 2 }, + ] + }) + const observations: string[] = [] + for (const point of points) { + await page.mouse.click(point.x, point.y) + await expect.poll(() => selectionState(page)).toEqual(selected) + observations.push(`- ${point.name}: ${describeSelection(await selectionState(page))}`) + } + + await expect.poll(() => input.getAttribute('data-selection-ready')).toBe('selected') + await input.evaluate((el) => { delete el.dataset.selectionReady }) + const top = points[0]! + const cardBox = await page.locator('[data-composer-card]').boundingBox() + await page.mouse.click(cardBox!.x - 12, top.y) + await expect.poll(async () => (await selectionState(page)).focused).toBe(false) + await page.mouse.click(top.x, top.y) + await expect.poll(() => selectionState(page)).toEqual(selected) + observations.push(`- return from outside: ${describeSelection(await selectionState(page))}`) + + await page.keyboard.insertText('BETA') + await expect.poll(() => input.textContent()).toBe('alpha BETA gamma') + await page.keyboard.press('ArrowLeft') + const caret = { focused: true, draft: 'alpha BETA gamma', text: '', anchor: 9, focus: 9 } + await expect.poll(() => selectionState(page)).toEqual(caret) + await page.mouse.click(top.x, top.y) + await expect.poll(() => selectionState(page)).toEqual(caret) + await page.keyboard.insertText('!') + await expect.poll(() => input.textContent()).toBe('alpha BET!A gamma') + observations.push(`- typing replaces the restored selection and resumes at the restored caret: ${await input.textContent()}`) + return observations +} + +it('restores the previous caret or selection in fresh and existing sessions', async () => { + const scaffold = await launchWebScaffold({}) + const browserPromise = chromium.launch() + onTestFinished(async () => { + try { await (await browserPromise).close() } + finally { await scaffold.close() } + }) + const browser = await browserPromise + const fixture = createChatScrollFixture({ markerPrefix: 'COMPOSER_FOCUS', title: 'Composer focus', turns: 1 }) + await seedSession(scaffold, fixture.log, 'composer-focus-web-e2e') + const page = await newEnglishPage(browser) + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-focus')) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-focus') + const hero = await exerciseComposer(page) + + await page.getByRole('button', { name: 'Search sessions' }).click() + await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1)) + const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1) + await result.click() + await page.getByText(fixture.markers.assistant(1), { exact: false }).last().waitFor() + const docked = await exerciseComposer(page) + + await compareOrRefreshGolden(EXPECTED, [ + '# Composer whitespace focus', '', '## Fresh session', '', ...hero, + '', '## Existing session', '', ...docked, + ].join('\n'), webSnapshotMode()) + await assertFixtureInventory(SNAPSHOT_DIR, ['focus.expected.md']) + if (webSnapshotMode() !== 'record') expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) +}, 180_000) diff --git a/apps/web/tests/cot-translation-paid.e2e.ts b/apps/web/tests/cot-translation-paid.e2e.ts index 3b04ece1e7..572118b7eb 100644 --- a/apps/web/tests/cot-translation-paid.e2e.ts +++ b/apps/web/tests/cot-translation-paid.e2e.ts @@ -58,6 +58,13 @@ async function messageBody(request: IncomingMessage): Promise } +/** Account model listing at the Messages origin; the version name is arbitrary because selection uses the id. */ +function listing(response: ServerResponse): void { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ object: 'list', data: [{ id: 'deepseek-flash', name: 'Native Flash arbitrary future version', + api_capabilities: { anthropic_messages: {} } }] })) +} + /** Complete external native Messages SSE; no Host method or adapter is replaced. */ function reply(response: ServerResponse, text: string): void { response.writeHead(200, { 'content-type': 'text/event-stream' }) @@ -81,6 +88,7 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr let holdNext = false async function respond(request: IncomingMessage, response: ServerResponse): Promise { const path = new URL(request.url ?? '/', 'http://native.test').pathname + if (path === '/models') { listing(response); return } if (path !== '/anthropic/v1/messages') throw new Error(`Unexpected external translation request: ${path}`) const auditSessionId = resources.sessionId if (auditSessionId === undefined || resources.scaffold === undefined) throw new Error('Paid I/O preceded the recorded Session') @@ -123,8 +131,7 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr const origin = `http://127.0.0.1:${address.port}` const overlay = join(root, 'paid-translation.patch.yml') await writeFile(overlay, JSON.stringify([ - { id: 'llm-deepseek-account', config: { baseURL: `${origin}/anthropic`, reasoningEffort: 'high', - models: [{ id: 'deepseek-flash', name: 'Native Flash arbitrary future version' }] } }, + { id: 'llm-deepseek-account', config: { baseURL: `${origin}/anthropic`, reasoningEffort: 'high' } }, { id: 'translator', config: { bingEndpoint: `${origin}/unexpected-free`, googleEndpoint: `${origin}/unexpected-free`, deepseekTimeoutMs: 120_000 } }, ])) diff --git a/apps/web/tests/deepseek-messages-settings.e2e.ts b/apps/web/tests/deepseek-messages-settings.e2e.ts index 52a3fd09a3..225cb37062 100644 --- a/apps/web/tests/deepseek-messages-settings.e2e.ts +++ b/apps/web/tests/deepseek-messages-settings.e2e.ts @@ -39,6 +39,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett expect(scaffold.ctx.llm.listProviders()).toContainEqual({ id: 'deepseek-official', name: 'DeepSeek' }) expect(scaffold.ctx.llm.listProviders().filter(provider => provider.id === 'deepseek-official')).toHaveLength(1) expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-official', model: 'deepseek-flash' }) + await page.getByRole('dialog', { name: '开始使用', exact: true }).getByRole('button', { name: '添加 API Key', exact: true }).click() const onboarding = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }) await onboarding.getByLabel('API 密钥', { exact: true }).fill('sk-messages-onboarding') await onboarding.getByRole('button', { name: '保存并继续' }).click() @@ -52,7 +53,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett const messages = dialog await messages.getByText('自定义设置', { exact: true }).click() expect(await messages.getByLabel('API 地址', { exact: true }).getAttribute('placeholder')) - .toBe('https://api.deepseek.com/anthropic') + .toBe('https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL)') await compareOrRefreshGolden(join(EXPECTED, 'cards.expected.md'), await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode()) await messages.getByLabel('API 密钥', { exact: true }).fill('sk-e2e-messages') @@ -96,14 +97,18 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) await page.reload({ waitUntil: 'load' }) const input = page.locator('[data-composer-input]').first() - const trigger = page.getByRole('button', { name: /^选择模型.*deepseek-official\/deepseek-v4-flash/ }) + const trigger = page.getByRole('button', { name: '请选择模型', exact: true }) await trigger.waitFor() + expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ + provider: 'deepseek-official', model: 'deepseek-v4-flash', + }) await expect.poll(() => input.getAttribute('contenteditable')).toBe('true') await trigger.click() - await page.getByRole('menuitem', { name: /模型/ }).click() await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).click() await expect.poll(() => input.getAttribute('contenteditable')).toBe('true') - await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().provider).toBe('deepseek-official') + await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection()).toMatchObject({ + provider: 'deepseek-official', model: 'deepseek-flash', + }) const settings = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8') expect(settings).toContain('provider: deepseek-official') expect(tripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index d87bf4ce4d..72ebfb69c2 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -487,14 +487,13 @@ describe('web e2e: the composer model switch is the default for later sessions', baseURL: 'https://gateway.origin.example/v1', models: [{ id: START_MODEL, name: 'Origin Large' }] }, } }) - const seat = page.getByRole('button', { name: new RegExp(`^选择模型.*${ROUTE}/${MODEL}`) }) + const seat = page.getByRole('button', { name: '请选择模型', exact: true }) await seat.waitFor() expect(await box.isEnabled()).toBe(true) expect(scaffold.ctx.agentDefaultModel.currentSelection()).toMatchObject({ provider: ROUTE, model: MODEL }) const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd) await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/unselected.expected.md', import.meta.url)), aria, webSnapshotMode()) await seat.click() - await page.getByRole('menuitem', { name: /模型/ }).click() await page.getByRole('menuitemradio').first().click() await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(true) expect(tripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/desktop-onboarding.e2e.ts b/apps/web/tests/desktop-onboarding.e2e.ts index e512813ffc..4f0b527885 100644 --- a/apps/web/tests/desktop-onboarding.e2e.ts +++ b/apps/web/tests/desktop-onboarding.e2e.ts @@ -100,6 +100,16 @@ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () => async function snapshot(name: string): Promise { await expect.poll(() => page.locator('[data-desktop-onboarding]').getAttribute('aria-busy')).toBe('false') + const hasIllustration = name.startsWith('welcome') || name.startsWith('credit') + if (hasIllustration) { + const decoded = await page.locator('[class*="illustration"] > img').evaluateAll(async elements => + Promise.all(elements.map(async (element) => { + const image = element as HTMLImageElement + await image.decode() + return image.naturalWidth > 0 && image.naturalHeight > 0 + }))) + expect(decoded).toEqual([true, true]) + } const aria = await captureStableAria(page, '[data-desktop-onboarding]', scaffold.workspaceCwd) await mkdir(EXPECTED, { recursive: true }) await compareOrRefreshGolden(join(EXPECTED, `${name}.expected.md`), aria, MODE) @@ -171,7 +181,7 @@ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () => expect(action?.width).toBeCloseTo(240, 0) expect(action!.y - (card!.y + card!.height)).toBeCloseTo(40, 0) } - if (name === 'welcome' || name === 'credit') { + if (hasIllustration) { const illustrations = await page.locator('[class*="illustration"] > img:visible').evaluateAll(elements => elements.map((element) => { const image = element as HTMLImageElement const box = image.getBoundingClientRect() diff --git a/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md b/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md index b9967c9722..6c7320a3da 100644 --- a/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md +++ b/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md @@ -1,8 +1,8 @@ -- dialog "确认启用 Auto review(实验)?": - - heading "确认启用 Auto review(实验)?" [level=2] +- dialog "确认启用自动审查(实验)?": + - heading "确认启用自动审查(实验)?" [level=2] - button "关闭" - - paragraph: Auto review 不使用沙箱。每次原生工具调用和 PTC 内层调用前,都会由与当前 agent 相同的模型进行审查;审查拒绝的调用由你批准或拒绝。此功能仍属实验性,可能误放行或误拒绝,并会消耗额外 token。 + - paragraph: 自动审查不使用沙箱。每次原生工具调用和 PTC 内层调用前,都会由与当前 agent 相同的模型进行审查;审查拒绝的调用由你批准或拒绝。此功能仍属实验性,可能误放行或误拒绝,并会消耗额外 token。 - checkbox "我已了解这些风险,并愿意继续" - text: 我已了解这些风险,并愿意继续 - button "取消" - - button "启用 Auto review" [disabled] + - button "启用自动审查" [disabled] diff --git a/apps/web/tests/expected/access-confirmation/current-session-picker.expected.md b/apps/web/tests/expected/access-confirmation/current-session-picker.expected.md index 9e942e44b5..688540b20e 100644 --- a/apps/web/tests/expected/access-confirmation/current-session-picker.expected.md +++ b/apps/web/tests/expected/access-confirmation/current-session-picker.expected.md @@ -2,6 +2,4 @@ - menuitem "仅可查看" - menuitem "工作区内修改" - menuitem "完全权限" - - menuitem "Auto review EXP": - - text: Auto review - - superscript: EXP + - menuitem "自动审查 EXP" diff --git a/apps/web/tests/expected/access-confirmation/slash-picker.expected.md b/apps/web/tests/expected/access-confirmation/slash-picker.expected.md index f1b12d28b0..cf54b54669 100644 --- a/apps/web/tests/expected/access-confirmation/slash-picker.expected.md +++ b/apps/web/tests/expected/access-confirmation/slash-picker.expected.md @@ -4,7 +4,7 @@ - option "仅可查看" - option "工作区内修改" [selected] - option "完全权限" - - option "Auto review EXP": - - text: Auto review + - option "自动审查 EXP": + - text: 自动审查 - superscript: EXP - text: 无沙箱运行;每次原生工具调用和 PTC 内层调用前由同一模型进行实验性审查。 diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index a7d47106e1..dd8628cd57 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -31,7 +31,7 @@ - 'button "设为新任务默认: 极简模式"': - text: 极简模式 内置 - code: minimal - - text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。 + - text: Agent 使用终端和图片读取工具,并支持上下文压缩,适合测试和对比其基础表现。 - 'button "模式说明: 极简模式"': 模式说明 - 'button "如何使用: 极简模式"': 如何使用 - 'button "查看配置: 极简模式"': 查看配置 diff --git a/apps/web/tests/expected/agent-preset-selection/header.expected.md b/apps/web/tests/expected/agent-preset-selection/header.expected.md index 81cc3c1175..2b87066124 100644 --- a/apps/web/tests/expected/agent-preset-selection/header.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/header.expected.md @@ -1,5 +1,5 @@ - navigation "Session hierarchy": Seeded turn -- button "1 subagent" - text: Minimal mode +- button "1 subagent" - button "More actions" - button "Open right sidebar" diff --git a/apps/web/tests/expected/agent-preset-selection/menu.expected.md b/apps/web/tests/expected/agent-preset-selection/menu.expected.md index 2f715b99b1..4372c504c0 100644 --- a/apps/web/tests/expected/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/menu.expected.md @@ -1,5 +1,5 @@ - menu: - menuitem "Standard mode Work with code, files, and information. Suitable for most tasks, with search, editing, terminal commands, and other tools available as needed." - menuitem "PTC mode Includes all Standard mode capabilities. Better suited to tasks that call tools in batches and then filter, organize, deduplicate, count, or summarize the results." - - menuitem "Minimal mode The agent works using only a terminal tool. Useful for testing and comparing its basic performance." + - menuitem "Minimal mode The agent uses a terminal and image reading, with context compaction. Useful for testing and comparing its basic performance." - menuitem "Creator mode Customize DSH through conversation. Let the agent write plugins that add features or UI, or combine tools and prompts to create your own mode." diff --git a/apps/web/tests/expected/client-plugin-live/session-actions.expected.md b/apps/web/tests/expected/client-plugin-live/session-actions.expected.md index f3c45cace9..51df861f77 100644 --- a/apps/web/tests/expected/client-plugin-live/session-actions.expected.md +++ b/apps/web/tests/expected/client-plugin-live/session-actions.expected.md @@ -3,6 +3,7 @@ - menuitem "Rename" - menuitem "Fork session" - menuitem "Archive session" + - menuitem "Copy Session ID" - separator - menuitem "Export session" - menuitem "Copy session ID" diff --git a/apps/web/tests/expected/composer-focus/focus.expected.md b/apps/web/tests/expected/composer-focus/focus.expected.md new file mode 100644 index 0000000000..03e7a8dbec --- /dev/null +++ b/apps/web/tests/expected/composer-focus/focus.expected.md @@ -0,0 +1,19 @@ +# Composer whitespace focus + +## Fresh session + +- top padding: focused; selection "beta" (10 → 6) +- row gap: focused; selection "beta" (10 → 6) +- toolbar gap: focused; selection "beta" (10 → 6) +- bottom padding: focused; selection "beta" (10 → 6) +- return from outside: focused; selection "beta" (10 → 6) +- typing replaces the restored selection and resumes at the restored caret: alpha BET!A gamma + +## Existing session + +- top padding: focused; selection "beta" (10 → 6) +- row gap: focused; selection "beta" (10 → 6) +- toolbar gap: focused; selection "beta" (10 → 6) +- bottom padding: focused; selection "beta" (10 → 6) +- return from outside: focused; selection "beta" (10 → 6) +- typing replaces the restored selection and resumes at the restored caret: alpha BET!A gamma diff --git a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md index 7275ed50b2..44be4fbdaf 100644 --- a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md +++ b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md @@ -2,7 +2,7 @@ - button "卸载 思考过程机器翻译" [disabled]: 卸载 - switch "启用 思考过程机器翻译" [checked] - heading "思考过程机器翻译" [level=3] -- text: v0.2.1-alpha.1 实验性 +- text: v0.2.1-alpha.2 实验性 - paragraph: - code: "@deepseek-ai/dsh-experimental-cot-translation-bundle" - paragraph: 机器翻译展开的思考内容;原文会发送给所选服务 @@ -39,4 +39,4 @@ - term: 代码来源 - definition: 内置 - term: 当前版本 -- definition: 0.2.1-alpha.1 +- definition: 0.2.1-alpha.2 diff --git a/apps/web/tests/expected/cot-translation/preferences.expected.md b/apps/web/tests/expected/cot-translation/preferences.expected.md index f4735cc02e..14f544b50b 100644 --- a/apps/web/tests/expected/cot-translation/preferences.expected.md +++ b/apps/web/tests/expected/cot-translation/preferences.expected.md @@ -2,7 +2,7 @@ - button "卸载 思考过程机器翻译" [disabled]: 卸载 - switch "启用 思考过程机器翻译" [checked] - heading "思考过程机器翻译" [level=3] -- text: v0.2.1-alpha.1 实验性 +- text: v0.2.1-alpha.2 实验性 - paragraph: - code: "@deepseek-ai/dsh-experimental-cot-translation-bundle" - paragraph: 机器翻译展开的思考内容;原文会发送给所选服务 @@ -36,4 +36,4 @@ - term: 代码来源 - definition: 内置 - term: 当前版本 -- definition: 0.2.1-alpha.1 +- definition: 0.2.1-alpha.2 diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 271acfa0a2..55ebc9b91b 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -15,12 +15,14 @@ - img "API 密钥已配置" - button "编辑 DeepSeek (deepseek-official)": 编辑 - text: DeepSeek deepseek-official API 密钥 + - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 已配置——输入新值可替换 + - /placeholder: 留空时保留现有认证(DEEPSEEK_API_KEY) + - text: API 密钥来源:已保存凭据 - group: - text: 自定义设置 API 地址 - textbox "API 地址": - - /placeholder: https://api.deepseek.com/anthropic + - /placeholder: https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL) - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 正在使用适配器默认模型 diff --git a/apps/web/tests/expected/default-model/unselected.expected.md b/apps/web/tests/expected/default-model/unselected.expected.md index 8845f5196b..b5b50682be 100644 --- a/apps/web/tests/expected/default-model/unselected.expected.md +++ b/apps/web/tests/expected/default-model/unselected.expected.md @@ -1,5 +1,5 @@ - textbox "描述你想要构建的内容, / 调用指令, @ 文件或对话" - button "添加文件或调用指令" - button "访问模式,当前:工作区内修改": 工作区内修改 -- button "选择模型,当前 acme-gateway/acme-large": acme-gateway/acme-large +- button "请选择模型" - button "发送消息" [disabled] diff --git a/apps/web/tests/expected/jobs-menu-layering/geometry.expected.md b/apps/web/tests/expected/jobs-menu-layering/geometry.expected.md new file mode 100644 index 0000000000..446843f9dc --- /dev/null +++ b/apps/web/tests/expected/jobs-menu-layering/geometry.expected.md @@ -0,0 +1,35 @@ +# Background jobs above the Browser dock + +```json +{ + "collapsed": { + "dockMode": "push", + "bodyMounted": true, + "fixed": true, + "anchored": true, + "viewportBounded": true, + "overlapsBrowser": true, + "aboveBrowser": true + }, + "expanded": { + "dockMode": "push", + "bodyMounted": true, + "fixed": true, + "anchored": true, + "viewportBounded": true, + "overlapsBrowser": true, + "aboveBrowser": true + }, + "resized": { + "dockMode": "push", + "bodyMounted": true, + "fixed": true, + "anchored": true, + "viewportBounded": true, + "overlapsBrowser": true, + "aboveBrowser": true + }, + "escapeRestoresFocus": true, + "outsideDismisses": true +} +``` diff --git a/apps/web/tests/expected/menu-material/session-menu.expected.md b/apps/web/tests/expected/menu-material/session-menu.expected.md index 88405f3950..ebb1786386 100644 --- a/apps/web/tests/expected/menu-material/session-menu.expected.md +++ b/apps/web/tests/expected/menu-material/session-menu.expected.md @@ -3,3 +3,4 @@ - menuitem "Rename" - menuitem "Fork session" - menuitem "Archive session" + - menuitem "Copy Session ID" diff --git a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md index d1047bfbb2..878d9eaf67 100644 --- a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md +++ b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md @@ -20,12 +20,13 @@ - button "编辑 openai": 编辑 - button "删除 openai": 删除 - text: openai API 密钥 + - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 输入 API 密钥,或留空使用环境认证 + - /placeholder: 留空时保留现有认证(OPENAI_API_KEY) - group: - text: 自定义设置 API 地址 - textbox "API 地址": - - /placeholder: 提供商默认 + - /placeholder: https://api.openai.com/v1 - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index 6bc07d9474..e0c3479659 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -21,7 +21,7 @@ - button "删除 Acme Gateway (acme-gateway)": 删除 - text: Acme Gateway acme-gateway API 密钥 - textbox "API 密钥": - - /placeholder: 输入 API 密钥,或留空使用环境认证 + - /placeholder: 输入 API 密钥 - group: - text: 自定义设置 显示名称 - textbox "显示名称": diff --git a/apps/web/tests/expected/models-settings/empty.expected.md b/apps/web/tests/expected/models-settings/empty.expected.md index 5c2b5c0b72..795e2bb440 100644 --- a/apps/web/tests/expected/models-settings/empty.expected.md +++ b/apps/web/tests/expected/models-settings/empty.expected.md @@ -20,7 +20,7 @@ - option "amazon-bedrock" - option "ant-ling" - option "anthropic" - - option "azure-openai-responses" + - option "azure" - option "baseten" - option "cerebras" - option "cloudflare-ai-gateway" @@ -59,8 +59,9 @@ - option "zai" - option "zai-coding-cn" - text: API 密钥 + - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 输入 API 密钥,或留空使用环境认证 + - /placeholder: 留空时保留现有认证(MINIMAX_CN_API_KEY) - group: 自定义设置 - button "取消" - button "保存" diff --git a/apps/web/tests/expected/models-settings/image-only.expected.md b/apps/web/tests/expected/models-settings/image-only.expected.md index 55b5f103a0..ccde68253e 100644 --- a/apps/web/tests/expected/models-settings/image-only.expected.md +++ b/apps/web/tests/expected/models-settings/image-only.expected.md @@ -21,7 +21,7 @@ - button "删除 Acme 网关 (acme-gateway)": 删除 - text: Acme 网关 acme-gateway API 密钥 - textbox "API 密钥": - - /placeholder: 输入 API 密钥,或留空使用环境认证 + - /placeholder: 输入 API 密钥 - group: - text: 自定义设置 显示名称 - textbox "显示名称": diff --git a/apps/web/tests/expected/models-settings/native-keyless.expected.md b/apps/web/tests/expected/models-settings/native-keyless.expected.md new file mode 100644 index 0000000000..e8ae4de994 --- /dev/null +++ b/apps/web/tests/expected/models-settings/native-keyless.expected.md @@ -0,0 +1,63 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "打开配置文件" + - button "关闭" + - heading "模型" [level=2] + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 + - list + - tablist "添加方式": + - tab "第三方模型提供商" [selected] + - tab "自定义模型 API" + - paragraph: 从内置目录中选择 OpenAI、Anthropic、Kimi 等提供商,填入其 API 密钥即可使用。 + - tabpanel "第三方模型提供商": + - text: 提供商 + - combobox "提供商": + - option "amazon-bedrock" + - option "ant-ling" + - option "anthropic" + - option "azure" + - option "baseten" + - option "cerebras" + - option "cloudflare-ai-gateway" + - option "cloudflare-workers-ai" + - option "deepseek" + - option "fireworks" + - option "github-copilot" + - option "google" + - option "google-vertex" + - option "groq" + - option "huggingface" + - option "kimi-coding" + - option "meta" + - option "minimax" + - option "minimax-cn" + - option "mistral" + - option "moonshotai" + - option "moonshotai-cn" + - option "nvidia" + - option "openai" + - option "openai-codex" [selected] + - option "opencode" + - option "opencode-go" + - option "openrouter" + - option "qwen-token-plan" + - option "qwen-token-plan-cn" + - option "qwen-token-plan-individual" + - option "radius" + - option "together" + - option "vercel-ai-gateway" + - option "xai" + - option "xiaomi" + - option "xiaomi-token-plan-ams" + - option "xiaomi-token-plan-cn" + - option "xiaomi-token-plan-sgp" + - option "zai" + - option "zai-coding-cn" + - group: 自定义设置 + - button "取消" + - button "保存" diff --git a/apps/web/tests/expected/navigation-panes/command-wrap.expected.md b/apps/web/tests/expected/navigation-panes/command-wrap.expected.md new file mode 100644 index 0000000000..cafac4c6df --- /dev/null +++ b/apps/web/tests/expected/navigation-panes/command-wrap.expected.md @@ -0,0 +1,14 @@ +[ + { + "colorScheme": "light", + "wrapped": true, + "text": "echo NAVIGATION_OK", + "copiedText": "echo NAVIGATION_OK" + }, + { + "colorScheme": "dark", + "wrapped": true, + "text": "echo NAVIGATION_OK", + "copiedText": "echo NAVIGATION_OK" + } +] diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index a4dfae4aa1..9e0deb4b08 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -15,12 +15,14 @@ - img "API 密钥已配置" - button "编辑 DeepSeek (deepseek-official)": 编辑 - text: DeepSeek deepseek-official API 密钥 + - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 已配置——输入新值可替换 + - /placeholder: 留空时保留现有认证(DEEPSEEK_API_KEY) + - text: API 密钥来源:已保存凭据 - group: - text: 自定义设置 API 地址 - textbox "API 地址": - - /placeholder: https://api.deepseek.com/anthropic + - /placeholder: https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL) - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 正在使用适配器默认模型 diff --git a/apps/web/tests/expected/onboarding-deepseek-config/empty.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/empty.expected.md new file mode 100644 index 0000000000..c0a161b0e3 --- /dev/null +++ b/apps/web/tests/expected/onboarding-deepseek-config/empty.expected.md @@ -0,0 +1,2 @@ +- group "模型与推理等级": + - status: 暂无可用模型 diff --git a/apps/web/tests/expected/onboarding-deepseek-config/missing.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/missing.expected.md index d42cf40681..374d7be11e 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/missing.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/missing.expected.md @@ -2,7 +2,8 @@ - heading "添加一个 API Key 开始使用" [level=2] - paragraph: 配置 DeepSeek 官方模型,即可开始使用。 - text: API 密钥 + - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 输入 API 密钥 + - /placeholder: 留空时保留现有认证(DEEPSEEK_API_KEY) - button "稍后配置" - button "保存并继续" [disabled] diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 84a7261b65..2bf65bfc78 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -15,12 +15,14 @@ - img "API 密钥已配置" - button "编辑 DeepSeek (deepseek-official)": 编辑 - text: DeepSeek deepseek-official API 密钥 + - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 已配置——输入新值可替换 + - /placeholder: 留空时保留现有认证(DEEPSEEK_API_KEY) + - text: API 密钥来源:已保存凭据 - group: - text: 自定义设置 API 地址 - textbox "API 地址": - - /placeholder: https://api.deepseek.com/anthropic + - /placeholder: https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL) - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 已自定义模型目录 diff --git a/apps/web/tests/expected/onboarding-deepseek-config/sign-in.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/sign-in.expected.md new file mode 100644 index 0000000000..a689c62560 --- /dev/null +++ b/apps/web/tests/expected/onboarding-deepseek-config/sign-in.expected.md @@ -0,0 +1,6 @@ +- dialog "开始使用": + - heading "开始使用" [level=2] + - button "关闭" + - paragraph: 登录 DeepSeek 账号,或添加 API Key,即可开始使用。你的项目和文件保存在本地。 + - button "添加 API Key" + - button "登录" diff --git a/apps/web/tests/expected/onboarding-native/models.expected.md b/apps/web/tests/expected/onboarding-native/models.expected.md index ce149bad3e..f8503c31f1 100644 --- a/apps/web/tests/expected/onboarding-native/models.expected.md +++ b/apps/web/tests/expected/onboarding-native/models.expected.md @@ -12,8 +12,9 @@ - list: - listitem: - text: DeepSeek deepseek-official API 密钥 + - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 输入 API 密钥 + - /placeholder: 留空时保留现有认证(DEEPSEEK_API_KEY) - group: 自定义设置 - button "取消" - button "保存" diff --git a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md index e65df1f213..9d9bae17dd 100644 --- a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md @@ -24,7 +24,7 @@ - option "amazon-bedrock" - option "ant-ling" - option "anthropic" - - option "azure-openai-responses" + - option "azure" - option "baseten" - option "cerebras" - option "cloudflare-ai-gateway" @@ -63,8 +63,9 @@ - option "zai" - option "zai-coding-cn" - text: API 密钥 + - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 输入 API 密钥,或留空使用环境认证 + - /placeholder: 留空时保留现有认证(MINIMAX_CN_API_KEY) - group: 自定义设置 - button "取消" - button "保存" diff --git a/apps/web/tests/expected/plugin-manager/header-responsive.expected.md b/apps/web/tests/expected/plugin-manager/header-responsive.expected.md new file mode 100644 index 0000000000..7967bfe352 --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/header-responsive.expected.md @@ -0,0 +1,19 @@ +# Plugin header responsive layout + +en 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained +en 768px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained +en 520px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained +en 440px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained +en 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained +en 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained +en 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained +zh 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained +zh 768px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained +zh 520px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained +zh 440px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained +zh 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained +zh 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained +zh 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained + +A forced no-wrap layout at 390px fails the same measured layout check in both locales; restoring wrap passes. +Add menu dismisses on Escape and returns focus in both locales. diff --git a/apps/web/tests/expected/schedule-header.expected.md b/apps/web/tests/expected/schedule-header.expected.md new file mode 100644 index 0000000000..96bff76bee --- /dev/null +++ b/apps/web/tests/expected/schedule-header.expected.md @@ -0,0 +1,17 @@ +# Automation tasks header + +en 1680px: title and New single-line, New beside the title; contained; New retains focus +en 800px: title and New single-line, New beside the title; contained; New retains focus +en 520px: title and New single-line, New beside the title; contained; New retains focus +en 440px: title and New single-line, New beside the title; contained; New retains focus +en 390px: title and New single-line, New beside the title; contained; New retains focus +en 430px: title and New single-line, New beside the title; contained; New retains focus +en 1024px: title and New single-line, New beside the title; contained; New retains focus +en 509px with sidebar expanded: title single-line; New moves below the title +zh 1680px: title and New single-line, New beside the title; contained; New retains focus +zh 800px: title and New single-line, New beside the title; contained; New retains focus +zh 520px: title and New single-line, New beside the title; contained; New retains focus +zh 440px: title and New single-line, New beside the title; contained; New retains focus +zh 390px: title and New single-line, New beside the title; contained; New retains focus +zh 430px: title and New single-line, New beside the title; contained; New retains focus +zh 1024px: title and New single-line, New beside the title; contained; New retains focus diff --git a/apps/web/tests/expected/seeded-history/blank-reasoning.expected.md b/apps/web/tests/expected/seeded-history/blank-reasoning.expected.md new file mode 100644 index 0000000000..ab493a1aff --- /dev/null +++ b/apps/web/tests/expected/seeded-history/blank-reasoning.expected.md @@ -0,0 +1,4 @@ +- button "Think" [expanded] +- paragraph: The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. + +- Reasoning rows: 1 diff --git a/apps/web/tests/expected/session-row-hover/actions.expected.json b/apps/web/tests/expected/session-row-hover/actions.expected.json new file mode 100644 index 0000000000..2423722518 --- /dev/null +++ b/apps/web/tests/expected/session-row-hover/actions.expected.json @@ -0,0 +1,46 @@ +[ + { + "stage": "details", + "details": true, + "tooltips": [] + }, + { + "stage": "more-hover", + "details": true, + "tooltips": [] + }, + { + "stage": "archive-before-dwell", + "details": true, + "tooltips": [] + }, + { + "stage": "archive", + "details": true, + "tooltips": [ + "Archive" + ] + }, + { + "stage": "pin", + "details": true, + "tooltips": [ + "Pin" + ] + }, + { + "stage": "back-to-title", + "details": true, + "tooltips": [] + }, + { + "stage": "menu-closed", + "details": false, + "tooltips": [] + }, + { + "stage": "row-reentered", + "details": true, + "tooltips": [] + } +] diff --git a/apps/web/tests/expected/session-row-hover/grouped.expected.json b/apps/web/tests/expected/session-row-hover/grouped.expected.json new file mode 100644 index 0000000000..185475c247 --- /dev/null +++ b/apps/web/tests/expected/session-row-hover/grouped.expected.json @@ -0,0 +1,52 @@ +[ + { + "stage": "first-before-dwell", + "first": false, + "second": false + }, + { + "stage": "first-shown", + "first": true, + "second": false + }, + { + "stage": "second-instant", + "first": false, + "second": true + }, + { + "stage": "first-instant", + "first": true, + "second": false + }, + { + "stage": "readable-portal", + "first": true, + "second": false + }, + { + "stage": "copied-portal", + "first": true, + "second": false + }, + { + "stage": "portal-return-instant", + "first": false, + "second": true + }, + { + "stage": "outside-cooled", + "first": false, + "second": false + }, + { + "stage": "list-reentry-before-dwell", + "first": false, + "second": false + }, + { + "stage": "list-reentry-shown", + "first": true, + "second": false + } +] diff --git a/apps/web/tests/expected/session-row-hover/mixed-rows.expected.json b/apps/web/tests/expected/session-row-hover/mixed-rows.expected.json new file mode 100644 index 0000000000..39a40107ad --- /dev/null +++ b/apps/web/tests/expected/session-row-hover/mixed-rows.expected.json @@ -0,0 +1,22 @@ +[ + { + "stage": "first-workspace-after-dwell", + "exclusive": true + }, + { + "stage": "workspace-to-workspace-immediate", + "exclusive": true + }, + { + "stage": "workspace-to-session-immediate", + "exclusive": true + }, + { + "stage": "session-to-workspace-immediate", + "exclusive": true + }, + { + "stage": "list-reentered-after-dwell", + "exclusive": true + } +] diff --git a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md index b4584b0486..239b4855e7 100644 --- a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md +++ b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md @@ -1,18 +1,20 @@ - list: - listitem: - - button "tool-subagent-control, 已启用": - - strong: tool-subagent-control - - text: Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations + - button "子智能体控制工具, tool-subagent-control, 已启用": + - strong: 子智能体控制工具 + - text: 让 AI 向子智能体发送消息或中断其运行。 + - code: tool-subagent-control - listitem: - - button "tool-subagent-control/list-agents, tool-subagent-list-agents, 已启用": - - strong: tool-subagent-control/list-agents + - button "子智能体列表工具, tool-subagent-list-agents, 已启用": + - strong: 子智能体列表工具 - code: tool-subagent-list-agents - listitem: - - button "tool-subagent, 已启用": - - strong: tool-subagent - - text: Model-facing subagent delegation tool over the ctx.subagents seam + - button "子智能体委派工具, tool-subagent, 已启用": + - strong: 子智能体委派工具 + - text: 让 AI 将任务交给子智能体,并获取运行结果。 + - code: tool-subagent - listitem: - - button "tool-subagent, tool-subagent-fork, 已启用": - - strong: tool-subagent - - text: Model-facing subagent delegation tool over the ctx.subagents seam + - button "子智能体委派工具, tool-subagent-fork, 已启用": + - strong: 子智能体委派工具 + - text: 让 AI 将任务交给子智能体,并获取运行结果。 - code: tool-subagent-fork diff --git a/apps/web/tests/expected/settings-chrome/plugins.expected.md b/apps/web/tests/expected/settings-chrome/plugins.expected.md index 20e096a308..22b5bc2c80 100644 --- a/apps/web/tests/expected/settings-chrome/plugins.expected.md +++ b/apps/web/tests/expected/settings-chrome/plugins.expected.md @@ -1,4 +1,5 @@ - listitem: - - button "tool-subagent, 已启用": - - strong: tool-subagent - - text: Model-facing subagent delegation tool over the ctx.subagents seam + - button "子智能体委派工具, tool-subagent, 已启用": + - strong: 子智能体委派工具 + - text: 让 AI 将任务交给子智能体,并获取运行结果。 + - code: tool-subagent diff --git a/apps/web/tests/expected/shell-command-details/background-long-token.expected.md b/apps/web/tests/expected/shell-command-details/background-long-token.expected.md new file mode 100644 index 0000000000..c9e1765740 --- /dev/null +++ b/apps/web/tests/expected/shell-command-details/background-long-token.expected.md @@ -0,0 +1,3 @@ +- button "Bash Start the diagnostic worker" [expanded] +- text: {{workspace}} node ./scripts/start-worker.mjs --workspace=/tmp/diagnostics/long-workspace-name-for-background-command-details --log-level=debug --trace-tag=background-shell-full-command-selection --include=worker,lifecycle,transport,subscriptions --output=/tmp/diagnostics/background-worker.log --token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +- button "Inspect" diff --git a/apps/web/tests/expected/shell-command-details/background.expected.md b/apps/web/tests/expected/shell-command-details/background.expected.md index ffc21b3098..1b579bfa11 100644 --- a/apps/web/tests/expected/shell-command-details/background.expected.md +++ b/apps/web/tests/expected/shell-command-details/background.expected.md @@ -1,5 +1,3 @@ - button "Bash Start the diagnostic worker" [expanded] -- text: IN -- group "Command arguments": "{ \"command\": \"node ./scripts/start-worker.mjs --workspace=/tmp/diagnostics/long-workspace-name-for-background-command-details --log-level=debug --trace-tag=background-shell-full-command-selection --include=worker,lifecycle,transport,subscriptions --output=/tmp/diagnostics/background-worker.log\", \"description\": \"Start the diagnostic worker\", \"run_in_background\": true }" -- text: OUT started background job bash-1 +- text: {{workspace}} node ./scripts/start-worker.mjs --workspace=/tmp/diagnostics/long-workspace-name-for-background-command-details --log-level=debug --trace-tag=background-shell-full-command-selection --include=worker,lifecycle,transport,subscriptions --output=/tmp/diagnostics/background-worker.log - button "Inspect" diff --git a/apps/web/tests/expected/shell-command-details/foreground-multiline.expected.md b/apps/web/tests/expected/shell-command-details/foreground-multiline.expected.md new file mode 100644 index 0000000000..05fadfea88 --- /dev/null +++ b/apps/web/tests/expected/shell-command-details/foreground-multiline.expected.md @@ -0,0 +1,5 @@ +- button "Bash Start the diagnostic worker" [expanded] +- text: Done {{workspace}} node ./scripts/start-worker.mjs --workspace=/tmp/diagnostics/long-workspace-name-for-background-command-details --log-level=debug --trace-tag=background-shell-full-command-selection --include=worker,lifecycle,transport,subscriptions --output=/tmp/diagnostics/background-worker.log --token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx $ echo done +- button "Copy" +- text: done +- button "Inspect" diff --git a/apps/web/tests/expected/shortcuts-desktop/font-sizes.expected.md b/apps/web/tests/expected/shortcuts-desktop/font-sizes.expected.md new file mode 100644 index 0000000000..a4afd069e8 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/font-sizes.expected.md @@ -0,0 +1,38 @@ +# Font sizes + +{ + "initial": { + "text": 14, + "code": 11, + "terminal": 13 + }, + "afterEqual": { + "text": 15, + "code": 12, + "terminal": 14 + }, + "afterPlus": { + "text": 16, + "code": 13, + "terminal": 15 + }, + "afterMinus": { + "text": 15, + "code": 12, + "terminal": 14 + }, + "afterReload": { + "text": 15, + "code": 12, + "terminal": 14 + }, + "restored": { + "text": 14, + "code": 11, + "terminal": 13 + }, + "referenceOrder": [ + "Increase font size", + "Decrease font size" + ] +} diff --git a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md index 88405f3950..ebb1786386 100644 --- a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md +++ b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md @@ -3,3 +3,4 @@ - menuitem "Rename" - menuitem "Fork session" - menuitem "Archive session" + - menuitem "Copy Session ID" diff --git a/apps/web/tests/expected/web-runtime-context/web-surface-prompt.expected.md b/apps/web/tests/expected/web-runtime-context/web-surface-prompt.expected.md index bb37e36cfb..cc84fced0a 100644 --- a/apps/web/tests/expected/web-runtime-context/web-surface-prompt.expected.md +++ b/apps/web/tests/expected/web-runtime-context/web-surface-prompt.expected.md @@ -1 +1 @@ -You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. +You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. diff --git a/apps/web/tests/expected/workspace-new-session-folding/watermark-kept.expected.md b/apps/web/tests/expected/workspace-new-session-folding/watermark-kept.expected.md new file mode 100644 index 0000000000..153f797a70 --- /dev/null +++ b/apps/web/tests/expected/workspace-new-session-folding/watermark-kept.expected.md @@ -0,0 +1,16 @@ +- tree "Sessions": + - treeitem "{{workspace}} Workspace actions for {{workspace}} New session in {{workspace}}" [expanded]: + - text: {{workspace}} + - button "Workspace actions for {{workspace}}" + - button "New session in {{workspace}}" + - treeitem "New Session" [selected] + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - button "Show 7 more sessions" diff --git a/apps/web/tests/fixtures/account-login/cordis.patch.yml b/apps/web/tests/fixtures/account-login/cordis.patch.yml new file mode 100644 index 0000000000..a31eac8f46 --- /dev/null +++ b/apps/web/tests/fixtures/account-login/cordis.patch.yml @@ -0,0 +1,12 @@ +# The Web account login scenario points the shipped account provider at a loopback +# Platform double. The scenario substitutes {{origin}} after the listener binds, +# so no port is ever committed here. +- id: deepseek-account + config: + platformOrigin: "{{origin}}" + allowLoopbackHttp: true +# The scenario signs in from the account menu after initial setup. +- id: ui-settings-account + config: + step: done + completion: completed diff --git a/apps/web/tests/fixtures/plugins/fixture-live-client/client.js b/apps/web/tests/fixtures/plugins/fixture-live-client/client.js index 6a6f8b9777..7736cb2949 100644 --- a/apps/web/tests/fixtures/plugins/fixture-live-client/client.js +++ b/apps/web/tests/fixtures/plugins/fixture-live-client/client.js @@ -89,7 +89,7 @@ window.__ModuleLoader__.load({ ) }) ctx.slots.inject('sidebar.workspaces.session.menu.item', function* () { - // The shipped rows end at archive (400); these follow as one group, + // The shipped rows end at Session ID (450); these follow as one group, // opened by the export row's hairline. yield registerSessionAction('fixture.export-session', 500, 'exportSession', true) yield registerSessionAction('fixture.copy-session-id', 600, 'copySessionId', false) diff --git a/apps/web/tests/jobs-menu-layering.e2e.ts b/apps/web/tests/jobs-menu-layering.e2e.ts new file mode 100644 index 0000000000..040531ba95 --- /dev/null +++ b/apps/web/tests/jobs-menu-layering.e2e.ts @@ -0,0 +1,199 @@ +/** Background-job menu geometry over the normal Browser dock in the shipped Web composition. */ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { ToolCallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { JobId } from '@deepseek-ai/dsh-jobs' +import { + assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, + watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) +const EXPECTED_DIR = fileURLToPath(new URL('./expected/jobs-menu-layering', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = SessionId('jobs-menu-layering-web-e2e') +// The private scaffold cwd owns the barrier; the loop bounds an orphan's lifetime. +const RELEASE = '.jobs-menu-layering.release' +const COMMAND = `printf 'jobs-menu-%s\\n' output; for _ in $(seq 1 3000); do [ -e ${RELEASE} ] && break; sleep 0.2; done` + +/** Measure actual overlap and hit testing; ARIA alone cannot expose clipping or stacking. */ +function placement(page: Page) { + return page.evaluate(() => { + const list = document.querySelector('ul[aria-label="Background jobs"]') + const anchor = document.querySelector('button[aria-label="1 background job running"]') + const frame = document.querySelector('[data-sidebar-browser-frame]') + const dock = document.querySelector('[data-sidebar-right-panel][data-sidebar-right-open]') + if (list === null || anchor === null || frame === null || dock === null) { + throw new Error('job menu, trigger, or Browser dock is missing') + } + const menu = list.closest('[data-menu-material]') ?? list + const box = menu.getBoundingClientRect() + const trigger = anchor.getBoundingClientRect() + const browser = frame.getBoundingClientRect() + const left = Math.max(box.left, browser.left) + const right = Math.min(box.right, browser.right) + const top = Math.max(box.top, browser.top) + const bottom = Math.min(box.bottom, browser.bottom) + const overlapsBrowser = right - left > 8 && bottom - top > 8 + const aboveBrowser = overlapsBrowser && [0.25, 0.5, 0.75].every(x => + [0.25, 0.5, 0.75].every(y => menu.contains(document.elementFromPoint( + left + (right - left) * x, top + (bottom - top) * y, + )))) + const expectedLeft = Math.min(Math.max(trigger.left, 12), innerWidth - box.width - 12) + const expectedTop = Math.min(Math.max(trigger.bottom + 5, 12), innerHeight - box.height - 12) + return { + dockMode: dock.dataset.sidebarRightPanel, + bodyMounted: menu.parentElement === document.body, + fixed: getComputedStyle(menu).position === 'fixed', + anchored: Math.abs(box.left - expectedLeft) <= 1 && Math.abs(box.top - expectedTop) <= 1, + viewportBounded: box.left >= 11 && box.top >= 11 + && box.right <= innerWidth - 11 && box.bottom <= innerHeight - 11, + overlapsBrowser, + aboveBrowser, + } + }) +} + +describe.skipIf(MODE === 'record')('web e2e: jobs menu layering', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let agent: Agent + let jobId: JobId | undefined + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)), + }) + await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.route('https://browser.test/**', async route => route.fulfill({ + contentType: 'text/html', body: '

Jobs menu browser fixture

', + })) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + }) + + afterAll(async () => { + const failures: unknown[] = [] + if (scaffold !== undefined) { + try { + await writeFile(join(scaffold.workspaceCwd, RELEASE), '') + if (jobId !== undefined) { + const settled = await scaffold.ctx.jobs.wait(jobId, 15_000, agent.id) + if (settled.status === 'running' || settled.status === 'stopping') { + throw new Error('jobs menu fixture did not settle after releasing its barrier') + } + } + } catch (error) { failures.push(error) } + } + try { await browser?.close() } catch (error) { failures.push(error) } + try { await scaffold?.close() } catch (error) { failures.push(error) } + if (failures.length > 0) throw new AggregateError(failures, 'jobs menu layering teardown failed') + }) + + it('keeps the list and live output above the Browser dock while anchored and dismissable', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-jobs-menu-layering')) + const session = page.locator(`[data-row-key="session:${SEED_ID}"]`) + await expect.poll(async () => { + if (await session.isVisible()) return true + const group = page.locator('[role="treeitem"][aria-expanded]').first() + if (await group.count() > 0 && await group.getAttribute('aria-expanded') === 'false') await group.click() + return session.isVisible() + }, { timeout: 15_000 }).toBe(true) + await session.click() + await expect.poll(() => scaffold.ctx.agents.get(SEED_ID), { timeout: 30_000 }).toBeDefined() + const opened = scaffold.ctx.agents.get(SEED_ID) + if (opened === undefined) throw new Error('opened session has no live Agent') + agent = opened + const started = await scaffold.ctx.tools.execute({ + signal: new AbortController().signal, + callId: ToolCallId('jobs-menu-layering-e2e'), + name: 'bash', + arguments: { command: COMMAND, description: 'Emit output and hold the jobs menu fixture', run_in_background: true }, + agent, + }) + const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') + const matched = /\bbash-[0-9a-f]{16}\b/.exec(reported) + if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) + const startedJobId = JobId(matched[0]) + jobId = startedJobId + // Registration precedes shell startup; inspect its output before depending on a live row. + await expect.poll(() => { + const job = scaffold.ctx.jobs.get(startedJobId, agent.id) + const output = scaffold.ctx.jobs.readAt(startedJobId, 0, agent.id).chunks.map(chunk => chunk.text).join('') + return job.status !== 'running' || output.includes('jobs-menu-output') + }, { timeout: 15_000 }).toBe(true) + const readyJob = scaffold.ctx.jobs.get(startedJobId, agent.id) + const readyOutput = scaffold.ctx.jobs.readAt(startedJobId, 0, agent.id).chunks.map(chunk => chunk.text).join('') + expect(readyJob.status, `${readyJob.detail ?? ''}\n${readyOutput}`).toBe('running') + + await page.locator('[data-sidebar-right-expand]').click() + const column = page.locator('[data-rightbar-col]') + await column.locator('[data-sidebar-right-guide-entry="browser"]').click() + const address = column.getByRole('textbox', { name: 'Enter an HTTP(S) address' }) + await address.fill('https://browser.test/jobs-menu') + await address.press('Enter') + await page.frameLocator('[data-sidebar-browser-frame]') + .getByRole('heading', { name: 'Jobs menu browser fixture' }).waitFor() + + const trigger = page.getByRole('button', { name: '1 background job running', exact: true }) + await trigger.waitFor({ timeout: 15_000 }) + await trigger.focus() + await page.keyboard.press('Enter') + const list = page.getByRole('list', { name: 'Background jobs' }) + await list.waitFor() + await page.keyboard.press('Tab') + expect(await list.getByRole('button', { name: /Show live output/ }) + .evaluate(element => element === document.activeElement)).toBe(true) + const expectedPlacement = { + dockMode: 'push', bodyMounted: true, fixed: true, anchored: true, + viewportBounded: true, overlapsBrowser: true, aboveBrowser: true, + } + await expect.poll(() => placement(page)).toEqual(expectedPlacement) + const collapsed = await placement(page) + + await list.getByRole('button', { name: /Show live output/ }).click() + await list.getByText('jobs-menu-output', { exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll(() => placement(page)).toEqual(expectedPlacement) + const expanded = await placement(page) + + await page.setViewportSize({ width: 1280, height: 760 }) + await expect.poll(() => placement(page)).toEqual(expectedPlacement) + const resized = await placement(page) + await list.getByRole('button', { name: /Hide live output/ }).focus() + await page.keyboard.press('Escape') + await list.waitFor({ state: 'detached' }) + const escapeRestoresFocus = await trigger.evaluate(node => node === document.activeElement) + expect(escapeRestoresFocus).toBe(true) + + await trigger.click() + await list.waitFor() + await address.click() + await list.waitFor({ state: 'detached' }) + const outsideDismisses = await trigger.getAttribute('aria-expanded') === 'false' + expect(outsideDismisses).toBe(true) + expect(scaffold.ctx.jobs.get(jobId, agent.id).status).toBe('running') + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + + await mkdir(EXPECTED_DIR, { recursive: true }) + await compareOrRefreshGolden(join(EXPECTED_DIR, 'geometry.expected.md'), [ + '# Background jobs above the Browser dock', '', + '```json', + JSON.stringify({ collapsed, expanded, resized, escapeRestoresFocus, outsideDismisses }, null, 2), + '```', + ].join('\n'), MODE) + await assertFixtureInventory(EXPECTED_DIR, ['geometry.expected.md']) + }) +}) diff --git a/apps/web/tests/katex-fonts.spec.ts b/apps/web/tests/katex-fonts.spec.ts new file mode 100644 index 0000000000..1e7d2f47c5 --- /dev/null +++ b/apps/web/tests/katex-fonts.spec.ts @@ -0,0 +1,75 @@ +import { readFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { transform } from 'lightningcss' +import { build, loadConfigFromFile } from 'vite' +import { expect, it, onTestFinished } from 'vitest' + +const require = createRequire(new URL('../../../packages/client/ui-primitives/package.json', import.meta.url)) +const stylesheet = require.resolve('katex/dist/katex.min.css') + +/** Build a stylesheet with the application's asset plugins without writing artifacts. */ +async function bundleStyle(source: { path: string } | { package: string; css: string }) { + const root = await mkdtemp(join(tmpdir(), 'katex-fonts-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) + let input: string + if ('path' in source) input = source.path + else { + input = join(root, 'node_modules', source.package, 'dist', 'katex.min.css') + await mkdir(dirname(input), { recursive: true }) + await writeFile(input, source.css) + await writeFile(join(dirname(input), 'font.woff'), 'fixture font') + } + const loaded = await loadConfigFromFile({ command: 'build', mode: 'production' }, fileURLToPath(new URL('../vite.config.ts', import.meta.url))) + if (!loaded) throw new Error('Expected the Web build configuration') + // The product isolation check requires the full application's HTML entry. + const plugins = (loaded.config.plugins ?? []).flat().filter(plugin => !(plugin && typeof plugin === 'object' + && 'name' in plugin && plugin.name === 'dsh-product-web-bundle-isolation')) + const result = await build({ + ...loaded.config, configFile: false, plugins, + root, logLevel: 'silent', + build: { write: false, cssMinify: false, assetsInlineLimit: 0, rollupOptions: { input } }, + }) + if ('on' in result) throw new Error('Expected a completed build') + return (Array.isArray(result) ? result : [result]).flatMap(bundle => bundle.output) +} + +/** Normalize layout CSS independently of emitted font URLs and format fallbacks. */ +function layoutCss(css: string): string { + return transform({ + filename: 'layout.css', code: Buffer.from(css), minify: true, + visitor: { Rule: { 'font-face'(rule) { + rule.value.properties = rule.value.properties.filter(property => property.type !== 'source') + return rule + } } }, + }).code.toString() +} + +it('ships all KaTeX WOFF2 faces with identical font bytes and layout styles', async () => { + const output = await bundleStyle({ path: stylesheet }) + const fonts = output.filter(asset => asset.type === 'asset' && /\.(?:woff2?|ttf)$/u.test(asset.fileName)) + expect(fonts).toHaveLength(20) + for (const font of fonts) { + expect(font.fileName).toMatch(/\.woff2$/u) + if (font.type !== 'asset') throw new Error('Expected a font asset') + const name = font.names[0]! + expect(Buffer.from(font.source)).toEqual(await readFile(join(dirname(stylesheet), 'fonts', name))) + } + const css = output.find(asset => asset.type === 'asset' && asset.fileName.endsWith('.css')) + if (css?.type !== 'asset') throw new Error('Expected a stylesheet asset') + const text = Buffer.from(css.source).toString() + expect(text).not.toMatch(/\.(?:woff|ttf)["')]/u) + expect(layoutCss(text)).toBe(layoutCss(await readFile(stylesheet, 'utf8'))) +}) + +it.each(['src: url(font.woff) format("woff");', ''])('rejects a KaTeX face without WOFF2 (src=%s)', async (source) => { + await expect(bundleStyle({ package: 'katex', css: `@font-face { font-family: KaTeX_Main; ${source} }` })) + .rejects.toThrow('KaTeX font face requires a WOFF2 source') +}) + +it('preserves older font formats in unrelated stylesheets', async () => { + const output = await bundleStyle({ package: 'other', css: '@font-face { font-family: Other; src: url(font.woff) format("woff"); }' }) + expect(output.some(asset => asset.type === 'asset' && asset.fileName.endsWith('.woff'))).toBe(true) +}) diff --git a/apps/web/tests/live-job-stream.e2e.ts b/apps/web/tests/live-job-stream.e2e.ts index c88923e8e6..2b4fb64d68 100644 --- a/apps/web/tests/live-job-stream.e2e.ts +++ b/apps/web/tests/live-job-stream.e2e.ts @@ -95,7 +95,7 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { agent, }) const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') - const matched = /\bbash-\d+\b/.exec(reported) + const matched = /\bbash-[0-9a-f]{16}\b/.exec(reported) if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) const jobId = JobId(matched[0]) diff --git a/apps/web/tests/markdown-file-links.e2e.ts b/apps/web/tests/markdown-file-links.e2e.ts index e7acd74152..3b9cb9879b 100644 --- a/apps/web/tests/markdown-file-links.e2e.ts +++ b/apps/web/tests/markdown-file-links.e2e.ts @@ -1,18 +1,66 @@ /** Cold-replayed Markdown references open Session files through the shipped Web composition. */ import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { fileURLToPath } from 'node:url' import { chromium, type Browser, type Page } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import type {} from '@deepseek-ai/dsh-session-title' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, 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/markdown-file-links', import.meta.url)) const MODE = webSnapshotMode() +/** Build live test input with a parent-path link and a read result naming the same Host file. */ +function parentPathFixture(path: string): string { + const session = Session.create(SessionId('parent-file-links-source')) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Read the parent-path file and link lines 24 and 30.' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { title: 'Parent file links', messageSeqs: [user.seq], source: { kind: 'fallback' } }) + session.append('step/start', { turn: 1, step: 1 }) + const callId = ToolCallId('parent-file-read') + const args = JSON.stringify({ file_path: path, offset: 24, limit: 7 }) + session.append('assistant/message', { + turn: 1, step: 1, stream: [], + message: createAssistantMessage({ + content: [{ type: 'tool-call', id: callId, name: 'read', arguments: args }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + const call = session.append('tool/call', { turn: 1, step: 1, callId, name: 'read', arguments: args }) + session.append('tool/result', { + turn: 1, step: 1, meta: { path: '{{cwd}}/parent.txt' }, + message: createToolResultMessage({ callId, content: [{ type: 'text', text: 'parent line 24\n' }], isError: false }), + }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('step/start', { turn: 1, step: 2 }) + session.append('assistant/message', { + turn: 1, step: 2, stream: [], + message: createAssistantMessage({ + content: [{ type: 'text', text: `[Parent file:24–30](<${path}#L24-L30>) · [Parent file:30](<${path}#L30>)` }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 2 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return [ + JSON.stringify({ + type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, + cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0, + }), + ...session.snapshotEvents().map(event => JSON.stringify(event)), + '', + ].join('\n') +} + describe('web e2e: Markdown file links', () => { let scaffold: WebScaffold let browser: Browser @@ -78,4 +126,55 @@ describe('web e2e: Markdown file links', () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'links.expected.md', 'preview.expected.md']) }) + + it('opens parent-path prose and read-card links while retaining each address across reload', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-parent-file-links')) + // Return through the parent into this owned temp root; no shared parent file is created. + const path = `../${basename(scaffold.workspaceCwd)}/parent.txt` + await writeFile(join(scaffold.workspaceCwd, 'parent.txt'), + Array.from({ length: 60 }, (_, i) => `parent line ${i + 1}\n`).join('')) + const id = await seedSession(scaffold, parentPathFixture(path), 'parent-file-links') + await page.reload({ waitUntil: 'load' }) + const workspace = page.getByRole('treeitem').first() + await workspace.waitFor() + if (await workspace.getAttribute('aria-expanded') !== 'true') await workspace.click() + await page.locator(`[data-row-key="session:${id}"]`).click() + + const source = page.getByRole('button', { name: 'Parent file:24–30', exact: true }) + await source.click() + const column = page.locator('[data-rightbar-col]') + const stored = () => page.evaluate(key => localStorage.getItem(key), `dsh.sidebar-right.v1.${id}`) + const address = sessionFileAddress(id, path) + await expect.poll(() => column.locator('[data-textpreview-target="24"]').textContent()).toBe('parent line 24\n') + await expect.poll(() => column.locator('[data-textpreview-path]').textContent()).toBe(join(scaffold.workspaceCwd, 'parent.txt')) + await expect.poll(stored).toContain(address) + const tabs = column.locator('[data-dockkit-tab-title]') + expect(await tabs.allTextContents()).toEqual(['parent.txt']) + + const later = page.getByRole('button', { name: 'Parent file:30', exact: true }) + await later.focus() + await later.press('Enter') + await expect.poll(() => column.locator('[data-textpreview-target="30"]').textContent()).toBe('parent line 30\n') + expect(await tabs.allTextContents()).toEqual(['parent.txt']) + + await page.reload({ waitUntil: 'load' }) + await expect.poll(() => column.locator('[data-textpreview-path]').textContent()).toBe(join(scaffold.workspaceCwd, 'parent.txt')) + await expect.poll(stored).toContain(address) + await source.click() + await expect.poll(() => column.locator('[data-textpreview-target="24"]').textContent()).toBe('parent line 24\n') + expect(await tabs.allTextContents()).toEqual(['parent.txt']) + + const readCardLink = page.locator('[data-tool="read"] button').filter({ hasText: 'parent.txt' }) + await expandOwningTurnProcess(page, readCardLink) + await readCardLink.click() + await expect.poll(() => column.locator('[data-textpreview-path]').textContent()).toBe(join(scaffold.workspaceCwd, 'parent.txt')) + await expect.poll(() => column.locator('[data-textpreview-target="24"]').textContent()).toBe('parent line 24\n') + // The Host-reported path and the unresolved prose path are distinct resource identities. + expect(await tabs.allTextContents()).toEqual(['parent.txt', 'parent.txt']) + await expect.poll(stored).toContain(sessionFileAddress(id, 'parent.txt')) + await source.click() + expect(await tabs.allTextContents()).toEqual(['parent.txt', 'parent.txt']) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) }) diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts index 5e7d11d7fd..d7fa2b8873 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/apps/web/tests/math-rendering.e2e.ts @@ -122,6 +122,13 @@ describe('web e2e: settled Markdown math rendering', () => { await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6) await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2) expect(await page.locator('.katex-error').count()).toBe(0) + const fonts = await page.evaluate(async () => { + const faces = [...document.fonts].filter(face => face.family.includes('KaTeX_')) + await Promise.all(faces.map(face => face.load())) + return faces.map(face => face.status) + }) + expect(fonts).toHaveLength(20) + expect(fonts.every(status => status === 'loaded')).toBe(true) await expect.poll( () => page.getByText('1 turns 1 steps', { exact: false }).count(), { timeout: 10_000 }, diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index 240ee39d28..16eebb7273 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -1,4 +1,4 @@ -import { mkdir } from 'node:fs/promises' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -6,6 +6,7 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { AgentHandle } from '@deepseek-ai/dsh-agent' import { ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-agent-preset-registry' @@ -14,6 +15,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + fixtureUserPrompts, launchWebScaffold, watchConsole, webSnapshotMode, @@ -76,7 +78,7 @@ describe('minimal agent preset', () => { if (failures.length > 1) throw new AggregateError(failures, 'minimal preset smoke teardown failed') }) - it('sends the exact RL prompt and shell schema, then executes the persistent shell', async () => { + it('sends the exact RL prompt with shell and image schemas, then executes the persistent shell', async () => { const requestHeader = agentHandle.agent.session.requestHeader() if (requestHeader === undefined) throw new Error('the minimal agent issued no model request') const systemPrompt = systemPromptText(agentHandle.agent.session) @@ -89,7 +91,8 @@ describe('minimal agent preset', () => { `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: ${JSON.stringify(agentHandle.agent.session.header.cwd)}.`, ]) expect(scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'fs')).toBeUndefined() - expect(scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'compaction')).toBeUndefined() + expect(scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'compaction')).toBeDefined() + expect(scaffold.ctx.commands.find(agentHandle.agent, 'compact')).toBeDefined() const stateDir = join(scaffold.workspaceCwd, 'persistent-state') await mkdir(stateDir) @@ -128,6 +131,7 @@ describe('minimal agent preset', () => { "prompt": "You are a helpful software engineer assistant.", "tools": [ "bash", + "read_image", "working_directory", ], } @@ -192,3 +196,199 @@ describe('minimal agent preset', () => { ]) }) }) + +describe('minimal manual compaction', () => { + const snapshotDir = fileURLToPath(new URL('../../../snapshots/web/minimal-compact', import.meta.url)) + let scaffold: WebScaffold + let handle: AgentHandle + let browser: Browser | undefined + let page: Page | undefined + const requests: GenerateOptions[] = [] + + beforeAll(async () => { + const fixture = join(snapshotDir, 'session.v4.jsonl') + const prompts = fixtureUserPrompts(await readFile(fixture, 'utf8')) + expect(prompts).toHaveLength(2) + const [initial, continuation] = prompts + if (initial === undefined || continuation === undefined) throw new Error('minimal compaction fixture requires two user prompts') + scaffold = await launchWebScaffold({ replayFixture: fixture, compareReplaySession: true, workspacePathLength: 192 }) + scaffold.ctx.on('llm/stream', async function* (options, next) { + requests.push(options) + yield* next() + }) + handle = await scaffold.ctx.agents.create({ + sessionId: SessionId('minimal-compact-smoke'), + meta: { cwd: scaffold.workspaceCwd, agentPreset: 'minimal' }, + agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + handle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: initial }], source: { kind: 'user' }, + })) + await handle.agent.whenIdle() + const session = handle.agent.session + expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) + const generation = session.surface.replaceGeneration + const execution = await scaffold.ctx.commands.execute(handle.agent, '/compact', [], new AbortController().signal) + expect(execution?.result.kind).toBe('success') + expect(session.surface.replaceGeneration).toBe(generation + 1) + handle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: continuation }], source: { kind: 'user' }, + })) + await handle.agent.whenIdle() + }) + + afterAll(async () => { + const failures: unknown[] = [] + await page?.close().catch((error: unknown) => failures.push(error)) + await browser?.close().catch((error: unknown) => failures.push(error)) + await handle?.dispose().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'minimal compaction teardown failed') + }) + + it('records the manual command, summary, and durable history replacement', () => { + const session = handle.agent.session + const events = session.snapshotEvents() + const command = events.find(event => event.type === 'command/run') + const start = events.find(event => event.type === 'compaction/start') + const summary = events.find(event => event.type === 'compaction/summary') + const end = events.find(event => event.type === 'compaction/end') + const done = events.find(event => event.type === 'command/done') + expect(command?.data.name).toBe('compact') + expect(start?.data).toMatchObject({ turn: null, sourceCommandId: command?.data.commandId }) + expect(summary?.data).toMatchObject({ sourceCommandId: command?.data.commandId, llmStreamCall: true }) + expect(end?.data).toEqual(start?.data) + expect(done?.data).toMatchObject({ kind: 'success', sourceEventSeq: summary?.seq }) + const checkpoint = events.find(event => event.type === 'user/message' && event.data.source.kind === 'compact-checkpoint') + if (checkpoint?.type !== 'user/message') throw new Error('manual compaction produced no checkpoint') + expect(checkpoint.surfaceOp).toMatchObject({ + op: 'replace', startSeq: summary?.data.shadowedRange.start, endSeq: summary?.data.shadowedRange.end, + }) + expect(session.deriveMessages().some(message => message.id === checkpoint.data.id)).toBe(true) + const original = events.find(event => event.type === 'user/message' && event.data.source.kind === 'user') + if (original?.type !== 'user/message') throw new Error('minimal compaction has no original user message') + expect(session.deriveMessages().some(message => message.id === original.data.id)).toBe(false) + }) + + it('continues with the summary and the same minimal prompt and tools', () => { + expect(requests.map(request => request.purpose)).toEqual([undefined, 'compaction', undefined]) + const summary = handle.agent.session.snapshotEvents().find(event => event.type === 'compaction/summary') + if (summary === undefined) throw new Error('minimal compaction produced no summary') + const [initial, , continuation] = requests + if (initial === undefined || continuation === undefined) throw new Error('minimal compaction did not continue') + expect(continuation.messages.flatMap(message => message.content)).toEqual(expect.arrayContaining(summary.data.summary)) + expect(continuation.messages[0]).toEqual(initial.messages[0]) + expect(continuation.tools).toEqual(initial.tools) + expect(continuation.messages.flatMap(message => message.content) + .some(block => block.type === 'text' && block.text === 'MINIMAL_COMPACT_READY')).toBe(true) + expect(handle.agent.session.deriveMessages().at(-1)?.content) + .toEqual([{ type: 'text', text: 'MINIMAL_COMPACT_OK' }]) + }) + + it.skipIf(MODE === 'record')('renders the manual compaction card and its summary', async () => { + onTestFailed(() => { if (page !== undefined) void saveFailureShot(page, 'web-minimal-compact') }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + 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_COMPACT_OK', { exact: true }).waitFor() + const card = page.getByRole('button', { name: /^compact Compacted / }) + await card.click() + await expect.poll(() => card.getAttribute('aria-expanded')).toBe('true') + await compareOrRefreshGolden(join(snapshotDir, 'ui.expected.md'), + await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(snapshotDir, [ + 'session.v4.jsonl', 'system-prompt.expected.md', 'tool-schemas.expected.json', 'ui.expected.md', + ]) + }) +}) + +describe('minimal image reading', () => { + const snapshotDir = fileURLToPath(new URL('../../../snapshots/web/minimal-read-image', import.meta.url)) + const prompt = 'Use read_image to inspect red.png. Then reply exactly MINIMAL_IMAGE_OK and stop.' + let scaffold: WebScaffold + let handle: AgentHandle + let browser: Browser | undefined + let page: Page | undefined + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + replayFixture: join(snapshotDir, 'session.v4.jsonl'), compareReplaySession: true, paceMs: 10, + }) + await writeFile(join(scaffold.workspaceCwd, 'red.png'), Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64', + )) + handle = await scaffold.ctx.agents.create({ + sessionId: SessionId('minimal-image-smoke'), + meta: { cwd: scaffold.workspaceCwd, agentPreset: 'minimal' }, + agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash-vision-exp' }, + setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } })) + await handle.agent.whenIdle() + }) + + afterAll(async () => { + const failures: unknown[] = [] + await page?.close().catch((error: unknown) => failures.push(error)) + await browser?.close().catch((error: unknown) => failures.push(error)) + await handle?.dispose().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'minimal image teardown failed') + }) + + it('logs a durable image with the minimal tool roster', async () => { + const session = handle.agent.session + expect(session.requestHeader()?.tools?.map(tool => tool.name)).toEqual(['bash', 'read_image', 'working_directory']) + const result = session.snapshotEvents().find(event => event.type === 'tool/result') + expect(result?.data.message.isError).toBe(false) + expect(result?.data.meta).toEqual({ path: join(scaffold.workspaceCwd, 'red.png') }) + const image = 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') + expect(image.attachment).toMatchObject({ mediaType: 'image/png', width: 1, height: 1, bytes: 69 }) + expect((await scaffold.ctx.attachments.readImage(image.attachment)).data.byteLength).toBe(69) + }) + + 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) + 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 preview = card.locator('img').first() + await preview.waitFor() + await expect.poll(() => preview.evaluate(img => img instanceof HTMLImageElement && img.naturalWidth)).toBe(1) + await compareOrRefreshGolden(join(snapshotDir, 'ui.expected.md'), + await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(snapshotDir, [ + 'session.v4.jsonl', 'system-prompt.expected.md', 'tool-schemas.expected.json', 'ui.expected.md', + ]) + }) +}) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 9cb6c5bbc2..d199b3b378 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -20,7 +20,7 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page, Request, Route } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, @@ -29,6 +29,7 @@ import { openSettings, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/models-settings', import.meta.url)) const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md') +const NATIVE_KEYLESS_EXPECTED = join(SNAPSHOT_DIR, 'native-keyless.expected.md') const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md') const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md') const DECLARED_EDIT_EXPECTED = join(SNAPSHOT_DIR, 'declared-edit.expected.md') @@ -44,6 +45,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { let tripwire: ReturnType beforeAll(async () => { + vi.stubEnv('OPENAI_API_KEY', 'models-settings-test-key') scaffold = await launchWebScaffold({}) browser = await chromium.launch() // The scenario asserts the shipped Chinese copy, so the browser asks for it. @@ -80,6 +82,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { }, 120_000) afterAll(async () => { + vi.unstubAllEnvs() await browser?.close() await scaffold?.close() }) @@ -120,6 +123,12 @@ describe('web e2e: Models settings page configures a dormant provider', () => { const options = await pick.locator('option').allTextContents() expect(options).toContain('anthropic') expect(options).toContain('minimax-cn') + await pick.selectOption('openai-codex') + await expect.poll(async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), { timeout: 10_000 }).toBe(0) + expect(await dialog.getByRole('button', { name: '认证说明', exact: true }).count()).toBe(0) + expect(await dialog.textContent()).not.toMatch(/oauth/i) + const nativeSnapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(NATIVE_KEYLESS_EXPECTED, nativeSnapshot, MODE) await pick.selectOption('minimax-cn') await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) @@ -206,6 +215,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized')) const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click() + await dialog.getByText('API 密钥来源:已保存凭据', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await dialog.textContent()).not.toMatch(/oauth/i) await dialog.getByText('自定义设置').click() const url = dialog.getByLabel('API 地址') await url.waitFor({ timeout: 10_000 }) @@ -535,7 +546,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'configured.expected.md', 'declared-edit.expected.md', 'declared.expected.md', 'delete.expected.md', 'empty.expected.md', 'model-picker.expected.md', - 'native-delete.expected.md', 'catalog-inputs.expected.md', + 'native-delete.expected.md', 'native-keyless.expected.md', 'catalog-inputs.expected.md', 'refreshing.expected.md', 'image-only.expected.md', ]) }) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index d121cbf579..75202aea47 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -20,7 +20,7 @@ import { launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' -import { expectSelectableTerminalCommand } from './terminal-command-browser.ts' +import { expectWrappedTerminalCommand } from './terminal-command-browser.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url)) const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl') @@ -29,6 +29,7 @@ const TIMING_EXPECTED = join(SNAPSHOT_DIR, 'timing.expected.md') const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md') const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md') const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', import.meta.url)) +const COMMAND_WRAP_EXPECTED = join(CODE_CARD_DIR, 'command-wrap.expected.md') const CODE_CARD_EXPECTED = join(CODE_CARD_DIR, 'code-card.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'navigation-panes-web-e2e' @@ -472,7 +473,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click() const card = page.locator('[data-sample="bash"] ~ div [data-terminal]').first() await card.waitFor({ timeout: 15_000 }) - await expectSelectableTerminalCommand(page, card, 'echo NAVIGATION_OK', 300) + const commandWrap = await expectWrappedTerminalCommand(page, card, 'echo NAVIGATION_OK', 300) + await compareOrRefreshGolden(COMMAND_WRAP_EXPECTED, JSON.stringify(commandWrap, null, 2), MODE) // Real layout, not jsdom's stub (which computes no geometry at all): // squeeze the output pane below its content width and the line must keep // its single row and overflow sideways instead of folding. Soft-wrapping @@ -547,7 +549,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { }, 60_000) it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => { - await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md']) + await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md', 'command-wrap.expected.md']) await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md', 'terminal-card.expected.md', 'timing.expected.md', diff --git a/apps/web/tests/notice-wake-tail.e2e.ts b/apps/web/tests/notice-wake-tail.e2e.ts new file mode 100644 index 0000000000..884adcb9d3 --- /dev/null +++ b/apps/web/tests/notice-wake-tail.e2e.ts @@ -0,0 +1,218 @@ +/** + * A Turn this Client did not submit (a subagent-settled notice) wakes while the + * reader follows the tail of a long Turn under "On next message" folding. The + * notice Turn belongs to the long Turn's exchange, so the long Turn stays + * expanded through the host's pre-Step gap and after the notice arrives, and a + * reader who was following sees the notice and its reply at the tail. + */ +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SubagentSettledMessageSource } from '@deepseek-ai/dsh-subagent' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage } from './support.ts' + +/** Settled Turns before the long one, so every work-details mode overflows the viewport. */ +const HISTORY_TURNS = 3 +const LONG_TURN = HISTORY_TURNS + 1 +const NOTICE_TURN = HISTORY_TURNS + 2 +/** Tool Steps in the finished Turn; their folded height must exceed the notice Turn's height. */ +const TOOL_STEPS = 12 +const OUTPUT_LINES = 40 +/** The recorded host gap between the waking claim and Step 1, spent assembling the system prompt. */ +const PRE_STEP_GAP_MS = 187 +const REPLY = 'NOTICE_WAKE_REPLY' + +interface Tail { + /** Height of the room reserved below the flow. */ + readonly spacer: number + readonly noticeVisible: boolean + readonly replyVisible: boolean + /** Whether the long Turn's process disclosure reports itself expanded. */ + readonly longTurnOpen: boolean +} + +function readTail(page: Page): Promise { + return page.evaluate(([reply, longTurn]) => { + const host = document.querySelector('[data-conversation-scroll]') + if (host === null) throw new Error('conversation scrollport is absent') + const viewTop = host.getBoundingClientRect().top + const viewBottom = host.querySelector('[data-composer-seat]')?.getBoundingClientRect().top + ?? host.getBoundingClientRect().bottom + const inView = (element: Element): boolean => { + const rect = element.getBoundingClientRect() + return rect.height > 0.5 && rect.bottom > viewTop && rect.top < viewBottom + } + const spacer = host.querySelector('[data-chat-turn-spacer]') + return { + spacer: spacer === null ? 0 : Number.parseFloat(spacer.style.height) || 0, + noticeVisible: [...host.querySelectorAll('[data-turn-trigger]')].some(inView), + replyVisible: [...host.querySelectorAll('[data-chat-flow-kind="assistant-step"]')] + .some(row => (row.textContent ?? '').includes(reply) && inView(row)), + longTurnOpen: host.querySelector(`[data-turn-process="${longTurn}"]`)?.getAttribute('aria-expanded') === 'true', + } + }, [REPLY, String(LONG_TURN)] as const) +} + +describe('web e2e: notice Turn after a long Detailed Turn', () => { + let browser: Browser + + beforeAll(async () => { + browser = await chromium.launch({ headless: true }) + }, 60_000) + + afterAll(async () => { + await browser?.close() + }) + + it.each(['detailed', 'standard'] as const)('keeps the new tail visible in %s work details', async (view) => { + const scaffold: WebScaffold = await launchWebScaffold({}) + const page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + try { + await page.setViewportSize({ width: 1280, height: 800 }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + await scaffold.ctx.settings.update('ui-chat', { transcriptView: view, collapseTiming: 'next-input' }) + const agent = scaffold.ctx.agents.list().at(-1) + if (agent === undefined) throw new Error('connected workspace created no Agent') + const session = agent.session + const setRunning = (running: boolean): void => { scaffold.ctx.emit('api-session/status', agent.id, running) } + + for (let turn = 1; turn <= HISTORY_TURNS; turn += 1) { + session.append('turn/start', { turn }) + session.append('step/start', { turn, step: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `Earlier question ${String(turn)}.` }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('assistant/message', { + stream: [], + turn, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: `Earlier answer ${String(turn)}. ${'This settled reply wraps across several lines of prose. '.repeat(24)}` }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + + // A long running Turn streams Step by Step while the reader follows the tail. + setRunning(true) + session.append('turn/start', { turn: LONG_TURN }) + session.append('step/start', { turn: LONG_TURN, step: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Run the long diagnostic.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + for (let step = 1; step <= TOOL_STEPS; step += 1) { + const callId = ToolCallId(`notice-wake-${String(step)}`) + const args = JSON.stringify({ command: `diagnose ${String(step)}`, description: `Diagnostic ${String(step)}` }) + session.append('assistant/message', { + stream: [], + turn: LONG_TURN, + step, + message: createAssistantMessage({ + content: [ + { type: 'reasoning', text: `Checking diagnostic ${String(step)}.` }, + { type: 'tool-call', id: callId, name: 'bash', arguments: args }, + ], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + const call = session.append('tool/call', { turn: LONG_TURN, step, callId, name: 'bash', arguments: args }) + session.append('tool/result', { + turn: LONG_TURN, + step, + message: createToolResultMessage({ + callId, + content: [{ + type: 'text', + text: Array.from({ length: OUTPUT_LINES }, (_, line) => `diagnostic ${String(step)} line ${String(line + 1)}`).join('\n'), + }], + isError: false, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) + session.append('step/end', { turn: LONG_TURN, step }) + session.append('step/start', { turn: LONG_TURN, step: step + 1 }) + await scaffold.ctx.sessions.flush(session) + await page.locator(`[data-chat-node-key$="${callId}"]`).waitFor({ state: 'attached', timeout: 15_000 }) + } + session.append('assistant/message', { + stream: [], + turn: LONG_TURN, + step: TOOL_STEPS + 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: 'All diagnostics finished.' }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: LONG_TURN, step: TOOL_STEPS + 1 }) + await scaffold.ctx.sessions.flush(session) + await page.getByText('All diagnostics finished.').waitFor({ timeout: 30_000 }) + await page.evaluate(() => { + const host = document.querySelector('[data-conversation-scroll]') + if (host !== null) host.scrollTop = host.scrollHeight + }) + await page.locator('[data-chat-following-tail]').waitFor({ timeout: 10_000 }) + + // The Turn ends; 15 ms later (the recorded production gap) a settled subagent wakes the next Turn. + session.append('turn/end', { turn: LONG_TURN, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(session) + setRunning(false) + await page.waitForTimeout(15) + const source: SubagentSettledMessageSource = { + kind: 'subagent-settled', + form: 'notice', + summary: 'Background subagent finished.', + senderSessionId: SessionId('notice-wake-child'), + } + const notice = createUserMessage({ + content: [{ type: 'text', text: 'Background subagent notice-wake-child finished.' }], + source, + }) + session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [notice] }) + session.append('turn/start', { turn: NOTICE_TURN }) + session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }) + setRunning(true) + await scaffold.ctx.sessions.flush(session) + // The browser has applied the notice Turn's start before the gap is held and sampled. + await page.locator(`[data-chat-turn="${String(NOTICE_TURN)}"]`).first().waitFor({ state: 'attached', timeout: 15_000 }) + await page.waitForTimeout(PRE_STEP_GAP_MS) + const gap = await readTail(page) + session.append('step/start', { turn: NOTICE_TURN, step: 1 }) + session.append('user/message', notice, { surfaceOp: 'append' }) + await scaffold.ctx.sessions.flush(session) + await page.locator('[data-turn-trigger]').first().waitFor({ state: 'attached', timeout: 15_000 }) + session.append('assistant/message', { + stream: [], + turn: NOTICE_TURN, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: `${REPLY} the subagent result is acknowledged.` }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + await scaffold.ctx.sessions.flush(session) + + expect(gap).toMatchObject({ longTurnOpen: true, spacer: 0 }) + const settled = { noticeVisible: true, replyVisible: true, longTurnOpen: true, spacer: 0 } + await expect.poll(() => readTail(page), { timeout: 15_000 }).toMatchObject(settled) + // The settled tail survives later frames instead of passing through on the way to a fold. + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) + })) + expect(await readTail(page)).toMatchObject(settled) + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } finally { + await page.context().close() + await scaffold.close() + } + }, 180_000) +}) diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 30fc3ee99a..50f6ecd61a 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' +import { credentialRef } from '@deepseek-ai/dsh-credentials' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -20,6 +21,7 @@ import { openSettings, ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureSh const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/onboarding-deepseek-config', import.meta.url)) const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md') +const SIGN_IN_EXPECTED = join(SNAPSHOT_DIR, 'sign-in.expected.md') const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md') const MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md') const DEFAULT_MODELS_EXPECTED = join(SNAPSHOT_DIR, 'default-models.expected.md') @@ -72,10 +74,13 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click() await welcome.waitFor({ state: 'detached', timeout: 15_000 }) + const signIn = page.getByRole('dialog', { name: '开始使用', exact: true }) + await signIn.waitFor({ timeout: 15_000 }) + await compareOrRefreshGolden(SIGN_IN_EXPECTED, + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE) + await signIn.getByRole('button', { name: '添加 API Key', exact: true }).click() const credentialStep = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }) await credentialStep.waitFor({ timeout: 15_000 }) - expect(await page.getByRole('dialog', { name: '开始你的创作' }).count()).toBe(0) - expect(await page.getByRole('button', { name: '账号菜单', exact: true }).count()).toBe(0) const keyInput = credentialStep.getByLabel('API 密钥', { exact: true }) await keyInput.waitFor({ timeout: 10_000 }) const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) @@ -110,7 +115,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await expect.poll( () => configuredInput.getAttribute('placeholder'), { timeout: 10_000 }, - ).toBe('已配置——输入新值可替换') + ).toBe('留空时保留现有认证(DEEPSEEK_API_KEY)') const secondReloadWarnings = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) @@ -158,7 +163,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup setInterval(() => { if (document.querySelector( '[role="dialog"][aria-label="预览版说明"], ' - + '[role="dialog"][aria-label="开始你的创作"], ' + + '[role="dialog"][aria-label="开始使用"], ' + '[role="dialog"][aria-label="添加一个 API Key 开始使用"]', ) !== null) { sightings.push('chrome') @@ -251,7 +256,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await expect.poll( () => settings.getByLabel('API 密钥', { exact: true }).getAttribute('placeholder'), { timeout: 10_000 }, - ).toBe('已配置——输入新值可替换') + ).toBe('留空时保留现有认证(DEEPSEEK_API_KEY)') const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE) await settings.getByRole('button', { name: '保存', exact: true }).click() @@ -278,20 +283,37 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup // trigger — on the page; the scaffold boots without one. await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e', false) - const modelTrigger = page.getByRole('button', { name: /^选择模型.*deepseek-official\// }) + const modelTrigger = page.getByRole('button', { name: '请选择模型', exact: true }) await modelTrigger.waitFor({ timeout: 10_000 }) await modelTrigger.click() - await page.getByRole('menuitem', { name: /模型/ }).click() expect(await page.getByText('Configured Flash', { exact: true }).count()).toBe(0) await page.getByRole('menuitemradio', { name: 'Private Preview' }).waitFor({ timeout: 10_000 }) expect(tripwire.warnings).toEqual([]) expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('shows an empty model list after removing the API key without replacing the saved default', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-model-catalog-empty')) + await page.keyboard.press('Escape') + const selection = scaffold.ctx.agentDefaultModel.currentSelection() + await scaffold.ctx.credentials.unset(credentialRef('DEEPSEEK_API_KEY')) + const seat = page.getByRole('button', { name: '请选择模型', exact: true }) + await seat.waitFor() + await seat.click() + const picker = page.getByRole('group', { name: '模型与推理等级' }) + await picker.getByText('暂无可用模型', { exact: true }).waitFor() + expect(await picker.getByRole('menuitemradio').count()).toBe(0) + expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual(selection) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'empty.expected.md'), await picker.ariaSnapshot(), MODE) + await seat.press('Escape') + await picker.waitFor({ state: 'hidden' }) + expect(tripwire.pageErrors).toEqual([]) + }) + it('keeps the fixture inventory closed', async () => { await assertFixtureInventory( SNAPSHOT_DIR, - ['welcome.expected.md', 'missing.expected.md', 'models.expected.md', 'default-models.expected.md'], + ['welcome.expected.md', 'sign-in.expected.md', 'missing.expected.md', 'models.expected.md', 'default-models.expected.md', 'empty.expected.md'], ) }) }) diff --git a/apps/web/tests/onboarding-native.e2e.ts b/apps/web/tests/onboarding-native.e2e.ts index 57f96bb8f0..75c50b8f27 100644 --- a/apps/web/tests/onboarding-native.e2e.ts +++ b/apps/web/tests/onboarding-native.e2e.ts @@ -58,40 +58,34 @@ describe.skipIf(MODE === 'record').each([false, true])('web e2e: native credenti acknowledgeReloadConnectionLoss(tripwire, warningsBefore) } const accountMenu = page.getByRole('button', { name: '账号菜单', exact: true }) - if (desktop) { - await accountMenu.waitFor() - expect(await accountMenu.textContent()).toBe('更多') - const triggerBox = (await accountMenu.boundingBox())! - expect(Math.abs(triggerBox.height - 32)).toBeLessThan(1) - await accountMenu.click() - const menu = page.getByRole('menu') - await menu.waitFor() - expect(await menu.getByRole('menuitem').count()).toBe(3) - // Shortcut hints vary by host platform; accessible names identify the three actions. - for (const name of ['设置', '意见反馈', '登录']) { - expect(await menu.getByRole('menuitem', { name, exact: true }).count()).toBe(1) - } - const menuBox = (await menu.boundingBox())! - expect(Math.abs(menuBox.width - 124)).toBeLessThan(1) - expect(Math.abs(menuBox.height - 128)).toBeLessThan(1) - expect(Math.abs(triggerBox.y - (menuBox.y + menuBox.height) - 4)).toBeLessThan(1) - for (const row of await menu.getByRole('menuitem').all()) { - const rowBox = (await row.boundingBox())! - expect(Math.abs(rowBox.height - 40)).toBeLessThan(1) - expect(Math.abs(menuBox.width - rowBox.width - 8)).toBeLessThan(1) - const glyph = (await row.locator('svg').first().boundingBox())! - expect(Math.abs(glyph.width - 16)).toBeLessThan(1) - expect(Math.abs(glyph.height - 16)).toBeLessThan(1) - } - const menuAria = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(SIGNED_OUT_MENU_EXPECTED, menuAria, MODE) - await page.keyboard.press('Escape') - await menu.waitFor({ state: 'detached' }) - } else { - await page.getByRole('button', { name: '设置', exact: true }).waitFor() - expect(await accountMenu.count()).toBe(0) - expect(await page.getByRole('dialog', { name: '开始你的创作' }).count()).toBe(0) + await accountMenu.waitFor() + expect(await accountMenu.textContent()).toBe('更多') + const triggerBox = (await accountMenu.boundingBox())! + expect(Math.abs(triggerBox.height - 32)).toBeLessThan(1) + await accountMenu.click() + const menu = page.getByRole('menu') + await menu.waitFor() + expect(await menu.getByRole('menuitem').count()).toBe(3) + for (const name of ['设置', '意见反馈', '登录']) { + expect(await menu.getByRole('menuitem', { name, exact: true }).count()).toBe(1) } + const menuBox = (await menu.boundingBox())! + expect(Math.abs(menuBox.width - 124)).toBeLessThan(1) + expect(Math.abs(menuBox.height - 128)).toBeLessThan(1) + expect(Math.abs(triggerBox.y - (menuBox.y + menuBox.height) - 4)).toBeLessThan(1) + for (const row of await menu.getByRole('menuitem').all()) { + const rowBox = (await row.boundingBox())! + expect(Math.abs(rowBox.height - 40)).toBeLessThan(1) + expect(Math.abs(menuBox.width - rowBox.width - 8)).toBeLessThan(1) + const glyph = (await row.locator('svg').first().boundingBox())! + expect(Math.abs(glyph.width - 16)).toBeLessThan(1) + expect(Math.abs(glyph.height - 16)).toBeLessThan(1) + } + const menuAria = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SIGNED_OUT_MENU_EXPECTED, menuAria, MODE) + await page.keyboard.press('Escape') + await menu.waitFor({ state: 'detached' }) + expect(await page.getByRole('dialog', { name: '开始使用', exact: true }).count()).toBe(0) await openSettings(page, 'zh') const settings = page.getByRole('dialog', { name: '设置', exact: true }) await settings.getByRole('button', { name: '模型', exact: true }).click() diff --git a/apps/web/tests/onboarding-usable-provider.e2e.ts b/apps/web/tests/onboarding-usable-provider.e2e.ts index ebddfb8e34..4c21dbd9bf 100644 --- a/apps/web/tests/onboarding-usable-provider.e2e.ts +++ b/apps/web/tests/onboarding-usable-provider.e2e.ts @@ -44,10 +44,11 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first- it('closes the setup card without discarding the add card beside it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-setup-card-cancel')) + const signIn = page.getByRole('dialog', { name: '开始使用', exact: true }) + await signIn.waitFor({ timeout: 15_000 }) + await signIn.getByRole('button', { name: '添加 API Key', exact: true }).click() const credentialStep = page.getByRole('dialog', { name: CREDENTIAL_STEP }) await credentialStep.waitFor({ timeout: 15_000 }) - expect(await page.getByRole('dialog', { name: '开始你的创作' }).count()).toBe(0) - expect(await page.getByRole('button', { name: '账号菜单', exact: true }).count()).toBe(0) await credentialStep.getByRole('button', { name: '稍后配置' }).click() await credentialStep.waitFor({ state: 'detached', timeout: 15_000 }) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index fa61e50def..560837cb67 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -96,6 +96,141 @@ describe('web e2e: plugin manager', () => { return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '') } + it('keeps plugin header actions on one line as the available width changes', async () => { + const facts: string[] = [] + // A dedicated scaffold keeps the measured header deterministic: an install + // task left by an earlier test swaps the toolbar's add control for the + // view-installation button, which moves the wrap threshold. + const headerScaffold = await launchWebScaffold() + try { + // Pin the extremes; at intermediate widths, the rendered font and locale + // determine whether the two intrinsic items fit beside each other. + const PINNED_PLACEMENT: Record = { 1680: 'beside', 1024: 'beside', 390: 'below', 430: 'below' } + for (const language of ['en', 'zh'] as const) { + const context = await browser.newContext({ + viewport: { width: 1680, height: 1000 }, locale: language === 'en' ? 'en-US' : ZH_BROWSER_LOCALE, + reducedMotion: 'reduce', + }) + try { + const probe = await context.newPage() + const consoleWatch = watchConsole(probe) + await probe.goto(headerScaffold.authenticatedUrl, { waitUntil: 'load' }) + const title = language === 'en' ? 'Plugins' : '插件' + await probe.getByRole('button', { name: title, exact: true }).click() + const panel = probe.locator('[data-plugin-panel]') + const header = panel.locator(':scope > header') + await header.getByRole('heading', { name: title, exact: true }).waitFor() + await probe.evaluate(() => document.fonts.ready) + const add = header.getByRole('button', { name: language === 'en' ? 'Add plugin' : '添加插件', exact: true }) + await expect.poll(() => add.isEnabled()).toBe(true) + await add.focus() + const measure = () => header.evaluate((element) => { + const heading = element.firstElementChild! + const actions = element.lastElementChild! + const headingRect = heading.getBoundingClientRect() + const actionRect = actions.getBoundingClientRect() + const headerRect = element.getBoundingClientRect() + const intrinsicHeading = heading.cloneNode(true) as HTMLElement + Object.assign(intrinsicHeading.style, { + position: 'absolute', width: 'max-content', maxWidth: 'none', visibility: 'hidden', pointerEvents: 'none', + }) + element.append(intrinsicHeading) + let intrinsicHeadingWidth: number + try { + intrinsicHeadingWidth = intrinsicHeading.getBoundingClientRect().width + } finally { + intrinsicHeading.remove() + } + const headerStyle = getComputedStyle(element) + const contentWidth = headerRect.width - Number.parseFloat(headerStyle.paddingLeft) - Number.parseFloat(headerStyle.paddingRight) + const fitsBeside = intrinsicHeadingWidth + actionRect.width + Number.parseFloat(headerStyle.columnGap) <= contentWidth + 1 + const subtitle = heading.querySelector('h1 + div > span')! + const range = document.createRange() + range.selectNodeContents(subtitle) + const lastText = Array.from(range.getClientRects()).at(-1)! + const info = heading.querySelector('button')!.getBoundingClientRect() + const buttons = Array.from(actions.querySelectorAll('button')) + const singleLine = buttons.every((button) => { + const walker = document.createTreeWalker(button, NodeFilter.SHOW_TEXT) + const tops: number[] = [] + while (walker.nextNode()) { + if (!walker.currentNode.textContent?.trim()) continue + range.selectNodeContents(walker.currentNode) + for (const rect of range.getClientRects()) tops.push(rect.top) + } + return tops.length === 0 || Math.max(...tops) - Math.min(...tops) < 1 + }) + return { + singleLine, fitsBeside, + diagnostic: { flexWrap: headerStyle.flexWrap, headingMaxWidth: getComputedStyle(heading).maxWidth, + introDisplay: getComputedStyle(heading.querySelector('h1 + div')!).display, + intrinsicHeadingWidth, headingWidth: headingRect.width, actionsWidth: actionRect.width, + contentWidth, gap: Number.parseFloat(headerStyle.columnGap), + subtitleRight: lastText.right, infoLeft: info.left, + }, + placement: actionRect.left >= headingRect.right - 1 + ? 'beside' as const + : actionRect.top >= headingRect.bottom - 1 ? 'below' as const : 'overlapping' as const, + contained: actionRect.left >= headerRect.left - 1 && actionRect.right <= headerRect.right + 1, + infoFollowsText: info.left >= lastText.right && info.left - lastText.right <= 5 + && info.top < lastText.bottom && info.bottom > lastText.top, + focused: buttons.includes(document.activeElement as HTMLButtonElement), + } + }) + const validLayout = (layout: Awaited>) => + layout.singleLine && layout.contained && layout.infoFollowsText && layout.focused + && layout.placement === (layout.fitsBeside ? 'beside' : 'below') + for (const width of [1680, 768, 520, 440, 390, 430, 1024]) { + await probe.setViewportSize({ width, height: 1000 }) + await expect.poll(async () => { + const layout = await measure() + return validLayout(layout) ? true : layout + }, { message: `${language} ${width}px plugin header geometry` }).toBe(true) + const { placement } = await measure() + const pinned = PINNED_PLACEMENT[width] + if (pinned !== undefined) expect(placement).toBe(pinned) + facts.push(`${language} ${width}px: actions ${pinned === undefined ? 'beside or below (according to intrinsic fit)' : placement} the heading, single-line; contained; information follows subtitle; focus retained`) + } + await probe.setViewportSize({ width: 390, height: 1000 }) + const previousWrap = await header.evaluate((element) => { + const style = element.style + const previous = { value: style.getPropertyValue('flex-wrap'), priority: style.getPropertyPriority('flex-wrap') } + style.setProperty('flex-wrap', 'nowrap') + return previous + }) + try { + const noWrap = await measure() + expect(noWrap.fitsBeside).toBe(false) + expect(noWrap.placement).not.toBe('below') + expect(validLayout(noWrap)).toBe(false) + } finally { + await header.evaluate((element, previous) => { + if (previous.value === '') element.style.removeProperty('flex-wrap') + else element.style.setProperty('flex-wrap', previous.value, previous.priority) + }, previousWrap) + } + await expect.poll(async () => validLayout(await measure())).toBe(true) + const menuTrigger = header.getByRole('button', { name: language === 'en' ? 'Choose how to add a plugin' : '选择添加插件方式' }) + await menuTrigger.click() + await probe.getByRole('menu').waitFor() + await probe.keyboard.press('Escape') + await expect.poll(() => probe.getByRole('menu').count()).toBe(0) + expect(await menuTrigger.evaluate(element => element === document.activeElement)).toBe(true) + expect(consoleWatch.pageErrors).toEqual([]) + } finally { + await context.close() + } + } + await compareOrRefreshGolden( + join(SNAPSHOT_DIR, 'header-responsive.expected.md'), + `# Plugin header responsive layout\n\n${facts.join('\n')}\n\nA forced no-wrap layout at 390px fails the same measured layout check in both locales; restoring wrap passes.\nAdd menu dismisses on Escape and returns focus in both locales.`, + MODE, + ) + } finally { + await headerScaffold.close() + } + }) + it('aligns the first-read skeleton with the loaded plugin cards', async () => { const facts: string[] = [] let aria = '' @@ -932,7 +1067,7 @@ describe('web e2e: plugin manager', () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', 'refresh.expected.md', - 'ime-enter.expected.md', + 'ime-enter.expected.md', 'header-responsive.expected.md', ]) }) }) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index ac2f8c72f7..3a931d8e89 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -37,6 +37,8 @@ import { } from '@deepseek-ai/dsh-experimental-webworker-runtime' import { VFS_EXAMPLE_SESSION_IDS, + VFS_EXAMPLE_REASONING, + VFS_EXAMPLE_WRAPPED_COMMAND, buildVfsExampleFiles, } from '../../../packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts' import { captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts' @@ -316,6 +318,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { const continueButton = page.getByRole('button', { name: 'Continue' }) await continueButton.waitFor({ timeout: HERO_TIMEOUT_MS }) await continueButton.click() + await page.getByRole('button', { name: 'Add API Key', exact: true }).click() const configureLater = page.getByRole('button', { name: 'Configure later' }) await configureLater.waitFor({ timeout: 30_000 }) await configureLater.click() @@ -455,6 +458,57 @@ async function bootPreview(origin: string, browser: Browser): Promise { await page.getByText('Update to-do list', { exact: true }).waitFor() await page.getByText('Error: ENOENT: no such file, open missing.txt', { exact: true }).waitFor() + const reasoning = page.locator('[data-variant="think"]') + await expect.poll(() => reasoning.count()).toBe(VFS_EXAMPLE_REASONING.length) + for (const [index, text] of VFS_EXAMPLE_REASONING.entries()) { + const row = reasoning.nth(index) + await row.getByRole('button').click() + await row.getByText(text.split('\n\n').at(-1)!, { exact: true }).waitFor({ state: 'visible' }) + } + + const wrappedCall = page.locator('[data-chat-call-id="preview-wrapped-bash"]') + await wrappedCall.locator('[data-sample="bash"]').click() + const terminal = wrappedCall.locator('[data-terminal]') + const originalWidth = await terminal.evaluate(element => element.style.width) + try { + await terminal.evaluate((element) => { element.style.width = '380px' }) + const commands = terminal.locator('[data-command-text]') + expect(await commands.allTextContents()).toEqual(VFS_EXAMPLE_WRAPPED_COMMAND.slice(0, -1).split('\n')) + await commands.first().scrollIntoViewIfNeeded() + const geometry = await commands.first().evaluate((element) => { + const prompt = element.parentElement! + const header = element.closest('[class*="_header_"]')! + header.scrollTop = 0 + const dot = prompt.querySelector('[data-state="done"]') + if (dot === null) throw new Error('wrapped preview command has no successful status dot') + const range = document.createRange() + range.selectNodeContents(element) + const lines = [...range.getClientRects()].filter(rect => rect.width > 0 && rect.height > 0) + const marker = dot.getBoundingClientRect() + const viewport = header.getBoundingClientRect() + return { + wrapped: new Set(lines.map(rect => Math.round(rect.top))).size > 1, + whitespace: getComputedStyle(element).whiteSpace, + alignmentError: Math.abs(marker.top + marker.height / 2 + - (prompt.getBoundingClientRect().top + Number.parseFloat(getComputedStyle(prompt).lineHeight) / 2)), + visible: marker.top >= viewport.top && marker.bottom <= viewport.bottom, + } + }) + expect(geometry.wrapped).toBe(true) + expect(geometry.whitespace).toBe('pre-wrap') + expect(geometry.alignmentError).toBeLessThanOrEqual(1) + expect(geometry.visible).toBe(true) + expect(await terminal.locator('[data-state="done"]').count()).toBe(1) + } finally { + await terminal.evaluate((element, width) => { element.style.width = width }, originalWidth) + } + + await page.getByRole('button', { name: 'Parent workspace guide:1–8', exact: true }).click() + const preview = page.locator('[data-rightbar-col]') + await expect.poll(() => preview.locator('[data-textpreview-path]').textContent()).toBe('/dsh/workspace/PREVIEW.md') + await preview.getByRole('heading', { name: 'Preview Workspace', exact: true }).waitFor({ state: 'visible' }) + expect(await preview.locator('[data-dockkit-tab-title]').allTextContents()).toEqual(['PREVIEW.md']) + const subagents = page.getByRole('button', { name: '2 subagents', exact: true }) await subagents.waitFor({ timeout: 15_000 }) await subagents.hover() diff --git a/apps/web/tests/remote-settings.e2e.ts b/apps/web/tests/remote-settings.e2e.ts new file mode 100644 index 0000000000..8f7ea86e1a --- /dev/null +++ b/apps/web/tests/remote-settings.e2e.ts @@ -0,0 +1,129 @@ +// A trusted remote browser uses the same Host settings service as loopback: +// welcome acknowledgement, model configuration and appearance persist in the +// Host profile patch across reloads and independent browser contexts. +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { Browser, Locator, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + acknowledgeReloadConnectionLoss, + launchWebScaffold, watchConsole, webSnapshotMode, WELCOME_NOTICE_COPY, + type WebScaffold, +} from './scaffold.ts' +import { openSettings, ZH_BROWSER_LOCALE } from './support.ts' + +const MODE = webSnapshotMode() + +/** + * A loading welcome store hides the notice, so absence is conclusive only + * after the shared settings read has enabled the Models view. + * @param page - the loaded page. + * @returns the open settings dialog on its Models view. + */ +async function expectOnboardingSettled(page: Page): Promise { + await page.waitForSelector('#root', { timeout: 30_000 }) + await page.getByRole('textbox', { name: '选择工作区' }).waitFor({ timeout: 30_000 }) + await openSettings(page, 'zh') + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.getByRole('button', { name: '模型', exact: true }).click() + await dialog.getByRole('button', { name: '添加模型提供商', exact: true }).waitFor({ timeout: 15_000 }) + expect(await page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }).count()).toBe(0) + return dialog +} + +describe.skipIf(MODE === 'record')('web e2e: trusted remote host preferences', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + remoteAuthority: 'remote.localhost', + welcomeNoticePending: true, + }) + browser = await chromium.launch() + page = await browser.newPage({ + viewport: { width: 1440, height: 960 }, + locale: ZH_BROWSER_LOCALE, + }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('#root', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + /** Reload the page and absorb the one expected reload-time connection loss. */ + async function reloadPage(): Promise { + const warnings = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warnings) + } + + it('persists welcome acknowledgement, model configuration, and appearance across reloads and browser contexts', async () => { + const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }) + await welcome.waitFor({ timeout: 15_000 }) + expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true) + + await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click() + await welcome.waitFor({ state: 'detached', timeout: 15_000 }) + await expect.poll( + () => page.locator('#root').evaluate(root => (root as HTMLElement).inert), + { timeout: 15_000 }, + ).toBe(false) + const settingsPath = join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml') + await expect.poll(() => readFile(settingsPath, 'utf8'), { timeout: 10_000 }) + .toContain('welcomeNoticeVersion:') + + // The acknowledged version is durable, so the notice stays away after a reload. + await reloadPage() + const dialog = await expectOnboardingSettled(page) + + await page.emulateMedia({ colorScheme: 'light' }) + await dialog.getByRole('button', { name: '添加模型提供商', exact: true }).click() + await dialog.getByLabel('提供商', { exact: true }).selectOption('minimax-cn') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll(() => readFile(settingsPath, 'utf8'), { timeout: 10_000 }) + .toContain('minimax-cn: {}') + + await dialog.getByRole('button', { name: '通用设置', exact: true }).click() + await dialog.getByRole('button', { name: '深色', exact: true }).click() + await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe('') + await expect.poll(() => readFile(settingsPath, 'utf8'), { timeout: 10_000 }) + .toContain('preference: dark') + + await reloadPage() + await expectOnboardingSettled(page) + await dialog.getByRole('button', { name: '编辑 minimax-cn', exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme'), { timeout: 15_000 }).toBe('') + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + + const context = await browser.newContext({ + viewport: { width: 1440, height: 960 }, + locale: ZH_BROWSER_LOCALE, + colorScheme: 'light', + }) + try { + const freshPage = await context.newPage() + const freshTripwire = watchConsole(freshPage) + await freshPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const freshDialog = await expectOnboardingSettled(freshPage) + await freshDialog.getByRole('button', { name: '编辑 minimax-cn', exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll( + () => freshPage.locator('body').getAttribute('data-ds-dark-theme'), + { timeout: 15_000 }, + ).toBe('') + expect(freshTripwire.warnings).toEqual([]) + expect(freshTripwire.pageErrors).toEqual([]) + } finally { + await context.close() + } + }, 180_000) +}) diff --git a/apps/web/tests/remote-welcome.e2e.ts b/apps/web/tests/remote-welcome.e2e.ts deleted file mode 100644 index b7b013dc2d..0000000000 --- a/apps/web/tests/remote-welcome.e2e.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Trusted non-loopback Web access cannot call the loopback-only settings API; -// the notice therefore advances for this browser process and returns on reload. -import type { Browser, Page } from 'playwright' -import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { - acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode, - WELCOME_NOTICE_COPY, - type WebScaffold, -} from './scaffold.ts' -import { ZH_BROWSER_LOCALE } from './support.ts' - -const MODE = webSnapshotMode() - -describe.skipIf(MODE === 'record')('web e2e: remote welcome notice', () => { - let scaffold: WebScaffold - let browser: Browser - let page: Page - let tripwire: ReturnType - - beforeAll(async () => { - scaffold = await launchWebScaffold({ - remoteAuthority: 'remote.localhost', - welcomeNoticePending: true, - }) - browser = await chromium.launch() - page = await browser.newPage({ - viewport: { width: 1440, height: 960 }, - locale: ZH_BROWSER_LOCALE, - }) - tripwire = watchConsole(page) - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.waitForSelector('#root', { timeout: 30_000 }) - }, 120_000) - - afterAll(async () => { - await browser?.close() - await scaffold?.close() - }) - - it('advances process-locally and presents the notice again after reload', async () => { - const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }) - await welcome.waitFor({ timeout: 15_000 }) - expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true) - - await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click() - await welcome.waitFor({ state: 'detached', timeout: 15_000 }) - await expect.poll( - () => page.locator('#root').evaluate(root => (root as HTMLElement).inert), - { timeout: 15_000 }, - ).toBe(false) - - const reloadWarnings = tripwire.warnings.length - await page.reload({ waitUntil: 'load' }) - acknowledgeReloadConnectionLoss(tripwire, reloadWarnings) - await welcome.waitFor({ timeout: 15_000 }) - expect(tripwire.warnings).toEqual([]) - expect(tripwire.pageErrors).toEqual([]) - }, 60_000) -}) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 77d9c9c54d..c5a382a55e 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -19,7 +19,7 @@ import type { Browser, Page, Route, WebSocketRoute } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { ToolCallId, expandAssistantStream } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionListValue } from '@deepseek-ai/dsh-api-session-controller/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { @@ -213,7 +213,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { } }, 200_000) - it('keeps source checkout and Web context in the system prompt and cwd in user context', async () => { + it('keeps value-free Web guidance in the system prompt and process values in runtime context', async () => { if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id') const agent = scaffold.ctx.agents.get(settledSessionId) if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`) @@ -230,6 +230,8 @@ describe('web e2e: fresh round trip through the real assembly', () => { const userText = agent.session.deriveMessages().filter(message => message.role === 'user') .flatMap(message => message.content.flatMap(block => block.type === 'text' ? [block.text] : [])) expect(userText.some(text => text.includes(`Current working directory: ${JSON.stringify(cwd)}.`))).toBe(true) + expect(system).not.toContain(scaffold.baseUrl) + expect(userText.some(text => text.includes(`Current DeepSeek Harness Web GUI URL: ${JSON.stringify(scaffold.baseUrl)}.`))).toBe(true) const suffix = paragraphs.slice(-2).join('\n\n') .split(REPO_ROOT).join('{{sourceRoot}}') .split(join(scaffold.workspaceCwd, 'workspace')).join('{{cwd}}') @@ -238,22 +240,34 @@ describe('web e2e: fresh round trip through the real assembly', () => { }) it('exposes the assembled Web URL to the real bash tool', async () => { - if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id') - const agent = scaffold.ctx.agents.get(settledSessionId) - if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`) - const result = await scaffold.ctx.tools.execute({ - signal: AbortSignal.timeout(5_000), - callId: ToolCallId('web-url-probe'), - name: 'bash', - arguments: { - command: 'printf \'%s\\n\' "$DSH_WEB_URL"', - description: 'Print current Web runtime', - }, - agent, - }) - expect(result.isError).toBe(false) - expect(result.content.filter(block => block.type === 'text').map(block => block.text).join('')) - .toBe(`${scaffold.baseUrl}\n`) + // This host probe records job events too; keep it out of the shared + // conversation fixture that other browser scenarios replay. + const probe = await launchWebScaffold() + try { + const handle = await probe.ctx.agents.create({ + sessionId: SessionId('web-url-probe'), + setup: agentCtx => probe.ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + try { + const result = await probe.ctx.tools.execute({ + signal: AbortSignal.timeout(5_000), + callId: ToolCallId('web-url-probe'), + name: 'bash', + arguments: { + command: 'printf \'%s\\n\' "$DSH_WEB_URL"', + description: 'Print current Web runtime', + }, + agent: handle.agent, + }) + expect(result.isError).toBe(false) + expect(result.content.filter(block => block.type === 'text').map(block => block.text).join('')) + .toBe(`${probe.baseUrl}\n`) + } finally { + await handle.dispose() + } + } finally { + await probe.close() + } }) it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => { diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 8009cf4453..593eb41d7a 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -78,7 +78,7 @@ import { type SessionEvent, type SessionHeader, } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' // Empty type imports carry the webServer/agents/sessionPersistence Context merges. import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-agent' @@ -310,6 +310,8 @@ export interface LaunchOptions { openInAppEnvironment?: LaunchEnvironmentSnapshot /** Compare the replayed root Session; `read-only` also forbids refresh writes to a borrowed fixture. */ compareReplaySession?: boolean | 'read-only' + /** Fixed JSON-quoted workspace path length for compaction estimates; omitted values use the ordinary prefix. */ + workspacePathLength?: number /** * Optional product overlay applied after the shipped Web surface and before * the scaffold's hermetic test patches, matching the launcher's `--patch` @@ -502,7 +504,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise sessionFormatCatalog.encodeCurrentEvent(event)) const header = sessionFormatCatalog.encodeCurrentHeader({ @@ -1126,6 +1138,9 @@ export function normalizeWebSessionVolatiles(log: string, workspaceCwd?: string) let record = normalizeClientTimeZones(mapJsonStringValues(JSON.parse(line), (value) => { let normalized = value .replace(/Anonymous user: [0-9a-f-]{36}(?=\.$)/gi, 'Anonymous user: {{anonymousUserId}}') + // The checkout path and listener port differ per machine and run. + .replace(/Current DeepSeek Harness implementation checkout: "(?:[^"\\]|\\.)*"\./g, 'Current DeepSeek Harness implementation checkout: "{{sourceRoot}}".') + .replace(/Current DeepSeek Harness Web GUI URL: "(?:[^"\\]|\\.)*"\./g, 'Current DeepSeek Harness Web GUI URL: "{{webUrl}}".') for (const cwd of cwdSpellings) normalized = replaceWebCwd(normalized, cwd) return normalized })) as { type?: unknown; data?: { endpoint?: unknown } } @@ -1545,6 +1560,7 @@ function normalizeAria(snapshot: string, workspaceCwd: string, age: boolean): st .split(workspaceCwd).join('{{cwd}}') .split(base).join('{{workspace}}') .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}') + .replace(/(?<=-)[0-9a-f]{16}(?![0-9a-f])/gi, '{{uuid}}') // The optional space in `\d+m ?\d+s` covers both minute spellings: the // stats line's compact `2m42s` and the message-chrome template's `2m 42s`. .replace( diff --git a/apps/web/tests/schedule-header.e2e.ts b/apps/web/tests/schedule-header.e2e.ts new file mode 100644 index 0000000000..fc79fd571c --- /dev/null +++ b/apps/web/tests/schedule-header.e2e.ts @@ -0,0 +1,97 @@ +// UI-only geometry for the Automation tasks page; no model calls or task mutations. +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode } from './scaffold.ts' + +it('keeps the automation title intact before moving New to another line', async () => { + const scaffold = await launchWebScaffold() + try { + const browser = await chromium.launch() + try { + const facts: string[] = [] + for (const language of ['en', 'zh'] as const) { + const context = await browser.newContext({ + viewport: { width: 1680, height: 1000 }, + locale: language === 'en' ? 'en-US' : 'zh-CN', + reducedMotion: 'reduce', + }) + try { + const page = await context.newPage() + const consoleWatch = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const title = language === 'en' ? 'Automation tasks' : '自动化任务' + await page.getByRole('button', { name: title, exact: true }).click() + const panel = page.getByTestId('task-manager-page') + const heading = panel.getByRole('heading', { name: title, exact: true }) + await heading.waitFor() + const header = heading.locator('..') + const create = header.getByRole('button', { name: language === 'en' ? 'New' : '新建', exact: true }) + await page.evaluate(() => document.fonts.ready) + await create.focus() + for (const width of [1680, 800, 520, 440, 390, 430, 1024]) { + await page.setViewportSize({ width, height: 1000 }) + // The title keeps its natural width at every viewport width, so New + // stays beside it here; the squeeze case below is what moves it down. + await expect.poll(() => header.evaluate((element) => { + const title = element.querySelector('h1')! + const button = element.querySelector('button')! + const titleRect = title.getBoundingClientRect() + const buttonRect = button.getBoundingClientRect() + const headerRect = element.getBoundingClientRect() + const range = document.createRange() + range.selectNodeContents(title) + const titleLines = Array.from(range.getClientRects()) + return { + titleSingleLine: titleLines.length === 1, + buttonSingleLine: button.scrollHeight <= button.clientHeight, + placement: buttonRect.left >= titleRect.right - 1 + ? 'beside' as const + : buttonRect.top >= titleRect.bottom - 1 ? 'below' as const : 'overlapping' as const, + contained: titleRect.left >= headerRect.left - 1 && titleRect.right <= headerRect.right + 1 + && buttonRect.left >= headerRect.left - 1 && buttonRect.right <= headerRect.right + 1, + focused: document.activeElement === button, + } + })).toEqual({ + titleSingleLine: true, buttonSingleLine: true, placement: 'beside', contained: true, focused: true, + }) + facts.push(`${language} ${width}px: title and New single-line, New beside the title; contained; New retains focus`) + } + if (language === 'en') { + // 509px collapses the left sidebar to its rail; expanding it leaves the + // page column narrow enough (measured 172px) to force the wrap. + await page.setViewportSize({ width: 509, height: 1000 }) + await page.getByRole('button', { name: 'Open sidebar', exact: true }).first().click() + await expect.poll(() => header.evaluate((element) => { + const title = element.querySelector('h1')! + const button = element.querySelector('button')! + const titleRect = title.getBoundingClientRect() + const buttonRect = button.getBoundingClientRect() + const range = document.createRange() + range.selectNodeContents(title) + return { + titleSingleLine: range.getClientRects().length === 1, + placement: buttonRect.left >= titleRect.right - 1 + ? 'beside' as const + : buttonRect.top >= titleRect.bottom - 1 ? 'below' as const : 'overlapping' as const, + } + })).toEqual({ titleSingleLine: true, placement: 'below' }) + facts.push('en 509px with sidebar expanded: title single-line; New moves below the title') + } + expect(consoleWatch.pageErrors).toEqual([]) + } finally { + await context.close() + } + } + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/schedule-header.expected.md', import.meta.url)), + `# Automation tasks header\n\n${facts.join('\n')}`, + webSnapshotMode(), + ) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 085f358b85..4c6fff7f18 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -16,7 +16,7 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, ContextFormed, Message, MessageSource } from '@deepseek-ai/dsh-llm' import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -41,6 +41,7 @@ type CheckpointSource = Extract const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/seeded-history', import.meta.url)) +const REASONING_EXPECTED_DIR = fileURLToPath(new URL('./expected/seeded-history', import.meta.url)) const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/ui.expected.md', import.meta.url)) const UI_EXPANDED_EXPECTED = fileURLToPath( @@ -856,11 +857,68 @@ describe('web e2e: seeded history renders through cold resume', () => { await terminal.getByRole('textbox', { name: 'Terminal', exact: true }).waitFor() }) + it.skipIf(MODE === 'record')('omits blank reasoning rows around recorded text in an appended fixture turn', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-blank-reasoning')) + const session = scaffold.ctx.sessions.get(SessionId(SEED_ID)) + if (session === undefined) throw new Error('seeded session is unavailable') + const events = session.snapshotEvents() + const recorded = events.flatMap(event => event.type === 'assistant/message' ? event.data.message.content : []) + .find(block => block.type === 'reasoning' && block.text.trim() !== '') + if (recorded?.type !== 'reasoning') throw new Error('seeded history requires recorded reasoning text') + const previousEnd = events.findLast(event => event.type === 'turn/end') + if (previousEnd?.type !== 'turn/end') throw new Error('seeded history requires a closed turn') + const turn = previousEnd.data.turn + 1 + const answer = 'BLANK_REASONING_FIXTURE_DONE' + // The blank blocks are a test condition around authentic recorded text, not model output. + session.append('turn/start', { turn }) + session.append('step/start', { turn, step: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Show recorded reasoning surrounded by blank blocks.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + const message = session.append('assistant/message', { + turn, step: 1, stream: [], + message: createMessage({ + role: 'assistant', + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + content: [ + { type: 'reasoning', text: '' }, + { type: 'reasoning', text: recorded.text }, + { type: 'reasoning', text: ' \t\r\n' }, + { type: 'reasoning', text: '' }, + { type: 'text', text: answer }, + ], + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(session) + await page.getByText(answer, { exact: true }).waitFor({ timeout: 10_000 }) + const selector = `[data-chat-turn="${turn}"][data-chat-group-part="reasoning"]` + const rows = page.locator(selector).locator('[data-variant="think"]') + await expect.poll(() => rows.count(), { timeout: 10_000 }).toBe(1) + await expandOwningTurnProcess(page, rows.first()) + const toggle = rows.getByRole('button').first() + expect(await toggle.getAttribute('aria-expanded')).toBe('false') + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('true') + const body = rows.locator('[data-markdown-variant="compact"]') + await body.waitFor({ state: 'visible' }) + expect(await body.innerText()).not.toBe('') + expect(message.data.message.content[1]).toEqual({ type: 'reasoning', text: recorded.text }) + await compareOrRefreshGolden(join(REASONING_EXPECTED_DIR, 'blank-reasoning.expected.md'), [ + await captureStableAria(page, selector, scaffold.workspaceCwd), + '', + `- Reasoning rows: ${await rows.count()}`, + ].join('\n'), MODE) + }) + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { // No replay fixture was installed and the llm seam is open — any stray // stream would have failed the turn loudly. Cleanliness pins the wire. expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(REASONING_EXPECTED_DIR, ['blank-reasoning.expected.md']) await assertFixtureInventory(SNAPSHOT_DIR, [ 'command-row.expected.md', 'feedback-row.expected.md', 'file-preview.expected.md', 'session.v3.jsonl', 'sticky-geometry.expected.md', 'thinking-expanded.expected.md', diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 2b64c107fc..5155daf95c 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -75,13 +75,15 @@ describe('web e2e: settings modal and General preferences', () => { if (await dialog.isVisible()) await page.keyboard.press('Escape') await dialog.waitFor({ state: 'hidden' }) }) - const trigger = page.getByRole('button', { name: '设置', exact: true }) - expect(await trigger.getAttribute('aria-haspopup')).toBe('dialog') + const trigger = page.getByRole('button', { name: '账号菜单', exact: true }) + expect(await trigger.getAttribute('aria-haspopup')).toBe('menu') expect(await trigger.getAttribute('aria-expanded')).toBe('false') await trigger.click() + expect(await trigger.getAttribute('aria-expanded')).toBe('true') + await page.getByRole('menuitem', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) - expect(await trigger.getAttribute('aria-expanded')).toBe('true') + expect(await trigger.getAttribute('aria-expanded')).toBe('false') // General is active by default; Permission, Language and Appearance are functional. expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true') await dialog.getByRole('button', { name: '工作区内修改' }).waitFor({ timeout: 10_000 }) @@ -163,8 +165,8 @@ describe('web e2e: settings modal and General preferences', () => { expect(await enabledRows.count()).toBeGreaterThan(0) expect(await enabledRows.getByText('已启用', { exact: true }).count()).toBe(0) expect(await dialog.locator('[data-plugin-scope="global"] [role="img"][aria-label="运行中"]').count()).toBe(0) - expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count')) - .toBe(String(expectedPluginCount)) + expect(await dialog.getByText('由 Agent 预设按会话组成', { exact: true }).count()).toBe(1) + expect(await dialog.getByText('系统与所有会话共用', { exact: true }).count()).toBe(1) expect(await dialog.getByRole('button', { name: '内置插件', exact: true }).getAttribute('aria-current')).toBe('true') // One contribution shows as the page itself, without a tab row. expect(await dialog.getByRole('tab').count()).toBe(0) @@ -182,17 +184,11 @@ describe('web e2e: settings modal and General preferences', () => { ] as const for (const [entryId, status] of instanceRows) { const row = dialog.locator(`[data-plugin-scope="preset"] [data-plugin-entry="${entryId}"]`) - // An id that repeats the title adds nothing, so its card shows no chip and its name omits it. - const repeatsTitle = entryId === 'tool-subagent' - const name = repeatsTitle ? `tool-subagent, ${status}` : `tool-subagent, ${entryId}, ${status}` + const name = `子智能体委派工具, ${entryId}, ${status}` const trigger = row.getByRole('button', { name, exact: true }) await trigger.waitFor({ timeout: 10_000 }) expect(await trigger.getAttribute('aria-expanded')).toBe('false') const identity = row.locator('code') - if (repeatsTitle) { - expect(await identity.count()).toBe(0) - continue - } expect(await identity.textContent()).toBe(entryId) expect(await identity.getAttribute('title')).toBe(entryId) } @@ -204,7 +200,7 @@ describe('web e2e: settings modal and General preferences', () => { ) await compareOrRefreshGolden(PLUGIN_INSTANCES_EXPECTED, instancesSnapshot, MODE) await dialog.getByRole('button', { - name: 'tool-subagent, tool-subagent-fork, 已启用', + name: '子智能体委派工具, tool-subagent-fork, 已启用', exact: true, }).click() expect(await dialog.locator('[data-plugin-entry="tool-subagent-fork"] button') @@ -797,7 +793,7 @@ describe('web e2e: settings modal and General preferences', () => { await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) - const enTrigger = page.getByRole('button', { name: 'Settings' }) + const enTrigger = page.getByRole('button', { name: 'Account menu' }) await enTrigger.waitFor({ timeout: 10_000 }) // A Chinese browser on another port still receives the explicit English @@ -943,7 +939,7 @@ describe('web e2e: settings modal and General preferences', () => { }) }) -describe('web e2e: browser-local collapse timing preference', () => { +describe('web e2e: Host-backed collapse timing preference', () => { let scaffold: WebScaffold let browser: Browser const uiWait = { timeout: 10_000 } @@ -974,11 +970,12 @@ describe('web e2e: browser-local collapse timing preference', () => { title: 'When to Collapse Work Details', completion: 'On completion', nextInput: 'On next message', description: 'Choose when to automatically collapse work details', }, - ] as const)('keeps $locale collapse timing on $theme only for the current Client', async (copy) => { + ] as const)('persists $locale collapse timing on $theme across Clients', async (copy) => { const contextOptions = { viewport: { width: 1680, height: 1000 }, locale: copy.browserLocale, colorScheme: copy.theme, timezoneId: 'Asia/Shanghai', } + await scaffold.ctx.settings.update('ui-chat', { collapseTiming: 'completion' }) const context = await browser.newContext(contextOptions) onTestFinished(() => context.close()) const page = await context.newPage() @@ -1076,19 +1073,23 @@ describe('web e2e: browser-local collapse timing preference', () => { await menu.waitFor({ state: 'hidden' }) await page.setViewportSize({ width: 1680, height: 1000 }) await timing.getByRole('button', { name: copy.nextInput, exact: true }).waitFor() - expect(await readPersistence()).toEqual(before) + await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value, uiWait) + .toMatchObject({ transcriptView: 'detailed', collapseTiming: 'next-input' }) + const saved = await readPersistence() + expect(saved.host).not.toBe(before.host) + expect(saved.storage).toEqual(before.storage) await dialog.getByRole('button', { name: copy.close, exact: true }).click() await openSettings(page, copy.locale) await timing.getByRole('button', { name: copy.nextInput, exact: true }).waitFor() - expect(await readPersistence()).toEqual(before) + expect(await readPersistence()).toEqual(saved) await dialog.getByRole('button', { name: copy.close, exact: true }).click() const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) await openSettings(page, copy.locale) - await timing.getByRole('button', { name: copy.completion, exact: true }).waitFor() + await timing.getByRole('button', { name: copy.nextInput, exact: true }).waitFor() acknowledgeReloadConnectionLoss(tripwire, warningStart) - expect(await readPersistence()).toEqual(before) + expect(await readPersistence()).toEqual(saved) if (screenshotDir !== undefined) { await page.screenshot({ path: join(screenshotDir, 'reloaded.png'), fullPage: true }) } @@ -1100,10 +1101,10 @@ describe('web e2e: browser-local collapse timing preference', () => { await openSettings(sharedPage, copy.locale) const sharedTiming = sharedPage.getByRole('dialog', { name: copy.settings, exact: true }) .getByText(copy.title, { exact: true }).locator('../..') - await sharedTiming.getByRole('button', { name: copy.completion, exact: true }).waitFor() + await sharedTiming.getByRole('button', { name: copy.nextInput, exact: true }).waitFor() const sharedPersistence = await readPersistence(sharedPage) - expect(sharedPersistence.host).toBe(before.host) - expect(sharedPersistence.settings).toEqual(before.settings) + expect(sharedPersistence.host).toBe(saved.host) + expect(sharedPersistence.settings).toEqual(saved.settings) expect(sharedPersistence.storage.local).toEqual(before.storage.local) if (screenshotDir !== undefined) { await sharedPage.screenshot({ path: join(screenshotDir, 'shared-tab.png'), fullPage: true }) @@ -1119,22 +1120,24 @@ describe('web e2e: browser-local collapse timing preference', () => { await openSettings(isolatedPage, copy.locale) const isolatedTiming = isolatedPage.getByRole('dialog', { name: copy.settings, exact: true }) .getByText(copy.title, { exact: true }).locator('../..') - await isolatedTiming.getByRole('button', { name: copy.completion, exact: true }).waitFor() + await isolatedTiming.getByRole('button', { name: copy.nextInput, exact: true }).waitFor() const isolatedPersistence = await readPersistence(isolatedPage) - expect(isolatedPersistence.host).toBe(before.host) - expect(isolatedPersistence.settings).toEqual(before.settings) + expect(isolatedPersistence.host).toBe(saved.host) + expect(isolatedPersistence.settings).toEqual(saved.settings) if (screenshotDir !== undefined) { await isolatedPage.screenshot({ path: join(screenshotDir, 'isolated-context.png'), fullPage: true }) } - expect(await readPersistence()).toEqual(before) - await timing.getByRole('button', { name: copy.completion, exact: true }).click() - await page.getByRole('menuitem', { name: copy.nextInput, exact: true }).click() + expect(await readPersistence()).toEqual(saved) await timing.getByRole('button', { name: copy.nextInput, exact: true }).click() await page.getByRole('menuitem', { name: copy.completion, exact: true }).click() await timing.getByRole('button', { name: copy.completion, exact: true }).waitFor() - expect(await readPersistence()).toEqual(before) - expect(settingsWrites).toEqual([]) + await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value, uiWait) + .toMatchObject({ transcriptView: 'detailed', collapseTiming: 'completion' }) + expect((await readPersistence()).storage).toEqual(before.storage) + await sharedTiming.getByRole('button', { name: copy.completion, exact: true }).waitFor() + await isolatedTiming.getByRole('button', { name: copy.completion, exact: true }).waitFor() + expect(settingsWrites.length).toBeGreaterThan(0) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) expect(sharedTripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/shell-command-details.e2e.ts b/apps/web/tests/shell-command-details.e2e.ts index a219fb6941..f9c50ad056 100644 --- a/apps/web/tests/shell-command-details.e2e.ts +++ b/apps/web/tests/shell-command-details.e2e.ts @@ -8,21 +8,22 @@ import { seedSession, watchConsole, webSnapshotMode, } from './scaffold.ts' import { expandOwningTurnProcess, newEnglishPage } from './support.ts' -import { expectCommandFocusAfterResize, expectSelectableCommandText } from './terminal-command-browser.ts' +import { expectFirstLineTerminalState, expectWrappedCommandText } from './terminal-command-browser.ts' const COMMAND = 'node ./scripts/start-worker.mjs --workspace=/tmp/diagnostics/long-workspace-name-for-background-command-details --log-level=debug --trace-tag=background-shell-full-command-selection --include=worker,lifecycle,transport,subscriptions --output=/tmp/diagnostics/background-worker.log' const ARGS = { command: COMMAND, description: 'Start the diagnostic worker', run_in_background: true } const OUTPUT = 'started background job bash-1' -const EXPECTED = fileURLToPath(new URL('./expected/shell-command-details/background.expected.md', import.meta.url)) +const LONG_ARGS = { ...ARGS, command: `${COMMAND} --token=${'x'.repeat(1024)}` } -/** A settled background launch acknowledgement rendered from durable call arguments. */ -function backgroundFixture(): string { +/** A settled shell call rendered from durable arguments and its original result. */ +function shellFixture(arguments_: typeof ARGS, output: string): string { const session = Session.create(SessionId('shell-command-details-source')) const callId = ToolCallId('shell-command-details-background') - const args = JSON.stringify(ARGS) + const args = JSON.stringify(arguments_) session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'Start the diagnostic worker in the background.' }], + content: [{ type: 'text', text: arguments_.run_in_background + ? 'Start the diagnostic worker in the background.' : 'Run the diagnostic worker.' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) session.append('step/start', { turn: 1, step: 1 }) @@ -36,7 +37,7 @@ function backgroundFixture(): string { const call = session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: args }) session.append('tool/result', { turn: 1, step: 1, - message: createToolResultMessage({ callId, content: [{ type: 'text', text: OUTPUT }], isError: false }), + message: createToolResultMessage({ callId, content: [{ type: 'text', text: output }], isError: false }), }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) session.append('step/end', { turn: 1, step: 1 }) session.append('step/start', { turn: 1, step: 2 }) @@ -56,10 +57,18 @@ function backgroundFixture(): string { ].join('\n') } -it('expands a background Bash launch to selectable arguments and its original acknowledgement', async () => { +it.each([ + { name: 'background', args: ARGS, output: OUTPUT }, + { name: 'background-long-token', args: LONG_ARGS, output: OUTPUT }, + { + name: 'foreground-multiline', + args: { ...LONG_ARGS, command: `${LONG_ARGS.command}\necho done\n`, run_in_background: false }, + output: 'done\n[exit code: 0]', + }, +])('expands $name to wrapped commands with only foreground output', async ({ name, args, output }) => { const scaffold = await launchWebScaffold({}) try { - await seedSession(scaffold, backgroundFixture(), 'shell-command-details-browser') + await seedSession(scaffold, shellFixture(args, output), 'shell-command-details-browser') const browser = await chromium.launch() try { const page = await newEnglishPage(browser) @@ -76,25 +85,52 @@ it('expands a background Bash launch to selectable arguments and its original ac expect(await row.getAttribute('aria-expanded')).toBe('false') await row.click() const card = row.locator('..') - const input = card.locator('[data-command-text]') + const commands = card.locator('[data-command-text]') + const input = commands.first() await input.waitFor() - expect(await input.textContent()).toBe(JSON.stringify(ARGS, null, 2)) - expect(await card.getByText(OUTPUT, { exact: true }).count()).toBe(1) - expect(await card.locator('[data-terminal]').count()).toBe(0) + const background = args.run_in_background + const selectableText = args.command.split('\n')[0]! + const inputSection = card.locator('[data-terminal] [class*="_header_"]') + expect(await input.textContent()).toBe(selectableText) + if (background) { + expect(await commands.allTextContents()).toEqual([args.command]) + expect(await card.locator('[data-terminal]').count()).toBe(1) + expect(await card.getByText(OUTPUT, { exact: true }).count()).toBe(0) + expect(await card.locator('[data-terminal][data-body], [data-terminal][data-running], [data-terminal] [data-state]').count()).toBe(0) + expect(await card.getByRole('button', { name: 'Copy', exact: true }).count()).toBe(0) + } else { + expect(await commands.allTextContents()).toEqual([selectableText, 'echo done']) + expect(await card.locator('[data-terminal]').count()).toBe(1) + expect(await card.getByText('done', { exact: true }).count()).toBe(1) + } expect(await card.getByRole('button', { name: 'Inspect', exact: true }).count()).toBe(1) for (const colorScheme of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme }) await expect.poll(() => page.locator('body').evaluate(element => element.hasAttribute('data-ds-dark-theme'))).toBe(colorScheme === 'dark') - for (const width of ['', '380px']) { + for (const width of ['', '380px', '']) { await card.evaluate((element, value) => { element.style.width = value }, width) - await input.scrollIntoViewIfNeeded() - await expectSelectableCommandText(page, input, COMMAND, 'Command arguments') - await expectCommandFocusAfterResize(card, input) + if (!background && width === '380px') await expectFirstLineTerminalState(card.locator('[data-terminal]'), 'done') + await expectWrappedCommandText(page, input, selectableText, inputSection) + expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + if (name !== 'background') { + expect(await inputSection.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true) + await inputSection.evaluate((element) => { element.scrollTop = 0 }) + await row.focus() + await page.keyboard.press('Tab') + const focusTarget = background ? inputSection : card.getByRole('button', { name: 'Copy', exact: true }) + expect(await focusTarget.evaluate(element => document.activeElement === element)).toBe(true) + await page.keyboard.press('End') + await expect.poll(() => inputSection.evaluate(element => + element.scrollTop + element.clientHeight >= element.scrollHeight - 1)).toBe(true) + await page.keyboard.press('Home') + await expect.poll(() => inputSection.evaluate(element => element.scrollTop)).toBe(0) + } } await card.evaluate((element) => { element.style.width = '' }) } await page.emulateMedia({ colorScheme: null }) - await compareOrRefreshGolden(EXPECTED, + const expected = fileURLToPath(new URL(`./expected/shell-command-details/${name}.expected.md`, import.meta.url)) + await compareOrRefreshGolden(expected, await captureStableAria(page, '[data-chat-call-id="shell-command-details-background"]', scaffold.workspaceCwd), webSnapshotMode()) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) @@ -105,3 +141,94 @@ it('expands a background Bash launch to selectable arguments and its original ac await scaffold.close() } }) + +it.each([ + { name: 'bash', background: false }, + { name: 'pwsh', background: false }, + { name: 'bash', background: true }, + { name: 'pwsh', background: true }, +] as const)('keeps the $name command mounted through settlement (background=$background)', async ({ name, background }) => { + const scaffold = await launchWebScaffold({}) + try { + const id = await seedSession(scaffold, shellFixture(ARGS, OUTPUT), 'shell-status-dot-browser') + const browser = await chromium.launch() + try { + const page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await page.getByRole('treeitem').first().click() + await page.getByRole('treeitem').nth(1).click() + await page.locator('[data-sample="bash"]').waitFor({ state: 'attached' }) + const resolved = await scaffold.ctx.sessionController.resolveAgent(id) + if ('error' in resolved) throw resolved.error + const session = resolved.agent.session + const callId = ToolCallId(`status-dot-${name}-${background ? 'background' : 'foreground'}`) + const args = JSON.stringify({ command: LONG_ARGS.command, description: `Long ${name} command`, run_in_background: background }) + session.append('turn/start', { turn: 2 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Show a long running command.' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('step/start', { turn: 2, step: 1 }) + session.append('assistant/message', { + turn: 2, step: 1, stream: [], + message: createAssistantMessage({ + content: [{ type: 'tool-call', id: callId, name, arguments: args }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + const call = session.append('tool/call', { turn: 2, step: 1, callId, name, arguments: args }) + await scaffold.ctx.sessions.flush(session) + const callRow = page.locator(`[data-chat-call-id="${callId}"]`) + const toggle = callRow.locator('[data-expandable]').first() + await toggle.waitFor({ state: 'attached' }) + await expandOwningTurnProcess(page, toggle) + await toggle.click() + const card = callRow.locator('[data-terminal]') + await card.waitFor() + await card.evaluate((element) => { element.style.width = '380px' }) + await card.scrollIntoViewIfNeeded() + const header = card.locator('[class*="_header_"]') + const mountedCard = await card.elementHandle() + if (mountedCard === null) throw new Error('Running command card is not mounted') + expect(await header.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true) + expect(await card.getByRole('button', { name: 'Copy', exact: true }).count()).toBe(0) + for (const state of ['ongoing', 'done'] as const) { + if (state === 'done') { + session.append('tool/result', { + turn: 2, step: 1, + message: createToolResultMessage({ + callId, content: [{ type: 'text', text: background ? OUTPUT : 'status dot output\n[exit code: 0]' }], isError: false, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) + session.append('step/end', { turn: 2, step: 1 }) + session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(session) + } + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.locator('body').evaluate(element => + element.hasAttribute('data-ds-dark-theme'))).toBe(colorScheme === 'dark') + if (background && state === 'done') { + await expect.poll(() => card.getAttribute('data-running')).toBeNull() + expect(await card.evaluate((element, previous) => element === previous, mountedCard)).toBe(true) + expect(await toggle.getAttribute('aria-expanded')).toBe('true') + expect(await card.getAttribute('data-body')).toBeNull() + expect(await card.getAttribute('data-running')).toBeNull() + expect(await card.locator('[data-state]').count()).toBe(0) + } else await expectFirstLineTerminalState(card, state) + expect(await card.locator('[data-command-text]').textContent()).toBe(LONG_ARGS.command) + } + } + expect(await card.getByText('status dot output', { exact: true }).count()).toBe(background ? 0 : 1) + expect(await card.getByText(OUTPUT, { exact: true }).count()).toBe(0) + expect(await card.getByRole('button', { name: 'Copy', exact: true }).count()).toBe(background ? 0 : 1) + expect(await callRow.getByRole('button', { name: 'Inspect', exact: true }).count()).toBe(1) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 6eefe81171..9c7d827aba 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -689,9 +689,13 @@ it('lets a preset producer reach the background-job registry', async () => { }, agent: handle.agent, }) + const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') + const matched = /^started background job (bash-[0-9a-f]{16})$/.exec(reported) + if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) + const jobId = matched[1] expect({ isError: started.isError, content: started.content }).toEqual({ isError: false, - content: [{ type: 'text', text: 'started background job bash-1' }], + content: [{ type: 'text', text: `started background job ${jobId}` }], }) // The controller reads what the producer started: same registry, one @@ -704,9 +708,8 @@ it('lets a preset producer reach the background-job registry', async () => { agent: handle.agent, }) expect(listed.isError).toBe(false) - expect(listed.content).toEqual([ - { type: 'text', text: expect.stringContaining('bash-1 [bash]') as unknown as string }, - ]) + expect(listed.content).toHaveLength(1) + expect(listed.content[0]?.type === 'text' ? listed.content[0].text : '').toContain(`${jobId} [bash]`) // The full round trip: the output a host-plane producer wrote is collected // through a preset-plane control, which is the linkage the realm severed. @@ -714,7 +717,7 @@ it('lets a preset producer reach the background-job registry', async () => { signal, callId: ToolCallId('shipped-task-output'), name: 'job_output', - arguments: { job_id: 'bash-1', wait: true }, + arguments: { job_id: jobId, wait: true }, agent: handle.agent, }) expect(collected.isError).toBe(false) diff --git a/apps/web/tests/shortcuts-desktop.e2e.ts b/apps/web/tests/shortcuts-desktop.e2e.ts index 9609e02de8..7560831694 100644 --- a/apps/web/tests/shortcuts-desktop.e2e.ts +++ b/apps/web/tests/shortcuts-desktop.e2e.ts @@ -9,7 +9,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' import { initialShortcutConfig, parseShortcutDefinitions, parseShortcutEdit } from '@deepseek-ai/dsh-client-shortcuts/protocol' import type { DesktopShortcutInput, DesktopShortcutsApi, ShortcutCommandId, ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol' import { desktopKeybindings } from '../../desktop/src/keybindings.ts' -import { compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts' +import { acknowledgeReloadConnectionLoss, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts' import { writeComposerDraft } from './support.ts' declare global { @@ -63,6 +63,12 @@ it.each([ } }) }, marker) const console = watchConsole(page) + const fontMutations: unknown[][] = [] + page.on('request', (request) => { + if (new URL(request.url()).pathname !== '/api/settings/mutate' || request.method() !== 'POST') return + const input = request.postDataJSON() as { payload: { args: { ns: string; ops: unknown[] } } } + if (input.payload.args.ns === 'ui-theme') fontMutations.push(input.payload.args.ops) + }) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) const deliverPrimary = (code: string, alt = false) => page.evaluate((input) => { window.shortcutFixture.deliver(input) @@ -131,6 +137,53 @@ it.each([ expect(await composer.innerText()).toBe('Desktop focus draft') await writeComposerDraft(page, composer, '') await expect.poll(() => composer.textContent()).toBe('') + const readFontSizes = () => page.evaluate(() => { + const style = document.body.style + return { + text: Number.parseInt(style.getPropertyValue('--dsh-content-font-size'), 10), + code: Number.parseInt(style.getPropertyValue('--dsh-code-font-size'), 10), + terminal: Number.parseInt(style.getPropertyValue('--dsh-terminal-font-size'), 10), + } + }) + const defaults = { text: 14, code: 11, terminal: 13 } + const patchPath = join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml') + const stepFontSizes = async (key: string, sizes: Awaited>): Promise => { + const before = fontMutations.length + const [response] = await Promise.all([ + page.waitForResponse((reply) => { + if (new URL(reply.url()).pathname !== '/api/settings/mutate' || reply.request().method() !== 'POST') return false + const input = reply.request().postDataJSON() as { payload: { args: { ns: string } } } + return input.payload.args.ns === 'ui-theme' + }), + page.keyboard.press(key), + ]) + expect(await response.finished()).toBeNull() + const envelope = await response.json() as { result: { ok: boolean } } + expect(envelope.result.ok).toBe(true) + const fields = [['fontSize', sizes.text], ['codeFontSize', sizes.code], ['terminalFontSize', sizes.terminal]] as const + expect(fontMutations.slice(before)).toEqual([fields.map(([field, value]) => ({ op: 'set', path: [field], value }))]) + await expect.poll(readFontSizes).toEqual(sizes) + for (const [field, value] of fields) { + await expect.poll(() => readFile(patchPath, 'utf8')).toContain(`${field}: ${value}`) + } + } + const initial = await readFontSizes() + expect(initial).toEqual(defaults) + await stepFontSizes(`${primary}+Equal`, { text: 15, code: 12, terminal: 14 }) + const afterEqual = await readFontSizes() + await stepFontSizes(`${primary}+Shift+Equal`, { text: 16, code: 13, terminal: 15 }) + const afterPlus = await readFontSizes() + await stepFontSizes(`${primary}+Minus`, { text: 15, code: 12, terminal: 14 }) + const afterMinus = await readFontSizes() + const warningStart = console.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(console, warningStart) + await page.getByText('DONE', { exact: true }).waitFor() + await expect.poll(readFontSizes).toEqual(afterMinus) + const afterReload = await readFontSizes() + await composer.focus() + await stepFontSizes(`${primary}+Minus`, defaults) + const restored = await readFontSizes() const dialog = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }) const settings = page.getByRole('dialog', { name: 'Settings', exact: true }) const automaticFocusStyle = () => page.evaluate(() => { @@ -187,6 +240,12 @@ it.each([ expect(await dialog.isVisible()).toBe(false) await openReference() await dialog.waitFor() + const referenceOrder = await dialog.getByRole('listitem').evaluateAll(rows => rows + .map(row => row.querySelector(':scope > span')?.textContent) + .filter(label => label === 'Increase font size' || label === 'Decrease font size')) + expect(referenceOrder).toEqual(['Increase font size', 'Decrease font size']) + await compareOrRefreshGolden(join(expected, 'font-sizes.expected.md'), + `# Font sizes\n\n${JSON.stringify({ initial, afterEqual, afterPlus, afterMinus, afterReload, restored, referenceOrder }, null, 2)}`, mode) expect(await dialog.getByRole('searchbox').evaluate(node => node === document.activeElement)).toBe(true) expect(await automaticFocusStyle()).toEqual(noFocusRing) const referenceShadow = await dialog.evaluate(node => getComputedStyle(node).boxShadow) @@ -299,7 +358,8 @@ it.each([ await expect.poll(() => recorder.isEnabled()).toBe(true) await recorder.focus() const accepted = snapshot.document - for (const key of [`${primary}+Slash`, 'Escape', 'Enter', 'ArrowUp', 'ArrowDown', 'Shift+Enter', `${primary}+Enter`, 'Slash', 'Shift+Digit2']) { + const mutationsBeforeRecording = fontMutations.length + for (const key of [`${primary}+Equal`, `${primary}+Shift+Equal`, `${primary}+Minus`, `${primary}+Slash`, 'Escape', 'Enter', 'ArrowUp', 'ArrowDown', 'Shift+Enter', `${primary}+Enter`, 'Slash', 'Shift+Digit2']) { expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true) const keys = key.split('+') for (const held of keys) await page.keyboard.down(held) @@ -310,6 +370,12 @@ it.each([ if (key === `${primary}+Slash`) { await dialog.getByText('Already used by “Open keyboard shortcuts”', { exact: true }).waitFor() } + if (key.endsWith('Equal') || key.endsWith('Minus')) { + const command = key.endsWith('Minus') ? 'Decrease font size' : 'Increase font size' + await dialog.getByText(`Already used by “${command}”`, { exact: true }).waitFor() + } + expect(await readFontSizes()).toEqual(defaults) + expect(fontMutations).toHaveLength(mutationsBeforeRecording) expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true) expect(snapshot.document).toEqual(accepted) expect(await dialog.getByRole('listitem').evaluateAll(rows => rows.map(row => row.getBoundingClientRect().height))) diff --git a/apps/web/tests/shortcuts-workspace.e2e.ts b/apps/web/tests/shortcuts-workspace.e2e.ts index 3b1e0db1b0..3bb20a0e96 100644 --- a/apps/web/tests/shortcuts-workspace.e2e.ts +++ b/apps/web/tests/shortcuts-workspace.e2e.ts @@ -146,8 +146,11 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => { await workspaceRow.getByText(workspaceLabel, { exact: true }).hover() await details.waitFor() await workspaceRow.getByRole('button', { name: `New session in ${workspaceLabel}`, exact: true }).hover() - await page.getByRole('tooltip').filter({ hasText: 'New Session' }).waitFor() - await expect.poll(() => details.count()).toBe(0) + const newSessionTooltip = page.getByRole('tooltip').filter({ hasText: 'New Session' }) + await newSessionTooltip.waitFor() + expect(await details.isVisible()).toBe(true) + expect(await newSessionTooltip.evaluate(element => Number(getComputedStyle(element).zIndex))) + .toBeGreaterThan(await details.evaluate(element => Number(getComputedStyle(element).zIndex))) await workspaceRow.getByText(workspaceLabel, { exact: true }).hover() await details.waitFor() await page.mouse.move(700, 100) diff --git a/apps/web/tests/shortcuts.e2e.ts b/apps/web/tests/shortcuts.e2e.ts index 7a49455c70..eadc973bff 100644 --- a/apps/web/tests/shortcuts.e2e.ts +++ b/apps/web/tests/shortcuts.e2e.ts @@ -19,9 +19,9 @@ describe('web e2e: shortcut reference', () => { afterAll(async () => { await browser?.close(); await scaffold?.close() }) it.each([ - { locale: 'zh-CN', platform: 'MacIntel', title: '快捷键', settings: '设置', view: '编辑快捷键', search: '搜索快捷键', key: 'Meta' }, - { locale: 'en-US', platform: 'Win32', title: 'Keyboard shortcuts', settings: 'Settings', view: 'Edit shortcuts', search: 'Search shortcuts', key: 'Control' }, - ])('opens, searches, and restores nested focus in $locale', async ({ locale, platform, title, settings, view, search, key }) => { + { locale: 'zh-CN', platform: 'MacIntel', title: '快捷键', accountMenu: '账号菜单', settings: '设置', view: '编辑快捷键', search: '搜索快捷键', key: 'Meta' }, + { locale: 'en-US', platform: 'Win32', title: 'Keyboard shortcuts', accountMenu: 'Account menu', settings: 'Settings', view: 'Edit shortcuts', search: 'Search shortcuts', key: 'Control' }, + ])('opens, searches, and restores nested focus in $locale', async ({ locale, platform, title, accountMenu, settings, view, search, key }) => { const referenceKey = platform === 'Win32' ? 'Control+Slash' : 'Meta+Slash' const context = await browser.newContext({ locale, colorScheme: 'light', viewport: { width: 1440, height: 1000 } }) try { @@ -31,12 +31,12 @@ describe('web e2e: shortcut reference', () => { const console = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - const trigger = page.getByRole('button', { name: settings, exact: true }) - expect(await trigger.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+,' : 'Control+Alt+,') - const settingsHint = `${settings} ${key === 'Meta' ? '⌥ ⌘ ,' : 'Ctrl + Alt + ,'}` - await trigger.hover() - await page.getByRole('tooltip', { name: settingsHint, exact: true }).waitFor() + const trigger = page.getByRole('button', { name: accountMenu, exact: true }) await trigger.click() + const settingsItem = page.getByRole('menuitem', { name: settings, exact: true }) + expect(await settingsItem.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+,' : 'Control+Alt+,') + expect(await settingsItem.innerText()).toMatch(key === 'Meta' ? /⌥\s*⌘\s*,$/ : /Ctrl\s*\+\s*Alt\s*\+\s*,$/) + await settingsItem.click() await page.getByRole('button', { name: view, exact: true }).click() const dialog = page.getByRole('dialog', { name: title, exact: true }) await dialog.waitFor() @@ -297,10 +297,11 @@ describe('web e2e: shortcut reference', () => { await page.keyboard.press(`${key}+Shift+Comma`) await dialog.getByRole('group').waitFor({ state: 'hidden' }) await page.keyboard.press('Escape') - expect(await trigger.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Shift+Meta+,' : 'Control+Shift+,') - await trigger.hover() - await page.getByRole('tooltip', { name: `${settings} ${key === 'Meta' ? '⇧ ⌘ ,' : 'Ctrl + Shift + ,'}`, exact: true }).waitFor() await trigger.click() + expect(await settingsItem.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Shift+Meta+,' : 'Control+Shift+,') + expect(await settingsItem.innerText()).toMatch(key === 'Meta' ? /⇧\s*⌘\s*,$/ : /Ctrl\s*\+?\s*Shift\s*\+?\s*,$/) + await settingsItem.click() + await page.getByRole('dialog', { name: settings, exact: true }).waitFor() await page.keyboard.press('Escape') expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) await page.keyboard.press(referenceKey) @@ -308,9 +309,10 @@ describe('web e2e: shortcut reference', () => { await dialog.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click() await dialog.getByRole('group').waitFor({ state: 'hidden' }) await page.keyboard.press('Escape') - expect(await trigger.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+,' : 'Control+Alt+,') - await trigger.hover() - await page.getByRole('tooltip', { name: settingsHint, exact: true }).waitFor() + await trigger.click() + expect(await settingsItem.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+,' : 'Control+Alt+,') + await page.keyboard.press('Escape') + await settingsItem.waitFor({ state: 'detached' }) const sidebar = page.getByRole('button', { name: locale === 'zh-CN' ? '收起侧边栏' : 'Collapse sidebar', exact: true }) expect(await sidebar.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+B' : 'Control+Alt+B') await page.reload({ waitUntil: 'load' }) @@ -390,7 +392,7 @@ describe('web e2e: shortcut reference', () => { await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform) const page = await context.newPage() await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.getByRole('button', { name: 'Settings', exact: true }).waitFor() + await page.getByRole('button', { name: 'Account menu', exact: true }).waitFor() const referenceKey = `${modifier}+Slash` const open = async () => { await page.keyboard.press(referenceKey) @@ -412,7 +414,7 @@ describe('web e2e: shortcut reference', () => { await page.keyboard.press(sidebarKey) await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor() await page.reload({ waitUntil: 'load' }) - await page.getByRole('button', { name: 'Settings', exact: true }).waitFor() + await page.getByRole('button', { name: 'Account menu', exact: true }).waitFor() const toggleAfterReload = page.getByRole('button', { name: /^(Open|Collapse) sidebar$/ }) await expect.poll(() => toggleAfterReload.getAttribute('aria-keyshortcuts')) .toBe(modifier === 'Meta' ? 'Shift+Meta+.' : 'Control+Shift+.') @@ -427,7 +429,7 @@ describe('web e2e: shortcut reference', () => { await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus() const other = await context.newPage() await other.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await other.getByRole('button', { name: 'Settings', exact: true }).waitFor() + await other.getByRole('button', { name: 'Account menu', exact: true }).waitFor() await other.keyboard.press(referenceKey) await other.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click() await other.getByRole('button', { name: 'Remove', exact: true }).click() diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index 80562071ad..a8173b6d27 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -286,6 +286,10 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { it('completes commands, preserves the process through collapse and reload, resizes, and kills on tab close', async () => { onTestFailed(() => saveFailureShot(page, 'sidebar-terminal')) + const unaryInput: string[] = [] + page.on('request', (request) => { + if (/\/api\/terminal\/(write|resize|follow)$/u.test(new URL(request.url()).pathname)) unaryInput.push(request.url()) + }) await openTerminal(page) const terminal = page.locator('[data-sidebar-terminal]') await command(page, "PS1=''; printf '\\033cTERMINAL_READY\\n'") @@ -370,6 +374,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await expect.poll(() => alive(firstProcess), { timeout: 10_000 }).toBe(false) await expect.poll(() => descendants.some(alive), { timeout: 10_000 }).toBe(false) await expect.poll(() => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).length).toBe(0) + expect(unaryInput).toEqual([]) expect(tripwire.pageErrors).toEqual([]) }) @@ -554,6 +559,107 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { expect(tripwire.pageErrors).toEqual([]) }) + it('validates terminal uplink frames and keeps a read-only attachment following output', async () => { + await openTerminal(page) + const sessionId = scaffold.ctx.agents.list()[0]!.id + const terminalId = scaffold.ctx.terminalController.list(sessionId)[0]!.id + const result = await page.evaluate(async ({ sessionId, terminalId }) => { + const socket = new WebSocket(new URL('/api/remote.mux', location.href).href.replace(/^http/u, 'ws')) + const frames: { + type: string + streamId: string + value?: { + type: string + data?: string + sequence?: number + writable?: boolean + refusal?: string + inputWindow?: { maxBytes: number; frameOverheadBytes: number } + } + error?: { code: string } + }[] = [] + const waiters = new Set<() => void>() + socket.addEventListener('message', (event) => { + frames.push(JSON.parse(String(event.data)) as (typeof frames)[number]) + for (const wake of waiters) wake() + }) + const wait = async (predicate: () => boolean) => { + if (predicate()) return + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { waiters.delete(inspect); reject(new Error('Terminal wire event timed out')) }, 10_000) + const inspect = () => { if (predicate()) { clearTimeout(timer); waiters.delete(inspect); resolve() } } + waiters.add(inspect) + }) + } + const send = (frame: object) => { socket.send(JSON.stringify(frame)) } + const open = (streamId: string) => { send({ type: 'open', streamId, endpoint: 'terminal/attach', payload: { args: { agentId: sessionId, id: terminalId } } }) } + await new Promise((resolve, reject) => { + socket.addEventListener('open', () => { resolve() }, { once: true }) + socket.addEventListener('error', () => { reject(new Error('Terminal socket failed')) }, { once: true }) + }) + try { + open('first') + await wait(() => frames.some(frame => frame.streamId === 'first' && frame.value?.type === 'snapshot')) + open('second') + await wait(() => frames.some(frame => frame.streamId === 'second' && frame.value?.type === 'snapshot')) + send({ type: 'item', streamId: 'first', value: { type: 'write', sequence: 1, data: 'IGNORED' } }) + await wait(() => frames.some(frame => frame.streamId === 'first' && frame.value?.type === 'ack')) + send({ type: 'item', streamId: 'second', value: { type: 'write', sequence: 1, data: "printf 'DUPLEX_%s\\n' PROOF\r" } }) + await wait(() => frames.filter(frame => frame.streamId === 'first').map(frame => frame.value?.data ?? '').join('').includes('DUPLEX_PROOF')) + send({ type: 'item', streamId: 'second', value: { type: 'write', sequence: 2, data: 42 } }) + await wait(() => frames.some(frame => frame.streamId === 'second' && frame.type === 'error')) + return frames + } finally { + socket.close() + await new Promise((resolve) => { socket.addEventListener('close', () => { resolve() }, { once: true }) }) + } + }, { sessionId, terminalId }) + const window = result.find(frame => frame.streamId === 'first' && frame.value?.type === 'snapshot')?.value?.inputWindow + expect(window).toEqual({ + maxBytes: 262_144, + frameOverheadBytes: Buffer.byteLength(JSON.stringify({ type: 'item', streamId: 'first', value: null }), 'utf8') - 'null'.length, + }) + expect(result.some(frame => frame.streamId === 'first' && frame.value?.type === 'state' && !frame.value.writable)).toBe(true) + expect(result.find(frame => frame.streamId === 'first' && frame.value?.type === 'ack')?.value) + .toEqual({ type: 'ack', sequence: 1, refusal: 'read-only' }) + expect(result.find(frame => frame.streamId === 'second' && frame.type === 'error')?.error?.code).toBe('gateway/input-invalid') + expect(handles).toHaveLength(1) + expect(tripwire.pageErrors).toEqual([]) + }) + + it('delivers a short-key burst through the real mux while the provider pauses input', async () => { + onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-input-window')) + await openTerminal(page) + const handle = handles[0]! + const write = handle.write.bind(handle) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const burst = `printf 'BUFFER_%s\\n' OK # ${'a'.repeat(3000)}` + let delivered = '' + const paused = vi.spyOn(handle, 'write').mockImplementation(async (data) => { + entered.resolve(undefined) + await release.promise + await write(data) + delivered += data + }) + try { + await page.locator('.xterm-helper-textarea:visible').click() + await page.keyboard.type(burst) + await page.keyboard.press('Enter') + await entered.promise + expect(paused).toHaveBeenCalledTimes(1) + release.resolve(undefined) + // The concurrent browser lane needs separate completion waits for queued input and rendered output. + await expect.poll(() => delivered.endsWith('\r'), { timeout: 30_000, message: 'The queued Enter must reach the PTY' }).toBe(true) + expect(delivered).toBe(`${burst}\r`) + await page.locator('.xterm-rows:visible').filter({ hasText: 'BUFFER_OK' }).waitFor({ timeout: 30_000 }) + expect(handles).toHaveLength(1) + expect(tripwire.pageErrors).toEqual([]) + } finally { + release.resolve(undefined) + } + }) + it('offers reconnection after transport loss and resumes the same process without a new terminal', async () => { onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-reconnect')) await openTerminal(page) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 31f102bff7..cf1546ccf2 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -476,8 +476,13 @@ describe('dsh web keyless CLI smoke', () => { .flatMap(message => message.content ?? []) .find(block => block.type === 'text' && block.text?.includes('web-workspace-context-probe')) const expectedWebSection = readFileSync(WEB_SURFACE_PROMPT, 'utf8').trimEnd() - .replace('{{webUrl}}', new URL(baseUrl).origin) expect(captured.system).toContain(expectedWebSection) + expect(captured.system).not.toContain(new URL(baseUrl).origin) + expect(captured.messages?.filter(message => message.role === 'user') + .flatMap(message => message.content ?? []) + .some(block => block.type === 'text' + && block.text?.includes(`Current DeepSeek Harness Web GUI URL: ${JSON.stringify(new URL(baseUrl).origin)}.`))) + .toBe(true) expect(workspaceMessage).toMatchInlineSnapshot(` { "text": " diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 49fe63b3d0..2324f0ae2b 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -567,7 +567,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = Math.round(clickAreaBox!.x - menuBox!.x), Math.round(menuBox!.x + menuBox!.width - clickAreaBox!.x - clickAreaBox!.width), // The outer menu padding insets the scrollable tree rows. - ]).toEqual([3, 3]) + ]).toEqual([4, 4]) await compareOrRefreshGolden( BRANCHLESS_EXPECTED, await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), diff --git a/apps/web/tests/subagent-fork-schedule.snapshot.ts b/apps/web/tests/subagent-fork-schedule.snapshot.ts new file mode 100644 index 0000000000..62f42cb69a --- /dev/null +++ b/apps/web/tests/subagent-fork-schedule.snapshot.ts @@ -0,0 +1,105 @@ +/** Forked Web agents inherit the standard preset and refuse every reminder tool. */ +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { parseSessionLog, prepareSessionSnapshotFixtureForComparison } from '@deepseek-ai/dsh-llm-replay' +import type { Session } from '@deepseek-ai/dsh-session' +import { + fixtureContext, normalizeSessionSnapshots, normalizedSystemPrompts, normalizedToolSchemas, + formatSystemPromptSnapshot, parseToolSchemasSnapshot, redactSessionSnapshotIds, refreshFixtureReplacements, + scrubSessionSnapshot, stabilizeFixtureMessageIds, stabilizeRefreshLog, tokenizeSessionFixtureCwd, +} from '@deepseek-ai/dsh-session-snapshot' +import type {} from '@deepseek-ai/dsh-api-session-controller' +import type {} from '@deepseek-ai/dsh-schedule' +import type {} from '@deepseek-ai/dsh-subagent' +import { + fixtureUserPrompts, launchWebScaffold, normalizeWebSessionVolatiles, rawSessionLog, readPersistedEvents, + recordedSessionFixturePath, selectedSessionFixture, webSnapshotMode, +} from './scaffold.ts' +import { REPO_ROOT } from './support.ts' + +const DIR = fileURLToPath(new URL('../../../snapshots/web/subagent-fork-schedule', import.meta.url)) +const PIN = join(DIR, '../fresh-round-trip') +const MODE = webSnapshotMode() + +it.skipIf(MODE === 'record')('replays inherited schedule tools and refusals in a seeded Web fork', async () => { + const paths = await Promise.all(['session.v4.jsonl', 'session.1.v4.jsonl'] + .map(file => selectedSessionFixture(join(DIR, file)))) + let expected = await Promise.all(paths.map(path => readFile(path, 'utf8'))) + const scaffold = await launchWebScaffold({ + replayFixture: paths[0]!, replayChildFixtures: [paths[1]!], compareReplaySession: false, + }) + try { + const sessions: Session[] = [] + scaffold.ctx.on('session/created', (session) => { sessions.push(session) }) + const { sessionId } = await scaffold.ctx.sessionController.create({ cwd: scaffold.workspaceCwd }) + const parent = scaffold.ctx.agents.get(sessionId) + if (parent === undefined) throw new Error('created parent has no Agent') + const prompt = fixtureUserPrompts(expected[0]!)[0] + if (prompt === undefined) throw new Error('parent fixture has no user prompt') + parent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } })) + await parent.whenIdle() + + const childPrompt = parseSessionLog(expected[1]!).findLast(event => event.type === 'user/message' + && event.data.source.kind === 'user') + if (childPrompt?.type !== 'user/message') throw new Error('child fixture has no user prompt') + const activation = await scaffold.ctx.subagents.startActivation({ + provider: 'fork', label: 'Check child reminder refusal', delivery: 'caller', + signal: new AbortController().signal, + request: { parent, prompt: [...childPrompt.data.content] }, + }) + try { + await activation.result + } finally { + await activation.dispose() + } + await scaffold.ctx.sessions.flush(parent.session) + expect(sessions).toHaveLength(2) + const child = sessions[1]! + expect(child.header).toMatchObject({ parentSession: parent.id, isSeeded: true, agentPreset: 'standard' }) + expect(child.inheritedEventCount).toBeGreaterThan(0) + expect(child.requestHeader()).toEqual(parent.session.requestHeader()) + + for (const session of sessions) { + expect(await readPersistedEvents(scaffold, session.id)).toEqual(session.snapshotEvents()) + } + const raw = sessions.map(session => normalizeWebSessionVolatiles(rawSessionLog(session), scaffold.workspaceCwd)) + const context = { sessionIds: sessions.map(session => String(session.id)), cwd: scaffold.workspaceCwd } + const schemas = parseToolSchemasSnapshot(await readFile(join(PIN, 'tool-schemas.expected.json'), 'utf8')) + const promptPin = await readFile(join(PIN, 'system-prompt.expected.md'), 'utf8') + for (const log of raw) { + const headers = normalizedToolSchemas(log, context) + expect(headers.length).toBeGreaterThan(0) + for (const tools of headers) expect(tools).toEqual(schemas.initial) + for (const prompt of normalizedSystemPrompts(log, context)) { + expect(formatSystemPromptSnapshot(prompt.split(REPO_ROOT).join('{{sourceRoot}}') + .split(scaffold.baseUrl).join('{{webUrl}}'))).toBe(promptPin) + } + } + const results = child.snapshotEvents().slice(child.inheritedEventCount) + .filter(event => event.type === 'tool/result') + expect(results.map(event => event.data.message.content)).toEqual(Array.from({ length: 4 }, () => [ + { type: 'text', text: '{"code":"subagent_session","message":"A delegated subagent cannot use reminders."}' }, + ])) + expect(await scaffold.ctx.schedule.list({ sessionId: child.id })).toEqual([]) + + if (MODE === 'refresh') { + const replacements = refreshFixtureReplacements(raw.map((content, index) => ({ + id: sessions[index]!.id, createdAt: sessions[index]!.header.createdAt, content, + })), expected) + expected = redactSessionSnapshotIds(stabilizeFixtureMessageIds(raw.map((log, index) => + tokenizeSessionFixtureCwd(scrubSessionSnapshot(stabilizeRefreshLog( + prepareSessionSnapshotFixtureForComparison(log), expected[index]!, replacements, context, + ))).split(scaffold.harnessHome).join('{{harnessHome}}')), expected)) + await Promise.all(expected.map((log, index) => writeFile( + recordedSessionFixturePath(paths[index]!, sessions[index]!.header.version), log, + ))) + } + expect(normalizeSessionSnapshots(raw, context)) + .toEqual(normalizeSessionSnapshots(expected, fixtureContext(expected[0]!))) + } finally { + await scaffold.close() + } +}) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 021b10e569..d60a81dba5 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -327,16 +327,11 @@ export function conversationContextKey(kind: string, id: string): string { return `${kind.length}:${kind}${id}` } -/** Open Settings through the Web gear or Desktop account menu. +/** Open Settings through the sidebar account menu. * @param page - browser page with the mounted sidebar. * @param locale - current UI language. */ export async function openSettings(page: Page, locale: 'en' | 'zh'): Promise { - const label = locale === 'zh' ? '设置' : 'Settings' - if (await page.evaluate(() => 'dshDesktop' in globalThis)) { - await page.getByRole('button', { name: locale === 'zh' ? '账号菜单' : 'Account menu', exact: true }).click() - await page.getByRole('menuitem', { name: label, exact: true }).click() - } else { - await page.getByRole('button', { name: label, exact: true }).click() - } + await page.getByRole('button', { name: locale === 'zh' ? '账号菜单' : 'Account menu', exact: true }).click() + await page.getByRole('menuitem', { name: locale === 'zh' ? '设置' : 'Settings', exact: true }).click() } diff --git a/apps/web/tests/terminal-command-browser.ts b/apps/web/tests/terminal-command-browser.ts index a1ebb77850..fb715cecea 100644 --- a/apps/web/tests/terminal-command-browser.ts +++ b/apps/web/tests/terminal-command-browser.ts @@ -64,6 +64,138 @@ export async function expectSelectableTerminalCommand(page: Page, card: Locator, } } +/** + * Select and copy text that wraps through the consumer's own stylesheet. + * @param page - Browser page that owns the text. + * @param command - One authored command line or the complete formatted arguments. + * @param expected - Exact selectable text, including authored whitespace. + * @param scrollport - Existing vertical scroll container around the text. + * @returns Observed wrapping and native clipboard text for a semantic snapshot. + */ +export async function expectWrappedCommandText( + page: Page, command: Locator, expected: string, scrollport: Locator, +): Promise<{ wrapped: boolean; text: string | null; copiedText: string }> { + expect(await command.textContent()).toBe(expected) + expect(await command.evaluate(element => getComputedStyle(element).whiteSpace)).toBe('pre-wrap') + await expectNoCommandTabStop(command) + const wrapped = await command.evaluate((element, value) => { + const text = element.firstChild + if (!(text instanceof Text)) throw new Error('Command has no text node') + const longest = value.split('\n').reduce((a, b) => a.length >= b.length ? a : b) + const offset = value.indexOf(longest) + const range = document.createRange() + range.setStart(text, offset) + range.setEnd(text, offset + longest.length) + const rows = [...range.getClientRects()].filter(rect => rect.width > 0 && rect.height > 0) + .map(rect => Math.round(rect.top)) + return new Set(rows).size > 1 + }, expected) + expect(wrapped).toBe(true) + expect(await scrollport.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + const originalScrollTop = await scrollport.evaluate(element => element.scrollTop) + const geometry = () => command.evaluate((element) => { + const text = element.firstChild + if (!(text instanceof Text) || text.length === 0) throw new Error('Command has no text node') + const first = document.createRange() + first.setStart(text, 0) + first.setEnd(text, 1) + const last = document.createRange() + last.setStart(text, text.length - 1) + last.setEnd(text, text.length) + const head = first.getBoundingClientRect() + const tail = last.getBoundingClientRect() + return { head: { x: head.left, y: head.top + head.height / 2 }, tail: { x: tail.right, y: tail.top + tail.height / 2 } } + }) + try { + await scrollport.scrollIntoViewIfNeeded() + await scrollport.evaluate((element) => { element.scrollTop = 0 }) + const start = await geometry() + const viewport = await scrollport.boundingBox() + if (viewport === null) throw new Error('Command scrollport is not visible') + await page.mouse.move(start.head.x + 0.1, start.head.y) + await page.mouse.down() + try { + if (start.tail.y > viewport.y + viewport.height) { + await page.mouse.move(start.head.x + 0.1, viewport.y + viewport.height - 2) + await page.mouse.wheel(0, await scrollport.evaluate(element => element.scrollHeight)) + await expect.poll(async () => (await geometry()).tail.y < viewport.y + viewport.height).toBe(true) + } + const end = await geometry() + await page.mouse.move(end.tail.x - 0.1, end.tail.y, { steps: 5 }) + } finally { + await page.mouse.up() + } + await expect.poll(() => page.evaluate(() => document.getSelection()?.toString())).toBe(expected) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin }) + const sentinel = `${expected}\nclipboard-before-copy` + await page.evaluate(value => navigator.clipboard.writeText(value), sentinel) + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(sentinel) + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(expected) + return { wrapped, text: await command.textContent(), copiedText: await page.evaluate(() => navigator.clipboard.readText()) } + } finally { + await scrollport.evaluate((element, top) => { element.scrollTop = top }, originalScrollTop) + await command.evaluate(() => { document.getSelection()?.removeAllRanges() }) + } +} + +/** + * Verify a chat terminal wraps at a narrow width without changing its text. + * @param page - Browser page that owns the card. + * @param card - Chat terminal card with consumer-owned wrapping styles. + * @param expected - Complete single authored command line. + * @param width - Narrow card width that forces soft wrapping. + * @returns Observed command wrapping and copied text in both themes. + */ +export async function expectWrappedTerminalCommand(page: Page, card: Locator, expected: string, width: number) { + const originalWidth = await card.evaluate(element => element.style.width) + const command = card.locator('[data-command-text]').first() + const header = card.locator('[class*="_header_"]').first() + const observations: ({ colorScheme: 'light' | 'dark' } & Awaited>)[] = [] + try { + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.locator('body').evaluate(element => element.hasAttribute('data-ds-dark-theme'))).toBe(colorScheme === 'dark') + await card.evaluate((element, value) => { element.style.width = `${value}px` }, width) + observations.push({ colorScheme, ...await expectWrappedCommandText(page, command, expected, header) }) + expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + await card.evaluate((element, value) => { element.style.width = value }, originalWidth) + await expectNoCommandTabStop(command) + expect(await command.textContent()).toBe(expected) + } + return observations + } finally { + await card.evaluate((element, value) => { element.style.width = value }, originalWidth) + await page.emulateMedia({ colorScheme: null }) + } +} + +/** + * Verify a terminal's status dot remains visible beside the first visual command line. + * @param card - Terminal card with its command banner scrolled to the start. + * @param state - Expected running or settled state. + * @returns Resolves after the dot fits inside the banner and aligns with the first line. + */ +export async function expectFirstLineTerminalState(card: Locator, state: 'ongoing' | 'done'): Promise { + const dot = card.locator('[class*="_runState_"][data-state]') + await expect.poll(() => dot.getAttribute('data-state')).toBe(state) + const geometry = await dot.evaluate((element) => { + const prompt = element.closest('[class*="_promptLine_"]') + const header = element.closest('[class*="_header_"]') + if (prompt === null || header === null) throw new Error('Terminal status has no prompt banner') + const bounds = element.getBoundingClientRect() + const viewport = header.getBoundingClientRect() + return { + visible: bounds.top >= viewport.top && bounds.bottom <= viewport.bottom + && bounds.left >= viewport.left && bounds.right <= viewport.right, + center: bounds.top + bounds.height / 2, + firstLineCenter: prompt.getBoundingClientRect().top + Number.parseFloat(getComputedStyle(prompt).lineHeight) / 2, + } + }) + expect(geometry.visible).toBe(true) + expect(geometry.center).toBeCloseTo(geometry.firstLineCenter, 1) +} + /** * Verify a resized command drops its explicit keyboard stop when all text fits. * @param container - Width owner of the command. diff --git a/apps/web/tests/user-text-secret-mask.e2e.ts b/apps/web/tests/user-text-secret-mask.e2e.ts new file mode 100644 index 0000000000..b45755e04f --- /dev/null +++ b/apps/web/tests/user-text-secret-mask.e2e.ts @@ -0,0 +1,74 @@ +/** A cold-seeded user message that carries a credential draws it masked behind its reveal control. */ +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, onTestFailed } from 'vitest' +import { + assertFixtureInventory, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/user-text-secret-mask', import.meta.url)) +// Borrowed read-only: this scenario needs any settled user message, not a new recording. +const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) +const MODE = webSnapshotMode() +const RECORDED_PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' +// Assembled at run time so the repository holds no literal in a credential format. +const KEY = ['sk', '-', '0123456789abcdef0123456789abcdef'].join('') +const PROMPT = `${KEY} is the key for this task.` + +describe('web e2e: credential mask in user text', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + const raw = (await readFile(SEED, 'utf8')).replace(RECORDED_PROMPT, PROMPT) + expect(fixtureUserPrompts(raw), 'adapted seed must carry the credential prompt').toContain(PROMPT) + await seedSession(scaffold, raw, 'user-text-secret-mask') + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[role="treeitem"]').nth(1).click() + await page.getByRole('button', { name: 'Show secret', exact: true }).waitFor() + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('masks the credential in the bubble and reveals it from its own control', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-user-text-secret-mask')) + const text = page.locator('[class*="bubble"]', { has: page.locator('[data-secret]') }) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'bubble.expected.md'), await text.ariaSnapshot(), MODE) + expect(await text.textContent()).not.toContain(KEY) + const run = page.locator('[data-secret]') + // The mask marks and the control share one vertical center. + const centers = await run.evaluate((box) => { + const middle = (element: Element): number => { + const rect = element.getBoundingClientRect() + return rect.top + rect.height / 2 + } + const [value, control] = [...box.children] + return { mask: middle(value!.lastElementChild!), control: middle(control!) } + }) + expect(Math.abs(centers.control - centers.mask)).toBeLessThanOrEqual(1) + const masked = { run: await run.boundingBox(), bubble: await text.boundingBox() } + await page.getByRole('button', { name: 'Show secret', exact: true }).click() + await expect.poll(() => text.textContent()).toBe(PROMPT) + // The monospace run keeps one mask character per original character, so revealing does not move the line. + expect({ run: await run.boundingBox(), bubble: await text.boundingBox() }).toEqual(masked) + await page.getByRole('button', { name: 'Hide secret', exact: true }).click() + await expect.poll(() => text.textContent()).not.toContain(KEY) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['bubble.expected.md']) + }) +}) diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index d97a0bd6b9..1ed57cde75 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -17,7 +17,7 @@ import { join, sep } from 'node:path' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session' import { logPath } from '../../../packages/session/session-persistence-jsonl/src/format.ts' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -507,21 +507,21 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo * blank Session from a deleted Workspace may also be visible, without actions. * @returns the session row locator, already present. */ - async function seededSessionRow() { - const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..') + async function seededSessionRow(target = page) { + const ungroupedRow = target.getByText('Ungrouped', { exact: true }).locator('..').locator('..') const ungroupedSection = ungroupedRow.locator('..') // Initial-current auto-expansion can race this gesture; converge on // expanded rather than assuming which update wins first. await expect.poll(async () => { if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') { - await page.getByText('Ungrouped', { exact: true }).click() - await page.waitForTimeout(50) + await target.getByText('Ungrouped', { exact: true }).click() + await target.waitForTimeout(50) } return await ungroupedRow.getAttribute('aria-expanded') }, { timeout: 5_000 }).toBe('true') // CSS includes the actions button while it is hidden until row hover. const rows = ungroupedSection.locator('[role="treeitem"]') - .filter({ has: page.locator('button[aria-label^="Session actions for "]') }) + .filter({ has: target.locator('button[aria-label^="Session actions for "]') }) await expect.poll(() => rows.count(), { timeout: 10_000 }).toBe(1) return rows.first() } @@ -556,11 +556,247 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo expect(await copied.isVisible()).toBe(true) expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(rowTitle) // Leaving anchor and card together closes it after the grace. - await page.getByRole('button', { name: 'Settings' }).hover() + await page.getByRole('button', { name: 'Account menu', exact: true }).hover() await expect.poll(() => card.count(), { timeout: 5_000 }).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('keeps session details beside action tooltips with the tooltips above the card', async () => { + const hoverPage = await newEnglishPage(browser) + try { + const consoleWatch = watchConsole(hoverPage) + await hoverPage.goto(scaffold.authenticatedUrl) + const row = await seededSessionRow(hoverPage) + const title = row.locator('[class*="title"]') + const card = hoverPage.getByRole('button', { name: `Copy: ${await title.innerText()}` }) + const tooltip = hoverPage.getByRole('tooltip') + const observations: { stage: string; details: boolean; tooltips: string[] }[] = [] + const observe = async (stage: string): Promise => { + observations.push({ stage, details: await card.count() > 0, tooltips: await tooltip.allTextContents() }) + } + const clockStart = Date.now() + await hoverPage.clock.install({ time: clockStart }) + await hoverPage.clock.pauseAt(clockStart + 1000) + await title.hover() + await hoverPage.clock.runFor(800) + await card.waitFor() + await observe('details') + await row.getByRole('button', { name: /^Session actions for / }).hover() + await hoverPage.clock.runFor(1000) + expect(await card.count()).toBe(1) + await observe('more-hover') + + await row.getByRole('button', { name: 'Archive session', exact: true }).hover() + await hoverPage.clock.runFor(499) + expect(await card.count()).toBe(1) + expect(await tooltip.count()).toBe(0) + await observe('archive-before-dwell') + await hoverPage.clock.runFor(1) + await tooltip.filter({ hasText: 'Archive' }).waitFor() + expect(await card.count()).toBe(1) + expect(await tooltip.evaluate(el => Number(getComputedStyle(el).zIndex))) + .toBeGreaterThan(await card.evaluate(el => Number(getComputedStyle(el).zIndex))) + await observe('archive') + await row.getByRole('button', { name: 'Pin session', exact: true }).hover() + await hoverPage.clock.runFor(500) + await tooltip.filter({ hasText: 'Pin' }).waitFor() + expect(await card.count()).toBe(1) + await observe('pin') + + await title.hover() + await tooltip.waitFor({ state: 'detached' }) + // Advance beyond both the 800ms card dwell and the 500ms action tooltip dwell. + await hoverPage.clock.runFor(1600) + expect(await card.count()).toBe(1) + await observe('back-to-title') + + const menu = row.getByRole('button', { name: /^Session actions for / }) + await menu.click() + await hoverPage.getByRole('menu').waitFor() + expect(await card.count()).toBe(0) + await hoverPage.keyboard.press('Escape') + await hoverPage.getByRole('menu').waitFor({ state: 'detached' }) + await title.hover() + await hoverPage.clock.runFor(1600) + expect(await card.count()).toBe(0) + await observe('menu-closed') + + await hoverPage.getByRole('button', { name: 'Account menu', exact: true }).hover() + await title.hover() + await hoverPage.clock.runFor(799) + expect(await card.count()).toBe(0) + await hoverPage.clock.runFor(1) + await card.waitFor() + await observe('row-reentered') + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/session-row-hover/actions.expected.json', import.meta.url)), + JSON.stringify(observations, null, 2), MODE, + ) + expect(consoleWatch.pageErrors).toEqual([]) + } finally { + await hoverPage.close() + } + }, 60_000) + + it('shares one dwell and one visible card across Workspace and Session rows', async () => { + const hoverScaffold = await launchWebScaffold({}) + let ownedPage: Page | undefined + try { + for (const title of ['gamma-ws', 'beta-ws']) { + const cwd = join(hoverScaffold.workspaceCwd, title) + await mkdir(cwd, { recursive: true }) + await hoverScaffold.ctx.workspaceRegistry.create(cwd, title) + } + const sessionId = SessionId('mixed-hover-session') + await seedSession(hoverScaffold, await readFile(SEED, 'utf8'), sessionId) + await hoverScaffold.ctx.sessionController.rename({ sessionId, title: 'Mixed hover session' }) + const hoverPage = await newEnglishPage(browser) + ownedPage = hoverPage + const consoleWatch = watchConsole(hoverPage) + await hoverPage.goto(hoverScaffold.authenticatedUrl) + const session = await seededSessionRow(hoverPage) + const sessionTitle = await session.locator('[class*="title"]').innerText() + const workspace = (title: string) => hoverPage.locator('[data-row-key^="workspace:"]') + .filter({ hasText: title }).locator('[class*="title"]') + const first = workspace('gamma-ws') + const second = workspace('beta-ws') + const card = hoverPage.getByRole('button', { name: /^Copy: / }) + const observations: { stage: string; exclusive: boolean }[] = [] + const clockStart = Date.now() + await hoverPage.clock.install({ time: clockStart }) + await hoverPage.clock.pauseAt(clockStart + 1000) + await hoverPage.getByRole('button', { name: 'Account menu', exact: true }).hover() + await hoverPage.clock.runFor(300) + await first.hover() + await hoverPage.clock.runFor(799) + expect(await card.count()).toBe(0) + await hoverPage.clock.runFor(1) + const record = async (stage: string, title: string): Promise => { + // The clock stays paused: wait for React's commit without advancing another hover dwell. + await card.getByText(title, { exact: true }).waitFor() + expect(await card.count()).toBe(1) + expect(await card.getByText(title, { exact: true }).isVisible()).toBe(true) + observations.push({ stage, exclusive: true }) + } + await record('first-workspace-after-dwell', 'gamma-ws') + await second.hover() + await record('workspace-to-workspace-immediate', 'beta-ws') + await session.locator('[class*="title"]').hover() + await record('workspace-to-session-immediate', sessionTitle) + await first.hover() + await record('session-to-workspace-immediate', 'gamma-ws') + await hoverPage.getByRole('button', { name: 'Account menu', exact: true }).hover() + await hoverPage.clock.runFor(300) + expect(await card.count()).toBe(0) + await second.hover() + await hoverPage.clock.runFor(799) + expect(await card.count()).toBe(0) + await hoverPage.clock.runFor(1) + await record('list-reentered-after-dwell', 'beta-ws') + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/session-row-hover/mixed-rows.expected.json', import.meta.url)), + JSON.stringify(observations, null, 2), MODE, + ) + expect(consoleWatch.pageErrors).toEqual([]) + } finally { + try { await ownedPage?.close() } finally { await hoverScaffold.close() } + } + }, 60_000) + + it('shares the first session dwell across each list and its active hover card', async () => { + const hoverScaffold = await launchWebScaffold({}) + try { + const seed = await readFile(SEED, 'utf8') + await seedSession(hoverScaffold, seed, 'hover-first') + await seedSession(hoverScaffold, seed, 'hover-second') + await hoverScaffold.ctx.sessionController.rename({ sessionId: SessionId('hover-first'), title: 'Hover first' }) + await hoverScaffold.ctx.sessionController.rename({ sessionId: SessionId('hover-second'), title: 'Hover second' }) + for (const layout of ['In one list', 'Workspace Tree']) { + // Each page owns a browser context, so layout preferences and paused clocks cannot leak between layouts. + const ui = await newEnglishPage(browser) + try { + const consoleWatch = watchConsole(ui) + await ui.context().grantPermissions(['clipboard-read', 'clipboard-write']) + await ui.goto(hoverScaffold.authenticatedUrl) + await ui.getByRole('button', { name: 'View options' }).click() + await ui.getByRole('menuitem', { name: layout, exact: true }).click() + if (layout !== 'In one list') { + const header = ui.getByText('Ungrouped', { exact: true }).locator('..').locator('..') + await expect.poll(async () => { + if (await header.getAttribute('aria-expanded') !== 'true') await ui.getByText('Ungrouped', { exact: true }).click() + return header.getAttribute('aria-expanded') + }).toBe('true') + } + const first = ui.locator('[data-row-key="session:hover-first"]').getByText('Hover first', { exact: true }) + const second = ui.locator('[data-row-key="session:hover-second"]').getByText('Hover second', { exact: true }) + await first.waitFor() + await second.waitFor() + const firstCard = ui.getByRole('button', { name: 'Copy: Hover first', exact: true }) + const secondCard = ui.getByRole('button', { name: 'Copy: Hover second', exact: true }) + const observations: { stage: string; first: boolean; second: boolean }[] = [] + const observe = async (stage: string): Promise => { + observations.push({ stage, first: await firstCard.count() > 0, second: await secondCard.count() > 0 }) + } + const clockStart = Date.now() + await ui.clock.install({ time: clockStart }) + await ui.clock.pauseAt(clockStart + 1000) + await first.hover() + await ui.clock.runFor(799) + expect(await firstCard.count()).toBe(0) + await observe('first-before-dwell') + await ui.clock.runFor(1) + await firstCard.waitFor() + await observe('first-shown') + await second.hover() + // Wait for React's commit with the clock paused: an immediate handoff must not need another dwell. + await secondCard.waitFor() + expect(await firstCard.count()).toBe(0) + await observe('second-instant') + await first.hover() + await firstCard.waitFor() + expect(await secondCard.count()).toBe(0) + await observe('first-instant') + + await firstCard.hover() + await ui.clock.runFor(500) + expect(await firstCard.isVisible()).toBe(true) + await observe('readable-portal') + await firstCard.click() + await ui.getByRole('status').getByText('Copied', { exact: true }).waitFor() + expect(await ui.evaluate(() => navigator.clipboard.readText())).toBe('Hover first') + await observe('copied-portal') + await second.hover() + await secondCard.waitFor() + expect(await firstCard.count()).toBe(0) + await observe('portal-return-instant') + + await secondCard.hover() + await ui.getByRole('button', { name: 'Account menu', exact: true }).hover() + await ui.clock.runFor(300) + await secondCard.waitFor({ state: 'detached' }) + expect(await firstCard.count()).toBe(0) + await observe('outside-cooled') + await first.hover() + await ui.clock.runFor(799) + expect(await firstCard.count()).toBe(0) + await observe('list-reentry-before-dwell') + await ui.clock.runFor(1) + await firstCard.waitFor() + await observe('list-reentry-shown') + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/session-row-hover/grouped.expected.json', import.meta.url)), + JSON.stringify(observations, null, 2), MODE, + ) + expect(consoleWatch.pageErrors).toEqual([]) + } finally { + await ui.close() + } + } + } finally { + await hoverScaffold.close() + } + }) + it('keeps an open row menu up while the pointer moves between trigger and list', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu')) const sessionRow = await seededSessionRow() @@ -586,7 +822,7 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo await page.waitForTimeout(POINTER_HOLD_MS) expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1) // Pointer-leave dismissal still applies once the pointer genuinely leaves. - await page.getByRole('button', { name: 'Settings' }).hover() + await page.getByRole('button', { name: 'Account menu', exact: true }).hover() await expect.poll(() => page.getByRole('menuitem', { name: 'Rename' }).count(), { timeout: 5_000 }).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) @@ -779,11 +1015,10 @@ describe('web e2e: New Session after an outdated blank cache', () => { undefined, { createdAt: now - 60_000 }) const stored = await scaffold.ctx.sessionPersistence.stat(id) if (stored === undefined) throw new Error('seeded Session is missing') - // A durable log may advance after its last blank projection checkpoint. - // An unregistered Session writes only the checkpoint, leaving the persisted log intact. - const blank = scaffold.ctx.sessions.prepare(id, { - eventState: 'detached', seed: [], meta: stored.header, inheritedEventCount: SessionLogOffset(0), - }) + // A durable log may advance after its last blank projection checkpoint, + // which then records the cut before the first turn. An unregistered + // Session writes only the checkpoint, leaving the persisted log intact. + const blank = scaffold.ctx.sessions.prepare(id, { meta: stored.header }) await scaffold.ctx.sessionProjectionCache.write(blank) const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd, 'New session regression') await workspace.attachSession(id) @@ -794,8 +1029,14 @@ describe('web e2e: New Session after an outdated blank cache', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-new-session-stale-blank')) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor() const selected = page.locator('[role="treeitem"][aria-selected="true"]') + // Startup asks for a new conversation with the stale blank as its reuse + // candidate; the Host declines the started Session and creates a blank one. + await expect.poll(() => selected.innerText()).toBe('New Session') + await expect.poll(() => workspace.sessionIds.length).toBe(2) + // The declined Session's projections are reloaded, so its stale blank row reappears. + await page.getByRole('treeitem', { name: /^Use the read tool twice/ }).click() + await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor() await expect.poll(() => selected.innerText()).not.toBe('New Session') const previous = (await selected.innerText()).split('\n')[0]! const before = await captureStableAria(page, '[role="tree"]', scaffold.workspaceCwd) @@ -803,7 +1044,8 @@ describe('web e2e: New Session after an outdated blank cache', () => { await page.getByRole('button', { name: 'New session', exact: true }).last().click() await expect.poll(() => selected.innerText()).toBe('New Session') await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ state: 'hidden' }) - await expect.poll(() => workspace.sessionIds.length).toBe(2) + // New Session creates a fresh Session; the blank startup created stays a member. + await expect.poll(() => workspace.sessionIds.length).toBe(3) expect(workspace.sessionIds).toContain(id) await page.getByRole('treeitem').filter({ hasText: previous }).waitFor() const after = await captureStableAria(page, '[role="tree"]', scaffold.workspaceCwd) diff --git a/apps/web/tests/workspace-new-session-folding.e2e.ts b/apps/web/tests/workspace-new-session-folding.e2e.ts index 176af733b0..4a24a46e59 100644 --- a/apps/web/tests/workspace-new-session-folding.e2e.ts +++ b/apps/web/tests/workspace-new-session-folding.e2e.ts @@ -1,4 +1,4 @@ -/** Blank New Session folding through the shipped Web composition. */ +/** Blank New Session folding and the session watermark through the shipped Web composition. */ import { readFile } from 'node:fs/promises' import { basename, join } from 'node:path' @@ -107,7 +107,42 @@ describe('web e2e: blank New Session folding quota', () => { await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(18) await sidebar.getByRole('button', { name: 'Show less' }).click() await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(7) - await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md', 'first-batch.expected.md']) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('raises the watermark to the open session, keeps it after leaving, and resets it on fold', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-workspace-session-watermark')) + const sidebar = page.getByRole('tree', { name: 'Sessions' }) + const workspaceTitle = basename(scaffold.workspaceCwd) + const sessions = () => sidebar.getByText('Untitled', { exact: true }).count() + await sidebar.getByRole('button', { name: 'Show 11 more sessions' }).click() + await expect.poll(sessions).toBe(10) + await sidebar.getByText('Untitled', { exact: true }).nth(8).click() + await sidebar.getByRole('button', { name: 'Show 6 more sessions' }).click() + await sidebar.getByRole('button', { name: 'Show 1 more sessions' }).click() + await expect.poll(sessions).toBe(16) + await sidebar.getByRole('button', { name: 'Show less' }).click() + await expect.poll(sessions).toBe(9) + await sidebar.getByRole('button', { name: 'Show 7 more sessions' }).waitFor() + + const workspaceRow = sidebar.getByRole('treeitem').first() + await workspaceRow.hover() + await page.getByRole('button', { name: `New session in ${workspaceTitle}` }).click() + await sidebar.getByText('New Session', { exact: true }).waitFor() + expect(await sessions()).toBe(9) + await workspaceRow.hover() + await compareOrRefreshGolden( + join(EXPECTED_DIR, 'watermark-kept.expected.md'), + await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd), + MODE, + ) + + await workspaceRow.click() + await workspaceRow.click() + await expect.poll(sessions).toBe(5) + await sidebar.getByRole('button', { name: 'Show 11 more sessions' }).waitFor() + await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md', 'first-batch.expected.md', 'watermark-kept.expected.md']) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 9c0350a2be..3c8d0dfd37 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -23,6 +23,7 @@ // cannot see both sides of the cordis Context merges). "exclude": [ "tests/collapse-timing.e2e.ts", + "tests/notice-wake-tail.e2e.ts", "tests/chat-flow-layout.e2e.ts", "tests/layout-bottom.e2e.ts", "tests/shortcuts-desktop.e2e.ts", @@ -51,6 +52,7 @@ "tests/minimal-preset.snapshot.ts", "tests/message-feedback-protocol.snapshot.ts", "tests/preset-migration.snapshot.ts", + "tests/subagent-fork-schedule.snapshot.ts", "tests/live-interactions.e2e.ts", "tests/question-composer.e2e.ts", "tests/approval-composer.e2e.ts", @@ -74,9 +76,11 @@ "tests/excel-opc.e2e.ts", "tests/office-fixture.ts", "tests/sidebar-browser.e2e.ts", + "tests/jobs-menu-layering.e2e.ts", "tests/plugin-config.e2e.ts", "tests/input-plugin-extensions.e2e.ts", "tests/plugin-manager.e2e.ts", + "tests/schedule-header.e2e.ts", "tests/worktree-opt-in.e2e.ts", "tests/session-inspector.e2e.ts", "tests/shared-inspector.e2e.ts", @@ -113,8 +117,9 @@ "tests/onboarding-native.e2e.ts", "tests/desktop-onboarding.e2e.ts", "tests/bonus-notice.e2e.ts", + "tests/account-login.e2e.ts", "tests/onboarding-usable-provider.e2e.ts", - "tests/remote-welcome.e2e.ts", + "tests/remote-settings.e2e.ts", "tests/public-mount.e2e.ts", "tests/workspace-new-session-folding.e2e.ts", "tests/workspace-management.e2e.ts", @@ -135,6 +140,7 @@ "tests/present.e2e.ts", "tests/present-svg.e2e.ts", "tests/composer-draft-scroll.e2e.ts", + "tests/composer-focus.e2e.ts", "tests/composer-placeholder.e2e.ts", "tests/context-meter.e2e.ts", "tests/cordis-tool-round.e2e.ts", @@ -146,6 +152,7 @@ "tests/message-feedback.e2e.ts", "tests/markdown-images.e2e.ts", "tests/markdown-file-links.e2e.ts", + "tests/user-text-secret-mask.e2e.ts", "tests/reference-composer.e2e.ts", "tests/markdown-wide-table.e2e.ts", "tests/thinking-markdown.e2e.ts", @@ -157,6 +164,7 @@ "tests/idle-submission-handoff.e2e.ts", "tests/cold-blank-session.e2e.ts", "tests/blank-writer-reuse.e2e.ts", + "tests/blank-preset-reuse.e2e.ts", "tests/queue-image.e2e.ts", "tests/skill-invocation-policy.e2e.ts", "tests/skill-user-invoke.e2e.ts", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 010e4307f7..d0463af079 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -6,6 +6,7 @@ import type { Plugin } from 'vite' import react from '@vitejs/plugin-react' import { clientBuildEnvironmentDefines } from '../../scripts/client-build-environment.ts' import { productWebBundleIsolation } from './product-isolation.ts' +import { katexWoff2Fonts } from './katex-fonts.ts' const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url)) const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. ' @@ -149,7 +150,7 @@ const BOOT_GRAMMAR_FILES: readonly string[] = [ 'dist/json.mjs', ] -/** Font asset extensions routed to assets/fonts/ (KaTeX's woff2/woff/ttf faces). */ +/** Font asset extensions routed to assets/fonts/. */ const FONT_EXTENSIONS: readonly string[] = ['.woff2', '.woff', '.ttf'] /** @@ -170,7 +171,7 @@ export default defineConfig({ // directory, and the served index resolves identically from the site root. base: './', plugins: [ - rejectStandaloneServe(), clientDocumentTitle(), brandFontLicense(), react(), emitPreviewPage(), + rejectStandaloneServe(), clientDocumentTitle(), brandFontLicense(), katexWoff2Fonts(), react(), emitPreviewPage(), productWebBundleIsolation(src('../..'), src('.')), ], build: { diff --git a/benchmarks/agent-continuation/agent-continuation.worker.ts b/benchmarks/agent-continuation/agent-continuation.worker.ts index 91faa5f347..b19cc2c43c 100644 --- a/benchmarks/agent-continuation/agent-continuation.worker.ts +++ b/benchmarks/agent-continuation/agent-continuation.worker.ts @@ -9,7 +9,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' import { PARENT_ID, response, resultText, syntheticHistory, TIME_ZERO, WORKLOAD } from './workload.ts' diff --git a/benchmarks/agent-continuation/child-catalog.worker.ts b/benchmarks/agent-continuation/child-catalog.worker.ts index 52e64fabf5..aedb279937 100644 --- a/benchmarks/agent-continuation/child-catalog.worker.ts +++ b/benchmarks/agent-continuation/child-catalog.worker.ts @@ -5,7 +5,7 @@ import { Context } from '@deepseek-ai/cordis' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' import SubagentRuntime, { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent' diff --git a/benchmarks/package.json b/benchmarks/package.json index 68ce69fa23..4a2be7ed1d 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -19,6 +19,7 @@ "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-ui-chat": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", "@deepseek-ai/dsh-deque": "workspace:*", "@deepseek-ai/dsh-lazy-require": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", diff --git a/benchmarks/session-corpus/projection-list.worker.ts b/benchmarks/session-corpus/projection-list.worker.ts index 78a60b5c06..106c520be2 100644 --- a/benchmarks/session-corpus/projection-list.worker.ts +++ b/benchmarks/session-corpus/projection-list.worker.ts @@ -9,7 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionController from '@deepseek-ai/dsh-api-session-controller' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, type SessionHeader } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionProjectionCache, { projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache' import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite' diff --git a/benchmarks/session-corpus/session-corpus.worker.ts b/benchmarks/session-corpus/session-corpus.worker.ts index 8dabac10ca..c533852fa1 100644 --- a/benchmarks/session-corpus/session-corpus.worker.ts +++ b/benchmarks/session-corpus/session-corpus.worker.ts @@ -12,7 +12,7 @@ import SessionController from '@deepseek-ai/dsh-api-session-controller' import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections' import { Session, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionCache, { checkpointRecord, projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache' import type { CheckpointRecord } from '@deepseek-ai/dsh-session-projection-cache' import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite' diff --git a/benchmarks/session-open/session-open.bench.ts b/benchmarks/session-open/session-open.bench.ts index 9868384c0a..9595ab6c89 100644 --- a/benchmarks/session-open/session-open.bench.ts +++ b/benchmarks/session-open/session-open.bench.ts @@ -6,7 +6,7 @@ import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { runBuiltBenchmarkWorker, type BuiltBenchmarkWorkerRun, diff --git a/benchmarks/session-open/session-open.worker.ts b/benchmarks/session-open/session-open.worker.ts index b1e2bfec1d..2b6d644960 100644 --- a/benchmarks/session-open/session-open.worker.ts +++ b/benchmarks/session-open/session-open.worker.ts @@ -13,7 +13,7 @@ import SessionStore, { SessionPreparation, } from '@deepseek-ai/dsh-session' import type { AgentHandle } from '@deepseek-ai/dsh-agent' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' import type { diff --git a/benchmarks/support/built-worker.ts b/benchmarks/support/built-worker.ts index 6e91fde0df..8167cc9a4f 100644 --- a/benchmarks/support/built-worker.ts +++ b/benchmarks/support/built-worker.ts @@ -18,6 +18,8 @@ export interface BuiltBenchmarkWorkerOptions { readonly timeoutMs: number readonly exposeGc?: boolean readonly heapLimitMb?: number + /** Fixed semi-space size, so an allocation interval smaller than it runs without a scavenge. */ + readonly semiSpaceMb?: number } /** @@ -40,6 +42,9 @@ export function runBuiltBenchmarkWorker( ...options.heapLimitMb === undefined ? [] : [`--max-old-space-size=${String(options.heapLimitMb)}`], + ...options.semiSpaceMb === undefined + ? [] + : [`--min-semi-space-size=${String(options.semiSpaceMb)}`, `--max-semi-space-size=${String(options.semiSpaceMb)}`], options.worker, ...options.args ?? [], ], { diff --git a/benchmarks/tsdown.config.ts b/benchmarks/tsdown.config.ts index 36c4386865..cb91a1a830 100644 --- a/benchmarks/tsdown.config.ts +++ b/benchmarks/tsdown.config.ts @@ -66,4 +66,11 @@ export default defineConfig([ clean: true, tsconfig: 'tsconfig.client.json', }, + { + ...shared, + entry: { 'user-text.worker': 'user-text/user-text.worker.client.ts' }, + outDir: '.dsh-build/user-text', + clean: true, + tsconfig: 'tsconfig.client.json', + }, ]) diff --git a/benchmarks/user-text/user-text.bench.client.ts b/benchmarks/user-text/user-text.bench.client.ts new file mode 100644 index 0000000000..9377281819 --- /dev/null +++ b/benchmarks/user-text/user-text.bench.client.ts @@ -0,0 +1,161 @@ +/** Required time and allocation budgets for sent-user-text evaluation with the credential matcher. */ + +import { join } from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' +import { runBuiltBenchmarkWorker } from '../support/built-worker.ts' +import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts' +import { recordTimings, type BenchmarkCase } from '../support/scaling-report.ts' +import type { + UserTextAllocationReport, UserTextTimeReport, UserTextWorkloadAllocation, UserTextWorkloadTiming, +} from './user-text.worker.client.ts' + +/** Fresh registries and documents per sample in one compiled worker; the fastest sample removes scheduler delay. */ +const ATTEMPTS = 5 +/** A stuck worker is reaped before the outer benchmark deadline. */ +const WORKER_TIMEOUT_MS = 180_000 +/** Larger than every workload's allocation, so the allocation interval runs without a scavenge. */ +const ALLOCATION_SEMI_SPACE_MB = 256 +const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'user-text', 'user-text.worker.js') + +/** + * Documents without credentials: reference-machine milliseconds with the matcher, and the + * ratio to the shipped rules without it. Ratios do not depend on machine speed. The matcher + * adds one native literal scan, about 2 ns per character, plus format checks where a literal + * occurs (`://`, `sk-` in identifiers); 5,000 messages also pay the scheduler's cost for one + * more matcher. Running sixteen format patterns from every query offset measures 1.6–4.9×. + */ +const UNMATCHED_EXPECTED = { + 'typical-messages': { ms: 28, ratio: 1.25 }, + 'prose-256k': { ms: 1.25, ratio: 1.8 }, + 'code-256k': { ms: 1.7, ratio: 2.3 }, + 'base64-256k': { ms: 1.4, ratio: 2.05 }, + 'markdown-256k': { ms: 1.6, ratio: 2.15 }, + 'log-256k': { ms: 1.75, ratio: 2.05 }, + 'json-256k': { ms: 2.05, ratio: 2.75 }, + 'shell-256k': { ms: 3.8, ratio: 1.3 }, +} as const +/** Reference-machine milliseconds with the matcher for documents whose credentials it masks. */ +const MATCHED_EXPECTED_MS = { + 'messages-with-model-key': 28, + 'messages-with-github-token': 28, + 'messages-with-cloud-key-id': 28, + 'messages-with-json-web-token': 29, + 'messages-with-address-password': 31, + 'messages-with-private-key-block': 45, + 'env-keys-2000': 2.1, +} as const +/** + * 128 KiB shaped against one format's prefix, length, or separator rules. Format patterns + * that backtrack, or a per-query memo whose digit search reaches the end of the text from + * every query, measure 60–720 ms and up to 4× per doubling. + */ +const ADVERSARIAL_EXPECTED_MS = 3.25 +/** Doubling the input doubles a linear scan. */ +const MAX_DOUBLING_SCALING = 2 * PERFORMANCE_BUDGET_HEADROOM + +/** Measurement floor: heap statistics objects created while reading the young generation. */ +const ALLOCATION_FLOOR_BYTES = 16 * 1024 +/** + * One credential node: the node and data objects, the visible-prefix slice, and the + * concatenation with a slice of a shared mark string; measured at about 180 bytes. + */ +const ALLOCATION_PER_CREDENTIAL_BYTES = 256 +/** A multi-line mask adds one slice of a shared mark string and one concatenation per line; measured at about 60 bytes. */ +const ALLOCATION_PER_MASKED_LINE_BREAK_BYTES = 64 +/** A slice shorter than a sliced string copies its characters: at most two bytes per masked character. */ +const ALLOCATION_PER_MASKED_CHARACTER_BYTES = 2 + +const ADVERSARIAL = [ + 'digitless-sk', 'jwt-header-runs', 'jwt-two-segments', 'unterminated-key-blocks', 'address-without-at', + 'colon-run', 'long-scheme-user', 'short-token-runs', 'tokens-between-digitless-keys', 'closed-one-line-key-blocks', +] as const + +function benchmarkCase(id: string, measures: string): BenchmarkCase { + return { id: `user-text/${id}`, measures, affects: 'Rendering sent user messages, queued messages, and goal input in the Web GUI.' } +} + +async function worker(args: readonly string[], semiSpaceMb?: number): Promise { + const run = await runBuiltBenchmarkWorker({ + worker: WORKER, args, timeoutMs: WORKER_TIMEOUT_MS, exposeGc: true, ...semiSpaceMb === undefined ? {} : { semiSpaceMb }, + }) + if (run.report === undefined) { + throw new Error(`user-text worker failed: exit=${String(run.exitCode)}, signal=${String(run.signal)}, ` + + `timedOut=${String(run.timedOut)}\n${run.stderr.trim().split('\n').slice(-10).join('\n')}`) + } + return run.report +} + +describe('sent user text with the credential matcher', () => { + let timings: ReadonlyMap + let allocations: ReadonlyMap + + beforeAll(async () => { + const time = await worker(['time', String(ATTEMPTS)]) + const allocation = await worker(['allocation'], ALLOCATION_SEMI_SPACE_MB) + console.log(JSON.stringify({ benchmark: 'user-text', ...time, allocation: allocation.workloads })) + timings = new Map(time.workloads.map(workload => [workload.id, workload])) + allocations = new Map(allocation.workloads.map(workload => [workload.id, workload])) + }, 2 * WORKER_TIMEOUT_MS) + + const timing = (id: string): UserTextWorkloadTiming => { + const found = timings.get(id) + if (found === undefined) throw new Error(`user-text worker reported no ${id} workload`) + return found + } + const record = (benchmark: BenchmarkCase, measured: UserTextWorkloadTiming, budgetMs: number): void => { + recordTimings(benchmark, { withSecretMs: { ms: measured.withSecretMs, cpuMs: measured.withSecretCpuMs } }, { withSecretMs: budgetMs }) + } + + it.each(Object.entries(UNMATCHED_EXPECTED))('evaluates %s without credentials within its budget and ratio', (id, expected) => { + const measured = timing(id) + const budgetMs = ciTimeBudget(expected.ms) + record(benchmarkCase(id, `Fresh ${id} documents without credentials through every shipped matcher.`), measured, budgetMs) + expect(measured.group).toBe('unmatched') + expect(measured.secrets).toBe(0) + expect(measured.withSecretMs).toBeLessThanOrEqual(budgetMs) + expect(measured.ratio).toBeLessThanOrEqual(expected.ratio * PERFORMANCE_BUDGET_HEADROOM) + }) + + it.each(Object.entries(MATCHED_EXPECTED_MS))('masks every credential of %s within its budget', (id, expectedMs) => { + const measured = timing(id) + const budgetMs = ciTimeBudget(expectedMs) + record(benchmarkCase(id, `Fresh ${id} documents, each credential masked, through every shipped matcher.`), measured, budgetMs) + expect(measured.group).toBe('matched') + expect(measured.secrets).toBe(id === 'env-keys-2000' ? 2_000 : 5_000) + expect(measured.withSecretMs).toBeLessThanOrEqual(budgetMs) + }) + + it('masks 2,000 pasted keys in time linear in their number', () => { + const half = timing('env-keys-1000') + const full = timing('env-keys-2000') + expect([half.secrets, full.secrets]).toEqual([1_000, 2_000]) + expect(full.withSecretMs / half.withSecretMs).toBeLessThanOrEqual(MAX_DOUBLING_SCALING) + }) + + it.each(ADVERSARIAL)('scans adversarial %s input linearly without backtracking', (name) => { + const half = timing(`adversarial-${name}-64k`) + const full = timing(`adversarial-${name}-128k`) + const budgetMs = ciTimeBudget(ADVERSARIAL_EXPECTED_MS) + record(benchmarkCase(`adversarial-${name}`, `One 128 KiB document repeating a ${name} unit.`), full, budgetMs) + expect(full.withSecretMs).toBeLessThanOrEqual(budgetMs) + expect(full.withSecretMs / half.withSecretMs).toBeLessThanOrEqual(MAX_DOUBLING_SCALING) + }) + + it.each(workloadIds())('allocates only result nodes and masks for %s', (id) => { + const allocation = allocations.get(id) + if (allocation === undefined) throw new Error(`user-text worker reported no ${id} allocation`) + expect(allocation.secrets).toBe(timing(id).secrets) + const budgetBytes = ALLOCATION_FLOOR_BYTES + ALLOCATION_PER_CREDENTIAL_BYTES * allocation.secrets + + ALLOCATION_PER_MASKED_LINE_BREAK_BYTES * allocation.maskedLineBreaks + + ALLOCATION_PER_MASKED_CHARACTER_BYTES * allocation.maskedCharacters + expect(allocation.ruleAllocatedBytes).toBeLessThanOrEqual(budgetBytes) + }) +}) + +/** Every workload id, in the worker's order, so each has an allocation case. */ +function workloadIds(): readonly string[] { + return [ + ...Object.keys(UNMATCHED_EXPECTED), ...Object.keys(MATCHED_EXPECTED_MS), 'env-keys-1000', + ...ADVERSARIAL.flatMap(name => [`adversarial-${name}-64k`, `adversarial-${name}-128k`]), + ] +} diff --git a/benchmarks/user-text/user-text.worker.client.ts b/benchmarks/user-text/user-text.worker.client.ts new file mode 100644 index 0000000000..ca7ab3c099 --- /dev/null +++ b/benchmarks/user-text/user-text.worker.client.ts @@ -0,0 +1,338 @@ +/** Compiled worker for sent-user-text evaluation cost and allocation with and without the credential matcher. */ + +import { PerformanceObserver, performance } from 'node:perf_hooks' +import { getHeapSpaceStatistics } from 'node:v8' +import { Context } from '@deepseek-ai/cordis' +import type { UserTextFacts, UserTextRule, UserTextTransformContext } from '@deepseek-ai/dsh-client-ui-conversation/client' +// These Client-only UserText modules have no plain-Node package export and are compiled into this worker. +import { UserTextRegistry } from '../../packages/client/ui-conversation/src/client/conversation/user-text-registry.ts' +import { sessionReferenceRule } from '../../packages/client/ui-conversation/src/client/user-text/session-reference-rule.ts' +import { collapseWhitespaceRule, trimWhitespaceRule } from '../../packages/client/ui-conversation/src/client/user-text/whitespace-rules.ts' +import { fileReferenceRule, folderReferenceRule } from '../../packages/client/ui-reference/src/client/user-text-rules.ts' +import { skillReferenceRule } from '../../packages/client/ui-skill/src/client/user-text-rules.ts' +import { goalCommandReferenceRule } from '../../packages/client/ui-goal/src/client/user-text-rules.ts' +import { createSecretRule } from '../../packages/client/ui-secret-mask/src/client/secret-rule.ts' +import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' + +/** What a workload contains: no credential, real credentials, or input shaped against one format. */ +export type UserTextWorkloadGroup = 'unmatched' | 'matched' | 'adversarial' + +/** Timing of one workload evaluated by the shipped rule set with and without the credential matcher. */ +export interface UserTextWorkloadTiming { + readonly id: string + readonly group: UserTextWorkloadGroup + readonly documents: number + readonly characters: number + readonly secrets: number + /** Characters inside credential ranges, whose masks the matcher builds. */ + readonly maskedCharacters: number + readonly baseMs: number + readonly withSecretMs: number + /** Process CPU time over the fastest with-credential sample. */ + readonly withSecretCpuMs: number + readonly ratio: number +} + +/** Heap allocation of the credential matcher over one workload, with the size of what it returns. */ +export interface UserTextWorkloadAllocation { + readonly id: string + /** Young-generation bytes the matcher allocates answering every query of every document, nodes included; no GC ran meanwhile. */ + readonly ruleAllocatedBytes: number + readonly secrets: number + /** Characters inside credential ranges. */ + readonly maskedCharacters: number + /** Line breaks inside credential ranges, each of which ends one piece of a multi-line mask. */ + readonly maskedLineBreaks: number +} + +/** Result of `time `: the fastest of `attempts` fresh samples per workload. */ +export interface UserTextTimeReport { + readonly attempts: number + readonly workloads: readonly UserTextWorkloadTiming[] +} + +/** Result of `allocation`, which needs `--expose-gc` and a young generation larger than one evaluation. */ +export interface UserTextAllocationReport { + readonly workloads: readonly UserTextWorkloadAllocation[] +} + +/** Matchers the Web composition registers besides the credential matcher. */ +const BASE_RULES: readonly UserTextRule[] = [ + sessionReferenceRule, fileReferenceRule, folderReferenceRule, skillReferenceRule, + goalCommandReferenceRule, collapseWhitespaceRule, trimWhitespaceRule, +] +/** Facts a sent message bubble supplies. */ +const FACTS: Readonly> = { sessionLabels: ['会话一'], skillNames: ['plan', 'review'] } + +const KIB = 1024 +const PROSE = '帮我看看这个函数为什么慢,顺便把 README 也更新一下。The quick brown fox jumps over the lazy dog, 1234 times. ' +const CODE = 'const value = await fetch(`https://api.example.com/v1/items?id=${id}`); if (!value.ok) throw new Error("bad: " + value.status);\n' +const BASE64 = 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkrLw' +const MARKDOWN = [ + '## 安装与配置', '', '- 运行 `pnpm install`,然后执行 `pnpm run build`。', + '- 详见 [文档](https://example.com/docs/setup?ref=readme#install)。', '', + '| 选项 | 默认值 | 说明 |', '|---|---|---|', '| `timeout` | 30s | 请求超时 |', '| `retries` | 3 | 失败重试次数 |', '', + '```ts', "const client = createClient({ baseUrl: 'https://api.example.com/v1', timeout: 30_000 })", '```', '', '', +].join('\n') +const LOG = '2026-10-11T12:00:00.123Z INFO [worker-3] GET https://api.example.com/v1/items?id=1234&page=2 200 12ms ' + + 'trace=4bf92f3577b34da6a3ce929d0e0e4736 user=alice@example.com\n' +const JSON_LINES = '{"id":"123e4567-e89b-12d3-a456-426614174000","url":"https://cdn.example.com/assets/img_0001.png",' + + '"name":"task-runner","tags":["sk-button","api_key_name"],"created_at":"2026-10-11T12:00:00Z","size":10240},\n' +const SHELL = 'pnpm --filter @deepseek-ai/dsh-client-ui-secret-mask run test -- --coverage --reporter=verbose' + + ' && git push origin feat/user-text-secret-mask\n' +/** Ordinary message fragments: prose, references, a slash skill, a URL without credentials, and code. */ +const MESSAGE_PARTS = [ + PROSE, '参照 @src/app/main.ts 修复一下', ' /plan 先列计划', ' 见 https://example.com/docs/a?b=c#d ', + '`const total = items.reduce((a, b) => a + b, 0)`', ' 然后跑 pnpm run test -- --watch=false。', +] as const + +const fill = (unit: string, characters: number): string => unit.repeat(Math.ceil(characters / unit.length)).slice(0, characters) +const MIXED = 'a1B2c3D4e5F6g7H8i9J0' +// Samples are assembled at run time so the repository holds no literal in a credential format. +const key = (index: number): string => ['sk', '-', `${index.toString(16).padStart(8, '0')}0123456789abcdef01234567`].join('') +const CREDENTIALS = { + 'model-key': key(7), + 'github-token': ['gh', 'p_', fill(MIXED, 36)].join(''), + 'cloud-key-id': ['AK', 'IA', 'ABCDEFGHIJ234567'].join(''), + 'json-web-token': ['ey', 'J', fill(MIXED, 17), '.', 'ey', 'J', fill(MIXED, 30), '.', fill(MIXED, 43)].join(''), + 'address-password': ['postgres://admin:', 'p4ss-W0rd', '@db.internal:5432/app'].join(''), + 'private-key-block': [ + ['-----BEGIN', ' RSA PRIVATE KEY-----'].join(''), ...Array.from({ length: 10 }, () => fill(BASE64, 64)), + ['-----END', ' RSA PRIVATE KEY-----'].join(''), + ].join('\n'), +} as const + +function messages(count: number): string[] { + return Array.from({ length: count }, (_, index) => { + const parts = Array.from({ length: 4 }, (_unused, part) => MESSAGE_PARTS[(index + part * 2) % MESSAGE_PARTS.length]!) + return `${String(index)} ${parts.join('')}`.slice(0, 200) + }) +} + +/** Ordinary 200-character messages, each carrying one credential whole. */ +function credentialMessages(count: number, credential: string): string[] { + return Array.from({ length: count }, (_, index) => { + const head = `${String(index)} 帮我看看这个配置能不能用:` + const tail = `,${MESSAGE_PARTS[index % MESSAGE_PARTS.length]!}${PROSE}` + return head + credential + tail.slice(0, Math.max(0, 200 - head.length - credential.length)) + }) +} + +const envFile = (count: number): string => + Array.from({ length: count }, (_, index) => `SERVICE_${String(index)}_API_KEY=${key(index)}`).join('\n') + +/** Inputs shaped against each credential format's prefix, length, and separator rules. */ +const ADVERSARIAL_UNITS = { + 'digitless-sk': 'sk-', + 'jwt-header-runs': 'eyJabcdefgh-', + 'jwt-two-segments': 'eyJabcdefgh.eyJabcdefgh-', + 'unterminated-key-blocks': ['-----BEGIN', ' RSA PRIVATE KEY-----\nabc\n'].join(''), + 'address-without-at': 'a://u:', + 'colon-run': ':', + 'long-scheme-user': `abcdefghijklmnop://${'u'.repeat(250)}:`, + 'short-token-runs': `ghp_${fill('a1B2c3', 35)} `, + // Closed one-line blocks, each its own credential: mask searches confined to each block keep them linear. + 'closed-one-line-key-blocks': ['-----BEGIN', ' PRIVATE KEY-----abcd', '-----END', ' PRIVATE KEY----- '].join(''), + // Each query starts after an accepted digit-free token and meets a digit-free `sk-` whose digit search reaches the end. + 'tokens-between-digitless-keys': ['-', 'sk', '-', 'gh', 'p_', fill('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 36)].join(''), +} as const + +interface Workload { + readonly id: string + readonly group: UserTextWorkloadGroup + readonly texts: readonly string[] +} + +/** Named fixed workloads; adversarial inputs appear at two sizes so their scaling is observable. */ +function workloads(): readonly Workload[] { + return [ + { id: 'typical-messages', group: 'unmatched', texts: messages(5_000) }, + ...Object.entries({ prose: PROSE, code: CODE, base64: BASE64, markdown: MARKDOWN, log: LOG, json: JSON_LINES, shell: SHELL }) + .map(([name, unit]): Workload => ({ id: `${name}-256k`, group: 'unmatched', texts: [fill(unit, 256 * KIB)] })), + ...Object.entries(CREDENTIALS).map(([name, credential]): Workload => ({ + id: `messages-with-${name}`, group: 'matched', texts: credentialMessages(5_000, credential), + })), + { id: 'env-keys-1000', group: 'matched', texts: [envFile(1_000)] }, + { id: 'env-keys-2000', group: 'matched', texts: [envFile(2_000)] }, + ...Object.entries(ADVERSARIAL_UNITS).flatMap(([name, unit]): Workload[] => [ + { id: `adversarial-${name}-64k`, group: 'adversarial', texts: [fill(unit, 64 * KIB)] }, + { id: `adversarial-${name}-128k`, group: 'adversarial', texts: [fill(unit, 128 * KIB)] }, + ]), + ] +} + +interface Sample { + readonly ms: number + readonly cpuMs: number + readonly secrets: number + readonly maskedCharacters: number +} + +/** Rules for one sample; a fresh credential matcher per sample carries no state from an earlier sample. */ +type RuleSet = () => readonly UserTextRule[] +const withoutSecret: RuleSet = () => BASE_RULES +const withSecret: RuleSet = () => [...BASE_RULES, createSecretRule()] + +function registryWith(rules: RuleSet): { readonly registry: UserTextRegistry; readonly scope: Context } { + const scope = new Context() + const registry = new UserTextRegistry(scope) + for (const rule of rules()) registry.register(rule) + return { registry, scope } +} + +/** + * Evaluate every text once as a fresh document, as a newly rendered message does. + * @returns elapsed and CPU milliseconds, credential nodes produced, and their characters. + */ +function evaluateAll(rules: RuleSet, texts: readonly string[]): Sample { + const { registry, scope } = registryWith(rules) + let secrets = 0 + let maskedCharacters = 0 + const cpuStart = process.cpuUsage() + const start = performance.now() + for (const text of texts) { + for (const node of registry.createEvaluator()({ text, facts: FACTS })) { + if (node.id !== 'secret') continue + secrets++ + maskedCharacters += node.end - node.start + } + } + const ms = performance.now() - start + const cpu = process.cpuUsage(cpuStart) + void scope.fiber.dispose() + return { ms, cpuMs: (cpu.user + cpu.system) / 1_000, secrets, maskedCharacters } +} + +function fastest(rules: RuleSet, texts: readonly string[], attempts: number): Sample { + let best = evaluateAll(rules, texts) + for (let attempt = 1; attempt < attempts; attempt++) { + const sample = evaluateAll(rules, texts) + if (sample.ms < best.ms) best = sample + } + return best +} + +declare const gc: (() => void) | undefined + +/** + * Young-generation bytes in use. Code and feedback that compilation allocates during a + * measurement live in other spaces, so they do not count as allocation by the evaluation. + */ +function youngBytes(): number { + let total = 0 + for (const space of getHeapSpaceStatistics()) { + if (space.space_name === 'new_space' || space.space_name === 'new_large_object_space') total += space.space_used_size + } + return total +} + +/** The credential matcher reads no Facts. */ +const NO_FACTS: UserTextTransformContext = { read: () => undefined } + +/** + * Query the credential matcher over every document as the scheduler does when no other + * matcher produces a node; an index loop, so the loop itself allocates nothing. + */ +function queryAll(rule: UserTextRule, texts: readonly string[]): void { + for (let index = 0; index < texts.length; index++) { + const text = texts[index]! + for (let from = 0; ;) { + const node = rule.match(text, from, NO_FACTS) + if (node === undefined) break + from = node.end + } + } +} + +/** Credential nodes the matcher returns over every document, counted outside any measured interval. */ +function returned(texts: readonly string[]): Omit { + const rule = createSecretRule() + let secrets = 0 + let maskedCharacters = 0 + let maskedLineBreaks = 0 + for (const text of texts) { + for (let node = rule.match(text, 0, NO_FACTS); node !== undefined; node = rule.match(text, node.end, NO_FACTS)) { + secrets++ + maskedCharacters += node.end - node.start + for (let index = node.start; index < node.end; index++) { + const code = text.charCodeAt(index) + if (code === 10 || code === 13) maskedLineBreaks++ + } + } + } + return { secrets, maskedCharacters, maskedLineBreaks } +} + +/** + * Bytes one warmed pass of every query allocates, measured across an interval without GC. + * @returns young-generation growth over the interval. + */ +async function allocatedBytes(texts: readonly string[], collect: () => void): Promise { + const collections: number[] = [] + const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) collections.push(entry.startTime) }) + observer.observe({ entryTypes: ['gc'] }) + try { + const rule = createSecretRule() + for (let warm = 0; warm < 10; warm++) queryAll(rule, texts) + collect() + collect() + const start = performance.now() + const used = youngBytes() + queryAll(rule, texts) + const grown = youngBytes() - used + const end = performance.now() + // GC entries arrive on a later turn. + await new Promise(resolve => { setTimeout(resolve, 20) }) + const inside = collections.filter(time => time >= start && time <= end).length + if (inside > 0) throw new Error(`${String(inside)} garbage collections ran inside the allocation interval; enlarge the young generation`) + return grown + } finally { + observer.disconnect() + } +} + +function positiveInteger(value: string | undefined, name: string): number { + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`) + return parsed +} + +async function allocations(): Promise { + if (gc === undefined) throw new Error('user-text allocation mode needs --expose-gc') + const collect = gc + const results: UserTextWorkloadAllocation[] = [] + for (const { id, texts } of workloads()) { + results.push({ id, ruleAllocatedBytes: await allocatedBytes(texts, collect), ...returned(texts) }) + } + return { workloads: results } +} + +function timings(attempts: number): UserTextTimeReport { + return { + attempts, + workloads: workloads().map(({ id, group, texts }) => { + const base = fastest(withoutSecret, texts, attempts) + const secret = fastest(withSecret, texts, attempts) + return { + id, + group, + documents: texts.length, + characters: texts.reduce((sum, text) => sum + text.length, 0), + secrets: secret.secrets, + maskedCharacters: secret.maskedCharacters, + baseMs: base.ms, + withSecretMs: secret.ms, + withSecretCpuMs: secret.cpuMs, + ratio: secret.ms / base.ms, + } + }), + } +} + +assertBuiltBenchmarkRuntime(import.meta.url, { + '@deepseek-ai/cordis': import.meta.resolve('@deepseek-ai/cordis'), + '@deepseek-ai/dsh-client-store': import.meta.resolve('@deepseek-ai/dsh-client-store'), +}) +const [mode, count] = process.argv.slice(2) +if (mode !== 'time' && mode !== 'allocation') throw new Error('usage: user-text.worker.js time | allocation') +console.log(JSON.stringify(mode === 'time' ? timings(positiveInteger(count, 'attempts')) : await allocations())) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index d4bbea471f..39ebd5cd9c 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -23,6 +23,7 @@ Each fact has one home: the tier whose job it is; elsewhere, link there. | [architecture.md](architecture.md) | Ordered map: composition, core packages, loop, seams, extension points; read before changing `packages/` | Type definitions (→ subsystems), per-package detail (→ package READMEs), decision rationale (→ Agent Notes), implementation-status annotations | | [subsystems/](subsystems/README.md) | One reference page per subsystem: type definitions, semantics, and the generated Cordis API | Behavior narration (→ architecture.md) | | [Agent Notes](../.agents/notes/README.md) | Active decision records: the why, what-was-given-up, and required verification; `implemented/` notes describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped; archived notes are frozen history, never current authority | +| [defensive-patterns/](defensive-patterns/README.md) | Rules this repository enforces in review, split by artifact: one general bug-class page plus per-domain pages | Type definitions and semantics (→ subsystems), decision rationale (→ Agent Notes) | | [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — | | [Persistence history](persistence-changes/README.md) and [format references](persistence-changes/historical-formats/README.md) | Type-change acknowledgements, release comparisons, and complete historical format schemas | Behavior-only changes; current runtime contracts | | [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the Agent Note each guide links) | @@ -32,7 +33,7 @@ Each fact has one home: the tier whose job it is; elsewhere, link there. | Generated reference: the per-page `cordis-surface` regions in [subsystems/](subsystems/README.md), the [Cordis core API + inherited tier](cordis-api/context.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md), [cli-help](cli-help.md) | Exhaustive English sources regenerated from source and freshness-gated; reviewed Chinese counterparts follow the [pairing workflow](i18n/README.md#scope-and-exclusions) | Hand edits to generated English sources or regions; Chinese counterparts update through pairing only | | Skills (`.agents/skills/`) | Reusable workflows and specialized decision standards | Product and runtime contracts (→ docs or source) | -Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookbooks; type definitions → subsystems; package contracts → READMEs; standing orders → root `AGENTS.md` with a rationale link. +Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookbooks; type definitions → subsystems; package contracts → READMEs; standing orders → root `AGENTS.md` with a rationale link; review-enforced rules → `defensive-patterns/`. ## Writing rules @@ -55,7 +56,7 @@ When the gate goes red: 2. **Condense** content that belongs here but can be shorter. 3. **Raise** the ceiling only when the words need the space; justify the manifest diff in the PR. A too-low ceiling is a budget bug. -Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the document still has room. Targets: root `AGENTS.md` ≤ 1,960; `architecture.md` ≤ 2,400; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 750 and this file ≤ 1,320; `packages/README.md` ≤ 994; plus `cordis-primer.md` 600, `defensive-patterns.md` 550, `testing.md` 1,300, `examples/AGENTS.md` 310. Review governs unbudgeted tiers. +Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the document still has room. Targets: root `AGENTS.md` ≤ 2,080; `architecture.md` ≤ 2,400; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 810 and this file ≤ 1,430; `packages/README.md` ≤ 994; plus `cordis-primer.md` 600, `testing.md` 1,300, `examples/AGENTS.md` 310. Review governs unbudgeted tiers. ## The slop checklist diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 1072232f44..3f9e8b79ab 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -4,7 +4,7 @@ # pnpm run verify-translation-pairing --write docs/api-gateway.md /api-gateway: en: 735eb4f22d5f6f71 - zh: 1ca2759961efdc06 + zh: 697eef269b133039 /api-gateway/programming-model: en: ea12b64211f0d377 zh: 032e5c0d799c7529 @@ -12,8 +12,8 @@ en: e29f1d7026291075 zh: 15e361e797d44fce /api-gateway/strict-generation-pipeline: - en: cb8da81a92acfa52 - zh: be5f3c268f8571df + en: 9c4a98eb0539ec7f + zh: ad2a145abd805fdd /api-gateway/runtime-invocation: en: 2a2fed340aaa926c zh: 46bb1f500c870a76 @@ -21,8 +21,11 @@ en: 9570ffe7a348e5ac zh: 2b70b61b1ff1fbb0 /api-gateway/development-mode: - en: ba090d76471dd663 - zh: fcddcb232de98248 + en: 23ae422bd65300da + zh: 67dd9c02f4ffec53 /api-gateway/boundaries: en: e6befc5f8c90f97b - zh: 3ade08102542a894 + zh: 4ace4a493fd2b41f +/api-gateway/boundaries/business-ownership: + en: 980ef04e501a3896 + zh: d2f6db14e5135b68 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 91f992dc5e..0a891cd244 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -96,7 +96,7 @@ The API Gateway package owns the Host dispatcher and Client Remote endpoint as p ## Strict generation pipeline -The root build runs `build:lib:host`, `build:lib:client`, and `build:web` in order. The Host lib phase first runs `tsc -b tsconfig.host.json`, then `tsdown --env.DSH_BUILD_FACE host`; the normal Host Project Reference graph compiles the Typert generator, which runs during this tsdown pass with the Host aggregate as its only `ts.Program` seed. The Client lib phase then runs `tsc -b tsconfig.client.json` and `tsdown --env.DSH_BUILD_FACE client`, consuming the newly generated Remote Client declarations and runtime contributions without starting Typert again. +The root build compiles and bundles the Host face, then the Client face, then builds the Web shell ([step order](development.md#typescript-project-layout)). The Host phase first compiles the Host package references, then runs `tsdown --env.DSH_BUILD_FACE host`; the normal Host Project Reference graph compiles the Typert generator, which runs during this tsdown pass with the Host aggregate as its only `ts.Program` seed. The Client phase then compiles the Client package references and runs `tsdown --env.DSH_BUILD_FACE client`, consuming the newly generated Remote Client declarations and runtime contributions without starting Typert again. Both tsdown passes match `vendor/*`, `packages/*/*`, and `apps/cli`, the Host pass also `apps/desktop-host` ([build order](development.md#typescript-project-layout)), and both bundle only JavaScript emitted to `lib/types` by the corresponding tsc phase. The root config does not scan Client artifacts, classify package names, or pass a maintained filter to tsdown; package-local configs return entries for the current phase based on `DSH_BUILD_FACE`. An ordinary Client plugin produces both its Node loader entry and browser bundle during the Client phase. @@ -154,7 +154,7 @@ Changing only a Remote method's implementation body without changing its contrac pnpm run build:lib ``` -The running Client watcher consumes these generated files when it rebundles. If `pnpm run build:lib:host` has already refreshed the Host contract, `pnpm run build:lib:client` can complete the Client side; a clean worktree cannot skip the Host phase. Recompiling only the frontend source cannot infer new types from Host decorators. `pnpm run typecheck` runs the Host lib phase before Client tsc, and CI and release builds use the same order. +The running Client watcher consumes these generated files when it rebundles. If `pnpm run build:lib:host` has already refreshed the Host contract, `pnpm run build:lib:client` can complete the Client side; a clean worktree cannot skip the Host phase. Recompiling only the frontend source cannot infer new types from Host decorators. `pnpm run typecheck` generates the Host contract with the bundle-free Typert pass before Client tsc, and the CI build lanes and release builds generate it in the Host bundle phase before Client tsc. ## Boundaries @@ -163,3 +163,7 @@ Remote handles unary method calls with one request and one result, and stream me The API layers are organized as `remotes → gateway → connection → webserver`. The BFF and Typert RPC layers live under `packages/api`; Connection and WebServer live at `packages/client/connection` and `packages/host/webserver`. Unary results containing `Uint8Array`, including nested fields and array entries, use [binary Remote transfer](../.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md). A feature that needs a response outside the RPC envelope registers an exact Connection Fetch route. Lookup policy is configured per key, so all `agent` or `session` parameters share the cold-resume behavior. Accepting live objects only would require an explicit per-parameter or per-endpoint policy, which does not exist; the business method must not guess whether the object came from restoration. + +### Business ownership + +`packages/api` owns transport, call shape, and session lifecycle; a business rule belongs to an independent business package and reaches this layer through a registration surface or projected data. The requirements a change here must satisfy — the four `@Remote` disciplines, the additive-names rule, and the boundary this layer keeps — are owned by [Remote and API rules](defensive-patterns/remote-and-api.md), which is where a review checks them. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 4e21bafa9b..4cc33ee24b 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -4,6 +4,8 @@ 本文是 Typert API Gateway 的当前状态参考。它描述业务服务如何声明一元与流式 Remote 方法、构建如何生成 Host 与 Client 约定,以及调用如何复用 Connection 的 RPC 与 `/api` 路由。会话事件、增量数据和其他流协议不属于本文范围;它们可以使用同一个 Connection,但不使用 Remote 方法描述符。 + + ## 编程模型 业务服务通过 `@Remote` 或 `@RemoteScope` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.remote` 调用。 @@ -96,7 +98,7 @@ API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对 ## 严格生成流水线 -根构建依次执行 `build:lib:host`、`build:lib:client` 与 `build:web`。Host lib 阶段先运行 `tsc -b tsconfig.host.json`,再运行 `tsdown --env.DSH_BUILD_FACE host`;Typert generator 由正常 Host Project Reference 图编译,并在这次 tsdown 中以 Host aggregate 为唯一 `ts.Program` 种子运行。Client lib 阶段随后运行 `tsc -b tsconfig.client.json` 与 `tsdown --env.DSH_BUILD_FACE client`,使用刚生成的 Remote Client 声明和运行时贡献,但不再次启动 Typert。 +根构建依次编译并打包 Host face、Client face,再构建 Web 壳层([步骤顺序](development.zh.md#typescript-project-layout))。Host 阶段先编译 Host 包 reference,再运行 `tsdown --env.DSH_BUILD_FACE host`;Typert generator 由正常 Host Project Reference 图编译,并在这次 tsdown 中以 Host aggregate 为唯一 `ts.Program` 种子运行。Client 阶段随后编译 Client 包 reference 并运行 `tsdown --env.DSH_BUILD_FACE client`,使用刚生成的 Remote Client 声明和运行时贡献,但不再次启动 Typert。 两次 tsdown 都匹配 `vendor/*`、`packages/*/*` 与 `apps/cli`,Host 阶段另外匹配 `apps/desktop-host`([构建顺序](development.zh.md#typescript-project-layout)),且都只打包 `lib/types` 中由对应 tsc 阶段发射的 JavaScript。根配置不扫描 Client 产物、不按包名分类,也不向 tsdown 传维护式 filter;各包的本地配置根据 `DSH_BUILD_FACE` 返回当前阶段的入口。普通 Client 插件在 Client 阶段一起生成 Node loader 入口与 browser bundle。 @@ -154,7 +156,9 @@ pnpm run dev:web pnpm run build:lib ``` -运行中的 Client watcher 会在重新打包时消费这些生成文件。若已单独运行 `pnpm run build:lib:host` 刷新 Host 约定,也可再运行 `pnpm run build:lib:client` 完成 Client 侧;干净工作树不能跳过 Host 阶段。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 会执行 Host lib 阶段后再运行 Client tsc,CI 与发布构建也使用同一顺序。 +运行中的 Client watcher 会在重新打包时消费这些生成文件。若已单独运行 `pnpm run build:lib:host` 刷新 Host 约定,也可再运行 `pnpm run build:lib:client` 完成 Client 侧;干净工作树不能跳过 Host 阶段。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 先以不打包的 Typert 生成刷新 Host 约定,再运行 Client tsc;CI 的构建 lane 与发布构建则在 Client tsc 之前的 Host bundle 阶段生成它。 + + ## 边界 @@ -163,3 +167,9 @@ Remote 处理单请求单结果的一元方法调用,以及项沿 Host → Cli API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 Typert RPC 层位于 `packages/api`;Connection 与 WebServer 位于 `packages/client/connection` 和 `packages/host/webserver`。包含 `Uint8Array` 的一元结果(包括嵌套字段和数组条目)使用[二进制 Remote 传输](../.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md)。需要 RPC 信封之外响应的功能注册精确的 Connection Fetch 路由。 lookup 策略按 key 配置,因此所有 `agent` 或 `session` 参数共享冷恢复行为。只接受 live 对象需要显式的逐参数或逐 endpoint 策略,而这种策略并不存在;不能通过业务方法内部猜测对象是否来自恢复。 + + + +### 业务所有权 + +`packages/api` 负责传输、调用形态与会话生命周期;业务规则归独立的业务包,经注册面或投影数据接入本层。这里改动必须满足的要求——`@Remote` 的四条纪律、存量名只增不改、以及本层保留的边界——正家是[远程与 API 规则](defensive-patterns/remote-and-api.zh.md),评审就在那里核对。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index e103453762..d97b4bc91c 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: d49027ca5aa121d7 - zh: 23464c262da51830 + en: c6463ed707466fce + zh: 691a167561202036 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 6760459b35..29143bfbfe 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -274,6 +274,9 @@ flowchart LR svc_webServer["ctx.webServer
HTTP route registration"] pkg_client_modules["client-modules"] pkg_client_hmr["client-hmr"] + pkg_web_app["web-app"] + svc_webApp["ctx.webApp
Served Web GUI"] + pkg_harness_dev_context["harness-dev-context"] svc_clientModules["ctx.clientModules
Client plugin graph host"] pkg_workflow["workflow"] svc_workflowEngine["ctx.workflowEngine
Workflow script engine"] @@ -431,6 +434,7 @@ flowchart LR pkg_user_approval --> svc_approval pkg_user_questions --> svc_userQuestions pkg_web --> svc_web + pkg_web_app --> svc_webApp pkg_web_fetch_http --> svc_web pkg_web_search_deepseek --> svc_web pkg_web_search_exa --> svc_web @@ -575,6 +579,7 @@ flowchart LR svc_typert --> pkg_typert_loader svc_userQuestions --> pkg_tool_ask_user svc_web --> pkg_tool_web + svc_webApp --> pkg_harness_dev_context svc_webServer --> pkg_client_connection svc_webServer --> pkg_client_hmr svc_webServer --> pkg_client_modules @@ -686,6 +691,7 @@ flowchart LR | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | | `ctx.webServer` | `core` | [`host-webserver`](../packages/host/webserver) | - | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-hmr`](../packages/client/hmr) | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. | +| `ctx.webApp` | `core` | [`web-app`](../packages/bundle/web-app) | - | [`harness-dev-context`](../packages/context/harness-dev-context) | - | The web-runtime row advertises one URL for its Web GUI: the configured publicUrl, otherwise the listener origin; DSH_WEB_URL, the startup URL, and harness-dev-context read the same value. | | `ctx.clientModules` | `core` | [`client-modules`](../packages/client/modules) | - | [`client-hmr`](../packages/client/hmr) | - | Composes the __DSH_BOOT__ entry graph from an incremental dsh.client scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. | | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-ptc`](../packages/workflow/workflow-ptc) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/experimental/tool-ralph) | - | One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | | `ctx.webhookRuntime` | `core` | [`webhook`](../packages/experimental/webhook) | - | [`webhook-github`](../packages/experimental/webhook-github) | - | Provider adapters dispatch authenticated deliveries; trusted plugins register independent process-local rules, and the runtime turns non-null results into ordinary Workspace-backed Sessions without delivery or completion state. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 374df6a38c..aba043cc8b 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -276,6 +276,9 @@ flowchart LR svc_webServer["ctx.webServer
HTTP route registration"] pkg_client_modules["client-modules"] pkg_client_hmr["client-hmr"] + pkg_web_app["web-app"] + svc_webApp["ctx.webApp
Served Web GUI"] + pkg_harness_dev_context["harness-dev-context"] svc_clientModules["ctx.clientModules
Client plugin graph host"] pkg_workflow["workflow"] svc_workflowEngine["ctx.workflowEngine
Workflow script engine"] @@ -433,6 +436,7 @@ flowchart LR pkg_user_approval --> svc_approval pkg_user_questions --> svc_userQuestions pkg_web --> svc_web + pkg_web_app --> svc_webApp pkg_web_fetch_http --> svc_web pkg_web_search_deepseek --> svc_web pkg_web_search_exa --> svc_web @@ -577,6 +581,7 @@ flowchart LR svc_typert --> pkg_typert_loader svc_userQuestions --> pkg_tool_ask_user svc_web --> pkg_tool_web + svc_webApp --> pkg_harness_dev_context svc_webServer --> pkg_client_connection svc_webServer --> pkg_client_hmr svc_webServer --> pkg_client_modules @@ -688,6 +693,7 @@ flowchart LR | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | | `ctx.webServer` | `core` | [`host-webserver`](../packages/host/webserver) | - | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-hmr`](../packages/client/hmr) | - | 普通的 node:http 载体:具名路由注册表、索引转换 tap,以及静态 dist 回退;Web 传输插件注册自己的路由。 | +| `ctx.webApp` | `core` | [`web-app`](../packages/bundle/web-app) | - | [`harness-dev-context`](../packages/context/harness-dev-context) | - | web-runtime 行为其 Web GUI 公告一个 URL:配置了 publicUrl 时用它,否则用监听器 origin;DSH_WEB_URL、启动 URL 与 harness-dev-context 读取同一个值。 | | `ctx.clientModules` | `core` | [`client-modules`](../packages/client/modules) | - | [`client-hmr`](../packages/client/hmr) | - | 通过增量 `dsh.client` 扫描组合 __DSH_BOOT__ 入口图,提供插件组合包,并通知重建/图变更订阅方。 | | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-ptc`](../packages/workflow/workflow-ptc) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/experimental/tool-ralph) | - | 每个上下文使用一个引擎,与 bash 相同,且没有具名提供方注册表;通用工作流与固定 Ralph 消费方启动运行,其中的 agent() 调用通过 ctx.subagents 扇出。 | | `ctx.webhookRuntime` | `core` | [`webhook`](../packages/experimental/webhook) | - | [`webhook-github`](../packages/experimental/webhook-github) | - | 提供方适配器分派已认证交付;可信插件注册独立的进程本地规则,runtime 把非 null 结果转换为普通的 Workspace-backed Session,不保留交付或完成状态。 | diff --git a/docs/cli-help.md b/docs/cli-help.md index 9796709587..c14b06ac73 100644 --- a/docs/cli-help.md +++ b/docs/cli-help.md @@ -180,7 +180,7 @@ Options: --port listen port; pass 0 to let the OS pick a free one --public-url advertise this HTTP(S) root in the printed, - opened, web-surface, and DSH_WEB_URL forms; + opened, runtime-context, and DSH_WEB_URL forms; grants no trust --trusted-host extra authority the /api browser-trust fence accepts (host or host:port; repeatable) diff --git a/docs/cli-help.zh.md b/docs/cli-help.zh.md index 54b10f9d16..f646eac073 100644 --- a/docs/cli-help.zh.md +++ b/docs/cli-help.zh.md @@ -182,7 +182,7 @@ Options: --port listen port; pass 0 to let the OS pick a free one --public-url advertise this HTTP(S) root in the printed, - opened, web-surface, and DSH_WEB_URL forms; + opened, runtime-context, and DSH_WEB_URL forms; grants no trust --trusted-host extra authority the /api browser-trust fence accepts (host or host:port; repeatable) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2e243b4fb2..8417a83f8a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -182,7 +182,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-gateway` - `inject`: `typert` -- `source`: [`packages/api/gateway/src/index.ts:146`](../packages/api/gateway/src/index.ts) +- `source`: [`packages/api/gateway/src/index.ts:147`](../packages/api/gateway/src/index.ts) ```ts config-catalog /** Gateway transport configuration. */ @@ -279,9 +279,9 @@ export interface Config { readonly maxRows: number /** Screen history rows retained for reconnecting clients. */ readonly scrollback: number - /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */ + /** Maximum queued UTF-8 frame bytes per output follower and in the Client input buffer. */ readonly maxBufferedBytes: number - /** Maximum UTF-8 bytes in one input request. */ + /** Maximum raw UTF-8 bytes per input command and across unacknowledged Client input. */ readonly maxInputBytes: number /** Provider process-termination grace period in milliseconds. */ readonly disposeGraceMs: number @@ -1253,6 +1253,8 @@ export interface Config { ```ts config-catalog /** Tool routing configuration. */ export interface Config { + /** Register `interrupt_agent` and its policy guidance. Defaults to true; false leaves the domain API available. */ + readonly interrupt?: boolean /** Continuable-subagent provider used for fresh teammates. */ readonly freshProvider?: string /** Continuable-subagent provider used for completed-prefix fork teammates. */ @@ -1315,7 +1317,7 @@ export interface Config { directory?: string /** Prefix for automatically generated branch and checkout names. */ namePrefix?: string - /** Git 2.45 or newer executable name or absolute execution-world path. */ + /** Executable name or absolute execution-world path of a Git release that honors `GIT_NO_LAZY_FETCH`. */ gitCommand?: string /** Node executable used for sandboxed directory allocation. */ nodeCommand?: string @@ -1679,7 +1681,7 @@ export interface TlsConfig { ## `@deepseek-ai/dsh-jobs-local` -- `source`: [`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts) +- `source`: [`packages/jobs/jobs-local/src/index.ts:46`](../packages/jobs/jobs-local/src/index.ts) ```ts config-catalog /** Configuration for the process-local job registry. */ @@ -1709,12 +1711,25 @@ export interface Config { ## `@deepseek-ai/dsh-llm-deepseek-account` - `inject`: `llm` -- `refs`: [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) -- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:5`](../packages/llm/llm-deepseek-account/src/config.ts) +- `refs`: [`DeepSeekCatalogModel`](../packages/llm/llm-deepseek/src/index.ts) · [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`) +- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:19`](../packages/llm/llm-deepseek-account/src/config.ts) ```ts config-catalog /** Account route configuration; authentication comes exclusively from the account service. */ -export type Config = ProtocolConfig +export interface Config extends Omit { + /** Deadline of one listing request, which also bounds a first-use wait (default five seconds). */ + modelsTimeoutMs: Volatile + /** + * Listing age after which the next access refreshes it in the background; + * a failed load retries after the same age (default ten minutes). + */ + modelsMaxAgeMs: Volatile + /** Field overrides by model id, layered over the account listing and the built-in catalog. */ + modelOverrides: Volatile> +} + +/** Field overrides for one model; unset fields and empty lists inherit the underlying entry. */ +export type ModelOverride = Omit ``` @@ -2504,13 +2519,15 @@ export interface Config { ## `@deepseek-ai/dsh-ptc-runtime-node` - `inject`: `fs` · `subprocess` · `sandbox` · `sandboxPolicy` -- `source`: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:27`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts) +- `source`: [`packages/ptc-runtime/ptc-runtime-node/src/config.ts:7`](../packages/ptc-runtime/ptc-runtime-node/src/config.ts) ```ts config-catalog /** Deployment-varying runtime bounds and launch choices. */ export interface Config { /** Worker invocation in the subprocess world; omitted selects the local carrier. */ launch?: LaunchConfig + /** per-call starts fresh processes; persistent-in-memory retains a REPL per owner under the same file policy. */ + executionMode?: 'per-call' | 'persistent-in-memory' /** Default elapsed deadline, including nested tool and approval waits. */ timeoutMs?: number /** Maximum numeric elapsed budget accepted by resolve. */ @@ -3123,7 +3140,7 @@ export interface Config { ```ts config-catalog /** Office skill resource location. */ export interface Config { - /** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */ + /** Absolute assets directory containing skill folders; defaults to packaged assets. */ assetRoot?: string /** Standalone Node executable; defaults to the current executable outside Electron and SEA. */ node?: string @@ -3538,7 +3555,7 @@ export interface Config { ## `@deepseek-ai/dsh-system-prompt` -- `source`: [`packages/core/system-prompt/src/index.ts:254`](../packages/core/system-prompt/src/index.ts) +- `source`: [`packages/core/system-prompt/src/index.ts:258`](../packages/core/system-prompt/src/index.ts) ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */ @@ -3760,6 +3777,14 @@ export interface Config { ```ts config-catalog /** Plugin config (all optional — `Config` supplies the defaults). */ export interface Config { + /** Register `read` and its guidance. Defaults to true. */ + read?: boolean + /** Register `read_image` while an attachment store is mounted. Defaults to true. */ + readImage?: boolean + /** Register `write` and its guidance. Defaults to true. */ + write?: boolean + /** Register `edit` and its guidance. Defaults to true. */ + edit?: boolean /** Default and maximum number of lines returned by one `read` call. */ readLimit?: number /** Maximum characters returned for a single line before truncation. */ @@ -4038,11 +4063,11 @@ export interface Config { - `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.md) -- `source`: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) +- `source`: [`packages/subagent/tool-subagent/src/index.ts:50`](../packages/subagent/tool-subagent/src/index.ts) ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ -export interface Config { +export interface Config extends PresetSelectionConfig { /** The `ctx.subagents` provider name to start runs on (e.g. `spawn`, `acp`). */ provider: string /** @@ -4059,22 +4084,6 @@ export interface Config { * Agent options applied to every child; omitted fields use child-loop defaults. */ agentOptions?: AgentOptions - /** - * Per-child persona that shadows `deployment:persona-prefix`. Requires the - * provider's `persona` capability; omission preserves the deployment persona. - */ - persona?: string - /** - * Tool filter applied to every child. Filtered tools disappear from its - * prompt and reject execution. Requires the provider's `toolFilter` - * capability; unknown names fail startup. - */ - toolFilter?: { - /** Global tool names the child keeps; everything else is removed. */ - allow?: string[] - /** Global tool names removed from the child. */ - deny?: string[] - } /** * Maximum child depth: a non-negative safe integer (`0` forbids delegation), * or `'provider-managed'` to send no cap. A numeric cap @@ -4087,6 +4096,14 @@ export interface Config { */ maxDepth?: number | 'provider-managed' } + +/** Optional default and model-selectable preset ids for a spawn tool. */ +export interface PresetSelectionConfig { + /** Default independent preset; omission inherits the parent when the model makes no selection. */ + agentPreset?: string + /** Non-empty allowlist exposing agent_preset to the model; omission disables model selection. */ + allowedAgentPresets?: string[] | undefined +} ``` @@ -4327,7 +4344,7 @@ export interface WebRuntimeConfig { ## `@deepseek-ai/dsh-web-app` - `inject`: `webServer` -- `source`: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts) +- `source`: [`packages/bundle/web-app/src/index.ts:55`](../packages/bundle/web-app/src/index.ts) ```ts config-catalog /** Plugin config: composed deployment settings plus per-invocation command-line values. */ @@ -4337,16 +4354,14 @@ export interface Config { /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** - * Register the model-visible surface context (the `app:web-surface` prompt - * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive - * layer can turn it off when its user is not in the GUI, so the - * orientation text would be false. + * Register the `DSH_WEB_URL` bash variable naming this GUI. A one-shot + * non-interactive layer can turn it off when its user is not in the GUI. */ surfaceContext: boolean /** * Canonical HTTP(S) root to advertise in the printed and opened URL, - * `DSH_WEB_URL`, and the web-surface orientation, e.g. - * `https://app.example/ui/`, normalized to end in `/`. Configures no routing + * `DSH_WEB_URL`, and `webApp.getAdvertisedUrl()`, which supplies the Web GUI + * URL in agent runtime context, e.g. `https://app.example/ui/`, normalized to end in `/`. Configures no routing * or authentication; see [public deployments](../README.md#public-deployments). * Absent or YAML `null` advertises the bind-address URL. */ @@ -4593,6 +4608,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@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) | +| `@deepseek-ai/dsh-client-ui-secret-mask` | — | [`packages/client/ui-secret-mask/src/index.ts`](../packages/client/ui-secret-mask/src/index.ts) | | `@deepseek-ai/dsh-client-ui-session` | — | [`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts) | | `@deepseek-ai/dsh-client-ui-settings` | — | [`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts) | | `@deepseek-ai/dsh-client-ui-settings-agent-loop` | — | [`packages/client/ui-settings-agent-loop/src/index.ts`](../packages/client/ui-settings-agent-loop/src/index.ts) | @@ -4635,6 +4651,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-fs-observation-policy` | — | [`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts) | | `@deepseek-ai/dsh-fs-ssh` | `ssh` · `sandboxPolicy` | [`packages/ssh/fs-ssh/src/index.ts`](../packages/ssh/fs-ssh/src/index.ts) | | `@deepseek-ai/dsh-goal-round-driver` | `agents` · `goals` · `sessions` | [`packages/goal/goal-round-driver/src/index.ts`](../packages/goal/goal-round-driver/src/index.ts) | +| `@deepseek-ai/dsh-harness-dev-context` | `systemPrompt` · `sessionProjections` · `webApp` | [`packages/context/harness-dev-context/src/index.ts`](../packages/context/harness-dev-context/src/index.ts) | | `@deepseek-ai/dsh-host-directory-picker-auto` | `webServer` · `connection` · `loader` | [`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts) | | `@deepseek-ai/dsh-host-directory-picker-native` | — | [`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts) | | `@deepseek-ai/dsh-host-plugin-inventory` | `loader` | [`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index c81b640693..158db16c47 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -184,7 +184,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-gateway` - `inject`: `typert` -- `source`: [`packages/api/gateway/src/index.ts:146`](../packages/api/gateway/src/index.ts) +- `source`: [`packages/api/gateway/src/index.ts:147`](../packages/api/gateway/src/index.ts) ```ts config-catalog /** Gateway transport configuration. */ @@ -281,9 +281,9 @@ export interface Config { readonly maxRows: number /** Screen history rows retained for reconnecting clients. */ readonly scrollback: number - /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */ + /** Maximum queued UTF-8 frame bytes per output follower and in the Client input buffer. */ readonly maxBufferedBytes: number - /** Maximum UTF-8 bytes in one input request. */ + /** Maximum raw UTF-8 bytes per input command and across unacknowledged Client input. */ readonly maxInputBytes: number /** Provider process-termination grace period in milliseconds. */ readonly disposeGraceMs: number @@ -1255,6 +1255,8 @@ export interface Config { ```ts config-catalog /** Tool routing configuration. */ export interface Config { + /** Register `interrupt_agent` and its policy guidance. Defaults to true; false leaves the domain API available. */ + readonly interrupt?: boolean /** Continuable-subagent provider used for fresh teammates. */ readonly freshProvider?: string /** Continuable-subagent provider used for completed-prefix fork teammates. */ @@ -1317,7 +1319,7 @@ export interface Config { directory?: string /** Prefix for automatically generated branch and checkout names. */ namePrefix?: string - /** Git 2.45 or newer executable name or absolute execution-world path. */ + /** Executable name or absolute execution-world path of a Git release that honors `GIT_NO_LAZY_FETCH`. */ gitCommand?: string /** Node executable used for sandboxed directory allocation. */ nodeCommand?: string @@ -1681,7 +1683,7 @@ export interface TlsConfig { ## `@deepseek-ai/dsh-jobs-local` -- `source`: [`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts) +- `source`: [`packages/jobs/jobs-local/src/index.ts:46`](../packages/jobs/jobs-local/src/index.ts) ```ts config-catalog /** Configuration for the process-local job registry. */ @@ -1711,12 +1713,25 @@ export interface Config { ## `@deepseek-ai/dsh-llm-deepseek-account` - `inject`: `llm` -- `refs`: [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) -- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:5`](../packages/llm/llm-deepseek-account/src/config.ts) +- `refs`: [`DeepSeekCatalogModel`](../packages/llm/llm-deepseek/src/index.ts) · [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`) +- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:19`](../packages/llm/llm-deepseek-account/src/config.ts) ```ts config-catalog /** Account route configuration; authentication comes exclusively from the account service. */ -export type Config = ProtocolConfig +export interface Config extends Omit { + /** Deadline of one listing request, which also bounds a first-use wait (default five seconds). */ + modelsTimeoutMs: Volatile + /** + * Listing age after which the next access refreshes it in the background; + * a failed load retries after the same age (default ten minutes). + */ + modelsMaxAgeMs: Volatile + /** Field overrides by model id, layered over the account listing and the built-in catalog. */ + modelOverrides: Volatile> +} + +/** Field overrides for one model; unset fields and empty lists inherit the underlying entry. */ +export type ModelOverride = Omit ``` @@ -2506,13 +2521,15 @@ export interface Config { ## `@deepseek-ai/dsh-ptc-runtime-node` - `inject`: `fs` · `subprocess` · `sandbox` · `sandboxPolicy` -- `source`: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:27`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts) +- `source`: [`packages/ptc-runtime/ptc-runtime-node/src/config.ts:7`](../packages/ptc-runtime/ptc-runtime-node/src/config.ts) ```ts config-catalog /** Deployment-varying runtime bounds and launch choices. */ export interface Config { /** Worker invocation in the subprocess world; omitted selects the local carrier. */ launch?: LaunchConfig + /** per-call starts fresh processes; persistent-in-memory retains a REPL per owner under the same file policy. */ + executionMode?: 'per-call' | 'persistent-in-memory' /** Default elapsed deadline, including nested tool and approval waits. */ timeoutMs?: number /** Maximum numeric elapsed budget accepted by resolve. */ @@ -3125,7 +3142,7 @@ export interface Config { ```ts config-catalog /** Office skill resource location. */ export interface Config { - /** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */ + /** Absolute assets directory containing skill folders; defaults to packaged assets. */ assetRoot?: string /** Standalone Node executable; defaults to the current executable outside Electron and SEA. */ node?: string @@ -3540,7 +3557,7 @@ export interface Config { ## `@deepseek-ai/dsh-system-prompt` -- `source`: [`packages/core/system-prompt/src/index.ts:254`](../packages/core/system-prompt/src/index.ts) +- `source`: [`packages/core/system-prompt/src/index.ts:258`](../packages/core/system-prompt/src/index.ts) ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */ @@ -3762,6 +3779,14 @@ export interface Config { ```ts config-catalog /** Plugin config (all optional — `Config` supplies the defaults). */ export interface Config { + /** Register `read` and its guidance. Defaults to true. */ + read?: boolean + /** Register `read_image` while an attachment store is mounted. Defaults to true. */ + readImage?: boolean + /** Register `write` and its guidance. Defaults to true. */ + write?: boolean + /** Register `edit` and its guidance. Defaults to true. */ + edit?: boolean /** Default and maximum number of lines returned by one `read` call. */ readLimit?: number /** Maximum characters returned for a single line before truncation. */ @@ -4040,11 +4065,11 @@ export interface Config { - `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.zh.md) -- `source`: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) +- `source`: [`packages/subagent/tool-subagent/src/index.ts:50`](../packages/subagent/tool-subagent/src/index.ts) ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ -export interface Config { +export interface Config extends PresetSelectionConfig { /** The `ctx.subagents` provider name to start runs on (e.g. `spawn`, `acp`). */ provider: string /** @@ -4061,22 +4086,6 @@ export interface Config { * Agent options applied to every child; omitted fields use child-loop defaults. */ agentOptions?: AgentOptions - /** - * Per-child persona that shadows `deployment:persona-prefix`. Requires the - * provider's `persona` capability; omission preserves the deployment persona. - */ - persona?: string - /** - * Tool filter applied to every child. Filtered tools disappear from its - * prompt and reject execution. Requires the provider's `toolFilter` - * capability; unknown names fail startup. - */ - toolFilter?: { - /** Global tool names the child keeps; everything else is removed. */ - allow?: string[] - /** Global tool names removed from the child. */ - deny?: string[] - } /** * Maximum child depth: a non-negative safe integer (`0` forbids delegation), * or `'provider-managed'` to send no cap. A numeric cap @@ -4089,6 +4098,14 @@ export interface Config { */ maxDepth?: number | 'provider-managed' } + +/** Optional default and model-selectable preset ids for a spawn tool. */ +export interface PresetSelectionConfig { + /** Default independent preset; omission inherits the parent when the model makes no selection. */ + agentPreset?: string + /** Non-empty allowlist exposing agent_preset to the model; omission disables model selection. */ + allowedAgentPresets?: string[] | undefined +} ``` @@ -4329,7 +4346,7 @@ export interface WebRuntimeConfig { ## `@deepseek-ai/dsh-web-app` - `inject`: `webServer` -- `source`: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts) +- `source`: [`packages/bundle/web-app/src/index.ts:55`](../packages/bundle/web-app/src/index.ts) ```ts config-catalog /** Plugin config: composed deployment settings plus per-invocation command-line values. */ @@ -4339,16 +4356,14 @@ export interface Config { /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** - * Register the model-visible surface context (the `app:web-surface` prompt - * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive - * layer can turn it off when its user is not in the GUI, so the - * orientation text would be false. + * Register the `DSH_WEB_URL` bash variable naming this GUI. A one-shot + * non-interactive layer can turn it off when its user is not in the GUI. */ surfaceContext: boolean /** * Canonical HTTP(S) root to advertise in the printed and opened URL, - * `DSH_WEB_URL`, and the web-surface orientation, e.g. - * `https://app.example/ui/`, normalized to end in `/`. Configures no routing + * `DSH_WEB_URL`, and `webApp.getAdvertisedUrl()`, which supplies the Web GUI + * URL in agent runtime context, e.g. `https://app.example/ui/`, normalized to end in `/`. Configures no routing * or authentication; see [public deployments](../README.md#public-deployments). * Absent or YAML `null` advertises the bind-address URL. */ @@ -4595,6 +4610,7 @@ export interface Config { | `@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) | +| `@deepseek-ai/dsh-client-ui-secret-mask` | — | [`packages/client/ui-secret-mask/src/index.ts`](../packages/client/ui-secret-mask/src/index.ts) | | `@deepseek-ai/dsh-client-ui-session` | — | [`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts) | | `@deepseek-ai/dsh-client-ui-settings` | — | [`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts) | | `@deepseek-ai/dsh-client-ui-settings-agent-loop` | — | [`packages/client/ui-settings-agent-loop/src/index.ts`](../packages/client/ui-settings-agent-loop/src/index.ts) | @@ -4637,6 +4653,7 @@ export interface Config { | `@deepseek-ai/dsh-fs-observation-policy` | — | [`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts) | | `@deepseek-ai/dsh-fs-ssh` | `ssh` · `sandboxPolicy` | [`packages/ssh/fs-ssh/src/index.ts`](../packages/ssh/fs-ssh/src/index.ts) | | `@deepseek-ai/dsh-goal-round-driver` | `agents` · `goals` · `sessions` | [`packages/goal/goal-round-driver/src/index.ts`](../packages/goal/goal-round-driver/src/index.ts) | +| `@deepseek-ai/dsh-harness-dev-context` | `systemPrompt` · `sessionProjections` · `webApp` | [`packages/context/harness-dev-context/src/index.ts`](../packages/context/harness-dev-context/src/index.ts) | | `@deepseek-ai/dsh-host-directory-picker-auto` | `webServer` · `connection` · `loader` | [`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts) | | `@deepseek-ai/dsh-host-directory-picker-native` | — | [`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts) | | `@deepseek-ai/dsh-host-plugin-inventory` | `loader` | [`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts) | diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml deleted file mode 100644 index 61e5aec2f2..0000000000 --- a/docs/defensive-patterns.i18n.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# Bilingual-pair consistency record for defensive-patterns.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/defensive-patterns.md -/defensive-patterns: - en: 6316b1da0ae4561b - zh: f059647429c707d5 -/defensive-patterns/report-orthogonal-outcomes-independently: - en: 09cdbaa7cf763dc9 - zh: 11e5c13d77b2a563 -/defensive-patterns/honor-public-contracts-on-both-sides: - en: 157f966db7329171 - zh: 80da311819125f36 -/defensive-patterns/async-state-is-not-synchronous-state: - en: 33085f2b58bab16c - zh: be38e69a9c7d3f0e -/defensive-patterns/dispose-must-reach-quiescence-not-just-request-it: - en: 5c906be504b88a65 - zh: 8f428f59a87bb1a8 -/defensive-patterns/contain-callback-exceptions-in-the-dispatcher: - en: 3275390293995f44 - zh: 4d143e99a2a7c8cb -/defensive-patterns/never-hand-untrusted-output-the-ambient-environment-or-predictable-paths: - en: 09ad9d6433026468 - zh: cc0ba1a52859bb08 -/defensive-patterns/unlink-link-shaped-paths: - en: 644e6b1a9bd7b7d7 - zh: 823600c76c81ead1 diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md deleted file mode 100644 index 25ec826913..0000000000 --- a/docs/defensive-patterns.md +++ /dev/null @@ -1,33 +0,0 @@ -# Defensive patterns - -English | [中文](defensive-patterns.zh.md) - -Hard-won bug-class rules: each pattern below is a class of defect that actually shipped or nearly shipped here, stated as the rule that prevents its recurrence. Read this before writing lifecycle, concurrency, subprocess, or teardown code. Test-tier counterparts (real entry path, world-verification, resource ownership) are in [testing.md](testing.md). - -## Report orthogonal outcomes independently - -A result can be several things at once — a process can time out AND exit 0 because it trapped the signal. Surface each independent fact (`timedOut`, `signal`, `exitCode`) on its own; never nest one flag's report inside another's branch, or a caller reads a cut-short run as a clean success. - -## Honor public contracts on BOTH sides - -When an implementation receives several representations of one outcome, normalize them before returning through the public API. `LlmAdapter.stream()` implementations may throw or emit `finish {kind:'error'|'aborted'}`, but `LlmRuntime.stream()` exposes model-request failures only as terminal finish chunks; middleware and consumer defects remain thrown. This keeps consumers from guessing whether a caught exception came from the provider, a wrapper, chunk logging, or their own assembly. Document the normalized contract where the type is defined; exercise every source form through the real consumer. - -## Async state is not synchronous state - -`agent.followup()` has no per-message completion or result; a background job's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never treat `agent/status` or `whenIdle()` as the result of one follow-up: several queued follow-ups, steering, and injected work may share one `running` interval, while cancellation or disposal can discard unstarted items. An automation caller that truly owns a run must define its interval explicitly—for example, from its message's durable inbox receipt through the next whole-agent `idle`—and describe any selected output as interval-wide rather than causally attributed to that message. The guard cuts both ways: if the awaited transition can never occur, the wait hangs, so handle the "nothing to wait for" branch explicitly. - -## Dispose must reach quiescence, not just request it - -A teardown that issues kills/aborts but returns before the work stops leaves orphans. Make cleanup async and await the children's exit (kill → await `done`), and close listener/notification registries BEFORE killing so late completions stay silent. - -## Contain callback exceptions in the dispatcher - -Catch and report each listener's exception without rejecting the dispatch promise or starving later listeners. The listener's owner must handle state it mutates under the [error-handling rules](../.agents/skills/dsh-error-handling/SKILL.md#catch-recover-or-stop); catching alone does not restore state. - -## Never hand untrusted output the ambient environment or predictable paths - -Spawned commands get a scrubbed env (drop `*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASSWORD*`) so harness credentials cannot leak into output, `env`, or spill files. Temp/spill files use a private (0700) dir, random names, and exclusive owner-only opens (`'wx'`, `0o600`) — predictable world-readable paths invite symlink races and disclosure. - -## Unlink link-shaped paths - -A path that may be a symlink or Windows junction is removed with `lstatSync().isSymbolicLink()` then `unlinkSync`: unlink deletes only the link and refuses a real directory, so it never follows the link into its target. Windows `rmSync(link)` throws `ERR_FS_EISDIR` on a junction; recursive deletion may descend through one into its target. Reserve recursive `rmSync` for known real directories. diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md deleted file mode 100644 index c21a87cb28..0000000000 --- a/docs/defensive-patterns.zh.md +++ /dev/null @@ -1,35 +0,0 @@ -# 防御性模式 - -[English](defensive-patterns.md) | 中文 - -来之不易的缺陷类别规则:下面每条模式都是本项目实际发布或差点发布的一类缺陷,以防止其复发的规则形式陈述。在编写生命周期、并发、子进程或清理代码之前请先阅读本文。测试层面的对应规则(真实入口路径、验证实际结果、资源归属)见 [testing.md](testing.zh.md)。 - -## 正交结果独立上报 - -一个结果可以同时具有多种性质:进程可能已经超时,却仍以退出码 0 结束,因为它捕获了终止信号。每个独立事实(`timedOut`、`signal`、`exitCode`)都应单独上报;切勿把一个标志的上报嵌套在另一个标志的分支中,否则调用方可能把提前终止的运行误判为正常成功。 - -## 公共约定两侧都要遵守 - -当一个实现收到同一结果的多种表示时,应在通过公共 API 返回前将其规范化。`LlmAdapter.stream()` 的实现可以抛出异常或发出 `finish {kind:'error'|'aborted'}`,但 `LlmRuntime.stream()` 只会通过终止型 finish 分片暴露模型请求失败;middleware 缺陷与消费方缺陷仍会以异常形式抛出。这使消费方不必猜测捕获的异常究竟来自提供方、包装层、分片日志记录还是自身组装逻辑。请在类型定义处记录规范化后的约定;通过真实消费方覆盖每种来源形式。 - -## 异步状态不是同步状态 - -`agent.followup()` 没有逐消息的完成状态或结果;后台任务的完成与轮次边界存在竞争;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。切勿把 `agent/status` 或 `whenIdle()` 当作某次 `followup()` 的结果:多条已排队的后续消息、steering(中途引导)和注入工作可能共用同一个 `running` 区间,而取消或资源释放可能丢弃尚未启动的项。真正拥有一次运行的自动化调用方必须显式定义其区间——例如从消息的持久 inbox 回执到整个 agent(智能体)下一次进入 `idle`——并将选取的任何输出描述为整个区间的输出,而不是把因果关系归于该消息。这条守则是双向的:如果等待的转换永远不会发生,等待就会挂起,因此应显式处理「无需等待」的分支。 - - - -## dispose 必须达到完全停稳,而不仅仅是请求停止 - -如果清理流程只发出终止或中止信号便返回,而不等待工作真正停止,就会留下孤儿进程。清理逻辑应采用异步流程,并等待子进程退出(发出终止信号后等待 `done`);还应在终止进程前关闭监听器注册表和通知注册表,使迟到的完成事件保持静默。 - -## 在分发器中隔离回调异常 - -逐个捕获并报告监听器的异常,不让分发 promise 被 reject,也不让后续监听器失去执行机会。监听器的所有者必须按[错误处理规范](../.agents/skills/dsh-error-handling/SKILL.md#catch-recover-or-stop)处置其修改的状态;仅捕获异常不会恢复状态。 - -## 绝不将环境变量或可预测路径暴露给不可信输出 - -启动的命令应使用经过清理的环境变量,移除名称匹配 `*KEY*`、`*SECRET*`、`*TOKEN*` 或 `*PASSWORD*` 的项,防止 harness 凭证通过命令输出、`env` 或 spill 文件泄漏。临时文件和 spill 文件应放在权限为 0700 的私有目录中,使用随机文件名,并以独占且仅所有者可访问的方式打开(`'wx'`、`0o600`);可预测且全局可读的路径会引发符号链接竞态和信息泄露。 - -## 用 unlink 删除链接形态的路径 - -可能是符号链接或 Windows junction 的路径,应先用 `lstatSync().isSymbolicLink()` 判断,再用 `unlinkSync` 删除:unlink 只删除链接本身并拒绝真实目录,因此绝不会跟随链接进入其目标。Windows 上对 junction 调用 `rmSync(link)` 会抛 `ERR_FS_EISDIR`;递归删除可能穿过 junction 进入其目标。真实目录才使用带 `recursive` 的 `rmSync`。 diff --git a/docs/defensive-patterns/README.i18n.yaml b/docs/defensive-patterns/README.i18n.yaml new file mode 100644 index 0000000000..79e3b6cd15 --- /dev/null +++ b/docs/defensive-patterns/README.i18n.yaml @@ -0,0 +1,10 @@ +# 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 docs/defensive-patterns/README.md +/defensive-patterns: + en: 7229c49e920aa5b3 + zh: 1dd52db6b4d84952 +/defensive-patterns/why-these-rules: + en: 7c012071b5af8c98 + zh: 298aaba83625c7a0 diff --git a/docs/defensive-patterns/README.md b/docs/defensive-patterns/README.md new file mode 100644 index 0000000000..c74d1a4c29 --- /dev/null +++ b/docs/defensive-patterns/README.md @@ -0,0 +1,25 @@ +# Defensive patterns + +English | [中文](README.zh.md) + +Hard-won rules: each pattern in this directory is a requirement this repository enforces in review, stated as the rule and the defect it prevents. Every rule states what must hold, the condition a reviewer judges it by, its boundary and who approves an exception, and a correct and a wrong example. Read the page for the artifact you are about to change, before you change it. + +## Why these rules + +Five habits cover most of what follows, and each page is their application to one artifact: + +- **Make correctness explicit.** Write it into the structure — named call points, explicit lifecycles, explicit conditions and types — rather than compensating for it afterwards with timing. +- **Keep one source of truth.** One read path for a piece of data, one home for a fact, one owner for shared state. +- **Extend through the registered seam.** A new capability registers; an existing carrier stops taking new logic instead of growing a branch inside it. +- **Keep the change face equal to the review face.** A change carries only the lines its stated behavior change needs, a migration moves code rather than rewriting it, and a performance-sensitive change carries its measurement. +- **Require evidence.** A claim is falsifiable and recomputable — measured before and after, or checked by a gate — rather than kept in sync by hand. + +| Changing | Read | +|---|---| +| Package exports, package shape, build faces, dependency declarations | [package-surface.md](package-surface.md) | +| Remote services, `@Remote` methods, API dispatch, business ownership at the API layer | [remote-and-api.md](remote-and-api.md) | +| Client components, slots, stores, hooks, effects | [client-components.md](client-components.md) | +| Session reads, retention, conversation nodes, renderers | [conversation-and-session.md](conversation-and-session.md) | +| Timing, state representation, shared ownership, decentralization, refactors, migrations, performance evidence, disposal, callbacks, subprocesses | [general.md](general.md) | + +Type definitions and per-subsystem semantics live in [subsystems/](../subsystems/README.md); this directory carries the rules, and the semantics pages carry what the types mean. [dsh-code-review](../../.agents/skills/dsh-code-review/SKILL.md) reads this directory when judging a pull request, and Development routes through the same table from [AGENTS.md](../../AGENTS.md). diff --git a/docs/defensive-patterns/README.zh.md b/docs/defensive-patterns/README.zh.md new file mode 100644 index 0000000000..38d4c617d2 --- /dev/null +++ b/docs/defensive-patterns/README.zh.md @@ -0,0 +1,27 @@ +# 防御性模式 + +[English](README.md) | 中文 + +来之不易的规则:本目录中每条模式都是本仓库在评审中执行的**要求**,以「规则 + 它所防止的缺陷」的形式陈述。每条规则都写明:必须成立什么、评审据什么判定、边界在哪、例外由谁批准,以及一个正例与一个反例。动手修改某类制品之前,先读对应那一页。 + + + +## 这些规则背后的思想 + +下面五条习惯覆盖了绝大部分内容,每一页都是它们在某类制品上的落地: + +- **把正确性显式化。** 把它写进结构——具名调用点、显式生命周期、显式条件与类型——而不是事后用时序去补偿。 +- **只留一个真相。** 一份数据一条读取路径,一个事实一个正家,共享状态一个所有者。 +- **走注册面扩展。** 新能力靠注册接入;既有载体不再承接新逻辑,而不是在它内部长出分支。 +- **把改动面压成评审面。** 改动只包含其声明的行为变化所需的行;迁移是搬代码而不是重写;涉及性能的改动附带测量。 +- **要求可证伪、可复算。** 口径能被改动前后的实测或门禁证明/否证,而不是靠人肉同步。 + +| 你在改什么 | 读哪篇 | +|---|---| +| 包导出面、包形态、构建面、依赖声明 | [package-surface.zh.md](package-surface.zh.md) | +| 远程服务、`@Remote` 方法、API 分发、API 层的业务归属 | [remote-and-api.zh.md](remote-and-api.zh.md) | +| 客户端组件、slots、store、hooks、effect | [client-components.zh.md](client-components.zh.md) | +| Session 读取、持有、会话节点、renderer | [conversation-and-session.zh.md](conversation-and-session.zh.md) | +| 时序、状态表示、共享归属、去中心化、重构、迁移、性能证据、清理、回调、子进程 | [general.zh.md](general.zh.md) | + +类型定义与各子系统语义见 [subsystems/](../subsystems/README.zh.md);本目录承载**规则**,语义页承载**类型的含义**。[dsh-code-review](../../.agents/skills/dsh-code-review/SKILL.md) 在评审 pull request 时读本目录,日常修改经 [AGENTS.md](../../AGENTS.md) 路由到同一张表。 diff --git a/docs/defensive-patterns/client-components.i18n.yaml b/docs/defensive-patterns/client-components.i18n.yaml new file mode 100644 index 0000000000..face24c503 --- /dev/null +++ b/docs/defensive-patterns/client-components.i18n.yaml @@ -0,0 +1,46 @@ +# Bilingual-pair consistency record for client-components.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/defensive-patterns/client-components.md +/client-component-rules: + en: a6e8a87976040c04 + zh: 08b59ad3c14fe250 +/client-component-rules/state-arrives-through-injection: + en: 112232a703ce472d + zh: cf42bda22d8e4da4 +/client-component-rules/props-carry-data-and-callbacks-not-react-nodes: + en: 253f05568dd7e26f + zh: fa12565ff5afb8bd +/client-component-rules/a-slot-registration-is-a-stable-function-component: + en: 784d3a4bba02f8a4 + zh: 0ccfcc1a504b135c +/client-component-rules/components-never-receive-a-cordis-context: + en: 8bb102b178615c03 + zh: 93cb177a578a1373 +/client-component-rules/context-comes-from-the-current-scope: + en: 652ec94d13ee51c9 + zh: b47c39e96c53cfac +/client-component-rules/resource-consumers-go-through-useresource: + en: 6650c772df96f935 + zh: 57fc0a564caf62a8 +/client-component-rules/store-usage-is-narrow-and-explicit: + en: 697b2e81a862e3bf + zh: 13cf31bef332a601 +/client-component-rules/identity-and-initial-state-are-deliberate: + en: a34c30b8f7c415d4 + zh: cbd0c0cf0c0db57c +/client-component-rules/uselayouteffect-is-not-a-default: + en: f53f17846a8a89b2 + zh: 94d205c4d85f1de9 +/client-component-rules/effects-never-push-render-derived-data-to-a-parent: + en: 580441bd6cce9ee9 + zh: a5f093d8e7dec2ee +/client-component-rules/build-and-style-follow-their-owners: + en: 561df1e1709a004c + zh: 89ca6eb4533648b8 +/client-component-rules/a-key-states-what-it-identifies: + en: 1d6f9bdc2b267fef + zh: cf4d579ca877c422 +/client-component-rules/each-shared-layer-keeps-its-own-isolation-level: + en: 3faec81a6a7c9fdd + zh: aa512c089b5f9afc diff --git a/docs/defensive-patterns/client-components.md b/docs/defensive-patterns/client-components.md new file mode 100644 index 0000000000..7b0ec0463a --- /dev/null +++ b/docs/defensive-patterns/client-components.md @@ -0,0 +1,493 @@ +# Client component rules + +English | [中文](client-components.zh.md) + +Rules for client components, their state, and their effects. [packages/client/AGENTS.md](../../packages/client/AGENTS.md) carries the detailed discipline; [slots.md](../subsystems/slots.md) owns the Slot design and [web-styling.md](../web-styling.md) the styling rules. Each section below states the requirement, how a reviewer judges it, where its boundary and exception sit, and a correct and a wrong example. + +## State arrives through injection + +A Slot's component receives everything it renders through the inject face of its own registration — plain data, action callbacks, and the reserved `hooks` compartment the renderer binds to `use` hooks — while the parent's render site passes owner data through the derived `renderSlot`. It never reads one large global state at the top of the tree to hand values down. Keeping every live value on that face is what makes a prop that did not change keep its identity, and what lets a component subscribe only to the sub-state it reads. Without it any unrelated field change re-renders the whole subtree, which is how a long session under streaming events loses frames while the user scrolls or types. + +- Judged by: at the registration and at its render site, the author must be able to name which sub-states the rendered component subscribes to and which of its incoming props keep a stable identity across renders. Watch the mutability of every prop: a value rebuilt each render — a fresh object, array, or inline callback — propagates re-renders even when nothing in it changed. +- Not covered: the component's own local state (`useState` for a fold, a hover, a draft) is not injection content, and a component that subscribes to nothing outside React needs no new seat. +- Exception: a new hook seat changes the Slot contract, so that change states its impact — which components subscribe to what — in review; using an existing face needs no per-component approval. +- Fix: narrow the injection to what the view renders, expose one field per read through a hook seat, and pass a narrow action set instead of a store or service handle. [Component inputs](../subsystems/slots.md#component-inputs) owns the inject factory, the standard hook seats, and how each becomes a prop. + +The registration carries plain data, a narrow action set, and one hook seat; the render site passes owner data, and the component reads only the field it renders: + +```tsx +// The inject factory returns plain data and callbacks; hooks is the reserved compartment. +ctx.slots.register( + { + name: 'sidebar.right', + inject: () => ({ + rename, + hooks: { info: infoSource }, // bound by the renderer as useInfo() + }), + }, + SidebarRight, +) + +// The parent's render site passes owner data through the derived renderSlot. +{renderSlot('sidebar.right', { id })} + +function SidebarRight({ id, useInfo, rename }: Props) { + const { title } = useInfo() // subscribes to title only + return +} +``` + +The top-level component reads the whole view store and hands it down as one prop, so the render site can no longer answer either question above: + +```tsx +function Sidebar() { + const view = useViewStore() // one top-level global read + return // the whole store travels down +} + +function SidebarRight({ view }: { view: ViewStore }) { + return {view.activePanel.title} +} +``` + +Every field change in that store re-renders the whole sidebar subtree, and the component's subscription set is invisible to the site that bound it. + +## Props carry data and callbacks, not React nodes + +A prop is data or a callback; a React node travels through a declared slot. Passing an element as a prop value or an injected member hides a second composition path that the Slot contract does not describe, moves render control from the registration surface to the call site, and lets one package assemble another package's markup. New node-valued props or injected members are never added; a location that needs host-supplied markup is declared as a child slot instead. + +- Judged by: `ReactNode`, `ReactElement`, or a `() => JSX.Element` in a component's props type, in an inject factory's return value, or in an owner-props object at a render site. +- Not covered: node-valued fields that already shipped keep working. The rule triggers when a change touches one, and that change converts it rather than extending it. +- Exception: when the conversion cannot happen in the same change, the owning domain expert confirms the deferral and the change records that confirmation; the author never self-approves. +- Fix: declare the location in the parent registration's `children`, render it with `renderSlot`, and pass data through the child's props. [Extension rules](../subsystems/slots.md#extension-rules) own which package may declare and render which slot. + +The header location is a declared child slot, so the host passes data and the registered component owns its markup: + +```tsx +// The card declares the header location; the host passes data only. +ctx.slots.register( + { + name: 'tool.call.toolview', + children: { header: { kind: 'single', scope: 'session' } }, + }, + ToolCard, +) + +function ToolCard({ title, renderSlot }: Props) { + return
{renderSlot('header', { title })}
+} +``` + +A React node crosses the boundary as a prop value, so the composition path it travels is not declared anywhere: + +```tsx +type ToolCardProps = { + header?: React.ReactNode + footer?: React.ReactNode +} + +// The call site assembles another package's markup. +} /> +``` + +The card's Slot contract stops describing what it renders, the call site keeps render control, and the badge's package becomes a runtime reference from whoever passes it. + +## A slot registration is a stable function component + +The value registered into a slot is a stable function component declared at module scope. A component function created during render is a new component type on every pass, and React treats a changed type as a different component: it unmounts the subtree and mounts a fresh one, which resets expanded rows, input focus, and scroll position. Registration-time parameters reach the component through props rather than by closing over render-time variables. + +- Judged by: where the registered value is created. An inline arrow in a registration or render call, a component defined inside a render body, and a `useCallback` wrapper around an inline component all fail; a module-scope `function` declaration passes. +- Not covered: the rule constrains the identity of the registered value, not its complexity. A stable component may still read injected props, hooks, and callbacks. +- Exception: creating a component during render is correct only when the caller wants the remount, and an ordinary change needs no separate approval; a change to the form of the Slot contract follows the injection-face review described under State arrives through injection. +- Fix: move the component to module scope, type its props from the registration's shares, and pass the varying value through the inject face instead of the closure. + +The registration holds one stable reference to a module-scope component, and the varying value arrives injected; the render site supplies the local component: + +```tsx +function PanelTitle({ title, count }: Props) { + return {title} ({count}) +} + +// Registration: the local slot's scope is declared, never a component. +ctx.slots.registerFactory( + { + name: 'panel', + scope: 'root', + slots: { title: { scope: 'root' } }, + inject: () => ({ title: 'Sessions', count: 3 }), + }, + Panel, +) + +// The render site passes the local component, keeping its identity stable. +renderFactorySlot('panel', { /* owner data */ }, { slots: { title: PanelTitle } }) +``` + +An inline arrow at the render site creates a new component type whenever that render runs: + +```tsx +// A new component type each time this render runs. +renderFactorySlot('panel', { /* owner data */ }, { slots: { title: (p) => {p.title} } }) +``` + +React sees a different type than the previous registration, remounts the seat, and resets its local state — expanded, focused, or scrolled position — on an otherwise unrelated update. + +## Components never receive a Cordis context + +`ctx` belongs to the apply world: the plugin body and the `inject` factories closed over it. A component never receives a Cordis context, never imports a service class to poke it, and never reads a React context, because business components see zero contexts — the renderer's binding contexts are internal to it. A bare observable reaches a component only after Injection wraps it in a hook; a component that subscribes directly owns a lifecycle that nothing on the Injection face releases. + +- Judged by: `ctx` or `Context` in a component's parameter or props type; a component body reading `observable.value`, calling `subscribe`, or wiring `useSyncExternalStore`; a service class imported into a feature `.tsx` module. +- Not covered: a component-internal behavioral hook that subscribes to nothing outside React (a timer, a media query) is not injection content and stays in the component. +- Exception: none. The direct subscription still present in `TabSlot` is remediation work owned by that entry's owner, not a precedent for new code. +- Fix: move the fact into the registration's `inject` — plain data and callbacks, with bare observables in the reserved `hooks` compartment — or into a declared store, and read it through the derived seat. + +Injection binds the observable, and the component reads one derived hook whose subscription the renderer owns: + +```tsx +// The inject factory is the only place that closes over ctx. +const inject = () => ({ + hooks: { tab: tabStore.observable }, + action: { openTab }, +}) + +function TabView({ useTab, action }: Props) { + const { loaded } = useTab() // the renderer owns the subscription + return +} +``` + +The whole view store is injected for the component to pick fields from, and a module-level handle takes instance lifecycle away from the framework: + +```tsx +// The component receives the store itself and picks its own fields. +const inject = () => ({ viewStore }) + +function SidebarHeader({ viewStore }: Props) { + return +} + +// Module-level handle: one identity shared by every plugin load. +export const paneStore = createPaneStore() +``` + +The component's dependency face stops being auditable, and the shared module identity outlives every scope teardown: two panes end up on one instance instead of one each. + +## Identity and initial state are deliberate + +Switching state does not remount a control or replace its DOM node: the instance and its DOM identity stay the same across a state change, so focus, selection, and an in-progress draft survive it. Initial state is ready when the first flow runs — the same path prepares the data and establishes the state — instead of being filled in later by an effect. A component receives hooks, selectors, and action callbacks rather than a whole service. + +- Judged by: after a state change, whether the control instance and its DOM node keep their identity; an unmount followed by a mount is the violation. The initialization path is judged the same way: state established inside the first flow passes, state written from an effect fails. +- Dependency face: hooks read, selectors pick a sub-state, and action callbacks write back. A whole service passed as a prop or an injected member fails. +- Scope: input and command controls that hold an internal instance and local state, and business views created by a factory. +- Exception: a genuinely required remount — switching to a data set whose instance state cannot migrate — is explained in review together with the state migration plan; the reviewer of a change that affects a control's instance lifetime accepts it. +- Fix: derive the initial value where the first flow builds its state, keep the layout node mounted and vary its props, and replace a service prop with the three narrow shares. + +The draft exists with the session and the composer node stays mounted while only its props change: + +```tsx +// The draft is built by the first flow; nothing fills it in later. +const draft = useDraftState(sessionId) +const { canSend } = useComposerSelectors(draft) + +// The node stays mounted; state changes reach it as props. +return ( +
+ {draft.text} +
+) +``` + +Initial state arrives one commit late, and the keyed control is rebuilt whenever the session changes: + +```tsx +// The draft is filled in after the first commit. +useEffect(() => { setDraftState(initialDraft) }, []) + +// The keyed composer unmounts on every session switch. + +``` + +The first frame renders an empty draft, and every session switch drops focus, selection, and whatever the user had typed. + +## `useLayoutEffect` is not a default + +`useLayoutEffect` is not used by default, and replacing it with `useEffect` is not a fix, because the two differ in timing rather than in what the code is doing. A layout measurement first answers why it happens here, why CSS cannot express it, and why it must be a pixel measurement; only then does it move into an injected measurement hook with the owning domain expert's sign-off. An effect of either kind never records state or binds logic, because that belongs in Injection. + +- Judged by purpose, not by hook name: which measurement or which binding the effect exists for. A state-recording or logic-binding effect returns to Injection as a Provider or an injected hook; only a measurement that survived the three questions stays, and its result is consumed as a declarative layout parameter. +- Not covered: the timing difference between the two effects, which `@types/react` documents for `useLayoutEffect` as synchronous after DOM mutation and before paint, is background fact and not a license to rewrite one as the other. +- Exception: any retained `useLayoutEffect` needs the owning domain expert's confirmation, and the change records that confirmation. +- Fix: express the layout in CSS — grid, `aspect-ratio`, a container query — or collect the measurement inside the injected hook and expose one value. + +Layout is declared in CSS, and the one measurement that survived the questions lives in an injected hook: + +```tsx +// Fix 1 — express the layout in CSS and delete the measurement. +// .card { display: grid; grid-template-columns: minmax(0, 1fr) auto; aspect-ratio: 16 / 9; } + +// Fix 2 — the approved measurement is injected and returns one value. +function CardBase({ useLayoutMeasurements }: Props) { + const ref = useRef(null) + const { gutterWidth } = useLayoutMeasurements(ref) + return
+} +``` + +Both of the first two effects below answer the wrong question — which effect to use — and the third parks a binding in an effect: + +```tsx +// A synchronous pixel measurement the design did not need. +useLayoutEffect(() => { setHeight(ref.current.getBoundingClientRect().height) }, []) + +// The same measurement with the hook name swapped. +useEffect(() => { setHeight(ref.current.getBoundingClientRect().height) }, []) + +// Logic binding parked in an effect instead of Injection. +useEffect(() => { bindShortcut(handler) }, []) +``` + +The rename leaves the layout problem in place, the measured height now depends on which effect was chosen, and the shortcut binds one render late instead of through Injection. + +## Effects never push render-derived data to a parent + +An effect body never calls a setter that arrived through props or slot props to push data back to a parent when that data could be decided during render from props or state the component already has. Such a push costs a second render, leaves the parent without the value on its first frame, and turns any later state change into a render cascade. The value is decided during render, the host takes it through a render-time channel, or a user gesture updates its own state in its handler. + +- Judged by: a setter from props or slot props called inside an effect, whose argument derives from the current props or state, with no external event or subscription callback driving it. A cleanup `return () => setX(undefined)` is part of the same violation. +- Consequences to name in review: the extra render pass; the parent's first frame missing the value; a dependency array that goes stale when a member is omitted; the effect → parent state → render cascade; and a child lifecycle entangled with parent state. +- Not covered: an effect that reacts to a genuine external event — a subscription callback, a socket frame, a user gesture — is not this pattern, and an event handler may update state directly. +- Exception: none by default; any exception needs the owning domain expert's confirmation. +- Fix, in order: render the UI where the state already is; if the host must render it, hand the value over during render; if the state is shared across entries, move it to the inject face or a declared store. + +The control is rendered where its state already lives, and the shared variant is read during render while the write stays in the gesture handler: + +```tsx +// 1. Render it where the state already lives; no channel is needed. +return + +// 2. The host renders it: the flag is declared state, read during render +// and written only from the gesture handler. +const { showOriginal } = useStore() +const { toggleOriginal } = actions +return +``` + +`label` and `disabled` are render-derivable, so calling the parent's setter from an effect makes the parent render twice and still lack the header on its first frame: + +```tsx +useEffect(() => { + setHeaderAction({ label, disabled, onClick }) // render-derivable data pushed up + return () => { setHeaderAction(undefined) } +}, [label, disabled, setHeaderAction]) +``` + +The parent renders twice for data it could have had on the first pass, the header appears a frame late or flashes, and the hand-written dependency list decides when it is published. + +## Build and style follow their owners + +Every build adjustment for a client package goes through the tsdown plugin for that package, and a package does not grow its own build branch. Styling goes through CSS Modules, and only images, GIFs, and SVG are inlined — inlined into CSS rather than into JSX. Large text never appears inline in TSX, and a `style` object holds only runtime-computed values, because static values and style variants belong to CSS Module class names. + +- Judged by: a new build branch inside a package instead of the tsdown plugin; a literal color, a component library, or Tailwind in feature styling; base64 data or large text in TSX; and a `style` object carrying static values or a variant switch. +- Not covered: a runtime-computed value such as a measured gutter or a progress percentage may enter `style`, and a component-local custom property may carry a layout value. [Component rules](../web-styling.md#component-rules) own the tokens, CSS Module, and class-name rules. +- Exception: a new build branch or a new inline form is explained in review before it lands, and the owning package's rules decide whether it stays. +- Fix: move the variant into a CSS Module class switched by `className`, keep base64 in CSS produced by the build chain, and delete the large text constant from the TSX module. + +The component switches class names and data attributes, and the CSS Module owns the presentation: + +```tsx +import s from './card.module.css' + +// Style variants are class names, not style-object branches. +export function Card({ active, tone, children }: Props) { + return ( +
+ {children} +
+ ) +} +``` + +Large text and inlined binary data enter the module, and the `style` object encodes a variant the theme cannot override: + +```tsx +const HUGE = '...several KB of text...' // large text inline in the module + +// A variant switch and an inlined asset both sit in the style object. +
+``` + +The bundle carries text and binary data that belong in CSS and assets, diffs carry unreadable blobs, and a theme override can no longer reach that colour. + +## A key states what it identifies + +Pass a `key` only when it names which entity or instance an element represents, and state that identity — a `turnId`, for example, is unique inside one session. Most lists already have a stable business id and need no extra key. A key invented to silence a warning or to encode render order states an identity the data does not have, and an array index is never that identity, because an insert or delete shifts every following item onto a different element. + +- Judged by: whether the item's identity can be expressed by position or by a stable id. When a key is passed, the change states what makes it unique, and `key={index}` over a mutable list fails. +- Not covered: React's own requirement that a list's items carry keys still applies; that key is the item's stable business id and not a second key beside it. +- Exception: none. An ordinary change needs no sign-off, while the key semantics of a shared component are stated in review. +- Fix: use the entity's stable id, and when two rows genuinely share one id, fix the id source instead of mixing an index into the key. + +The key is the turn's own id, whose uniqueness inside a session is the identity the element represents: + +```tsx +// turnId is unique inside a session; the id is the identity. +export function TurnList({ turns }: Props) { + return ( +
    + {turns.map((turn) => )} +
+ ) +} +``` + +The index stands in for identity, so every following row changes identity when the list changes: + +```tsx +// The index stands in for identity. +{items.map((item, index) => ( + +))} +``` + +After an insert or a delete, each following row keeps its previous state on new data, so an expanded row or an in-progress edit appears on the wrong item. + +## Each shared layer keeps its own isolation level + +A feature absorbs its requirement in its own package, and how much separation it must keep from shared code depends on which shared layer that code belongs to. The violation to look for is a feature-specific concept placed in a shared layer, because every other consumer then has to understand a concept that is not theirs and the branch is hard to withdraw. + +| Shared code | Isolation a feature keeps | +|---|---| +| `ui-primitives`, the catalogued controls | Consume the catalogued control. A new control starts in the feature package and is promoted into the shared catalog only when a second consumer needs it. | +| `ui-layout` and the shell layout | Consume it; adapt in the feature package with a wrapper, an overridden style, or an added layer. | +| `ui-renderer` and the other render machinery | No value dependency from business code; extend only through the documented registration seams — slots, provide/inject-bound hooks, declared stores. | +| Another feature plugin | No runtime import and no re-export of its values. UI crosses through slots and behavior through injected services; shared runtime code lives in a narrow static owner such as `client/store`, `ui-primitives`, or a browser-safe utility package. | +| The data object layer (`client/connection`, `session-controller/client`, `client/store`) | Read through the documented surface; a feature's own concepts do not enter these objects or their stores. | +| The feature's own package | Unrestricted — the requirement is absorbed here, including its own rows, ordering, and presentation. | + +- Judged by: whether the diff puts a feature-specific branch, field, or concept into any layer but the feature's own package. +- Not covered: code the feature owns, and a change to a shared layer that is genuinely general rather than one feature's need. +- Exception: a shared-layer change carries the owning domain expert's confirmation and regresses every consumer; a GUI change runs the minimum check in [packages/client/AGENTS.md](../../packages/client/AGENTS.md). +- Fix: absorb the requirement in the feature package, or, when the need is genuinely general, promote it to the shared layer in its own change with the owner's sign-off. + +The feature owns its row rendering and its ordering rule, and the base component is consumed unchanged: + +```tsx +// The feature owns its rows; the base is consumed, not edited. +// packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +export function WorkspaceBrowser({ rows, onArchive }: Props) { + const ordered = [...rows].sort((a, b) => Number(b.archived) - Number(a.archived)) + return ( +
    + {ordered.map((row) => )} +
+ ) +} +``` + +A base component learns one feature's ordering rule, so every consumer inherits a concept that is not theirs: + +```tsx +// ui-layout: the base learns one feature's ordering rule. +export function BrowserList({ rows, ...rest }: Props) { + const ordered = [...rows].sort((a, b) => Number(b.archived) - Number(a.archived)) + return
    {ordered.map((row) => )}
+} +``` + +Every other consumer of `ui-layout` now has to understand an archive concept that only one feature has, and withdrawing the branch means auditing all of them. diff --git a/docs/defensive-patterns/client-components.zh.md b/docs/defensive-patterns/client-components.zh.md new file mode 100644 index 0000000000..6bd92e4e57 --- /dev/null +++ b/docs/defensive-patterns/client-components.zh.md @@ -0,0 +1,495 @@ +# 客户端组件规则 + +[English](client-components.md) | 中文 + +关于客户端组件、其状态与 effect 的规则。详细纪律见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md);Slot 设计正家是 [slots.zh.md](../subsystems/slots.zh.md),样式规则正家是 [web-styling.zh.md](../web-styling.zh.md)。以下每一节给出要求、评审判定方式、边界与例外,以及一正一反两个例子。 + +## 状态经 Injection 到达 + +Slot 的组件经**自己注册项**的 inject 面获得它渲染所需的一切——普通数据、action 回调,以及渲染器绑定成 `use` hook 的保留 `hooks` 隔间——而父组件的渲染点经派生的 `renderSlot` 传 owner 数据。它绝不先在顶层读一棵大全局状态再逐层手工传下去。把每个活值都放在那个面上,props 才能在没变时保持引用不变,组件也才能只订阅自己读取的那部分子状态。缺了这条,任何一个无关字段变化都会重渲整棵子树——长会话在流式事件下的掉帧、滚动与输入卡顿就是这么来的。 + +- 判定方式:在注册项与其渲染点上,作者都必须能回答两件事——被渲染组件订阅了哪些子状态,以及它收到的哪些 props 在多次渲染之间保持引用稳定。要盯住每个 prop 的可变性:每次渲染都重建的值(新对象、新数组、内联回调)即使内容没变也会向下传播重渲。 +- 不在范围内:组件自己的局部状态(折叠、悬停、草稿用的 `useState`)不属于注入内容;不订阅 React 之外任何东西的组件也不需要新席位。 +- 例外:新增 hook 席位会改动 Slot 契约,因此该改动要在 review 中说明影响面——哪些组件订阅什么;使用既有注入面不需要逐组件批准。 +- 修法:把注入收窄到该视图真正渲染的内容,每个读取项经一个 hook 席位暴露一个字段,用窄 action 集合取代 store 或 service 句柄。[组件输入](../subsystems/slots.zh.md#component-inputs) 正家负责 inject 工厂、标准 hook 席位,以及它们各自如何成为 prop。 + +注册项承载普通数据、一组窄 action 与一个 hook 席位;渲染点只传 owner 数据,组件只读它渲染的那个字段: + +```tsx +// The inject factory returns plain data and callbacks; hooks is the reserved compartment. +ctx.slots.register( + { + name: 'sidebar.right', + inject: () => ({ + rename, + hooks: { info: infoSource }, // bound by the renderer as useInfo() + }), + }, + SidebarRight, +) + +// The parent's render site passes owner data through the derived renderSlot. +{renderSlot('sidebar.right', { id })} + +function SidebarRight({ id, useInfo, rename }: Props) { + const { title } = useInfo() // subscribes to title only + return +} +``` + +顶层组件读了整个 view store 并把它作为一个 prop 传下去,于是渲染点再也回答不了上面两个问题: + +```tsx +function Sidebar() { + const view = useViewStore() // one top-level global read + return // the whole store travels down +} + +function SidebarRight({ view }: { view: ViewStore }) { + return {view.activePanel.title} +} +``` + +该 store 里任何字段变化都会重渲整个侧栏子树,而且绑定它的渲染点完全看不到这个组件订阅了什么。 + +## props 承载数据与回调,不承载 React 节点 + +prop 是数据或回调;React 节点经声明的 slot 传递。把元素作为 prop 值或注入成员传下去,会藏起一条 Slot 契约没有描述的第二条组合路径,把渲染控制权从注册面挪到调用点,并让一个包去拼装另一个包的标记。新增的节点型 prop 或注入成员一律禁止;需要宿主提供标记的位置,改为声明一个 child slot。 + +- 判定方式:组件的 props 类型、inject 工厂的返回值、或渲染点的 owner props 对象里出现 `ReactNode`、`ReactElement` 或 `() => JSX.Element`。 +- 不在范围内:已经发布的节点型字段继续可用。规则在改动波及它时触发,该改动把它改掉,而不是继续扩展它。 +- 例外:同一改动内无法完成改造时,由对应领域专家确认延期,且改动中记录该确认;作者不得自行确认。 +- 修法:在父级注册的 `children` 中声明该位置,用 `renderSlot` 渲染它,数据经子组件的 props 传入。[扩展规则](../subsystems/slots.zh.md#extension-rules) 正家负责哪个包可以声明和渲染哪个 slot。 + +头部位置是一个声明的 child slot,因此宿主只传数据,标记由注册组件自己决定: + +```tsx +// The card declares the header location; the host passes data only. +ctx.slots.register( + { + name: 'tool.call.toolview', + children: { header: { kind: 'single', scope: 'session' } }, + }, + ToolCard, +) + +function ToolCard({ title, renderSlot }: Props) { + return
{renderSlot('header', { title })}
+} +``` + +React 节点作为 prop 值越过边界,于是它所走的组合路径没有任何地方声明过: + +```tsx +type ToolCardProps = { + header?: React.ReactNode + footer?: React.ReactNode +} + +// The call site assembles another package's markup. +} /> +``` + +卡片的 Slot 契约不再描述它渲染什么,调用点继续握着渲染控制权,而徽章所在的包变成了传节点方的运行时引用。 + +## slot 注册物是稳定的 function component + +注册进 slot 的值是在模块作用域声明的稳定 function component。渲染期创建的组件函数每一轮都是新的组件类型,而 React 把变化的类型当作另一个组件:它卸载这棵子树并挂载一棵新的,展开的行、输入焦点与滚动位置随之重置。注册期参数经 props 到达组件,而不是靠闭包捕获渲染期变量。 + +- 判定方式:注册物在哪里创建。注册调用或渲染调用里的内联箭头、渲染函数体内定义的组件、以及用 `useCallback` 包住内联组件的写法都不合格;模块作用域的 `function` 声明合格。 +- 不在范围内:本条约束的是注册物的身份,不是它的复杂度。稳定组件照样可以读取注入的 props、hooks 与回调。 +- 例外:只有调用方确实想要这次重挂载时,渲染期创建组件才是对的,普通改动无需单独批准;Slot 契约形态的变更按「状态经 Injection 到达」一节描述的注入面 review 走。 +- 修法:把组件移到模块作用域,用注册面的各个 share 给它的 props 定型,让变化的值经 inject 面传入而不是走闭包。 + +注册项持有对模块作用域组件的稳定引用,变化的值经注入到达;局部组件由渲染点传入: + +```tsx +function PanelTitle({ title, count }: Props) { + return {title} ({count}) +} + +// Registration: the local slot's scope is declared, never a component. +ctx.slots.registerFactory( + { + name: 'panel', + scope: 'root', + slots: { title: { scope: 'root' } }, + inject: () => ({ title: 'Sessions', count: 3 }), + }, + Panel, +) + +// The render site passes the local component, keeping its identity stable. +renderFactorySlot('panel', { /* owner data */ }, { slots: { title: PanelTitle } }) +``` + +渲染调用里的内联箭头会在该次渲染运行时产生新的组件类型: + +```tsx +// A new component type each time this render runs. +renderFactorySlot('panel', { /* owner data */ }, { slots: { title: (p) => {p.title} } }) +``` + +React 看到的类型与上一次注册不同,于是重挂载该席位,并在一次无关更新中重置它的局部状态——展开态、焦点或滚动位置。 + +## 组件绝不接收 Cordis context + +`ctx` 属于 apply 世界:插件主体与闭包持有它的 `inject` 工厂。组件不接收 Cordis context、不导入 service 类去戳它、也不读 React context,因为业务组件看不到任何 context——渲染器的 binding context 是它内部的。裸 observable 只有在 Injection 把它包成 hook 之后才到达组件;直接自行订阅的组件持有一份 Injection 面上无人释放的生命周期。 + +- 判定方式:组件的参数或 props 类型里出现 `ctx` 或 `Context`;组件体内读 `observable.value`、调用 `subscribe`、或接 `useSyncExternalStore`;feature 的 `.tsx` 模块里导入 service 类。 +- 不在范围内:组件内部、不订阅 React 之外任何东西的行为 hook(计时器、媒体查询)不是注入内容,留在组件里。 +- 例外:无。`TabSlot` 里现存的直接订阅是该席位 owner 的整改工作,不是新代码的先例。 +- 修法:把该事实移进注册的 `inject`——纯数据与回调,裸 observable 放进保留的 `hooks` 隔间——或移进声明的 store,再经派生的席位读取。 + +Injection 绑定 observable,组件读一个派生的 hook,订阅生命周期由渲染器持有: + +```tsx +// The inject factory is the only place that closes over ctx. +const inject = () => ({ + hooks: { tab: tabStore.observable }, + action: { openTab }, +}) + +function TabView({ useTab, action }: Props) { + const { loaded } = useTab() // the renderer owns the subscription + return +} +``` + +整个 view store 被注入给组件自行挑选字段,模块级句柄则把实例生命周期从框架手里拿走: + +```tsx +// The component receives the store itself and picks its own fields. +const inject = () => ({ viewStore }) + +function SidebarHeader({ viewStore }: Props) { + return +} + +// Module-level handle: one identity shared by every plugin load. +export const paneStore = createPaneStore() +``` + +组件的依赖面不再可审计,而共享的模块身份活得比任何一次 scope 清理都久:两个 pane 会落到同一个实例上,而不是各自一个。 + +## 身份与初始状态都是刻意的 + +状态切换不重建控件、也不替换其 DOM 节点:实例与其 DOM 身份在状态变化之间保持同一,焦点、选中与进行中的草稿因此得以保留。初始状态在首个流程运行时即就绪——同一条路径既准备数据也建立状态——而不是之后由 effect 回填。组件接收 hooks、选择器与 action 回调,而不是整个 service。 + +- 判定方式:状态变化之后,控件实例与其 DOM 节点是否保持同一身份;出现卸载再挂载即违反。初始化路径同样判定:状态在首个流程内建立合格,从 effect 写入不合格。 +- 依赖面:hooks 读取、选择器选中子状态、action 回调写回。整个 service 作为 prop 或注入成员传入即不合格。 +- 范围:带内部实例与本地状态的输入与命令控件,以及由 factory 创建的业务视图。 +- 例外:确需重挂载(切换的数据集其实例状态不可迁移)时,在 review 中连同状态迁移方案一并说明;由评审该控件实例生命周期改动的评审者接受。 +- 修法:在首个流程建立状态的地方派生初始值,保持布局节点挂载、只变化其 props,并用三个窄 share 取代 service prop。 + +草稿随会话一起建立,composer 节点保持挂载,只有 props 随状态变化: + +```tsx +// The draft is built by the first flow; nothing fills it in later. +const draft = useDraftState(sessionId) +const { canSend } = useComposerSelectors(draft) + +// The node stays mounted; state changes reach it as props. +return ( +
+ {draft.text} +
+) +``` + +初始状态迟一个提交到达,带 key 的控件在每次会话切换时被重建: + +```tsx +// The draft is filled in after the first commit. +useEffect(() => { setDraftState(initialDraft) }, []) + +// The keyed composer unmounts on every session switch. + +``` + +首帧渲染出空草稿,每次会话切换都会丢掉焦点、选中,以及用户已经输入的内容。 + +## `useLayoutEffect` 不是默认选项 + +`useLayoutEffect` 默认不用,换成 `useEffect` 也不算修好,因为二者的差别在时序,而不在代码在做什么。布局测量先回答三个问题——为何在此处发生、为何 CSS 无法表达、为何必须是像素测量;只有回答完,才收进注入的测量 hook 并取得对应领域专家的确认。两类 effect 都不承担状态记录或逻辑绑定,那是 Injection 的职责。 + +- 判定依据是用途而不是 hook 名字:这个 effect 为哪次测量或哪次绑定而存在。状态记录或逻辑绑定的 effect 回到 Injection——Provider 或注入 hook;只有通过三问的测量才留下,其结果作为声明式布局参数消费。 +- 不在范围内:两个 effect 的时序差异(`@types/react` 记载 `useLayoutEffect` 在 DOM 变更后同步触发、绘制前刷出)是背景事实,不是把其一改写为另一的许可。 +- 例外:任何保留下来的 `useLayoutEffect` 都要经对应领域专家确认,改动中记录该确认。 +- 修法:用 CSS 表达布局——grid、`aspect-ratio`、容器查询——或把测量收进注入 hook 并暴露一个值。 + +布局用 CSS 声明,唯一通过三问的测量住在注入 hook 里: + +```tsx +// Fix 1 — express the layout in CSS and delete the measurement. +// .card { display: grid; grid-template-columns: minmax(0, 1fr) auto; aspect-ratio: 16 / 9; } + +// Fix 2 — the approved measurement is injected and returns one value. +function CardBase({ useLayoutMeasurements }: Props) { + const ref = useRef(null) + const { gutterWidth } = useLayoutMeasurements(ref) + return
+} +``` + +下面前两个 effect 回答的是错的问题——该用哪个 effect——第三个把绑定停在 effect 里: + +```tsx +// A synchronous pixel measurement the design did not need. +useLayoutEffect(() => { setHeight(ref.current.getBoundingClientRect().height) }, []) + +// The same measurement with the hook name swapped. +useEffect(() => { setHeight(ref.current.getBoundingClientRect().height) }, []) + +// Logic binding parked in an effect instead of Injection. +useEffect(() => { bindShortcut(handler) }, []) +``` + +改名并不能解决布局问题,量到的高度从此取决于选了哪个 effect,而快捷键绑定迟一轮渲染生效,没有回到 Injection。 + +## effect 绝不把渲染期可派生的数据推给父层 + +effect 体内绝不调用经 props 或 slot props 传来的 setter,把本可在渲染期由组件已有的 props / state 决定的数据推回父层。这种推送要多付一轮渲染、让父层首帧拿不到值,并把之后任何一次状态抖动放大成渲染级联。该值在渲染期决定,宿主经渲染期通道取得,或由用户手势在其处理器内更新自己的状态。 + +- 判定方式:effect 体内调用了 props 或 slot props 传来的 setter,其入参由当前 props / state 派生,且没有外部事件或订阅回调驱动它。清理函数 `return () => setX(undefined)` 属于同一违规。 +- review 中要点名的后果:多一轮渲染;父层首帧缺值;依赖数组漏项即 stale;effect → 父 state → 再渲染的级联;子组件生命周期与父层状态纠缠。 +- 不在范围内:响应真实外部事件的 effect——订阅回调、socket 帧、用户手势——不是本形态;事件处理器可以直接更新状态。 +- 例外:默认无例外;任何例外都要经对应领域专家确认。 +- 修法按顺序:状态在哪里就让 UI 在哪里渲染;必须由宿主渲染时,在渲染期把值交出去;跨条目共享的状态移进注入面或声明的 store。 + +控件在状态所在处渲染,共享的开关在渲染期读取,写回只发生在手势处理器里: + +```tsx +// 1. Render it where the state already lives; no channel is needed. +return + +// 2. The host renders it: the flag is declared state, read during render +// and written only from the gesture handler. +const { showOriginal } = useStore() +const { toggleOriginal } = actions +return +``` + +`label` 与 `disabled` 都是渲染期可派生的,从 effect 调用父层 setter 让父层渲染两轮、首帧仍然没有头部动作: + +```tsx +useEffect(() => { + setHeaderAction({ label, disabled, onClick }) // render-derivable data pushed up + return () => { setHeaderAction(undefined) } +}, [label, disabled, setHeaderAction]) +``` + +父层为本来第一轮就能拿到的数据渲染两轮,头部迟一帧出现或闪烁,而发布时间由手写的依赖数组决定。 + +## 构建与样式各归其主 + +客户端包的每项构建调整都经该包的 tsdown 插件完成,包内不新开自己的构建分支。样式走 CSS Module,只有图像、GIF 与 SVG 需要内联——且内联到 CSS 而不是 JSX。TSX 里绝不内联大文本,`style` 对象只放运行期计算的值,因为静态值与多样式切换属于 CSS Module 的 class name。 + +- 判定方式:包内新开构建分支而不是走 tsdown 插件;feature 样式里出现字面颜色、组件库或 Tailwind;TSX 里出现 base64 数据或大文本;`style` 对象承载静态值或样式切换。 +- 不在范围内:运行期计算的值(量到的 gutter、进度百分比)可以进 `style`,组件局部自定义属性也可以承载布局值。[组件规则](../web-styling.zh.md#component-rules) 正家负责 token、CSS Module 与 class name 规则。 +- 例外:新增构建分支或新的内联形态在落地前于 review 中说明理由,由所属包的规则决定是否保留。 +- 修法:把样式切换移进用 `className` 切换的 CSS Module class,base64 交给构建链写进 CSS,并从 TSX 模块里删掉大文本常量。 + +组件切换 class name 与 data 属性,呈现由 CSS Module 负责: + +```tsx +import s from './card.module.css' + +// Style variants are class names, not style-object branches. +export function Card({ active, tone, children }: Props) { + return ( +
+ {children} +
+ ) +} +``` + +大文本与内联二进制数据进了模块,`style` 对象还编码了主题无法覆盖的样式切换: + +```tsx +const HUGE = '...several KB of text...' // large text inline in the module + +// A variant switch and an inlined asset both sit in the style object. +
+``` + +包里带上了本该属于 CSS 与资源的文本和二进制数据,diff 里躺着无法阅读的 blob,主题覆盖也再够不到那个颜色。 + +## 传 key 就要说明它标识什么 + +只有当 `key` 指明元素代表哪个实体或实例时才传它,并把这份身份说清楚——例如 `turnId` 在一个 session 内唯一。多数列表本就有稳定的业务 id,不需要额外的 key。为消除警告或编码渲染顺序而造的 key,声明的是一份数据并不具备的身份;数组下标从来不是这份身份,因为插入或删除会把后面每一项挪到另一个元素上。 + +- 判定方式:条目的身份能否由位置或稳定 id 表达。传 key 时,改动要说明它因何唯一,而可变列表上的 `key={index}` 不合格。 +- 不在范围内:React 要求列表项带 key 这条规则依然成立;那个 key 就是条目的稳定业务 id,不是它旁边的第二个 key。 +- 例外:无。普通改动无需批准,公共组件的 key 语义要在 review 中说明。 +- 修法:使用实体的稳定 id;两行确实共用一个 id 时,修 id 的来源,而不是往 key 里掺下标。 + +key 就是 turn 自己的 id,它在 session 内唯一,正是元素所代表的身份: + +```tsx +// turnId is unique inside a session; the id is the identity. +export function TurnList({ turns }: Props) { + return ( +
    + {turns.map((turn) => )} +
+ ) +} +``` + +下标冒充身份,于是列表一变,后面每一行的身份都跟着变: + +```tsx +// The index stands in for identity. +{items.map((item, index) => ( + +))} +``` + +插入或删除之后,后面每一行都会把先前的状态带到新数据上,展开的行或编辑中的内容会出现在错误的条目上。 + +## 每层共享代码各有自己的隔离度 + +功能把诉求吸收在自己的包里;它需要与共享代码保持多大隔离,取决于那段代码属于哪一层共享。要盯的违规形态是把业务专属概念放进共享层,因为此后每个使用方都得理解一个不属于自己的概念,而这个分支很难撤回。 + +| 共享代码 | 功能应保持的隔离度 | +|---|---| +| `ui-primitives`,目录中的控件 | 消费目录中的控件。新控件先在功能包内落地,只有出现第二个使用方时才提升进共享目录。 | +| `ui-layout` 与 shell 布局 | 消费它;在功能包内通过 wrapper、覆盖样式或加一层来适配。 | +| `ui-renderer` 及其他渲染机制 | 业务代码不产生值依赖;只经文档化的注册面扩展——slots、provide/inject 绑定的 hooks、声明的 store。 | +| 另一个功能插件 | 不运行时导入、也不重新导出它的值。UI 经 slots 跨越,行为经注入的服务跨越;共享运行时代码住在窄的静态正家,如 `client/store`、`ui-primitives` 或浏览器安全的工具包。 | +| 数据对象层(`client/connection`、`session-controller/client`、`client/store`) | 经文档化接口读取;功能自己的概念不进入这些对象及其 store。 | +| 功能自己的包 | 不受限——诉求在这里被吸收,包括它自己的行、排序与呈现。 | + +- 判定方式:diff 是否把业务专属的分支、字段或概念放进了功能自己包以外的任何一层。 +- 不在范围内:功能自己拥有的代码;以及确实属于通用需求、而不是某一个功能需要的共享层改动。 +- 例外:共享层的改动要带上所属领域专家的确认,并回归所有使用方;GUI 改动按 [packages/client/AGENTS.md](../../packages/client/AGENTS.md) 的最低检查自验。 +- 修法:把诉求吸收进功能包;确属通用需求时,在独立改动中连同 owner 的确认一起提升到共享层。 + +功能自己拥有行渲染与排序规则,基座组件按原样消费: + +```tsx +// The feature owns its rows; the base is consumed, not edited. +// packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +export function WorkspaceBrowser({ rows, onArchive }: Props) { + const ordered = [...rows].sort((a, b) => Number(b.archived) - Number(a.archived)) + return ( +
    + {ordered.map((row) => )} +
+ ) +} +``` + +基座组件学会某一个功能的排序规则,于是每个使用方都继承了一个不属于自己的概念: + +```tsx +// ui-layout: the base learns one feature's ordering rule. +export function BrowserList({ rows, ...rest }: Props) { + const ordered = [...rows].sort((a, b) => Number(b.archived) - Number(a.archived)) + return
    {ordered.map((row) => )}
+} +``` + +`ui-layout` 的每个其他使用方从此都要理解只有那一个功能才有的归档概念,撤回该分支意味着把它们全部审一遍。 diff --git a/docs/defensive-patterns/conversation-and-session.i18n.yaml b/docs/defensive-patterns/conversation-and-session.i18n.yaml new file mode 100644 index 0000000000..210cfe8b06 --- /dev/null +++ b/docs/defensive-patterns/conversation-and-session.i18n.yaml @@ -0,0 +1,31 @@ +# Bilingual-pair consistency record for conversation-and-session.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/defensive-patterns/conversation-and-session.md +/conversation-and-session-rules: + en: 499609ba1c6822d8 + zh: 7429eabcce7d7c14 +/conversation-and-session-rules/projection-is-the-only-read-and-compute-path: + en: 9fa9a332c93ba386 + zh: ce01ccb47dfa5df7 +/conversation-and-session-rules/cold-reads-are-the-last-resort: + en: 32991da738e47f7f + zh: b138910f8c16d103 +/conversation-and-session-rules/retention-follows-one-lifecycle-rule: + en: fcb40a31d0881e84 + zh: 779bc49cce81651e +/conversation-and-session-rules/node-computation-is-incremental-and-streaming-publication-is-batched: + en: 315c7ac2186d0721 + zh: 06bb4e072b242fe1 +/conversation-and-session-rules/the-node-build-and-grouping-engine-carries-no-business-rule: + en: ebfa96408f32c3c1 + zh: fb854b2b3e37aa9b +/conversation-and-session-rules/the-node-list-and-seat-components-carry-no-business-branch: + en: f69c4c16f472adcc + zh: 4570a62aa3d934fb +/conversation-and-session-rules/the-markdown-pipeline-carries-no-business-branch: + en: b124a7515404ed9e + zh: 4b544c2d1bbb6e76 +/conversation-and-session-rules/the-user-message-model-and-rendering-carry-no-business-field: + en: 8f97bbeaba14be0d + zh: cfad05711395cbd2 diff --git a/docs/defensive-patterns/conversation-and-session.md b/docs/defensive-patterns/conversation-and-session.md new file mode 100644 index 0000000000..7b91d6a676 --- /dev/null +++ b/docs/defensive-patterns/conversation-and-session.md @@ -0,0 +1,316 @@ +# Conversation and Session rules + +English | [中文](conversation-and-session.zh.md) + +Rules for reading a Session, holding one, computing conversation nodes, and rendering them. The data model and per-type semantics live in [session-projection.md](../subsystems/session-projection.md) and [conversation.md](../subsystems/conversation.md). + +## Projection is the only read and compute path + +Session data is read and computed through Projection. A client reads a projection value instead of pulling the Session log, and a computation that belongs to the Session runs as a registered projection unit that publishes its result instead of being recomputed on demand. Two answers to the same question — one pulled, one pushed — drift apart, so a render can show a value the Session log no longer supports. + +- Judged condition: every Session-derived value a client renders arrives through a projection read at the render position, and every new in-Session computation is registered as a unit whose result reaches clients as a published value. A component that fetches Session data itself, or a computation a reader triggers on demand, is the violation. +- State and View: a unit folds host-side State and declares the client-visible View over it, and the registration states that division. The `ProjectionDefinition` fields and the registry that drives them belong to [session-projection.md](../subsystems/session-projection.md#ctxsessionprojections--sessionprojectionregistry). +- Version: `stateVersion` starts at 1 and is bumped whenever the serialized state fields or the fold semantics change, compatible or not. Prefer a compatible change where one exists, but still bump; registering one key twice with different `stateVersion` values throws instead of merging. +- Compute strategy: units compute in full by default. Narrowing what a unit computes needs the owning domain expert's confirmation, never the implementer's own decision. +- Boundary: a partial projection view may serve interface availability for individual keys, such as a cached block drawn before the connected Session answers. It never decides an externally visible result, which waits for the standard projection value. + +A component that reads at its render position, and a computation that registers instead of being pulled, both comply: + +```tsx +// The client reads the published value; it never fetches Session data itself. +function PlanChip({ useProjection }: PlanChipProps) { + const plan = useProjection('plan') + return plan === undefined ? null : +} + +// The computation is a registered unit; the registry drives it and publishes changes. +const planDefinition: ProjectionDefinition<'plan'> = { + key: 'plan', + stateVersion: 1, + init: () => emptyPlan, + apply: (state, event) => foldPlan(state, event), + wire: { viewSchema: planSchema, view: planView }, +} +``` + +A component that pulls the same value over its own request breaks the single read path: + +```tsx +function PlanChip() { + const [plan, setPlan] = useState() + useEffect(() => { + void fetchSessionPlan(sessionId).then(setPlan) // a second read path beside Projection + }, [sessionId]) + return plan === undefined ? null : +} +``` + +The pulled copy and the pushed value disagree, and no version check can tell which one the Session currently holds. + +## Cold reads are the last resort + +Read an available cache before the original log, and reach for a cold read only when no projection covers the data. The "small Session may be read in full" exception is removed and is not reconsidered. When cached and official data are not equivalent, a missing or stale row produces an explicit outcome instead of silently switching onto the slow path. + +- Judged condition: a Session read consults the projection cache first, and an absent row leaves an explicit state for the caller; a cache test whose `else` branch opens the complete log is the violation. +- Fallback semantics: the caller decides what a miss means and states it in the returned value. There is no cache-size or Session-size exception, and no approver for one. +- Boundary: this restriction is the client's. An absolute "never fork a cold read" on the host is not required, because the host may need to open a log the client cannot. + +The stored projection rows are read first, and a miss stays a state the caller renders: + +```ts ignore-check +// The listing reads stored projection rows; a miss remains an explicit state. +type ListingView = + | { kind: 'cached'; snapshot: ProjectionSnapshot } + | { kind: 'unknown' } + +function readListing(header: SessionHeader, keys: readonly ProjectionKey[]): ListingView { + const cached = ctx.sessionProjectionCache.cachedSnapshot(header, keys) + return cached === undefined ? { kind: 'unknown' } : { kind: 'cached', snapshot: cached } +} +// Opening the complete log is a separate operation the caller names, never this fallback. +``` + +A cache test that falls through to the complete log turns the slow path into the ordinary one: + +```ts ignore-check +function readListing(header: SessionHeader, session: Session): ListingView { + const cached = ctx.sessionProjectionCache.cachedSnapshot(header) + if (cached !== undefined) return { kind: 'cached', snapshot: cached } + return coldReadCompleteLog(session) // every miss pays the full-log read +} +``` + +Every miss now reads the whole log in the foreground, so a slow Session is slow on every listing rather than only on the path that asks for it. + +## Retention follows one lifecycle rule + +A client holds a Session through an explicit reference (`SessionReference`), and the reference count reaching zero releases it. Idle reclamation follows the one lifecycle rule — no references and nothing in flight — and never enumerates which Sessions may be reclaimed from business state, because that list has to be maintained by every feature that adds one. An implicit holder is invisible, so reclamation cannot tell who still uses the Session. + +- Judged condition: the predicate is `refCount == 0 && inflight == 0`. Does the code hold the Session across its acquire → use window through a reference that retain and release can be audited against? A module-level Session object or a global current id is the violation. +- Boundary: the enforced parts are the unified predicate and the absence of business-state enumeration; the release trigger itself is checked by hand, so hand-checking alone is not a rejection reason. +- Ownership: `retain`, `release`, `ready`, and teardown ordering belong to [the Session Controller's client references](../../packages/api/session-controller/README.md#client-references). A source-session mention carried in a message is a different type, owned by [session-reference.md](../subsystems/session-reference.md). + +A view that retains through an explicit reference and releases it on teardown complies: + +```ts ignore-check +class SidebarSessionView implements Disposable { + private readonly reference: SessionReference + + constructor(readonly id: SessionId, sessions: ISessions) { + this.reference = sessions.retain(id, { source: 'sidebarView' }) + void this.reference.ready.catch((error: unknown) => { console.error('opening failed:', error) }) + } + + [Symbol.dispose](): void { + this.reference.release() // the last reference releases the Session + } +} +``` + +Module state that keeps the Session object hides every holder from the reclaimer: + +```ts ignore-check +let currentSessionId: SessionId | undefined +const sessionCache = new Map() + +export function remember(session: Session): void { + currentSessionId = session.id + sessionCache.set(session.id, session) +} + +export function reclaimIdle(): void { + if (state === 'idle' || state === 'closed') reclaim() // a newly added state is missed +} +``` + +Nobody can count who still uses the Session, and the next state added to the Session escapes the enumeration and is never reclaimed. + +## Node computation is incremental and streaming publication is batched + +A new node computation states its complexity, and high-frequency streaming chunks never publish immediately and never recompute in full. A per-chunk full rebuild published at once puts the whole node collection on the frame budget, and the transcript stalls behind the stream. The append-path cost — with `D` registered Definitions, `D` matches and one constant-time Context-key lookup — and the publication vocabulary (`none`, `animation-frame`, `immediate`) are owned by [conversation.md](../subsystems/conversation.md#definition-and-typed-chat-payload). + +- Judged condition: how much one chunk computes — the increment on the affected subtree, not the event window — and when its result is published — a batching policy, not one publication per chunk. +- Boundary: this rule binds the implementation; cadence alone is not a rejection reason, so a change is judged on the cadence it states and the published path it uses. +- Forbidden on the high-frequency path: `immediate` publication, and a full recomputation per chunk. +- Boundary: a structural or terminal change may publish `immediate`, because the cadence is chosen per publication. Only the high-frequency delta path is bound to batching. + +A chunk that updates one Context and asks for a coalesced publication complies: + +```ts ignore-check +const assistantDefinition: ConversationNodeDefinition = { + kind: 'assistant-step', + target: 'chat', + match: event => matchAssistant(event), + start: (_context, match) => initialState(match), + update: (context, match) => appendChunk(context.state, match.event), // increment, not the window + publication: () => 'animation-frame', // high-frequency deltas coalesce; never immediate + buildViewNode: context => assistantNode(context), +} +``` + +Rebuilding the whole node collection for every chunk and publishing it immediately: + +```ts ignore-check +function onChunk(chunk: AssistantLiveChunk): void { + const nodes = buildNodesFromCompleteWindow(session) // full recompute per chunk + target.publish(nodes, { publication: 'immediate' }) // and an immediate publish +} +``` + +Each chunk costs a full-window rebuild inside the frame budget, so grouping and rendering stall behind the stream. + +## The node build and grouping engine carries no business rule + +Business rules never enter the grouping engine, and a business requirement never reshapes the built payload. `ProcessState`, the group product hierarchy, and the set of group semantics in `conversation-nodes/process-groups.ts` are infrastructure: the engine understands enum placement values such as grouped, standalone, and reply, and the caller computes which one applies. A special case inside the engine changes the produced structure for every consumer and puts a business condition on the group computation path. + +- Judged condition: does the engine contain a business condition? A diff that adds a `ProcessState` field, adds or removes a group level, or changes group semantics for one business fails; the placement decision belongs to the caller that computes the enum value. +- Boundary: the layering, its cost, and existing business expectations are the reason a business does not rework the grouping design. +- Boundary: a new enum value carrying a new placement meaning is the supported extension, wherever the caller computes it. +- Exception and approver: none by default. A change to this infrastructure needs the corresponding domain expert's confirmation. [conversation.md](../subsystems/conversation.md#group-definitions) owns the group Definition types. + +The caller decides the business meaning and passes the placement enum the engine places by: + +```ts ignore-check +// Business judgment stays in the caller. +function placementOf(command: CommandView): NodePlacement { + return isHandoff(command) ? 'reply' : 'grouped' +} + +// The engine consumes the enum as data; it reads no business condition. +function groupEntries(commands: readonly CommandView[]): readonly NodePlacement[] { + return commands.map(placementOf) +} +``` + +A business condition inside the engine's own computation: + +```ts ignore-check +// The grouping engine asks what a node means, not where it goes. +function placeProcessNode(node: ChatConversationViewNode): GroupPlacement { + if (node.kind === 'command' && isRagMessage(node)) return 'rag' // business special case + return 'grouped' +} +``` + +The produced structure now carries one business's meaning, so every consumer and the group computation follow that feature. + +## The node list and seat components carry no business branch + +`ChatNodeList`, `ChatNodeSeat`, and `ChatGroupSeat` are infrastructure. They carry no business condition and no business-specific field, and a single business never changes the list traversal or the seat selection. A new node shape contributes its kind to `ChatNodeDataMap` and its renderer through the kind registration surface, so the list keeps dispatching on kind while business semantics stay in the business module. A branch added for one feature makes every node's rendering depend on that feature. + +- Judged condition: a business branch in a list or seat component, a business-only field on its props, or a traversal or seat-selection change that serves one business. +- Correct outlet: the business package merges its own key into `ChatNodeDataMap` and registers a renderer for the node kind; the seat renders the registered component for the node's kind. The registration discipline is owned by [slots.md](../subsystems/slots.md#extension-rules). +- Boundary: a rendering change that applies to every business — ordering, virtualization, accessibility — is not a business modification. +- Exception and approver: none by default; the corresponding domain expert confirms any exception. + +A business package that declares its kind and registers its renderer leaves the shared list untouched: + +```tsx +// The business declares its kind and registers its renderer; the shared list is untouched. +declare module '@deepseek-ai/dsh-client-ui-chat/client' { + interface ChatNodeDataMap { handoff: HandoffData } +} + +const HandoffNodeView = memo(function HandoffNodeView({ node, t }: ChatNodeOwnerProps<'handoff'>) { + return +}) + +ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'handoff', locale: NS }, HandoffNodeView)) +``` + +A business branch and a business field inside the shared seat: + +```tsx +export const ChatNodeSeat = memo(function ChatNodeSeat({ node, handoffFlag }: ChatNodeSeatProps) { + if (node.kind === 'user' && handoffFlag) return // business dispatch + return
{renderByKind(node)}
+}) +``` + +One feature's condition now decides how every node renders, and the shared seat must change again for the next feature. + +## The Markdown pipeline carries no business branch + +`AssistantMarkdown` and its rendering pipeline are infrastructure: no business condition enters the Markdown render path, and no business rewrites the node mapping or the style injection rules. A presentation need registers a renderer on the existing chain or expresses itself in the data, and the official default content participates in that same registration surface rather than existing as an optional renderer with a host-side inline fallback. A pipeline that hosts business special cases can no longer be reviewed or optimized on its own. + +- Judged condition: a business branch inside `AssistantMarkdown` or its block mapping — for example `if (isXxxBusiness) renderXxx()` — or a style injection rule keyed on one business. +- Correct outlet: a renderer registered on the existing chain, or a semantic field in the data that the generic renderer reads. The Component Factory registration is owned by the [ui-renderer README](../../packages/client/ui-renderer/README.md). +- Wrapping: a wrapper that needs the standard rendering calls the official entry and keeps its fallback; it never re-renders the slot and never imports the private React component across packages. +- Responsibility: the host owns position and invocation; the plugin owns its own state, cleanup, and controls, and the official host carries no business action for a contribution. +- `labels`: an optional complete `MarkdownLabels` object is used whole, with no field-level merge against the default; omitting it uses the official default. `variant="compact"` is fixed by the Factory. +- Boundary: generic Markdown capability — syntax support, performance — is not a business modification. +- Exception and approver: none by default; the corresponding domain expert confirms any exception. + +A contribution that registers into the existing single seat, and reuses the published Factory instead of re-rendering the standard content: + +```tsx +// The business contributes its own body into the seat the host already renders. +ctx.slots.inject('conversation.chat.reasoning.body', () => ctx.slots.register( + { name: 'conversation.chat.reasoning.body', scope: 'session', locale: NS }, + TranslatedReasoningBody, +)) + +// The contribution keeps the standard rendering by calling the published Factory. +function TranslatedReasoningBody({ text, running, renderFactorySlot, t }: ReasoningBodyProps) { + return ( + <> +

{t('reasoning.translated')}

+ {renderFactorySlot('conversation.chat.reasoning.content', { text, running })} + + ) +} +``` + +A business branch inside the shared Markdown pipeline: + +```tsx +export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, ...props }: AssistantMarkdownProps) { + return <>{blocks.map(block => ( + isHandoffBlock(block) + ? // a business special case in the pipeline + : renderBlock(block, props) + ))} +}) +``` + +Every business special case lands in one shared pipeline, and Markdown rendering can no longer change or be measured without the businesses it now carries. + +## The user message model and rendering carry no business field + +`UserMessage` and the UI's `user` kind rendering are infrastructure: no business-only field on the message model, and no business branch in the user message renderer. A business difference travels in the message payload — its own merge-extensible source kind or a content field — or through a registered renderer. A field added for one business is handled by every consumer of the model, and the next business adds another branch to the same shared renderer. + +- Judged condition: a business-named field on `UserMessage`, or a business condition in the rendering of the `user` kind. +- Correct outlet: expression in the payload. `MessageSourceMap` is declaration-merge extensible, so a producer declares its own source kind instead of widening `UserMessage`; the message model is documented in [session.md](../subsystems/session.md). +- Boundary: a generic message capability — attachments, quotes, edit state — is not a business modification. +- Exception and approver: none by default; the corresponding domain expert confirms any exception. + +A producer that declares its own source kind keeps the business difference in the payload: + +```ts ignore-check +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + handoff: { kind: 'handoff'; sessionId: SessionId } + } +} + +const message = createUserMessage({ + role: 'user', + content: [{ type: 'text', text: prompt }], + source: { kind: 'handoff', sessionId: source.id }, +}) +``` + +A business field on the shared model, and the branch it forces into the shared renderer: + +```ts ignore-check +interface UserMessage extends MessageBase { + readonly role: 'user' + readonly handoffKind?: 'handoff' | 'rag' // one business's field on the shared model +} + +// ...and the renderer branches on it. +if (message.handoffKind === 'handoff') return +``` + +Every consumer of the message model handles one business's field, and the shared renderer grows a branch for each business that follows. diff --git a/docs/defensive-patterns/conversation-and-session.zh.md b/docs/defensive-patterns/conversation-and-session.zh.md new file mode 100644 index 0000000000..855fdd876f --- /dev/null +++ b/docs/defensive-patterns/conversation-and-session.zh.md @@ -0,0 +1,316 @@ +# 会话与 Session 规则 + +[English](conversation-and-session.md) | 中文 + +关于读取 Session、持有 Session、计算会话节点与渲染它们的规则。数据模型与各类型语义见 [session-projection.zh.md](../subsystems/session-projection.zh.md) 与 [conversation.zh.md](../subsystems/conversation.zh.md)。 + +## Projection 是唯一的读取与计算路径 + +Session 数据一律经 Projection 读取与计算。客户端读投影值,而不是拉 Session 日志;属于该会话的计算以注册单元的形式运行并**推送**结果,而不是按需重算。同一个问题若有两个答案——一个拉取、一个推送——两者就会分叉,渲染可能显示出 Session 日志已不再支持的值。 + +- 判定条件:客户端渲染的每个 Session 派生值都在渲染位置经投影读取取得,每个新增的会话内计算都注册为单元、其结果以已发布值的形式到达客户端。组件自己拉取 Session 数据、或读者按需触发一次计算,都属违规。 +- State 与 View:单元折叠 host 侧 State,并在其上声明客户端可见的 View,二者分工写在注册里。`ProjectionDefinition` 的字段与驱动它们的注册表归 [session-projection.zh.md](../subsystems/session-projection.zh.md#ctxsessionprojections--sessionprojectionregistry)。 +- 版本:`stateVersion` 从 1 起,只要序列化 state 字段或折叠语义变化就自增,兼容与否都一样。存在兼容改法时优先兼容,但版本仍要自增;同一 key 以不同 `stateVersion` 注册两次会抛错,而不是合并。 +- 计算策略:单元默认全量计算。收窄某个单元的计算范围需要对应领域专家确认,绝不能由实现者自行决定。 +- 边界:非完全 projection 渲染可用于某些 key 的界面可用性展示,例如在连接中的 Session 应答之前先画缓存块。它绝不参与对外可见结果的判定,那些结果等标准 projection 值。 + +在渲染位置读取、并以注册代替拉取,两者都合规: + +```tsx +// The client reads the published value; it never fetches Session data itself. +function PlanChip({ useProjection }: PlanChipProps) { + const plan = useProjection('plan') + return plan === undefined ? null : +} + +// The computation is a registered unit; the registry drives it and publishes changes. +const planDefinition: ProjectionDefinition<'plan'> = { + key: 'plan', + stateVersion: 1, + init: () => emptyPlan, + apply: (state, event) => foldPlan(state, event), + wire: { viewSchema: planSchema, view: planView }, +} +``` + +自行发请求拉取同一个值,就破坏了唯一读面: + +```tsx +function PlanChip() { + const [plan, setPlan] = useState() + useEffect(() => { + void fetchSessionPlan(sessionId).then(setPlan) // a second read path beside Projection + }, [sessionId]) + return plan === undefined ? null : +} +``` + +拉取到的副本与推送值互相矛盾,版本检查也无法说明 Session 当前持有的是哪一个。 + +## 冷读是最后手段 + +可用的缓存要先于原始日志被读取;只有没有任何投影覆盖该数据时才走冷读。「小会话可以全读」这一例外已移除、不再考虑。当缓存与正式数据不完全对等时,缺失或过期要产出显式结果,而不是静默改走重路径。 + +- 判定条件:Session 读取先查 projection 缓存,行缺失时为调用方留下显式状态;缓存判断的 `else` 分支直接打开完整日志,即属违规。 +- 回退语义:缺失意味着什么由调用方决定,并写进返回值。不存在按缓存大小或 Session 大小开出的例外,也没有对应的批准人。 +- 边界:这条限制是前端的。host 侧「禁止 fork 冷读」的绝对化表述不成立,因为 host 可能需要打开前端打不开的日志。 + +先读已存储的投影行,缺失仍是一个由调用方渲染的状态: + +```ts ignore-check +// The listing reads stored projection rows; a miss remains an explicit state. +type ListingView = + | { kind: 'cached'; snapshot: ProjectionSnapshot } + | { kind: 'unknown' } + +function readListing(header: SessionHeader, keys: readonly ProjectionKey[]): ListingView { + const cached = ctx.sessionProjectionCache.cachedSnapshot(header, keys) + return cached === undefined ? { kind: 'unknown' } : { kind: 'cached', snapshot: cached } +} +// Opening the complete log is a separate operation the caller names, never this fallback. +``` + +缓存判断落回完整日志,就等于把慢路径变成常规路径: + +```ts ignore-check +function readListing(header: SessionHeader, session: Session): ListingView { + const cached = ctx.sessionProjectionCache.cachedSnapshot(header) + if (cached !== undefined) return { kind: 'cached', snapshot: cached } + return coldReadCompleteLog(session) // every miss pays the full-log read +} +``` + +现在每次缺失都在前台读完整日志,于是慢会话在每次列表读取时都慢,而不只是在那条主动请求它的路径上慢。 + +## 持有与回收遵循同一条生命周期判据 + +客户端经显式引用(`SessionReference`)持有 Session,引用计数归零即释放。闲置回收只看那一条生命周期判据——零引用且无在途——绝不按业务状态枚举「哪些 Session 可以回收」,因为那份清单要靠每个新增状态的业务功能自己去维护。隐式持有者不可见,回收便判断不了谁还在使用该 Session。 + +- 判定条件:判据 = `refCount == 0 && inflight == 0`。代码是否在「获取 → 使用」的时间窗口内通过一份可审计 retain / release 的引用来持有 Session?模块级 Session 对象或全局 current id 都属违规。 +- 边界:强制的是统一判据与不做业务状态枚举这两点;释放触发本身靠人工核对,因此仅凭人工核对不构成打回理由。 +- 归口:`retain`、`release`、`ready` 与 teardown 顺序归 [Session Controller 的 client references](../../packages/api/session-controller/README.zh.md#client-references)。消息里携带的 source session mention 是另一种类型,归 [session-reference.zh.md](../subsystems/session-reference.zh.md)。 + +经显式引用持有并在 teardown 释放的视图合规: + +```ts ignore-check +class SidebarSessionView implements Disposable { + private readonly reference: SessionReference + + constructor(readonly id: SessionId, sessions: ISessions) { + this.reference = sessions.retain(id, { source: 'sidebarView' }) + void this.reference.ready.catch((error: unknown) => { console.error('opening failed:', error) }) + } + + [Symbol.dispose](): void { + this.reference.release() // the last reference releases the Session + } +} +``` + +模块状态缓存 Session 对象,会把所有持有者对回收器隐藏: + +```ts ignore-check +let currentSessionId: SessionId | undefined +const sessionCache = new Map() + +export function remember(session: Session): void { + currentSessionId = session.id + sessionCache.set(session.id, session) +} + +export function reclaimIdle(): void { + if (state === 'idle' || state === 'closed') reclaim() // a newly added state is missed +} +``` + +没有人能数清谁仍在使用该 Session,下一个新增的 Session 状态也会漏出枚举、永远不被回收。 + +## 节点计算是增量的,流式发布是合批的 + +新增节点计算要说明其复杂度;高频流式 chunk 绝不立即发布、也不做全量计算。每个 chunk 全量重算并立即发布,等于把整个节点集合压进帧预算,转写区便会堵在流后面。追加速路径的开销——在 `D` 个已注册 Definition 下执行 `D` 次匹配与一次常量级 Context key 查找——以及发布节拍取值(`none`、`animation-frame`、`immediate`)归 [conversation.zh.md](../subsystems/conversation.zh.md#definition-and-typed-chat-payload)。 + +- 判定条件:单个 chunk 计算了多少——受影响子树的增量,而不是事件窗口——以及结果何时发布——合批策略,而不是每个 chunk 发布一次。 +- 边界:本条约束实现;节拍本身不构成打回理由,改动按它声明的节拍与所用的已发布路径评判。 +- 高频路径上禁止:`immediate` 发布,以及每个 chunk 做一次全量重算。 +- 边界:结构性或终态变化可以 `immediate` 发布,因为节拍是按单次发布选择的。只有高频 delta 路径受合批约束。 + +只更新一个 Context 并请求合批发布的 chunk 合规: + +```ts ignore-check +const assistantDefinition: ConversationNodeDefinition = { + kind: 'assistant-step', + target: 'chat', + match: event => matchAssistant(event), + start: (_context, match) => initialState(match), + update: (context, match) => appendChunk(context.state, match.event), // increment, not the window + publication: () => 'animation-frame', // high-frequency deltas coalesce; never immediate + buildViewNode: context => assistantNode(context), +} +``` + +每个 chunk 重建整个节点集合并立即发布: + +```ts ignore-check +function onChunk(chunk: AssistantLiveChunk): void { + const nodes = buildNodesFromCompleteWindow(session) // full recompute per chunk + target.publish(nodes, { publication: 'immediate' }) // and an immediate publish +} +``` + +每个 chunk 都要在帧预算内做一次全窗口重建,于是分组与渲染都堵在流后面。 + +## 节点构建与分组引擎不承载业务规则 + +业务规则绝不进入分组引擎,业务诉求也绝不改变构建产物的形态。`ProcessState`、分组产物层级,以及 `conversation-nodes/process-groups.ts` 里的分组语义集合都属基础设施:引擎只认枚举化的落位语义(如成组 / 独立 / 回复),由调用方算出该用哪一个。引擎内的业务特判会改变所有使用方共享的产物结构,并把业务条件带上分组计算路径。 + +- 判定条件:引擎内部是否存在业务条件?新增 `ProcessState` 字段、增删分组产物层级、为某个业务改变分组语义,都属违规;落位判定归算出枚举值的调用方。 +- 边界:架构分层、其开销与既有业务预期,就是业务不得重做分组设计的原因。 +- 边界:携带新落位含义的新枚举值才是受支持的扩展,无论调用方在哪里算出它。 +- 例外与批准人:无默认例外。改动该基础设施须经对应领域专家确认。分组 Definition 类型归 [conversation.zh.md](../subsystems/conversation.zh.md#group-definitions)。 + +调用方决定业务含义,并把引擎据以落位的枚举值传进去: + +```ts ignore-check +// Business judgment stays in the caller. +function placementOf(command: CommandView): NodePlacement { + return isHandoff(command) ? 'reply' : 'grouped' +} + +// The engine consumes the enum as data; it reads no business condition. +function groupEntries(commands: readonly CommandView[]): readonly NodePlacement[] { + return commands.map(placementOf) +} +``` + +业务条件写进引擎自己的计算过程: + +```ts ignore-check +// The grouping engine asks what a node means, not where it goes. +function placeProcessNode(node: ChatConversationViewNode): GroupPlacement { + if (node.kind === 'command' && isRagMessage(node)) return 'rag' // business special case + return 'grouped' +} +``` + +产物从此承载某一个业务的含义,所有使用方与分组计算都要跟着该功能走。 + +## 节点列表与席位组件不承载业务分支 + +`ChatNodeList`、`ChatNodeSeat` 与 `ChatGroupSeat` 属基础设施。它们不承载业务判断,也不承载业务专用字段;单一业务绝不改变列表遍历或席位选择。新的节点形态把自己的 kind 贡献给 `ChatNodeDataMap`,并经 kind 注册面注册 renderer,于是列表继续按 kind 分发,业务语义留在业务模块内。为某个功能加的分支,会让每个节点的渲染都依赖该功能。 + +- 判定条件:在列表或席位组件里加业务分支、给其 props 加业务专用字段,或为单一业务改变列表遍历与席位选择。 +- 正确出路:业务包把自己的 key merge 进 `ChatNodeDataMap`,并为该节点 kind 注册 renderer;席位渲染该 kind 已注册的组件。注册纪律归 [slots.zh.md](../subsystems/slots.zh.md#extension-rules)。 +- 边界:对所有业务生效的渲染增强——排序、虚拟化、可访问性——不属于为业务诉求修改。 +- 例外与批准人:无默认例外;任何例外须经对应领域专家确认。 + +声明自己的 kind 并注册 renderer 的业务包,不改动共享列表: + +```tsx +// The business declares its kind and registers its renderer; the shared list is untouched. +declare module '@deepseek-ai/dsh-client-ui-chat/client' { + interface ChatNodeDataMap { handoff: HandoffData } +} + +const HandoffNodeView = memo(function HandoffNodeView({ node, t }: ChatNodeOwnerProps<'handoff'>) { + return +}) + +ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'handoff', locale: NS }, HandoffNodeView)) +``` + +把业务分支与业务字段写进共享席位: + +```tsx +export const ChatNodeSeat = memo(function ChatNodeSeat({ node, handoffFlag }: ChatNodeSeatProps) { + if (node.kind === 'user' && handoffFlag) return // business dispatch + return
{renderByKind(node)}
+}) +``` + +某个功能的条件从此决定每个节点怎么渲染,下一个功能还得再改一次共享席位。 + +## Markdown 管线不承载业务分支 + +`AssistantMarkdown` 及其渲染管线属基础设施:Markdown 渲染路径里不得出现业务条件,也不得按业务改写节点映射或样式注入规则。展示需求要么在既有链上注册 renderer,要么在数据里表达;官方默认内容也参与同一个注册面,而不是做成「可选 renderer + 宿主内联 fallback」。承载业务特判的管线,再也无法单独评审或单独优化。 + +- 判定条件:`AssistantMarkdown` 或其 block 映射里出现业务分支——例如 `if (isXxxBusiness) renderXxx()`——或按某个业务注入样式规则。 +- 正确出路:在既有链上注册 renderer,或在数据里带上语义字段、由通用 renderer 读取。Component Factory 的注册归 [ui-renderer README](../../packages/client/ui-renderer/README.zh.md)。 +- 包装展示:需要标准渲染的包装组件调用官方入口并保留其 fallback;绝不重复渲染 Slot,也绝不跨包导入官方私有 React 组件。 +- 权责边界:宿主只负责位置与调用;插件的状态、清理、控件归插件自己,官方宿主不为某个 contribution 承载业务动作。 +- `labels`:可选传入完整 `MarkdownLabels`,**传入即整体使用**、不与默认值做字段级合并;省略则用官方默认。`variant="compact"` 由 Factory 固定。 +- 边界:通用 Markdown 能力——语法支持、性能优化——不属于为业务诉求修改。 +- 例外与批准人:无默认例外;任何例外须经对应领域专家确认。 + +注册进既有单席位、并复用已发布 Factory(而不是自己重渲标准内容)的 contribution: + +```tsx +// The business contributes its own body into the seat the host already renders. +ctx.slots.inject('conversation.chat.reasoning.body', () => ctx.slots.register( + { name: 'conversation.chat.reasoning.body', scope: 'session', locale: NS }, + TranslatedReasoningBody, +)) + +// The contribution keeps the standard rendering by calling the published Factory. +function TranslatedReasoningBody({ text, running, renderFactorySlot, t }: ReasoningBodyProps) { + return ( + <> +

{t('reasoning.translated')}

+ {renderFactorySlot('conversation.chat.reasoning.content', { text, running })} + + ) +} +``` + +业务分支写进共享 Markdown 管线: + +```tsx +export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, ...props }: AssistantMarkdownProps) { + return <>{blocks.map(block => ( + isHandoffBlock(block) + ? // a business special case in the pipeline + : renderBlock(block, props) + ))} +}) +``` + +每个业务特判都落进同一条共享管线,Markdown 渲染再也无法脱离它如今承载的这些业务去改动或度量。 + +## 用户消息模型与渲染不承载业务字段 + +`UserMessage` 与 UI 侧 `user` kind 渲染属基础设施:消息模型上不得有业务专用字段,用户消息渲染里不得有业务分支。业务差异经消息 payload 传递——自己可 merge 扩展的 source kind 或某个 content 字段——或经注册的 renderer 表达。为某个业务加的字段,所有消息模型使用方都要处理;下一个业务还会往同一个共享 renderer 里再加一条分支。 + +- 判定条件:`UserMessage` 上出现业务命名字段,或 `user` kind 的渲染里出现业务条件。 +- 正确出路:在 payload 里表达。`MessageSourceMap` 可经 declaration merging 扩展,因此生产者声明自己的 source kind,而不是加宽 `UserMessage`;消息模型见 [session.zh.md](../subsystems/session.zh.md)。 +- 边界:通用消息能力——附件、引用、编辑态——不属于为业务诉求修改。 +- 例外与批准人:无默认例外;任何例外须经对应领域专家确认。 + +声明自己的 source kind 的生产者,把业务差异留在 payload 里: + +```ts ignore-check +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + handoff: { kind: 'handoff'; sessionId: SessionId } + } +} + +const message = createUserMessage({ + role: 'user', + content: [{ type: 'text', text: prompt }], + source: { kind: 'handoff', sessionId: source.id }, +}) +``` + +共享模型上的业务字段,以及它逼进共享 renderer 的分支: + +```ts ignore-check +interface UserMessage extends MessageBase { + readonly role: 'user' + readonly handoffKind?: 'handoff' | 'rag' // one business's field on the shared model +} + +// ...and the renderer branches on it. +if (message.handoffKind === 'handoff') return +``` + +消息模型的每个使用方都要处理某一个业务的字段,共享 renderer 则随每个后续业务再长一条分支。 diff --git a/docs/defensive-patterns/general.i18n.yaml b/docs/defensive-patterns/general.i18n.yaml new file mode 100644 index 0000000000..eccdc2015d --- /dev/null +++ b/docs/defensive-patterns/general.i18n.yaml @@ -0,0 +1,52 @@ +# Bilingual-pair consistency record for general.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/defensive-patterns/general.md +/defensive-patterns: + en: 324de84cf5239e89 + zh: b3bdfb17d01e5a6c +/defensive-patterns/report-orthogonal-outcomes-independently: + en: c16f636f8f522db4 + zh: 94c523b536df786d +/defensive-patterns/honor-public-contracts-on-both-sides: + en: 656746d485640b3a + zh: f75cd0333dc5bc9b +/defensive-patterns/async-state-is-not-synchronous-state: + en: f58b3f3fb047f434 + zh: daea224503617808 +/defensive-patterns/timing-does-not-carry-correctness: + en: 5778664b676a0079 + zh: e99aeabf0631f647 +/defensive-patterns/optional-inputs-state-both-behaviors: + en: 243e275fee74f54e + zh: a58e04b6e3ac0e7a +/defensive-patterns/one-value-states-one-fact: + en: 598d9041ccfaba7c + zh: 2ac9f98c97015f66 +/defensive-patterns/a-new-active-current-singleton-is-refused: + en: 8bf36acf96fa4c33 + zh: bb6db31a63bcf383 +/defensive-patterns/shared-state-has-one-owner: + en: 053e2c485eec2613 + zh: 2817da00572c789f +/defensive-patterns/a-business-capability-owns-itself: + en: 7588b8dba9279dac + zh: 2760468ede60220c +/defensive-patterns/changes-carry-performance-evidence: + en: 0d6855d1ae0ef4ae + zh: 0f771999398ce052 +/defensive-patterns/migrations-preserve-behavior: + en: 6213e051f51437b8 + zh: 853580b3a4fec100 +/defensive-patterns/dispose-must-reach-quiescence-not-just-request-it: + en: eeb9fe3158484618 + zh: 97187d4dbe4d75ee +/defensive-patterns/contain-callback-exceptions-in-the-dispatcher: + en: ae2332afa54cb253 + zh: db51fedb070d9a83 +/defensive-patterns/never-hand-untrusted-output-the-ambient-environment-or-predictable-paths: + en: 6fb7938f5197f520 + zh: 01eb670a852f24e6 +/defensive-patterns/unlink-link-shaped-paths: + en: 35944dc084b41ded + zh: 0e59beebd9fb6d22 diff --git a/docs/defensive-patterns/general.md b/docs/defensive-patterns/general.md new file mode 100644 index 0000000000..b1f0bba7b9 --- /dev/null +++ b/docs/defensive-patterns/general.md @@ -0,0 +1,222 @@ +# Defensive patterns + +English | [中文](general.zh.md) + +Hard-won bug-class rules: each pattern below is a class of defect that actually shipped or nearly shipped here, stated as the rule that prevents its recurrence. Read this before writing lifecycle, concurrency, subprocess, or teardown code. Test-tier counterparts (real entry path, world-verification, resource ownership) are in [testing.md](../testing.md); the type-level definitions these rules refer to live in [subsystems/](../subsystems/README.md). + +## Report orthogonal outcomes independently + +A result can be several things at once — a process can time out AND exit 0 because it trapped the signal. Surface each independent fact (`timedOut`, `signal`, `exitCode`) on its own; never nest one flag's report inside another's branch, or a caller reads a cut-short run as a clean success. + +A reviewer checks the reporting shape rather than the killing mechanism: can each fact be true independently, and is each one readable without entering another condition's branch? The boundary is the report — how the process is signalled or how long it is given is a separate decision. + +## Honor public contracts on BOTH sides + +When an implementation receives several representations of one outcome, normalize them before returning through the public API. `LlmAdapter.stream()` implementations may throw or emit `finish {kind:'error'|'aborted'}`, but `LlmRuntime.stream()` exposes model-request failures only as terminal finish chunks; middleware and consumer defects remain thrown. This keeps consumers from guessing whether a caught exception came from the provider, a wrapper, chunk logging, or their own assembly. Document the normalized contract where the type is defined; exercise every source form through the real consumer. + +The judged condition is whether a consumer can name the source of a failure from the public API alone. Internal helpers stay free to throw: the requirement binds the boundary the consumer crosses, not every function behind it. + +## Async state is not synchronous state + +`agent.followup()` has no per-message completion or result; a background job's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never treat `agent/status` or `whenIdle()` as the result of one follow-up: several queued follow-ups, steering, and injected work may share one `running` interval, while cancellation or disposal can discard unstarted items. An automation caller that truly owns a run must define its interval explicitly—for example, from its message's durable inbox receipt through the next whole-agent `idle`—and describe any selected output as interval-wide rather than causally attributed to that message. The guard cuts both ways: if the awaited transition can never occur, the wait hangs, so handle the "nothing to wait for" branch explicitly. + +The judged condition is whether an agent can observe a per-unit completion the API does not define. Where the interval genuinely belongs to the caller, it is the caller that states the interval and the output scope; a shared `running` flag is never that statement. + +## Timing does not carry correctness + +Correctness comes from explicit call points and explicit state, never from where a callback lands in the event loop. A `microtask`, `setTimeout`, or `queueMicrotask` that decides whether work is correct races the rest of the program: it holds under a light load and silently fails under a busy one or when batching changes. React code follows the same rule — an effect never supplies a "just after render" ordering that the design should have made explicit. + +The judged condition is whether correctness depends on "that callback already ran" instead of on a named call point or state machine. Two boundaries: a test's timeout budget waits for a failure and is not timing-dependent correctness, and a deliberate delay that only paces user-visible work is not this rule. There is no developer-side exception; a genuine need for a timing technique goes to the domain owner. + +```ts +// The order is written into the code: dispose the old store, then build its successor. +declare let store: { dispose(): void } +declare function createStore(scopeKey: string): { dispose(): void } +declare const scopeKey: string + +store.dispose() +store = createStore(scopeKey) +``` + +```ts ignore-check +queueMicrotask(() => { if (!ready) refresh() }) // assumes the microtask runs before the next frame +setTimeout(() => setMounted(true), 0) // waits for a render that was never sequenced +``` + +The wrong form fails intermittently: a dropped refresh or a state that never syncs, reproducing only under load. + +## Optional inputs state both behaviors + +An optional parameter states what the callee does when it is present and when it is absent, so a caller reads both behaviors from the contract instead of inferring one from a defensive operator. Accumulating `?.` is the signal: each one substitutes for a decision about whether the field should exist at all. + +The judged condition is whether `?.` replaces a determination that a field exists rather than handling a genuinely optional hop on a known value. The requirement binds the interface contract: both branches state their return value and their side effects, and the contract is where the caller reads them. There is no exception channel — a call site that cannot state both behaviors is a review blocker, not a style preference. + +```ts +// Both behaviors are in the contract: no options object means "unknown", not "skip". +interface ResolveInput { options?: readonly string[] } +type Resolution = { kind: 'unknown' } | { kind: 'value'; value: string } + +function resolve(input: ResolveInput): Resolution { + if (input.options === undefined) return { kind: 'unknown' } + return { kind: 'value', value: input.options.join(',') } +} +``` + +```ts ignore-check +const value = a?.b?.c?.d ?? fallback // four layers of fallback hide whether d should exist +``` + +The wrong form keeps the contract vague and swallows the error, so the next caller adds another fallback on top. + +## One value states one fact + +An unknown value, an empty collection or string, and a genuinely absent field are three different facts and take three representations. One `undefined` or `null` never stands for several of them, and a consumer that must act differently on each discriminates explicitly instead of collapsing them at the boundary. + +The judged condition is whether a single "no value" carries more than one semantic: not yet queried, queried and empty, and cannot be determined. A discriminated union or an explicit enum value is the ordinary implementation, documented where the type is defined in [core.md](../subsystems/core.md#repo-wide-type-patterns). + +```ts +type ValueState = { kind: 'unknown' } | { kind: 'empty' } | { kind: 'value'; value: T } +``` + +```ts ignore-check +return null // means "not queried", "queried, nothing there", and "cannot tell", all at once +``` + +The wrong form leaves the caller unable to distinguish the cases, so UI states, cache policy, and retry decisions all go wrong. + +## A new active/current singleton is refused + +No new global `active` or `current` singleton is introduced, and existing ones are migrated out. A globally unique selection is a design statement this codebase does not make: several views of one piece of data each hold their own reference or key. + +The judged condition is whether the change introduces a globally unique selected-state field. The replacement shape is one piece of data with several views over it, each view carrying its own reference. There is no default exception; where the concept genuinely exists once per service, a service or instance field expressed through `inject` carries it, never a module-level field. + +```ts ignore-check +// Each view holds the key it renders; nothing global decides what is "current". +function ConversationView({ sessionKey }: { sessionKey: SessionKey }) { /* … */ } +``` + +```ts ignore-check +let activeTabId: TabId | undefined // module-level; every component reads the same one +``` + +The wrong form makes independent instances interfere with each other, and no reviewer can audit which view owns the focus. + +## Shared state has one owner + +Shared state hangs on a service or an instance, so its owner is a named object with a lifecycle. A module-level cache, a singleton, a global `WeakMap`, or a `Symbol.for` / `globalThis` / `window.__X__` bypass hides that owner and outlives every teardown. + +The judged condition: name the service or instance that owns the value; if no owner can be named, the owner is decided before the implementation. The boundary is that a field on a service or instance may be read by any number of injected consumers, and passing that value across domains goes through the existing interface rather than a global namespace. There is no default exception; a genuine need for a global lookup key goes to the domain owner together with its reclamation policy. + +```ts +// The shared value hangs on the session service instance; consumers inject it. +interface Sessions { readonly current: string } +declare const ctx: { get(name: 'sessions'): Sessions } + +const sessions = ctx.get('sessions') // one owner, lifetime tied to the service +``` + +```ts ignore-check +globalThis.__dshCache = new Map() // shared across instances, reclaimed never +``` + +The wrong form lets instances overwrite each other, makes reclamation timing unknowable, and leaks state between tests. + +## A business capability owns itself + +A business capability lives in its own package and attaches through a registration surface — a registry, `slots.inject`, a waterfall, or an event subscription. A runtime, controller, manager, or gateway carries generic orchestration: it may hold the registry structure and the disposal lifecycle, while the entries are written by the packages that own them, and it never hardcodes a central table of business rows. + +The judged condition is whether the capability declares and registers itself, with the center layer only forwarding and managing lifetime rather than making business decisions. The boundary is that the center layer holds the registry structure and the disposal lifecycle while the entries come from the owning packages. One exception: pure infrastructure — logging, transport, storage — does not attach through a business registration surface. A new branch in a center layer must explain why it cannot live in the owning package. + +```ts +// The business package registers its own slot and handlers in its apply. +interface Slots { + inject(name: string, register: () => void): void + register(name: string): void +} +interface Context { readonly slots: Slots } + +export function apply(ctx: Context): void { + ctx.slots.inject('conversation.footer', () => ctx.slots.register('conversation.footer')) +} +``` + +```ts ignore-check +if (pluginName === 'ui-attachment') { /* business branch inside the manager */ } +``` + +The wrong form makes every new capability edit the center layer, which becomes the change bottleneck and the conflict hotspot. + +## Changes carry performance evidence + +A change that can move the cost of a hot path measures the code it touched before and after and reports the numbers with their conditions. The three metrics are runtime, peak memory, and steady-state memory, measured in one environment on one input; the granularity is the changed path, not a whole-machine score. Reporting that "nothing got slower overall" is not evidence, and a path that improved elsewhere does not excuse a regression here — an optimised first paint still has to answer for `append`. + +The judged condition: under one environment and one input, any of the three metrics is significantly worse on the changed path. + +The baseline is the same path without the change under measurement — normally the native implementation that carries no measurement hook, not the previous implementation. Comparing old against new with the same hook attached on both sides dilutes the difference until it disappears: both sides pay the same overhead, so the measurement reports "no change" while both are slow. + +Measurement is owned by [benchmarks/](../../benchmarks/AGENTS.md), one directory per measured user path, and its scenarios are the entry point: + +```sh +# Run the owning scenario before and after the change; the numbers go into the PR. +pnpm run test:bench +``` + +Package-local diagnostics stay beside their owner with the `.perf.ts` suffix and do not join `test:bench`. The CI gate is `pnpm run check:ci:bench` (job `node 24 / benchmarks`), which publishes a scaled report — that gate is an alert, not a pass: a regression there is located by re-running the owning scenario and profiling segments locally to find which part slowed and which change introduced it. Retained-memory claims use `pnpm run test:bench:memory 4g|8g`, which reruns its listed files under an unchanged budget in a swapless cgroup. + +Boundaries: edits that touch no runtime path — comments, documentation, test-only code — need no measurement, and production performance code is not benchmark instrumentation to be deleted opportunistically (`performance.now()` and `scheduler.yield()` are part of production decode and encode chunk scheduling). An architectural change that genuinely costs performance states the measured cost and the reason, and the domain owner approves it; "the layering needed it" is not a self-service reason. Record the command, the numbers, the environment, and the input size so the baseline can be rebuilt. + +```text +CI benchmark reported green, so the change is accepted as performance-neutral. +``` + +That form ships a real regression in a path the benchmark does not model. + +## Migrations preserve behavior + +A migration or a package split keeps externally observable behavior equal: error codes, diagnostics, data-flow order, and UI timing are compared item by item. Moving code is preferred over deleting and rewriting it; a rewrite suits code whose semantics are changing anyway, and then the change lists its differences. A package or export-path change is a carrier change rather than a behavior change, and a behavior change is its own change. + +The judged condition is the item-by-item comparison of observable behavior before and after. Where full equality is impossible, the change states the differing items and how each is verified, and the domain owner approves. A defect found during a migration is fixed as its own change rather than folded into the move. + +```sh +# The move keeps paths and error codes; the reference updates are the whole diff. +git mv packages/old/src/endpoint.ts packages/new/src/endpoint.ts +``` + +```ts ignore-check +// The migration silently upgrades the error handling it was only supposed to move. +throw new EndpointError('not found') // was: return { ok: false, code: 'not_found' } +``` + +The wrong form buries behavior changes inside a move, so review cannot separate the relocation from the semantics and regressions cannot be localized. + +## Dispose must reach quiescence, not just request it + +A teardown that issues kills/aborts but returns before the work stops leaves orphans. Make cleanup async and await the children's exit (kill → await `done`), and close listener/notification registries BEFORE killing so late completions stay silent. + +The judged condition is whether teardown awaits actual quiescence or only requests it. The boundary is work with no owner to await — a detached, unobserved task — which the teardown is not obliged to track; anything the component started and can still observe must be awaited before it reports completion. + +## Contain callback exceptions in the dispatcher + +Catch and report each listener's exception without rejecting the dispatch promise or starving later listeners. The listener's owner must handle state it mutates under the [error-handling rules](../../.agents/skills/dsh-error-handling/SKILL.md#catch-recover-or-stop); catching alone does not restore state. + +The judged condition is whether a throwing listener can reject the dispatch promise or prevent later listeners from running. The boundary is the caller's own await: an error the dispatcher deliberately propagates to the operation that requested it is not a containment failure. + +```ts +async function dispatch(listeners: ((event: string) => Promise)[], event: string): Promise { + for (const listener of listeners) { + try { await listener(event) } catch (error) { console.warn('listener failed', error) } + } +} +``` + +## Never hand untrusted output the ambient environment or predictable paths + +Spawned commands get a scrubbed env (drop `*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASSWORD*`) so harness credentials cannot leak into output, `env`, or spill files. Temp/spill files use a private (0700) dir, random names, and exclusive owner-only opens (`'wx'`, `0o600`) — predictable world-readable paths invite symlink races and disclosure. + +The judged condition is whether spawned processes inherit the ambient environment or write somewhere another process can predict and replace. The boundary is trusted, in-process code with no external input, which the rule does not govern. + +## Unlink link-shaped paths + +A path that may be a symlink or Windows junction is removed with `lstatSync().isSymbolicLink()` then `unlinkSync`: unlink deletes only the link and refuses a real directory, so it never follows the link into its target. Windows `rmSync(link)` throws `ERR_FS_EISDIR` on a junction; recursive deletion may descend through one into its target. Reserve recursive `rmSync` for known real directories. + +The judged condition is whether the removal primitive can follow a link into its target. The boundary is a path the code created itself in a directory it owns, where a plain recursive removal is already known to be safe. diff --git a/docs/defensive-patterns/general.zh.md b/docs/defensive-patterns/general.zh.md new file mode 100644 index 0000000000..3aa2b55038 --- /dev/null +++ b/docs/defensive-patterns/general.zh.md @@ -0,0 +1,252 @@ +# 防御性模式 + +[English](general.md) | 中文 + +来之不易的缺陷类别规则:下面每条模式都是本项目实际发布或差点发布的一类缺陷,以防止其复发的规则形式陈述。在编写生命周期、并发、子进程或清理代码之前请先阅读本文。测试层面的对应规则(真实入口路径、验证实际结果、资源归属)见 [testing.zh.md](../testing.zh.md);这些规则引用的类型定义正家见 [subsystems/](../subsystems/README.zh.md)。 + + + +## 正交结果独立上报 + +一个结果可以同时具有多种性质:进程可能已经超时,却仍以退出码 0 结束,因为它捕获了终止信号。每个独立事实(`timedOut`、`signal`、`exitCode`)都应单独上报;切勿把一个标志的上报嵌套在另一个标志的分支中,否则调用方可能把提前终止的运行误判为正常成功。 + +评审看的是**上报形态**而不是终止机制:每个事实能否独立为真,以及能否不进入另一个条件分支就读到它?边界就是这个上报本身——如何发信号、给多长时间是另一个决定。 + + + +## 公共约定两侧都要遵守 + +当一个实现收到同一结果的多种表示时,应在通过公共 API 返回前将其规范化。`LlmAdapter.stream()` 的实现可以抛出异常或发出 `finish {kind:'error'|'aborted'}`,但 `LlmRuntime.stream()` 只会通过终止型 finish 分片暴露模型请求失败;middleware 缺陷与消费方缺陷仍会以异常形式抛出。这使消费方不必猜测捕获的异常究竟来自提供方、包装层、分片日志记录还是自身组装逻辑。请在类型定义处记录规范化后的约定;通过真实消费方覆盖每种来源形式。 + +判定条件是:消费方能否仅凭公共 API 说出故障的来源。内部 helper 仍可自由抛错:这条要求约束的是消费方跨越的那个边界,而不是它背后的每个函数。 + + + +## 异步状态不是同步状态 + +`agent.followup()` 没有逐消息的完成状态或结果;后台任务的完成与轮次边界存在竞争;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。切勿把 `agent/status` 或 `whenIdle()` 当作某次 `followup()` 的结果:多条已排队的后续消息、steering(中途引导)和注入工作可能共用同一个 `running` 区间,而取消或资源释放可能丢弃尚未启动的项。真正拥有一次运行的自动化调用方必须显式定义其区间——例如从消息的持久 inbox 回执到整个 agent(智能体)下一次进入 `idle`——并将选取的任何输出描述为整个区间的输出,而不是把因果关系归于该消息。这条守则是双向的:如果等待的转换永远不会发生,等待就会挂起,因此应显式处理「无需等待」的分支。 + +判定条件是:调用方是否观察到一个 API 并未定义的逐单元完成态。区间确实属于调用方时,就由调用方声明区间与输出范围;共享的 `running` 标志永远不是这份声明。 + + + +## 时序不承载正确性 + +正确性来自显式调用点与显式状态,而不是回调落在事件循环的哪个位置。用 `microtask`、`setTimeout` 或 `queueMicrotask` 决定某项工作是否正确,是在与程序其余部分竞争:轻负载下成立,重负载或合批行为一变就静默失效。React 代码同理——effect 绝不提供那种本应由设计显式给出的「渲染之后」次序。 + +判定条件是:正确性是否依赖「那个回调已经跑过」,而不是具名调用点或状态机。两条边界:测试的 timeout 预算是在等失败,不属于靠时序保正确性;纯粹为可见工作 pacing 的延迟也不属本条。开发者一侧没有例外通道,确需时序技巧须经对应领域专家确认。 + +```ts +// The order is written into the code: dispose the old store, then build its successor. +declare let store: { dispose(): void } +declare function createStore(scopeKey: string): { dispose(): void } +declare const scopeKey: string + +store.dispose() +store = createStore(scopeKey) +``` + +```ts ignore-check +queueMicrotask(() => { if (!ready) refresh() }) // assumes the microtask runs before the next frame +setTimeout(() => setMounted(true), 0) // waits for a render that was never sequenced +``` + +反例的失效是间歇性的:偶发丢刷新,或状态永远没同步,只在压力大时复现。 + + + +## 可选输入要写明两种行为 + +可选参数要写明被调用方在**传入**与**不传入**两种情况下各自的行为,调用方从契约里读到两种行为,而不是靠防御性运算符去猜其中一种。堆积 `?.` 就是这个信号:每一个都在替代「这个字段到底应不应该存在」的判断。 + +判定条件是:`?.` 是否被用来替代「该字段存在」的判定,而不是在处理一个已知值上真正可选的一跳。这条要求约束接口契约:两个分支都要写明返回值与副作用,并由调用方从契约处读到。没有例外通道——无法说清两种行为的调用点属于评审打回项,不是风格偏好。 + +```ts +// Both behaviors are in the contract: no options object means "unknown", not "skip". +interface ResolveInput { options?: readonly string[] } +type Resolution = { kind: 'unknown' } | { kind: 'value'; value: string } + +function resolve(input: ResolveInput): Resolution { + if (input.options === undefined) return { kind: 'unknown' } + return { kind: 'value', value: input.options.join(',') } +} +``` + +```ts ignore-check +const value = a?.b?.c?.d ?? fallback // four layers of fallback hide whether d should exist +``` + +反例让契约持续含糊、错误被静默吞掉,于是下一个调用方又叠一层兜底。 + + + +## 一个值只表达一个事实 + +未知值、空集合或空字符串、以及字段真的不存在,是三件不同的事实,用三种不同的表示。一个 `undefined` 或 `null` 绝不代表其中多个;需要在三者上分别处理的消费方显式判别,而不是在边界处把它们压平。 + +判定条件是:同一个「无值」是否承载了多种语义——**未查询**、**查到为空**、**无法判定**。通常的实现是可辨识联合或显式枚举值,并在类型定义处记录(见 [core.zh.md](../subsystems/core.zh.md#repo-wide-type-patterns))。 + +```ts +type ValueState = { kind: 'unknown' } | { kind: 'empty' } | { kind: 'value'; value: T } +``` + +```ts ignore-check +return null // means "not queried", "queried, nothing there", and "cannot tell", all at once +``` + +反例让调用方无法区分这些情况,UI 状态、缓存策略与重试判断都会跟着做错。 + + + +## 新增 active/current 单例一律禁止 + +不得新增全局的 `active` 或 `current` 单例,存量逐步迁出。全局唯一选中态是本仓库不做出的设计声明:同一份数据的多个视图各自持有自己的引用或键。 + +判定条件是:该改动是否引入了全局唯一的选中态字段。替代形态是**一份数据、多份视图**,每个视图带自己的引用。没有默认例外;确实每个服务只有一份的概念由服务或实例的字段承载并经 `inject` 取得,绝不落成模块级字段。 + +```ts ignore-check +// Each view holds the key it renders; nothing global decides what is "current". +function ConversationView({ sessionKey }: { sessionKey: SessionKey }) { /* … */ } +``` + +```ts ignore-check +let activeTabId: TabId | undefined // module-level; every component reads the same one +``` + +反例让彼此独立的实例互相串台,也没有人能审计焦点与状态归谁。 + + + +## 共享状态只有一个所有者 + +共享状态挂在服务或实例上,因此它的所有者是一个有生命周期的具名对象。模块级缓存、单例、全局 `WeakMap`,以及 `Symbol.for` / `globalThis` / `window.__X__` 旁路都会把这个所有者藏起来,并活得比任何一次清理都久。 + +判定条件:说出拥有该值的服务或实例;说不出所有者时,先定所有者再实现。边界是:服务或实例上的字段可以被任意多个注入方读取,跨域传递走既有接口而不是全局命名空间。没有默认例外;确需全局检索键时须经对应领域专家确认,并一并给出回收策略。 + +```ts +// The shared value hangs on the session service instance; consumers inject it. +interface Sessions { readonly current: string } +declare const ctx: { get(name: 'sessions'): Sessions } + +const sessions = ctx.get('sessions') // one owner, lifetime tied to the service +``` + +```ts ignore-check +globalThis.__dshCache = new Map() // shared across instances, reclaimed never +``` + +反例让实例之间互相覆盖、回收时机不可控,并让状态在测试之间泄漏。 + + + +## 业务能力自己拥有自己 + +业务能力住在自己的包里,经注册面接入——registry、`slots.inject`、waterfall 或事件订阅。runtime、controller、manager、gateway 承载通用编排:它可以持有注册表结构与 disposal 生命周期,但条目由拥有它们的包写入,且绝不硬编码一张业务中心表。 + +判定条件是:该能力是否由业务包自己声明并注册,中心层只做转发与生命周期管理、不做业务判定。边界是:中心层持有注册表结构与 disposal 生命周期,条目来自拥有它们的包。一条例外:纯基础设施能力——日志、传输、存储——不经业务注册面接入。中心层新增分支必须说明它为何无法下沉到业务包。 + +```ts +// The business package registers its own slot and handlers in its apply. +interface Slots { + inject(name: string, register: () => void): void + register(name: string): void +} +interface Context { readonly slots: Slots } + +export function apply(ctx: Context): void { + ctx.slots.inject('conversation.footer', () => ctx.slots.register('conversation.footer')) +} +``` + +```ts ignore-check +if (pluginName === 'ui-attachment') { /* business branch inside the manager */ } +``` + +反例让每个新能力都要改中心层,中心层随即成为变更瓶颈与冲突热点。 + + + +## 改动要附带性能证据 + +可能改变热点路径成本的改动,要测量它所触及的代码在改动前后的表现,并把数字与测量条件一并给出。三项指标是运行耗时、内存峰值与稳态内存,在同一环境、同一输入下测量;粒度是**被改动的那条路径**,不是整机跑分。只报「整体没变慢」不算证据,别处变快也不能抵消这里的倒退——优化了首屏,仍然要回答 `append` 是否倒退。 + +判定条件:同一环境、同一输入下,改动后那条路径的三项指标中任一项显著变差。 + +基线是被测路径**不带本次改动**的形态——通常是不挂测量 hook 的原生实现,而不是旧实现。新旧两侧挂着同一个 hook、共同开销把真实差值稀释掉,测量就会报告「没变化」,而实际上两边一起慢。 + +测量的正家是 [benchmarks/](../../benchmarks/AGENTS.md),按被测用户路径分目录,它的场景就是入口: + +```sh +# Run the owning scenario before and after the change; the numbers go into the PR. +pnpm run test:bench +``` + +包内诊断留在包旁边、用 `.perf.ts` 后缀,不进 `test:bench`。CI 门禁是 `pnpm run check:ci:bench`(作业 `node 24 / benchmarks`),产出 scaled report——该门禁只是**报警**,不是通过依据:出现退化时重跑对应场景并本地分段 profile,定位是哪一段变慢、哪次变更引入。内存留存类结论用 `pnpm run test:bench:memory 4g|8g`,它会在无 swap 的 cgroup 里以不变预算重跑其列出的文件。 + +边界:不触碰运行路径的改动——注释、文档、仅测试代码——不需要测量;生产性能代码不是可以顺手删掉的 benchmark instrumentation(`performance.now()`、`scheduler.yield()` 属于生产 decode/encode 分片调度的一部分)。架构调整确实要付出性能代价时,写明实测代价与理由,并由对应领域专家确认;「分层需要」不是自助理由。命令、数字、环境与输入规模都要落账,以便返工时重建同一基线。 + +```text +CI benchmark reported green, so the change is accepted as performance-neutral. +``` + +这种写法会把 benchmark 未建模路径上的真实退化直接放行。 + + + +## 迁移保持行为 + +迁移或拆包保持对外可观测行为全等:错误码、诊断信息、数据流顺序与 UI 时机逐项比对。优先搬运代码而不是删了重写;重写适用于本来就要改语义的代码,且此时要在改动里列出差异。包或导出路径变化属于载体变化而非行为变化,行为变化则是独立的一刀。 + +判定条件是改动前后可观测行为的逐项比对。无法做到完全全等时,改动要写明差异项与各自的验收方式,并由对应领域专家确认。迁移过程中发现的缺陷按其自身另开一刀,不并入这次搬运。 + +```sh +# The move keeps paths and error codes; the reference updates are the whole diff. +git mv packages/old/src/endpoint.ts packages/new/src/endpoint.ts +``` + +```ts ignore-check +// The migration silently upgrades the error handling it was only supposed to move. +throw new EndpointError('not found') // was: return { ok: false, code: 'not_found' } +``` + +反例把行为变化埋进搬运里,评审无法把位置移动与语义变化分开,回归也就无法定位。 + + + +## dispose 必须达到完全停稳,而不仅仅是请求停止 + +如果清理流程只发出终止或中止信号便返回,而不等待工作真正停止,就会留下孤儿进程。清理逻辑应采用异步流程,并等待子进程退出(发出终止信号后等待 `done`);还应在终止进程前关闭监听器注册表和通知注册表,使迟到的完成事件保持静默。 + +判定条件是清理是否等待到真正停稳,而不只是发出请求。边界是没有可等待归属的工作——无人观察的游离任务——清理逻辑没有义务追踪它;但组件自己启动、且仍能观察到的工作,必须在它报告完成之前等待完毕。 + + + +## 在分发器中隔离回调异常 + +逐个捕获并报告监听器的异常,不让分发 promise 被 reject,也不让后续监听器失去执行机会。监听器的所有者必须按[错误处理规范](../../.agents/skills/dsh-error-handling/SKILL.md#catch-recover-or-stop)处置其修改的状态;仅捕获异常不会恢复状态。 + +判定条件是:抛异常的监听器是否能让分发 promise 被 reject,或让其后的监听器不再运行。边界是调用方自己的 await——分发器有意抛给发起该操作的那条路径的错误,不属于隔离失败。 + +```ts +async function dispatch(listeners: ((event: string) => Promise)[], event: string): Promise { + for (const listener of listeners) { + try { await listener(event) } catch (error) { console.warn('listener failed', error) } + } +} +``` + + + +## 绝不将环境变量或可预测路径暴露给不可信输出 + +启动的命令应使用经过清理的环境变量,移除名称匹配 `*KEY*`、`*SECRET*`、`*TOKEN*` 或 `*PASSWORD*` 的项,防止 harness 凭证通过命令输出、`env` 或 spill 文件泄漏。临时文件和 spill 文件应放在权限为 0700 的私有目录中,使用随机文件名,并以独占且仅所有者可访问的方式打开(`'wx'`、`0o600`);可预测且全局可读的路径会引发符号链接竞态和信息泄露。 + +判定条件是:启动的进程是否继承环境变量,或是否写入别人可以预测并替换的位置。边界是不接受外部输入的受信进程内代码,本条不约束它。 + + + +## 用 unlink 删除链接形态的路径 + +可能是符号链接或 Windows junction 的路径,应先用 `lstatSync().isSymbolicLink()` 判断,再用 `unlinkSync` 删除:unlink 只删除链接本身并拒绝真实目录,因此绝不会跟随链接进入其目标。Windows 上对 junction 调用 `rmSync(link)` 会抛 `ERR_FS_EISDIR`;递归删除可能穿过 junction 进入其目标。真实目录才使用带 `recursive` 的 `rmSync`。 + +判定条件是:所用的删除原语是否可能跟随链接进入其目标。边界是代码在自己拥有的目录里自行创建的路径,那里已知普通递归删除是安全的。 diff --git a/docs/defensive-patterns/package-surface.i18n.yaml b/docs/defensive-patterns/package-surface.i18n.yaml new file mode 100644 index 0000000000..6d1bd9ae33 --- /dev/null +++ b/docs/defensive-patterns/package-surface.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for package-surface.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/defensive-patterns/package-surface.md +/package-surface-rules: + en: 28d4c2d7da24269f + zh: d723b807815401d7 +/package-surface-rules/a-plugin-package-exports-what-the-loader-loads: + en: 44a06f50e751f06b + zh: 339dea7be403fd0b +/package-surface-rules/two-package-shapes-and-both-are-dynamic-plugin-packages: + en: bf79c6acf35c90a5 + zh: 9f2ce6c7f6493b17 +/package-surface-rules/package-json-exports-uses-the-standard-key-set: + en: 3604bf435be1c430 + zh: 169621c8a0c2dc4c +/package-surface-rules/dependency-declarations-and-version-consistency-are-enforced-by-the-dependency-scan: + en: e68bfcde70f845b4 + zh: 73e5c4fb067c4fa1 +/package-surface-rules/internal-dependency-syntax-follows-the-package-family: + en: d6bb855545b7b05b + zh: 6c489ed5a2ed0ae5 +/package-surface-rules/a-new-resolve-hook-is-refused-outright: + en: b420ae3e228432d5 + zh: 0c49c4a9c3a5262c diff --git a/docs/defensive-patterns/package-surface.md b/docs/defensive-patterns/package-surface.md new file mode 100644 index 0000000000..9a4b772b1e --- /dev/null +++ b/docs/defensive-patterns/package-surface.md @@ -0,0 +1,224 @@ +# Package surface rules + +English | [中文](package-surface.zh.md) + +Rules for what a package exports, how it is built, and how it declares dependencies. [development.md](../development.md#typescript-project-layout) describes the layout itself; [packages/AGENTS.md](../../packages/AGENTS.md) carries the standing orders. + +## A plugin package exports what the Loader loads + +A plugin package's entry point takes one of two forms, and they are not mixed. A **function plugin** named-exports `name`, `inject`, `Config`, and `apply`: `name`, `inject`, and `apply` are lower-case, while `Config` is upper-case and appears both as an `interface` and as its Schemastery schema value. A **service plugin** default-exports its service class, which is how the Loader loads it; a default export beside the four makes the Loader discard the function plugin's namespace. Beyond those, a Host entry may export the Service Definition classes and error types that other packages extend or throw — the capability seam consumes exactly those — and never a test-convenience value. The `/client` entry of a UI plugin package is stricter, and [packages/client/AGENTS.md](../../packages/client/AGENTS.md) owns that discipline: a same-package test reaches internals through a relative `../src/...` path or the `./src/*` subpath, so a test-only need never becomes a permanent export. The rule stops a test convenience from freezing into published API. + +- The Loader four and its exact form are defined in [cordis-primer.md](../cordis-primer.md#loader-configuration). +- Judged condition: a function plugin's entry (`src/index.ts`) exports an entity beyond the four; a default export is added beside the four, or the two forms are mixed; a `/client` entry exports an implementation value or lets another feature plugin import one; a fixture reaches another package by relative path. +- Boundary: only Loader plugin entry points are constrained. Internal modules, helper functions, and hooks keep exporting values; a Host entry's Service Definition classes and error types are expected exports; and a test fixture stays inside its own package — a relative path into another package's `src` bypasses the `exports` map and the package-boundary checks. +- Exception: none by default; an exception that is genuinely needed requires the domain owner's confirmation. + +The compliant entry — the four exports, with the same-package test path recorded as a comment rather than as a fifth export. + +```ts +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' + +// The plugin entry: the Loader four, and no fifth value +export const name = 'web-app' +export const inject = ['webServer'] +export interface Config { /* … */ } +export const Config = z.object({ /* … */ }) +export function apply(ctx: Context, config: Config): void { /* … */ } + +// A same-package test imports '../src/internal/header.ts' instead of this entry +``` + +Three ways the entry gains an export it must not have; all three are visible in the diff. + +```ts ignore-check +// The entry widened so a test could reach one helper +export { isHeaderLine } from './internal/header.ts' + +// Both plugin forms on one entry: the Loader discards the four +export default class WebApp {} + +// A fixture reaching into another package by relative path +import { isSurfaceEligibleType } from '../../core/session/src/surface.ts' +``` + +## Two package shapes, and both are dynamic plugin packages + +A new package is either a Host package or a Host + Client package, and both are dynamic plugin packages; no third form is available and there is no exception. The static client libraries the shell seeds through `PLATFORM_MODULES` in [packages/client/web/src/platform.ts](../../packages/client/web/src/platform.ts) — `client-store`, `ui-slots`, `ui-primitives`, `ui-dockkit` — and the zero-dependency libraries under [packages/util/](../../packages/util/) predate this rule and are closed: they keep working as they are, and a new package never joins them. Only a genuine both-face package splits its compiler faces, build output goes to `lib`, and the `include`, `external`, and roster lists are derived by scanning the workspace rather than hand-written. A hand-written list or an invented compiler face makes build output and the manifest disagree, and every later package would have to maintain its own list. + +- Judged condition: a new package built as anything but a dynamic plugin package (static-link bundling, a hand-written all-external list); a third `tsconfig` variant; a hand-written `include`, `external`, or roster list. +- Recompute the shapes instead of trusting a number here: list the `tsconfig.json` files under `packages/*/*`, then the packages that also carry `tsconfig.client.json` and `tsconfig.host.json`, then those declaring `dsh.client`. +- Boundary: a both-face package may appear in `include` or `exclude`. +- Exception: none; there is no approval path. A change to a package's shape or compiler faces starts with the owning documents and needs the domain owner's confirmation. +- Owners: [development.md](../development.md#typescript-project-layout) for the compiler faces, [packages/README.md](../../packages/README.md) for the package groups, and [adding-a-package.md](../cookbook/adding-a-package.md) for the steps. + +A package in one of the two shapes, with the standard manifest fields and no hand-written external list. + +```json +{ + "name": "@deepseek-ai/dsh-example", + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" } + } +} +``` + +A hand-written `external` list — and a `tsconfig.foo.json` invented beside it for one package — leaves the scans nothing to derive, so build output and manifest stop agreeing. + +```json +{ "name": "@deepseek-ai/dsh-example", "external": ["@deepseek-ai/*"] } +``` + +## `package.json` `exports` uses the standard key set + +A `package.json` `exports` map uses only the standard key set, in which every key already has one meaning across the repository, and a package never opens a key for its own local purpose. A key that only a handful of packages use without a documenting owner is not a precedent: formalizing a key, including one of the undocumented keys below, means changing the owning document and the check in the same change. The small shared key set is what lets a consumer infer a key's purpose from its name and lets a scan judge the export map at all. + +- Standard keys, each with one meaning repo-wide, across `packages/*/*/package.json` and `apps/*/package.json`: `.` (the package entry), `./package.json` (toolchain access, not a semantic export), `./src/*` (source passthrough for tests and tools, whose documented status is still open), `./client` (the browser entry, paired with the `dsh.client` declaration), `./types` (the type entry), `./typert` (the typert type runtime), `./remote` (the `@Remote` call surface), `./locale/*.json` (locale resources), `./cordis.patch.yml` (the bundle patch layer declared by `dsh.bundle`). Counts are not listed here because they rot on the next package; the recompute command below prints them. +- Keys outside that set (`./brand` in 5 packages, `./worker` in 4, `./startup`, `./runner`, and `./protocol` in 2 each, plus many one-off keys) have no documented meaning; they are pending review, not a pattern to copy. +- Judged condition: does a change add an export key outside the standard set? +- Exception: none by default; a new key changes the owning document and the check first, with the domain owner's confirmation. +- Recompute the distribution: + +```sh +python3 -c "import json,glob,collections; c=collections.Counter(); [c.update(json.load(open(p)).get('exports',{}).keys()) for p in glob.glob('packages/*/*/package.json')+glob.glob('apps/*/package.json') if isinstance(json.load(open(p)).get('exports'),dict)]; print(c.most_common())" +``` + +The compliant map — three standard keys used with their repo-wide meanings, in a package that also declares its client entry. + +```json +{ + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" }, + "./src/*": "./src/*", + "./package.json": "./package.json" + } +} +``` + +Keys opened for one package's local purposes — here `./brand` and `./worker`, whose documented status is pending — turn the export map into a per-package convention that neither a consumer nor a scan can read. + +```json +{ + "exports": { + ".": "./lib/index.js", + "./brand": "./lib/brand.js", + "./worker": "./lib/worker.js" + } +} +``` + +## Dependency declarations and version consistency are enforced by the dependency scan + +Dependency declarations and version consistency are guaranteed mechanically by the dependency scan; this page repeats neither the `devDependencies` and `peerDependencies` field rules nor a package admission list of its own. When the scan fails, reproduce it locally and fix the manifest it names, because the scan and its policy file own the rules. Editing the scan or relaxing its policy so that a change can land is not a normal edit and needs the domain owner's confirmation. + +- Entry point: [verify-package-dependencies.ts](../../scripts/verify-package-dependencies.ts), run as `pnpm run verify-package-dependencies`, by `scripts/run-gates.ts` as the `package-dependencies` gate, and directly by `.github/workflows/release.yml`. +- Policy owner: [package-dependency-policy.ts](../../scripts/package-dependency-policy.ts) exports `PACKAGE_DEPENDENCY_POLICY`; the declaration sections, version forms, and exemptions are defined there, with package-level context in [packages/README.md](../../packages/README.md). +- Boundary: this page lists no packages, field rules, or thresholds; it points at the owning file instead. +- Find the wiring: `grep -rn "verify-package-dependencies" package.json .github/workflows scripts/run-gates.ts`. +- Exception: a change to the scan or to the policy requires the domain owner's confirmation and the reason recorded on the change. + +The repeatable procedure: reproduce, read the diagnostic, and fix the manifest it names. + +```sh +# 1. Reproduce what CI reports +pnpm run verify-package-dependencies + +# 2. The diagnostic names the manifest, the section, the dependency, and the expected range +# packages/example/package.json: dependencies.@deepseek-ai/dsh-base must use workspace:*, found workspace:~ + +# 3. The fix goes into the manifest, never into the policy file or the scan +# "dependencies": { "@deepseek-ai/dsh-base": "workspace:*" } + +# 4. Re-run the scan until every package matches the published policy +pnpm run verify-package-dependencies +``` + +Two ways to lose the mechanical guarantee: change the check instead of the manifest, or restate the rule here so that it drifts from its owner. + +```text +Relaxing PACKAGE_DEPENDENCY_POLICY, or dropping the scanned section that fails, to go green +Restating a required-devDependency package list or the field rules on this page instead of linking the policy file +``` + +## Internal dependency syntax follows the package family + +Internal dependency syntax follows the package family: `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` use `workspace:*`, every other internal package uses `workspace:~`, and `workspace:^` has retired. The same dependency name is written the same way in every workspace, and a mismatch is a hard scan failure rather than a warning a reviewer can waive. The uniform syntax keeps one resolution path and prevents duplicate instances of a dependency and the resolution differences that follow them. + +- Judged by `workspaceRange()` in [verify-package-dependencies.ts](../../scripts/verify-package-dependencies.ts): the `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` families get `workspace:*`, every other internal package gets `workspace:~`; the scan walks the managed manifests and compares every declaration of the same dependency name. +- Boundary: a same-namespace package that is not a workspace member declares an ordinary third-party range and is outside the family rule — `@deepseek-ai/libreoffice-kit` is declared `^0.1.5` in `apps/desktop-host/package.json` (dependencies), `packages/bundle/web-app/package.json` (devDependencies), `packages/document/office-to-pdf/package.json` (dependencies), and `packages/skill/skill-office/package.json` (dependencies). +- Distribution is not restated here; the recompute command below prints the current counts of `workspace:*`, `workspace:~`, `workspace:^`, and any exact-range declaration outside the family rule. +- Exception: the scan decides mechanically; a genuine exception means changing the scan and the policy file with the domain owner's confirmation and the reason on the change. +- Recompute: + +```sh +python3 -c "import json,glob,collections; c=collections.Counter(); [c.update([r]) for p in glob.glob('apps/*/package.json')+glob.glob('packages/*/*/package.json')+glob.glob('vendor/*/package.json') for s in ('dependencies','devDependencies','optionalDependencies','peerDependencies') for n,r in (json.load(open(p)).get(s) or {}).items() if n.startswith('@deepseek-ai/')]; print(c.most_common())" +``` + +Each family in its form, with the non-member exception left as an ordinary version range. + +```json +{ + "dependencies": { + "@deepseek-ai/dsh-base": "workspace:*", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/libreoffice-kit": "^0.1.5" + }, + "devDependencies": { + "@deepseek-ai/dsh-typert-registry": "workspace:*" + } +} +``` + +A `dsh-*` package on `workspace:~` and a workspace member pinned to an external range — each is a hard scan failure, and the retired rule of pinning exact versions is not available as an alternative. + +```json +{ + "dependencies": { + "@deepseek-ai/dsh-base": "workspace:~", + "@deepseek-ai/cordis": "^4.0.0" + } +} +``` + +## A new resolve hook is refused outright + +A new `resolve` hook is refused by default — a definite prohibition, not a preference to avoid one. A genuine need has two preconditions together: re-auditing the resolve strategy as a whole rather than patching the specifier that motivated the change, and the domain owner's confirmation before anything lands. An author's own judgment that this case is special is not a confirmation. + +- Judged condition: does the change add a `resolve` hook, including a wrapper around an existing hook that rewrites the same specifiers? +- Boundary: this rule decides whether a hook may be added at all; the separation of resolution, hot reload, and plugin management responsibilities is governed separately and does not permit a new hook. +- The one resolution path is the tsx ESM hook registered by the `dsh` source launch, whose rationale is in the [source-launch Agent Note](../../.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md). +- Exception: only the two preconditions above, both recorded on the change. + +The compliant path when new resolution behavior is genuinely needed, in order. + +```text +A new resolve hook is allowed only after both preconditions hold: + +1. Re-audit the resolve strategy as a whole — resolution, hot reload, and plugin + management responsibilities together, not only the specifier at hand. +2. Record the domain owner's confirmation on the change. +3. Only then add the hook, linking both records from the change. + +Until both hold, the answer is no; "this case is special" is the author's judgment, +not a confirmation. +``` + +A hook added so that one specifier lands where this package expects it, or the equivalent wrapper around the existing hook — the resolution path gains a branch, and what the resolver returns no longer matches `node_modules`. + +```ts ignore-check +// Added so one specifier resolves to the local source copy +export async function resolve(specifier: string, parentURL: string, importAttributes: object) { + if (specifier === '@deepseek-ai/dsh-example') return { url: localSourceUrl, shortCircuit: true } + return defaultResolve(specifier, parentURL, importAttributes) +} + +// The equivalent shape: wrapping the hook that already exists +const previous = hooks.resolve +hooks.resolve = (specifier, context, next) => + pinned.has(specifier) ? pinned.get(specifier) : previous(specifier, context, next) +``` diff --git a/docs/defensive-patterns/package-surface.zh.md b/docs/defensive-patterns/package-surface.zh.md new file mode 100644 index 0000000000..140f103264 --- /dev/null +++ b/docs/defensive-patterns/package-surface.zh.md @@ -0,0 +1,224 @@ +# 包面规则 + +[English](package-surface.md) | 中文 + +关于包导出什么、怎么构建、如何声明依赖的规则。布局本身见 [development.zh.md](../development.zh.md#typescript-project-layout);常设条款见 [packages/AGENTS.md](../../packages/AGENTS.md)。 + +## 插件包导出 Loader 所需的东西 + +插件包的入口取两种形态之一,两者不得混用。**函数插件**具名导出 `name`、`inject`、`Config`、`apply`:`name`、`inject`、`apply` 全小写,`Config` 首字母大写,且同时以 `interface` 与 Schemastery schema 值两种形态出现。**服务插件**默认导出它的服务类,Loader 就是这么加载它的;在四件套旁边再加一个默认导出,会让 Loader 丢弃函数插件的命名空间。除这两者之外,Host 入口可以导出供其他包 `extends` 或 `throw` 的 Service Definition 类与错误类型——能力接缝消费的正是这些——但绝不导出测试便利值。UI 插件包的 `/client` 入口更严,[packages/client/AGENTS.md](../../packages/client/AGENTS.md) 是那条纪律的正家:同包测试用相对 `../src/...` 路径或 `./src/*` 子路径触达内部,测试专用的需要因此永远不会变成常驻导出。 + +- Loader 四件套及其准确形态见 [cordis-primer.zh.md](../cordis-primer.zh.md#loader-configuration)。 +- 判定条件:函数插件的入口(`src/index.ts`)导出了四件套以外的实体;在四件套旁边加了默认导出,或两种形态被混用;`/client` 入口导出了实现值、或让另一个功能插件导入了实现值;fixture 用相对路径引用其他包。 +- 边界:只约束 Loader 插件入口。内部模块、工具函数与 hooks 照常导出值;Host 入口的 Service Definition 类与错误类型是应有的导出;测试 fixture 也留在本包内——用相对路径伸进别的包的 `src` 会绕过 `exports` 映射与包边界检查。 +- 例外:无默认例外;确需破例须经对应领域专家确认。 + +合规的入口——四件套,同包测试路径以注释记录,而不是加出第五个导出。 + +```ts +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' + +// The plugin entry: the Loader four, and no fifth value +export const name = 'web-app' +export const inject = ['webServer'] +export interface Config { /* … */ } +export const Config = z.object({ /* … */ }) +export function apply(ctx: Context, config: Config): void { /* … */ } + +// A same-package test imports '../src/internal/header.ts' instead of this entry +``` + +三种写法都会让入口多出不该有的导出;三种都能在 diff 里看到。 + +```ts ignore-check +// The entry widened so a test could reach one helper +export { isHeaderLine } from './internal/header.ts' + +// Both plugin forms on one entry: the Loader discards the four +export default class WebApp {} + +// A fixture reaching into another package by relative path +import { isSurfaceEligibleType } from '../../core/session/src/surface.ts' +``` + +## 包只有两类,且两类都是动态插件包 + +新增包要么是 Host 包,要么是 Host + Client 包,两类都是动态插件包;没有第三种形态可选,也没有例外。shell 经 [packages/client/web/src/platform.ts](../../packages/client/web/src/platform.ts) 的 `PLATFORM_MODULES` 播种的静态客户端库——`client-store`、`ui-slots`、`ui-primitives`、`ui-dockkit`——以及 [packages/util/](../../packages/util/) 下的零依赖库先于本条规则存在,且**已关闭**:它们照原样继续工作,新增包永不加入其中。只有真正的双面包才拆编译面,生成物进 `lib`,`include`、`external` 与 roster 清单由扫描 workspace 得出,不手写。手写清单或新造编译面会让构建产物与清单不再一致,此后每个新增包都得各自维护清单。 + +- 判定条件:新增包未按动态插件包形态构建(static link 打包、手写的全 external 清单);出现第三种 `tsconfig` 变体;手写 `include`、`external` 或 roster 清单。 +- 形态不要照抄数字,就地复算:先列 `packages/*/*` 下的 `tsconfig.json`,再列同时带 `tsconfig.client.json` 与 `tsconfig.host.json` 的包,最后列声明 `dsh.client` 的包。 +- 边界:双面包允许被 `include` 或 `exclude`。 +- 例外:无;没有审批通道。包的形态或编译面变更须先改正家文档,并须经对应领域专家确认。 +- 正家:[development.zh.md](../development.zh.md#typescript-project-layout) 管编译面,[packages/README.zh.md](../../packages/README.zh.md) 管包分组,[adding-a-package.zh.md](../cookbook/adding-a-package.zh.md) 管步骤。 + +属于两类之一的包:使用标准清单字段,没有手写 external 清单。 + +```json +{ + "name": "@deepseek-ai/dsh-example", + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" } + } +} +``` + +手写的 `external` 清单——以及在它旁边为单个包新造的 `tsconfig.foo.json`——让扫描无从推导,构建产物与清单随之不再一致。 + +```json +{ "name": "@deepseek-ai/dsh-example", "external": ["@deepseek-ai/*"] } +``` + +## `package.json` `exports` 使用标准键集合 + +`package.json` 的 `exports` 只使用标准键集合,集合中每个键在全仓已经只有一个含义,包不得为自己的局部用途开键。只有少数几个包在用、又没有文档正家的键不构成先例:把某个键正式化(包括下面那些未记录在案的键)意味着在同一次改动里改正家文档与检查。键集合小且共享,消费者才能从键名推断用途,扫描也才有可能判定导出映射。 + +- 标准键,每个键在全仓只有一个含义,统计口径是 `packages/*/*/package.json` 与 `apps/*/package.json`:`.`(包主入口)、`./package.json`(工具链访问,不是语义导出)、`./src/*`(测试与工具的源码透传,文档状态仍待定)、`./client`(浏览器入口,与 `dsh.client` 声明配对)、`./types`(类型入口)、`./typert`(typert 类型运行时)、`./remote`(`@Remote` 调用面)、`./locale/*.json`(本地化资源)、`./cordis.patch.yml`(`dsh.bundle` 声明的 bundle 补丁层)。这里不列各键的包数,因为下一个包就会让它过期;下面的复算命令会打印当前数字。 +- 集合之外的键(`./brand` 5 个包、`./worker` 4 个、`./startup`、`./runner`、`./protocol` 各 2 个,另有大量一次性键)没有文档含义,属待核项,不是可照抄的先例。 +- 判定条件:改动是否新增了标准集合以外的 export 键? +- 例外:无默认例外;新增键须先改正家文档与检查,并取得对应领域专家确认。 +- 复算分布: + +```sh +python3 -c "import json,glob,collections; c=collections.Counter(); [c.update(json.load(open(p)).get('exports',{}).keys()) for p in glob.glob('packages/*/*/package.json')+glob.glob('apps/*/package.json') if isinstance(json.load(open(p)).get('exports'),dict)]; print(c.most_common())" +``` + +合规的映射——三个标准键各按全仓含义使用,该包同时声明了 client 入口。 + +```json +{ + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" }, + "./src/*": "./src/*", + "./package.json": "./package.json" + } +} +``` + +为单个包的局部用途开的键——这里是文档地位待核的 `./brand` 与 `./worker`——让导出映射退化为各包自便的约定,消费者与扫描都读不懂。 + +```json +{ + "exports": { + ".": "./lib/index.js", + "./brand": "./lib/brand.js", + "./worker": "./lib/worker.js" + } +} +``` + +## 依赖声明与版本一致性由依赖扫描门禁保证 + +依赖声明与版本一致性由依赖扫描机械保证;本页既不复述 `devDependencies` 与 `peerDependencies` 的字段规则,也不另立包准入清单。扫描失败时,在本地复现并修正它指名的清单,因为规则的正家是扫描与其策略文件。为了让改动落地而修改扫描或放宽策略不属于常规改动,须经对应领域专家确认。 + +- 入口:[verify-package-dependencies.ts](../../scripts/verify-package-dependencies.ts),以 `pnpm run verify-package-dependencies` 运行,被 `scripts/run-gates.ts` 作为 `package-dependencies` 门禁调用,`.github/workflows/release.yml` 也直接运行它。 +- 策略正家:[package-dependency-policy.ts](../../scripts/package-dependency-policy.ts) 导出 `PACKAGE_DEPENDENCY_POLICY`;声明面、版本形态与豁免都在该文件定义,包级背景见 [packages/README.zh.md](../../packages/README.zh.md)。 +- 边界:本页不列包清单、字段规则或阈值,只指向正家文件。 +- 查接线:`grep -rn "verify-package-dependencies" package.json .github/workflows scripts/run-gates.ts`。 +- 例外:修改扫描或策略须经对应领域专家确认,并在改动上记录理由。 + +可重复的流程:复现、读诊断、修正它指名的清单。 + +```sh +# 1. Reproduce what CI reports +pnpm run verify-package-dependencies + +# 2. The diagnostic names the manifest, the section, the dependency, and the expected range +# packages/example/package.json: dependencies.@deepseek-ai/dsh-base must use workspace:*, found workspace:~ + +# 3. The fix goes into the manifest, never into the policy file or the scan +# "dependencies": { "@deepseek-ai/dsh-base": "workspace:*" } + +# 4. Re-run the scan until every package matches the published policy +pnpm run verify-package-dependencies +``` + +失去机械保证的两种做法:改检查而不改清单,或者把规则复述到本页、从此与正家漂移。 + +```text +Relaxing PACKAGE_DEPENDENCY_POLICY, or dropping the scanned section that fails, to go green +Restating a required-devDependency package list or the field rules on this page instead of linking the policy file +``` + +## 内部依赖写法按包族保证 + +内部依赖写法按包族决定:`@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-*` 用 `workspace:*`,其余内部包用 `workspace:~`,`workspace:^` 已退出。同一个依赖名在所有 workspace 内写法一致,不一致是扫描硬失败,不是评审可以放行的警告。写法统一才能保持一条解析路径,避免同一个依赖出现重复实例以及随之而来的解析差异。 + +- 判定在 [verify-package-dependencies.ts](../../scripts/verify-package-dependencies.ts) 的 `workspaceRange()`:`@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-*` 包族取 `workspace:*`,其余内部包取 `workspace:~`;扫描遍历受管清单,比对同一依赖名的每一处声明。 +- 边界:同命名空间但不是 workspace 成员的包按普通第三方版本范围声明,不受包族规则约束——`@deepseek-ai/libreoffice-kit` 在 `apps/desktop-host/package.json`(dependencies)、`packages/bundle/web-app/package.json`(devDependencies)、`packages/document/office-to-pdf/package.json`(dependencies)、`packages/skill/skill-office/package.json`(dependencies)中声明为 `^0.1.5`。 +- 分布不在此复述;下面的复算命令会打印 `workspace:*`、`workspace:~`、`workspace:^` 的当前数量,以及族规则之外的精确版本声明。 +- 例外:门禁机械判定;确需例外意味着修改扫描与策略文件,并须经对应领域专家确认、在改动上说明理由。 +- 复算: + +```sh +python3 -c "import json,glob,collections; c=collections.Counter(); [c.update([r]) for p in glob.glob('apps/*/package.json')+glob.glob('packages/*/*/package.json')+glob.glob('vendor/*/package.json') for s in ('dependencies','devDependencies','optionalDependencies','peerDependencies') for n,r in (json.load(open(p)).get(s) or {}).items() if n.startswith('@deepseek-ai/')]; print(c.most_common())" +``` + +每个包族各用其写法,非成员的例外保持普通版本范围。 + +```json +{ + "dependencies": { + "@deepseek-ai/dsh-base": "workspace:*", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/libreoffice-kit": "^0.1.5" + }, + "devDependencies": { + "@deepseek-ai/dsh-typert-registry": "workspace:*" + } +} +``` + +`dsh-*` 包写成 `workspace:~`,以及把 workspace 成员钉到外部版本范围——两者都是扫描硬失败,已退出的「一律钉精确版本」旧口径不能作为替代写法。 + +```json +{ + "dependencies": { + "@deepseek-ai/dsh-base": "workspace:~", + "@deepseek-ai/cordis": "^4.0.0" + } +} +``` + +## 新增 resolve 钩子直接禁止 + +新增 `resolve` 钩子默认被拒——这是确定性禁止,不是「尽量不」。确有需要时,两个前置条件必须同时成立:整体重审 resolve 策略,而不是给触发本次改动的那个 specifier 打补丁;以及在任何内容落地之前取得对应领域专家的确认。开发者自行判断「这次特殊」不构成确认。 + +- 判定条件:改动是否新增 `resolve` 钩子,包括包装既有钩子、改写同一批 specifier 的等价写法? +- 边界:本条只管能否新增钩子;解析、热更新与插件管理三者的职责划分另行治理,并不因此允许新增钩子。 +- 唯一的解析路径是 `dsh` 源码启动注册的 tsx ESM 钩子,其理由见[源码启动 Agent Note](../../.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md)。 +- 例外:只有上述两个前置条件,且都要记录在改动上。 + +确有新的解析需求时的合规路径,按顺序。 + +```text +A new resolve hook is allowed only after both preconditions hold: + +1. Re-audit the resolve strategy as a whole — resolution, hot reload, and plugin + management responsibilities together, not only the specifier at hand. +2. Record the domain owner's confirmation on the change. +3. Only then add the hook, linking both records from the change. + +Until both hold, the answer is no; "this case is special" is the author's judgment, +not a confirmation. +``` + +为了让某个 specifier 落到本包期望的位置而加的钩子,或包装既有钩子达到同样效果的等价写法——解析路径多出一个分支,resolver 的返回与 `node_modules` 实况不再对应。 + +```ts ignore-check +// Added so one specifier resolves to the local source copy +export async function resolve(specifier: string, parentURL: string, importAttributes: object) { + if (specifier === '@deepseek-ai/dsh-example') return { url: localSourceUrl, shortCircuit: true } + return defaultResolve(specifier, parentURL, importAttributes) +} + +// The equivalent shape: wrapping the hook that already exists +const previous = hooks.resolve +hooks.resolve = (specifier, context, next) => + pinned.has(specifier) ? pinned.get(specifier) : previous(specifier, context, next) +``` diff --git a/docs/defensive-patterns/remote-and-api.i18n.yaml b/docs/defensive-patterns/remote-and-api.i18n.yaml new file mode 100644 index 0000000000..e36bfb5699 --- /dev/null +++ b/docs/defensive-patterns/remote-and-api.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for remote-and-api.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/defensive-patterns/remote-and-api.md +/remote-and-api-rules: + en: 26fb6e92d16925b0 + zh: 72d13c0481fc61e9 +/remote-and-api-rules/a-new-remote-capability-gets-its-own-service: + en: 5759a7b0f96ea4bc + zh: 62465fbe91a4d2af +/remote-and-api-rules/remote-marks-capability-methods: + en: e5613ec07023429d + zh: 9272c9ae5718060a +/remote-and-api-rules/packages-api-carries-generic-orchestration-only: + en: 12a964b9c04fccf9 + zh: cf804f8044535727 diff --git a/docs/defensive-patterns/remote-and-api.md b/docs/defensive-patterns/remote-and-api.md new file mode 100644 index 0000000000..25281e96fb --- /dev/null +++ b/docs/defensive-patterns/remote-and-api.md @@ -0,0 +1,114 @@ +# Remote and API rules + +English | [中文](remote-and-api.zh.md) + +Rules for the `packages/api` layer and the remote methods it exposes. [api-gateway.md](../api-gateway.md) describes the call model itself. Each section below states the requirement, how a reviewer judges it, what it does not cover, which exception applies and who approves it, and a correct and a wrong example. + +## A new remote capability gets its own service + +A new remote business capability is a new service — its own package or its own object — and `@Remote` marks that service's concrete methods. Growing an existing large service one method at a time leaves one object answering for auth, Session, workspace, and event carriage at once, mixes per-domain activation policy with history pagination, and makes every later change to one capability touch all of them. Split by business boundary, not by caller count: a capability with a single caller today still gets its own service, and an endpoint that changes owners moves to its new owner instead of joining the object it is leaving. + +- Judged by: which object holds the new remote method. A method for a new business line added to a service that already owns another line is the defect; the fix is a service named for the new capability, with `@Remote` on its concrete methods. A migration is judged the same way — the moved method appears on its new owner, as `SubagentRuntime` does in `packages/subagent/subagent`, not on the object it left. +- Not covered: a service whose methods all serve one business boundary stays whole, and a new private helper of that capability belongs on it. Data that needs its own protocol does not become a Remote method on the new service either; session event streams, pagination, incremental reduce, projection, and entity substreams keep their separate registration model ([gateway boundaries](../api-gateway.md#boundaries)). +- Exception: none recorded; a single caller is not a reason to merge. Where the business boundary itself is contested, the domain owner settles it as an architecture question before the change lands. +- Fix: create the capability's own service object or package, put `@Remote` on its concrete methods, and let callers reach it through `ctx.remote.`. + +The subagent capability owns its own service, and every remote method is a concrete method of that service: + +```ts ignore-check +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' + +interface SubagentList { ids: string[] } +interface PromptRequest { text: string } +interface PromptResult { accepted: boolean } + +class SubagentRuntime extends TypertRemoteService { + @Remote list(): SubagentList { return { ids: [] } } + @Remote prompt(request: PromptRequest): PromptResult { + return { accepted: request.text !== '' } + } + @Remote interruptByParent(id: string): void { } +} +``` + +The wrong version adds the same capability to the object that already carries other domains: + +```text +class ApiProxy { + @Remote goalList(): GoalList // goal domain + @Remote subagentPrompt(request): ... // subagent domain +} +``` + +One object then answers for both domains, so a change to either capability's activation policy, pagination, or event handling lands in the same place, and neither capability can move without the other. + +## `@Remote` marks capability methods + +`@Remote` carries four disciplines that a reviewer checks separately: it marks the concrete method that performs a capability; client-facing types come from the existing definitions through the generator instead of a hand-written copy; the payload is JSON and the remote method performs no output-side decode; and existing method and field names are additive. Each discipline removes a different failure — a wrapper hides which operation is exposed, a second hand-written type set drifts from the generated one, an output-side decode adds a second result path beside the codec the Gateway already runs, and a rename breaks every caller in the same change. + +- **Capability methods.** The annotation goes on the concrete method that performs the operation (`list`, `prompt`, `interruptByParent`), never on a wrapper that forwards to the real implementation. Judged by the decorated method's body: a body that only delegates means the annotation is on the wrong method. +- **Types from inference.** Client-facing types are aliased from what the generator produces — `type SessionRemote = TypertClientRemote['session']` — instead of being written a second time. Judged by a hand-written declaration whose fields repeat a generated Client type; [typert.md](../subsystems/typert.md) owns the generated maps and their declaration merges. +- **JSON on the wire, no output-side decode.** The **wire payload** of a Remote method is JSON, with binary content expressed field-level. The Host signature keeps what the Gateway already supports — lookup arguments resolved to Host objects, a trailing `AbortSignal`, a stream declared with `@Remote({ mode: 'stream' })` — and `@Remote` performs no decode on the way out; the generated codec and the Gateway validate the result. Judged by a decode, a stream reassembly, or a whole non-JSON value in the payload of a method carrying the decorator; [programming model](../api-gateway.md#programming-model) owns what may cross a Remote method. +- **Existing names are additive.** A migration does not rename a remote method or field that a Client already calls; a new semantic gets a new entry, and a rename is its own change with its own review. Judged by a diff that renames or removes a name already exposed through `ctx.remote.`. +- **Not covered.** A method without the decorator stays an ordinary service method with in-process callers; the four disciplines govern what becomes a Remote method, not what a service may implement. +- **Exception.** A rename or a new decode path states its benefit and the impact on existing callers first and needs the domain owner's confirmation; the author never self-approves. + +The generated map is the only source for Client-facing types, the decorator sits on the concrete method, and the payload stays JSON: + +```ts ignore-check +import { Remote, TypertRemoteService, type TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' + +// Client-facing types come from the generated map, never from a second hand-written set. +type SessionRemote = TypertClientRemote['session'] + +class SessionController extends TypertRemoteService { + @Remote('search') + search(query: string): SessionSearchValue { + return this.runSearch(query) + } +} +``` + +A hand-written second type set, a rename of a shipped name, and an output-side decode each break a different discipline: + +```text +type SessionRemote = { list(): ...; rename(id: string): ... } // hand-written copy +@Remote('renameSession') rename(...) // shipped name changed +@Remote decode(payload: Uint8Array): Result // output-side decode +``` + +The hand-written copy drifts from the generated type without failing a build, the rename breaks every existing Client call at once, and the decode replaces the generated codec with a second, unchecked result path. + +## `packages/api` carries generic orchestration only + +The packages under `packages/api` — the controllers, `gateway`, and `remotes` — carry transport, call shape, Session lifecycle, and other generic orchestration. A business branch never aggregates there: a decision that depends on concrete business state, handling bound to one feature, or a per-feature filter belongs in its own package or module and reaches this layer as projected data or through a registration surface. The criterion is where the logic belongs, not where the file sits, so a branch that only makes sense for one feature is a defect even when the file holding it is generic. + +- Judged by: where the branch reads its condition. An `if` or `switch` over feature state, a per-feature filter, or a helper named for one feature inside `packages/api` is the defect; the same decision taken in the owning package and consumed here as projected data or a registered entry is not. The package census shows the impact: `packages/api` holds nine packages — `account-controller`, `gateway`, `job-controller`, `remotes`, `session-controller`, `settings-controller`, `terminal-controller`, `workspace-controller`, `workspace-files` — and `session-controller` is the largest at 34 `src` files (terminal-controller 12, gateway 11, workspace-controller 10, workspace-files 8, job-controller 8, remotes 4, settings-controller 3, account-controller 2). +- Not covered: generic Session lifecycle, transport, and storage abstraction stay — `gateway` dispatch, `remotes` selection, and shared session-list and catalog reads are infrastructure, not business logic. [Business ownership](../api-gateway.md#business-ownership) states the same split from the Gateway side. +- Exception: none by default. Adding a business branch here needs an independent-package review, and that review answers why the logic cannot live in the package that owns the feature. +- Fix: move the decision into the owning package, publish its result as projected data or register it on a registry this layer already injects, and leave only the orchestration that reads it. + +The owning package decides and registers its unit, and the controller only reads the projected value: + +```ts ignore-check +// packages/workspace//src/index.ts: the rule lives with the feature it serves. +ctx.sessionProjections.register({ + key: 'durablePage', + stateSchema: durablePageState, + init: () => ({ durable: false }), + apply: applyDurablePage, + stateVersion: 1, +}) + +// packages/api/session-controller: generic orchestration reads the projected value. +const { durable } = ctx.sessionProjections.stateOf(session, 'durablePage') +``` + +The wrong version decides the rule inside the shared layer: + +```text +// packages/api/session-controller: a feature branch inside the shared layer +if (session.page.transport === 'durable') { /* durable-transport handling */ } +``` + +The rule then has a second home, and every later change to it has to find both while the shared layer keeps carrying one feature's semantics. diff --git a/docs/defensive-patterns/remote-and-api.zh.md b/docs/defensive-patterns/remote-and-api.zh.md new file mode 100644 index 0000000000..1a9c291d9c --- /dev/null +++ b/docs/defensive-patterns/remote-and-api.zh.md @@ -0,0 +1,114 @@ +# 远程与 API 规则 + +[English](remote-and-api.md) | 中文 + +`packages/api` 层及其对外远程方法的规则。调用模型本身见 [api-gateway.zh.md](../api-gateway.zh.md)。以下每条规则都给出要求、审查者的判定方式、不覆盖的内容、例外与批准人,以及一个正例与一个反例。 + +## 新的远程能力要有自己的 service + +新的远程业务能力就是一个新的 service——独立包或独立对象——`@Remote` 标在它的具体方法上。往既有的大 service 上一个方法一个方法地加,会让同一个对象同时承担 auth、Session、workspace 与事件载体,把逐领域的激活策略与历史分页混在一起,并让之后对任一能力的变更都要动所有这些面。按业务边界切,而不是按调用方数量切:当前只有一个调用方的能力也建独立 service;端点迁移是搬到新归属,而不是并进它正要离开的那个对象。 + +- 判定:新远程方法最终落在哪个对象上。把属于新业务线的方法加到已经承担另一条业务线的 service 上即为违反;修法是为新能力建一个以它命名的 service,并把 `@Remote` 标在它的具体方法上。迁移按同一标准判定——被搬走的方法出现在新归属上(如 `packages/subagent/subagent` 的 `SubagentRuntime`),而不是留在它离开的那个对象上。 +- 不覆盖:所有方法都服务同一条业务边界的 service 保持完整,该能力新增的私有辅助方法就属于它。需要独立协议的数据也不会变成新 service 上的 Remote 方法;会话事件流、分页、增量 reduce、projection 与实体子流保持各自独立的注册模型([gateway 边界](../api-gateway.zh.md#boundaries))。 +- 例外:无。只有一个调用方不构成合并的理由;业务边界本身有争议时,由对应领域专家作为架构问题先行裁定,再落地。 +- 修法:为新能力建独立 service 对象或包,把 `@Remote` 标在它的具体方法上,调用方经 `ctx.remote.` 使用。 + +subagent 能力拥有自己的 service,它的每个远程方法都是该 service 的具体方法: + +```ts ignore-check +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' + +interface SubagentList { ids: string[] } +interface PromptRequest { text: string } +interface PromptResult { accepted: boolean } + +class SubagentRuntime extends TypertRemoteService { + @Remote list(): SubagentList { return { ids: [] } } + @Remote prompt(request: PromptRequest): PromptResult { + return { accepted: request.text !== '' } + } + @Remote interruptByParent(id: string): void { } +} +``` + +反例把同一能力加到已经在承担其他领域的对象上: + +```text +class ApiProxy { + @Remote goalList(): GoalList // goal domain + @Remote subagentPrompt(request): ... // subagent domain +} +``` + +于是一个对象同时为两个领域作答:任一能力的激活策略、分页或事件处理变更都落在同一处,两个能力也无法各自搬迁。 + +## `@Remote` 标在能力方法上 + +`@Remote` 有四项纪律,审查者分别核对:标注执行能力的具体方法;面向 Client 的类型经生成器来自既有定义,而不是手写第二份;载荷只承载 JSON,远程方法不做输出侧 decode;既有的方法与字段名只增不改。每项纪律消除一类不同的缺陷——壳方法藏起真正暴露的是哪个操作,手写的第二份类型会与生成结果漂移,输出侧 decode 在 Gateway 已有的 codec 之外再造一条结果路径,改名则让所有调用方在同一次变更里一起断掉。 + +- **标能力方法。** 标注落在执行该操作的具体方法上(`list`、`prompt`、`interruptByParent`),绝不落在转发给真正实现的壳方法上。判定看被标注方法的函数体:函数体只做转发,标注就标错了方法。 +- **类型靠推断。** 面向 Client 的类型从生成结果取别名——`type SessionRemote = TypertClientRemote['session']`——而不是重写一份。判定看是否出现字段与生成的 Client 类型重复的手写声明;生成映射及其声明合并的正家是 [typert.zh.md](../subsystems/typert.zh.md)。 +- **线上载荷只承载 JSON、不做输出侧 decode。** Remote 方法的**线上载荷**是 JSON,二进制在字段级表达。Host 签名保留 Gateway 已支持的形式——经解析变成 Host 对象的 lookup 参数、末尾的 `AbortSignal`、以 `@Remote({ mode: 'stream' })` 声明的流式返回;`@Remote` 不做输出侧 decode,结果由生成的 codec 与 Gateway 校验。判定看带该标注的方法上是否出现 decode、流重组或整体非 JSON 值;可跨越 Remote 方法的内容正家是 [编程模型](../api-gateway.zh.md#programming-model)。 +- **存量名只增不改。** 迁移不改 Client 已在调用的远程方法名或字段名;新语义新增条目,改名是独立的一刀、单独评审。判定看 diff 是否改名或删除了已经经 `ctx.remote.` 暴露的名字。 +- **不覆盖。** 不带该标注的方法仍是有进程内调用者的普通 service 方法;这四项纪律约束的是什么成为 Remote 方法,而不是 service 可以实现什么。 +- **例外。** 确需改名或新增 decode 路径,须先说明收益与对既有调用方的影响,并取得对应领域专家的确认;作者不得自行批准。 + +生成的映射是面向 Client 类型的唯一来源,标注落在具体方法上,载荷保持 JSON: + +```ts ignore-check +import { Remote, TypertRemoteService, type TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' + +// Client-facing types come from the generated map, never from a second hand-written set. +type SessionRemote = TypertClientRemote['session'] + +class SessionController extends TypertRemoteService { + @Remote('search') + search(query: string): SessionSearchValue { + return this.runSearch(query) + } +} +``` + +手写第二份类型、改名已发布的名字、在远程方法上做输出侧 decode,各自破坏一项纪律: + +```text +type SessionRemote = { list(): ...; rename(id: string): ... } // hand-written copy +@Remote('renameSession') rename(...) // shipped name changed +@Remote decode(payload: Uint8Array): Result // output-side decode +``` + +手写的副本会与生成类型漂移而不会让构建失败,改名一次性打断所有既有 Client 调用,decode 则用第二条未经校验的结果路径替换生成的 codec。 + +## `packages/api` 只承载通用编排 + +`packages/api` 下的包——各 controller、`gateway`、`remotes`——承载传输、调用形态、Session 生命周期等通用编排。业务分支绝不在其中聚合:依赖具体业务状态的判断、与单个功能绑定的处理、逐功能的过滤,都归独立包或模块,并作为投影数据或经注册面接入本层。判据是逻辑的归属,而不是文件的位置,所以只对一个功能成立的业务分支即使放在通用文件里也是缺陷。 + +- 判定:分支的条件从哪里读取。在 `packages/api` 内对功能状态做 `if` 或 `switch`、写逐功能的过滤、或以某个功能命名的辅助方法,即为缺陷;同样的判断由归属包做出、在本层作为投影数据或注册条目消费,则不是。包普查是影响面的证据:`packages/api` 共 9 个包——`account-controller`、`gateway`、`job-controller`、`remotes`、`session-controller`、`settings-controller`、`terminal-controller`、`workspace-controller`、`workspace-files`——其中 `session-controller` 以 34 个 `src` 文件为最多(terminal-controller 12、gateway 11、workspace-controller 10、workspace-files 8、job-controller 8、remotes 4、settings-controller 3、account-controller 2)。 +- 不覆盖:通用 Session 生命周期、传输与存储抽象留下——`gateway` 的分发、`remotes` 的选择、共享的会话列表与目录读取属于基础设施,不是业务逻辑。[业务所有权](../api-gateway.zh.md#business-ownership)从 Gateway 一侧给出同一个划分。 +- 例外:默认无例外。在此新增业务分支须走独立包评审,评审要回答这些逻辑为什么不能住在拥有该功能的包里。 +- 修法:把判断移进归属包,将其结果作为投影数据发布、或注册到本层本就注入的注册表上,这里只留下读取它的编排。 + +归属包做出判断并注册自己的单元,controller 只读取投影值: + +```ts ignore-check +// packages/workspace//src/index.ts: the rule lives with the feature it serves. +ctx.sessionProjections.register({ + key: 'durablePage', + stateSchema: durablePageState, + init: () => ({ durable: false }), + apply: applyDurablePage, + stateVersion: 1, +}) + +// packages/api/session-controller: generic orchestration reads the projected value. +const { durable } = ctx.sessionProjections.stateOf(session, 'durablePage') +``` + +反例把这条规则的决定放进共享层: + +```text +// packages/api/session-controller: a feature branch inside the shared layer +if (session.page.transport === 'durable') { /* durable-transport handling */ } +``` + +这条规则于是有了第二个家,之后每次变更都要同时找到两处,而共享层继续承载某一个功能的语义。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 2a08605a85..297872610b 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -21,14 +21,14 @@ en: 3221d1076554001f zh: 29f166be6a5c04d9 /development-guide/contributor-reference/typescript-project-layout: - en: e3327c5492a593cb - zh: d43a5edcb11523ae + en: f0582a3a145a4d93 + zh: 9cab4abec6935b3c /development-guide/contributor-reference/environment-variables: en: dba2c72ee6ffa930 zh: b65c27fbb9129ec8 /development-guide/contributor-reference/git-integrations: - en: dc98eda4f4021feb - zh: b08766708803cf10 + en: 4e782ec2ca27dd1f + zh: 7a60e8c0d60de3c8 /development-guide/contributor-reference/ci-gates: en: b64a914e41b1d285 zh: 6c031b507c3c0e84 @@ -39,8 +39,8 @@ en: 7ce6264a6a06dc4a zh: e12b684f2bef942b /development-guide/contributor-reference/application-commands: - en: 28a029c8768f0880 - zh: 67c17256e046624e + en: 98828fc2b972bf39 + zh: 1dc889a3d766bf35 /development-guide/contributor-reference/todo-markers: en: 85b38f78d87fc88f zh: 00d5eb625e7dfb61 diff --git a/docs/development.md b/docs/development.md index 668beec326..39e05c25ef 100644 --- a/docs/development.md +++ b/docs/development.md @@ -74,24 +74,38 @@ Host and Client stay two aggregate programs because both sides declaration-merge 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. -The root build follows the generated dependency order: +`pnpm run build:artifacts` emits every artifact in the generated dependency order, checking and compiling only the package references of each compiler face: ```sh -tsc -b tsconfig.host.json +pnpm run build:native-system +tsc -b tsdown --env.DSH_BUILD_FACE host pnpm --filter @deepseek-ai/dsh-desktop run bundle -tsc -b tsconfig.client.json +tsc -b tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -`pnpm run build --artifacts-only` emits the same project and application artifacts while checking and compiling only the existing package references of each compiler face. Required Linux and Windows normal builds and public `typecheck` also check the repository-wide test and script programs. CI benchmark preparation additionally uses `--noCheck` for its disposable library outputs, relying on those required builds for diagnostics; other artifact builders keep package checking ([rationale](../.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md)). +`pnpm run build:check` runs only `tsc -p tsconfig.host.json` and `tsc -p tsconfig.client.json` concurrently. Project mode checks each aggregate's repository-wide test and script program against the package declarations already on disk and never compiles a reference, so `build:check` neither detects nor rebuilds missing or stale `build:artifacts` output; missing output surfaces as compiler errors. `pnpm run build` runs both step sets as one [scheduled](../scripts/run-gates.ts) dependency graph: each aggregate check starts once its own face's package compilation finishes and overlaps the remaining artifact steps. In all three commands `DSH_GATE_CONCURRENCY` bounds concurrent steps (`1` runs them in dependency order) and `DSH_GATE_FAIL_FAST=1` stops the remaining steps at the first failure; otherwise a failure skips only its dependents, so independent errors are all reported. A failed step prints its own output, and any failure exits non-zero without writing the client build record. + +Choose the command by what the change needs: + +| Situation | Command | +|---|---| +| Iterating on package sources and needing runnable artifacts | `pnpm run build:artifacts`; `dev:web` and `dev:desktop` run it before launching | +| Type-checking tests, scripts, benchmarks, or website code | `pnpm run build:check` after the latest `build:artifacts`; rerun `build:artifacts` first when package sources changed | +| Before declaring TypeScript work complete | `pnpm run build:check` after the latest `build:artifacts`, or `pnpm run build` | +| Complete verification, as the required CI build lanes run it | `pnpm run build` | +| Type checks without the Web and Desktop artifacts | `pnpm run typecheck` | +| Lint or documentation code-block checks | `pnpm run lint` or `pnpm run doc-typecheck`; each prepares `host-contracts` itself | + +PR artifact builders run `build:artifacts`; required Linux and Windows normal builds and public `typecheck` also check the repository-wide test and script programs. CI benchmark preparation additionally uses `--noCheck` for its disposable library outputs, relying on those required builds for diagnostics; other artifact builders keep package checking ([rationale](../.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md)). Both tsdown passes match `vendor/*`, `packages/*/*`, and `apps/cli`; the Host pass also matches `apps/desktop-host`. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase. Tsdown builds the matched workspace members concurrently, so `apps/desktop`, whose main bundle inlines workspace devDependencies from their `lib/` output, bundles in its own step after the Host pass ([Desktop README](../apps/desktop/README.md#bundled-workspace-dependencies)). -Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. +Typert analyzes Host types, seeded by `tsconfig.host.json`, and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Host tsdown runs it as a plugin, and [`generate-typert.ts`](../scripts/generate-typert.ts) runs the same generation without bundling and writes byte-identical files. The bundle-free pass reads workspace packages only from source and fails when the Host program would load a package's `lib/` output, naming the import and the `paths` alias to add, so it can run beside Host package compilation. Client package compilation always follows one of the two passes because Client sources import the generated Remote declarations. `pnpm run typecheck` runs the `build.ts --scope typecheck` graph: Host package compilation beside the bundle-free Typert pass, then Client package compilation, with each aggregate checked in build mode (`tsc -b`) once its own face's packages compile; it skips the native addon, every bundle, Web, and the client build record. `lint`, `lint:fix`, and `doc-typecheck` read the generated declarations but not the Host aggregate check, so they prepare only Host package compilation and the bundle-free Typert pass (`compile-referenced-projects.ts host-contracts`). None of these commands writes bundles: the runnable `lib/` output stays as the last `build:artifacts` left it, so run `build:artifacts` before launching or testing built output. -`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` runs that complete build first (`--skip-build` reuses an existing artifact tree instead), then samples the current version and Git state once and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts. +`pnpm run build` and `build:artifacts` embed the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; they also inherit other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful `build` or `build:artifacts` writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` runs `build:artifacts` first (`--skip-build` reuses an existing artifact tree instead), then samples the current version and Git state once and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts. Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. @@ -122,9 +136,9 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook is configured in `lefthook.yml` as a fast local checkpoint: -- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard. +- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, checks the staged diff for whitespace errors, and runs the vendor manifest guard. - `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit. -- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated Typert contracts, before the Client TypeScript check. +- `pre-push` runs `pnpm run typecheck`, which generates Typert contracts without bundling before the Client TypeScript check and checks both aggregates. The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. @@ -132,6 +146,8 @@ Apart from the scoped staged-record verification, the hooks intentionally do not Contributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction. +Outside CI (`CI` other than `true`), default worker counts follow the [local worker budget](../scripts/parallelism.ts): 12 for gate schedulers, `build`, `typecheck`, Oxlint, and publint, and 48 for Vitest forks and local coverage. `doc-sync`, `test:docs`, `hygiene`, and `check:all` run at most 8 gates at once, in CI as well. `os.availableParallelism()` reports host CPUs rather than a container's CPU quota, so uncapped defaults oversubscribe shared hosts. CI keeps the runner's CPU count. `DSH_GATE_CONCURRENCY`, `DSH_OXLINT_THREADS`, `DSH_COVERAGE_MAX_WORKERS`, and Vitest `--maxWorkers` override the defaults. + ### CI gates The keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. Coverage partitions reuse file timings scoped to their platform and runner pool to balance work within the configured worker limit. Required benchmarks run separately on standard GitHub-hosted Linux; the [benchmark runner reference](../benchmarks/AGENTS.md) owns routing and the job timeout. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory. @@ -164,7 +180,7 @@ pnpm run demo:ptc -- "summarize this workspace" ### Application commands -Web and Desktop share one command pair. `start:*` launches the artifacts of a prior `pnpm run build`; `dev:*` runs that build first and then launches. Web additionally keeps client bundles rebuilt on source edits, because its Host runs from source while the browser loads built bundles: +Web and Desktop share one command pair. `start:*` launches the artifacts of a prior `pnpm run build` or `build:artifacts`; `dev:*` runs `build:artifacts` first and then launches. Web additionally keeps client bundles rebuilt on source edits, because its Host runs from source while the browser loads built bundles: ```sh pnpm run start:web # serve built Web artifacts through the source launcher (the same launch as pnpm dsh web) diff --git a/docs/development.zh.md b/docs/development.zh.md index 3154c4d988..c8b50343fd 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -78,24 +78,38 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 拆分 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)分别说明其拆分。 -根构建按生成依赖排序: +`pnpm run build:artifacts` 按生成依赖顺序产出全部产物,只检查并编译各 compiler face 的包 reference: ```sh -tsc -b tsconfig.host.json +pnpm run build:native-system +tsc -b tsdown --env.DSH_BUILD_FACE host pnpm --filter @deepseek-ai/dsh-desktop run bundle -tsc -b tsconfig.client.json +tsc -b tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -`pnpm run build --artifacts-only` 产出相同的 project 和应用产物,但只检查并编译各 compiler face 的现有包 reference。必需的 Linux 与 Windows 普通构建以及公开 `typecheck` 还检查仓库级测试和脚本 program。CI benchmark 准备阶段还对其临时 library 输出使用 `--noCheck`,由这些必需构建负责诊断;其他产物 builder 保留包检查([理由](../.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md))。 +`pnpm run build:check` 只并发运行 `tsc -p tsconfig.host.json` 与 `tsc -p tsconfig.client.json`。project mode 依据磁盘上已有的包声明检查各 aggregate 的仓库级测试和脚本 program,从不编译 reference,因此 `build:check` 既不检测也不重建缺失或过期的 `build:artifacts` 产物;产物缺失时表现为编译器错误。`pnpm run build` 把两组步骤作为一张[调度](../scripts/run-gates.ts)依赖图运行:每个 aggregate 检查在本 face 的包编译完成后立即开始,与其余产物步骤重叠执行。三个命令中,`DSH_GATE_CONCURRENCY` 限制并发步骤数(`1` 按依赖顺序执行),`DSH_GATE_FAIL_FAST=1` 在第一个失败时停止其余步骤;否则失败只跳过其依赖方,因此会报告所有相互独立的错误。失败步骤打印自身输出,任一失败都以非零状态退出且不写 client build record。 + +按改动需要选择命令: + +| 场景 | 命令 | +|---|---| +| 迭代包源码并需要可运行的产物 | `pnpm run build:artifacts`;`dev:web` 与 `dev:desktop` 启动前会先执行它 | +| 检查测试、脚本、benchmarks 或 website 代码的类型 | 在最近一次 `build:artifacts` 之后运行 `pnpm run build:check`;包源码有改动时先重新运行 `build:artifacts` | +| 声明 TypeScript 工作完成之前 | 在最近一次 `build:artifacts` 之后运行 `pnpm run build:check`,或运行 `pnpm run build` | +| 完整验证(与 CI 必需的构建 lane 相同) | `pnpm run build` | +| 只做类型检查、不需要 Web 与 Desktop 产物 | `pnpm run typecheck` | +| lint 或文档代码块检查 | `pnpm run lint` 或 `pnpm run doc-typecheck`;它们各自先准备 `host-contracts` | + +PR 产物 builder 运行 `build:artifacts`;必需的 Linux 与 Windows 普通构建以及公开 `typecheck` 还检查仓库级测试和脚本 program。CI benchmark 准备阶段还对其临时 library 输出使用 `--noCheck`,由这些必需构建负责诊断;其他产物 builder 保留包检查([理由](../.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md))。 两次 tsdown 都匹配 `vendor/*`、`packages/*/*` 与 `apps/cli`,Host 阶段另外匹配 `apps/desktop-host`;两者都不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。tsdown 并发构建匹配到的 workspace 成员;`apps/desktop` 的主进程 bundle 需要从工作区 devDependencies 的 `lib/` 产物内联这些包,因此在 Host 阶段之后单独一步打 bundle([Desktop README](../apps/desktop/README.zh.md#bundled-workspace-dependencies))。 -Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。 +Typert 以 `tsconfig.host.json` 为种子分析 Host 类型,生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。Host tsdown 以插件形式运行它,[`generate-typert.ts`](../scripts/generate-typert.ts) 则不打包地执行同一生成,写出逐字节相同的文件。不打包的这一轮只从源码读取工作区包;若 Host program 会载入某个包的 `lib/` 产物,它就失败并指出该 import 及应补的 `paths` 别名,因此可以与 Host 包编译并行。Client 源码 import 生成的 Remote 声明,所以 Client 包编译总在这两种生成之一之后。`pnpm run typecheck` 运行 `build.ts --scope typecheck` 图:Host 包编译与不打包的 Typert 生成并行,之后是 Client 包编译,每个 aggregate 在本 face 的包编译完成后以 build mode(`tsc -b`)检查;它跳过 native addon、所有 bundle、Web 以及 client build record。`lint`、`lint:fix` 与 `doc-typecheck` 读取生成的声明但不依赖 Host aggregate 检查,因此只准备 Host 包编译与不打包的 Typert 生成(`compile-referenced-projects.ts host-contracts`)。这些命令都不写 bundle:可运行的 `lib/` 产物保持上一次 `build:artifacts` 的状态,因此启动或测试构建产物之前先运行 `build:artifacts`。 -`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 会先执行这次完整构建(`--skip-build` 则复用现有产物树),再读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 +`pnpm run build` 与 `build:artifacts` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次 `build` 或 `build:artifacts` 成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 会先执行 `build:artifacts`(`--skip-build` 则复用现有产物树),再读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)。 @@ -109,6 +123,7 @@ pnpm run build `pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验包入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。 + ### 环境变量 真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证: @@ -126,9 +141,9 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点: -- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; +- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; - `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查; -- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 Typert 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。 +- `pre-push` 运行 `pnpm run typecheck`;该命令先不打包地生成 Typert 约定,再运行 Client TypeScript 检查,并检查两个 aggregate。 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。 @@ -136,6 +151,8 @@ vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `v 贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。 +在 CI 之外(`CI` 不为 `true`),默认 worker 数遵循[本地 worker 预算](../scripts/parallelism.ts):门禁调度器、`build`、`typecheck`、Oxlint 与 publint 为 12,Vitest fork 与本地覆盖率为 48。`doc-sync`、`test:docs`、`hygiene` 与 `check:all` 最多同时运行 8 个门禁,CI 中同样如此。`os.availableParallelism()` 报告的是宿主 CPU 数而不是容器的 CPU 配额,因此不设上限的默认值会在共享主机上超额订阅。CI 仍使用 runner 的 CPU 数。`DSH_GATE_CONCURRENCY`、`DSH_OXLINT_THREADS`、`DSH_COVERAGE_MAX_WORKERS` 与 Vitest 的 `--maxWorkers` 可覆盖这些默认值。 + ### CI 门禁 keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。覆盖率分区复用按平台和运行器池隔离的文件耗时,在配置的 worker 上限内均衡工作量。必需 benchmark 在标准 GitHub 托管 Linux 上独立运行;[benchmark 运行器说明](../benchmarks/AGENTS.md)拥有路由及 job 超时。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。 @@ -168,7 +185,7 @@ pnpm run demo:ptc -- "summarize this workspace" ### 应用命令 -Web 与 Desktop 共用同一对命令。`start:*` 启动上一次 `pnpm run build` 的产物;`dev:*` 先执行该构建再启动。Web 还会在源码修改时持续重建 client bundle,因为它的 Host 从源码运行,而浏览器加载的是构建产物: +Web 与 Desktop 共用同一对命令。`start:*` 启动上一次 `pnpm run build` 或 `build:artifacts` 的产物;`dev:*` 先执行 `build:artifacts` 再启动。Web 还会在源码修改时持续重建 client bundle,因为它的 Host 从源码运行,而浏览器加载的是构建产物: ```sh pnpm run start:web # serve built Web artifacts through the source launcher (the same launch as pnpm dsh web) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 92720c468b..b835c6293c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,12 +22,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../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:280`](../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:381`](../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:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:605`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:624`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:631`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:617`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../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` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -64,7 +64,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index fa3ca0dd94..a485d11b84 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,12 +24,12 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../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:280`](../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:381`](../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:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:605`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:624`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:631`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:617`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../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` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -66,7 +66,7 @@ | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml index e2e49e31f5..532c84896c 100644 --- a/docs/glossary.i18n.yaml +++ b/docs/glossary.i18n.yaml @@ -18,8 +18,8 @@ en: 38a55fc61b359ace zh: 567cae201b3a1bdf /glossary/loop-hierarchy: - en: 343a7cb31b8191c2 - zh: 6831415a7fe1726a + en: 6bf1ad7dd53f79cc + zh: 6231e68c769fdefa /glossary/ralph: en: 5059f8a377980b9d zh: 39bf11c10e5c0a84 diff --git a/docs/glossary.md b/docs/glossary.md index 1a9ac39a73..e30e4e2122 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -37,6 +37,7 @@ Domain vocabulary for DeepSeek Harness uses one canonical term per concept. Term - **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes. - **step** — one model request plus the tool executions caused by its response; a turn contains zero or more steps. - **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round) or one fresh-agent Ralph attempt. Round counters belong to that policy and do not count every turn in a session. +- **exchange** — consecutive [turns](#turn) from a turn whose first [step](#step) admits human input until the next such turn; turns woken by notices, such as settled subagents, goal rounds, or schedules, stay in the preceding exchange. Chat's next-message folding keeps the latest exchange expanded. ## Ralph diff --git a/docs/glossary.zh.md b/docs/glossary.zh.md index cd41876d41..68f5e4bf8c 100644 --- a/docs/glossary.zh.md +++ b/docs/glossary.zh.md @@ -37,6 +37,7 @@ DeepSeek Harness 的领域词汇为每个概念规定一个规范术语。各术 - **轮次**:会话中一次对已接纳输入的排空过程,在模型及其工具停止工作或终止策略介入后结束。 - **步骤**:一次模型请求,以及由模型响应引发的工具执行;一个轮次包含零个或多个步骤。 - **Round**:承载一个轮次的外层策略迭代,例如一个 [Goal Round](#goal-round) 或一次使用全新 agent 的 Ralph 尝试。Round 计数器归该策略所有,并不统计会话中的每个轮次。 +- **交互段**:连续的[轮次](#turn),从首个[步骤](#step)接纳真人输入的轮次起,到下一个这样的轮次为止;由通知(子 agent 完成、Goal Round、定时任务等)唤醒的轮次归入前面的交互段。Chat 的「下次发送时收起」保持最新交互段展开。 ## Ralph diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index cf7009ff7f..5971f88600 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -127,6 +127,7 @@ | event log | 事件日志 | | | | | event stream | 事件流 | | | | | event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 | +| exchange | 交互段 | | 回合、交换 | Chat 中从真人输入开启的轮次起、到下一个这样的轮次为止的连续轮次;见术语表 | | Executive summary | 摘要 | | | 事故复盘标题用语 | | executor | 执行器 | | | | | expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 | diff --git a/docs/module-graph.md b/docs/module-graph.md index 4eff2c676b..c5ff4399eb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -175,6 +175,7 @@ flowchart TD pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_renderer["client-ui-renderer"] pkg_client_ui_schedule["client-ui-schedule"] + pkg_client_ui_secret_mask["client-ui-secret-mask"] pkg_client_ui_session["client-ui-session"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_account["client-ui-settings-account"] @@ -219,6 +220,7 @@ flowchart TD pkg_agent_instructions["agent-instructions"] pkg_file_reference["file-reference"] pkg_file_reference_local["file-reference-local"] + pkg_harness_dev_context["harness-dev-context"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] pkg_tmux_context["tmux-context"] @@ -451,6 +453,8 @@ flowchart TD pkg_web --> pkg_llm pkg_api_remotes --> pkg_scope pkg_attachment --> pkg_brand + pkg_app_boot --> pkg_home_paths + pkg_app_boot --> pkg_launch_environment pkg_browser_use --> pkg_brand pkg_client_connection --> pkg_scope pkg_computer_use --> pkg_brand @@ -485,9 +489,8 @@ flowchart TD pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths - pkg_app_boot --> pkg_home_paths - pkg_app_boot --> pkg_launch_environment - pkg_app_boot --> pkg_system_prompt + pkg_hmr --> pkg_app_boot + pkg_hmr --> pkg_cmdline pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use pkg_experimental_inspector --> pkg_client_connection pkg_experimental_inspector --> pkg_client_modules @@ -529,8 +532,8 @@ flowchart TD pkg_spill_local --> pkg_spill pkg_session_log_export --> pkg_session pkg_session_log_export --> pkg_session_persistence - pkg_hmr --> pkg_app_boot - pkg_hmr --> pkg_cmdline + pkg_config_editor --> pkg_app_boot + pkg_config_editor --> pkg_hmr pkg_experimental_speech_to_text --> pkg_settings pkg_experimental_translator --> pkg_credentials pkg_experimental_translator --> pkg_launch_environment @@ -598,8 +601,6 @@ flowchart TD pkg_api_workspace_controller --> pkg_storage_domain pkg_api_workspace_controller --> pkg_typert_protocol pkg_api_workspace_controller --> pkg_workspace - pkg_config_editor --> pkg_app_boot - pkg_config_editor --> pkg_hmr pkg_experimental_api_speech_to_text --> pkg_experimental_speech_to_text pkg_experimental_api_speech_to_text --> pkg_typert_protocol pkg_experimental_speech_to_text_sensevoice --> pkg_experimental_speech_to_text @@ -638,6 +639,10 @@ flowchart TD pkg_goal --> pkg_session_projection pkg_goal --> pkg_typert_protocol pkg_file_reference --> pkg_agent + pkg_harness_dev_context --> pkg_agent + pkg_harness_dev_context --> pkg_session + pkg_harness_dev_context --> pkg_session_projection + pkg_harness_dev_context --> pkg_system_prompt pkg_time_context --> pkg_agent pkg_time_context --> pkg_llm pkg_time_context --> pkg_session @@ -704,6 +709,7 @@ flowchart TD pkg_llm_deepseek_account --> pkg_launch_environment pkg_llm_deepseek_account --> pkg_llm pkg_llm_deepseek_account --> pkg_llm_deepseek + pkg_llm_deepseek_account --> pkg_timeout pkg_tools --> pkg_agent pkg_tools --> pkg_llm pkg_tools --> pkg_ptc_runtime @@ -717,10 +723,6 @@ flowchart TD pkg_command_goal --> pkg_commands pkg_command_goal --> pkg_goal pkg_command_goal --> pkg_llm - pkg_goal_round_driver --> pkg_agent - pkg_goal_round_driver --> pkg_goal - pkg_goal_round_driver --> pkg_llm - pkg_goal_round_driver --> pkg_session pkg_fs_sandbox --> pkg_fs pkg_fs_sandbox --> pkg_fs_local pkg_fs_sandbox --> pkg_sandbox @@ -1046,7 +1048,6 @@ flowchart TD pkg_api_settings_controller --> pkg_settings pkg_api_settings_controller --> pkg_typert_protocol pkg_web_app --> pkg_shell_env - pkg_web_app --> pkg_system_prompt pkg_experimental_auto_review --> pkg_agent pkg_experimental_auto_review --> pkg_agent_instructions pkg_experimental_auto_review --> pkg_llm @@ -1214,6 +1215,11 @@ flowchart TD pkg_mcp_manager --> pkg_tools pkg_mcp_manager --> pkg_typert_protocol pkg_mcp_manager --> pkg_util_values + pkg_goal_round_driver --> pkg_agent + pkg_goal_round_driver --> pkg_goal + pkg_goal_round_driver --> pkg_llm + pkg_goal_round_driver --> pkg_session + pkg_goal_round_driver --> pkg_subagent pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_llm pkg_subagent_acp --> pkg_session @@ -1235,6 +1241,7 @@ flowchart TD pkg_subagent_fork_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent pkg_tool_subagent --> pkg_agent + pkg_tool_subagent --> pkg_agent_preset_registry pkg_tool_subagent --> pkg_llm pkg_tool_subagent --> pkg_scope pkg_tool_subagent --> pkg_session @@ -1500,6 +1507,7 @@ flowchart TD | [`client-ui-reference`](../packages/client/ui-reference) | `client` | — | | [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | — | | [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | — | +| [`client-ui-secret-mask`](../packages/client/ui-secret-mask) | `client` | — | | [`client-ui-session`](../packages/client/ui-session) | `client` | — | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | — | | [`client-ui-settings-account`](../packages/client/ui-settings-account) | `client` | — | @@ -1567,6 +1575,7 @@ flowchart TD | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | +| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | @@ -1588,7 +1597,7 @@ flowchart TD | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | -| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | +| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | @@ -1609,7 +1618,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | -| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | +| [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) | | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) | | [`experimental-translator`](../packages/experimental/translator) | `experimental` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) | @@ -1628,7 +1637,6 @@ flowchart TD | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs) | | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`skill`](../packages/skill/skill) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | -| [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) | | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`subprocess`](../packages/subprocess/subprocess) | | [`hook-protocol`](../packages/experimental/hook-protocol) | `experimental` | [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | @@ -1640,6 +1648,7 @@ flowchart TD | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | +| [`harness-dev-context`](../packages/context/harness-dev-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | | [`deepseek-account`](../packages/credentials/deepseek-account) | `credentials` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | @@ -1654,10 +1663,9 @@ flowchart TD | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | +| [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`timeout`](../packages/util/timeout) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | -| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | @@ -1720,7 +1728,7 @@ flowchart TD | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | -| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env) | | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`webhook`](../packages/experimental/webhook) | `experimental` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | @@ -1745,12 +1753,13 @@ flowchart TD | [`tool-session-query`](../packages/experimental/tool-session-query) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`webhook-github`](../packages/experimental/webhook-github) | `experimental` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/experimental/webhook) | | [`mcp-manager`](../packages/mcp/mcp-manager) | `mcp` | [`mcp-client`](../packages/mcp/mcp-client), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) | +| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) | -| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 913fb13b87..6bc5b10393 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -177,6 +177,7 @@ flowchart TD pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_renderer["client-ui-renderer"] pkg_client_ui_schedule["client-ui-schedule"] + pkg_client_ui_secret_mask["client-ui-secret-mask"] pkg_client_ui_session["client-ui-session"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_account["client-ui-settings-account"] @@ -221,6 +222,7 @@ flowchart TD pkg_agent_instructions["agent-instructions"] pkg_file_reference["file-reference"] pkg_file_reference_local["file-reference-local"] + pkg_harness_dev_context["harness-dev-context"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] pkg_tmux_context["tmux-context"] @@ -453,6 +455,8 @@ flowchart TD pkg_web --> pkg_llm pkg_api_remotes --> pkg_scope pkg_attachment --> pkg_brand + pkg_app_boot --> pkg_home_paths + pkg_app_boot --> pkg_launch_environment pkg_browser_use --> pkg_brand pkg_client_connection --> pkg_scope pkg_computer_use --> pkg_brand @@ -487,9 +491,8 @@ flowchart TD pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths - pkg_app_boot --> pkg_home_paths - pkg_app_boot --> pkg_launch_environment - pkg_app_boot --> pkg_system_prompt + pkg_hmr --> pkg_app_boot + pkg_hmr --> pkg_cmdline pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use pkg_experimental_inspector --> pkg_client_connection pkg_experimental_inspector --> pkg_client_modules @@ -531,8 +534,8 @@ flowchart TD pkg_spill_local --> pkg_spill pkg_session_log_export --> pkg_session pkg_session_log_export --> pkg_session_persistence - pkg_hmr --> pkg_app_boot - pkg_hmr --> pkg_cmdline + pkg_config_editor --> pkg_app_boot + pkg_config_editor --> pkg_hmr pkg_experimental_speech_to_text --> pkg_settings pkg_experimental_translator --> pkg_credentials pkg_experimental_translator --> pkg_launch_environment @@ -600,8 +603,6 @@ flowchart TD pkg_api_workspace_controller --> pkg_storage_domain pkg_api_workspace_controller --> pkg_typert_protocol pkg_api_workspace_controller --> pkg_workspace - pkg_config_editor --> pkg_app_boot - pkg_config_editor --> pkg_hmr pkg_experimental_api_speech_to_text --> pkg_experimental_speech_to_text pkg_experimental_api_speech_to_text --> pkg_typert_protocol pkg_experimental_speech_to_text_sensevoice --> pkg_experimental_speech_to_text @@ -640,6 +641,10 @@ flowchart TD pkg_goal --> pkg_session_projection pkg_goal --> pkg_typert_protocol pkg_file_reference --> pkg_agent + pkg_harness_dev_context --> pkg_agent + pkg_harness_dev_context --> pkg_session + pkg_harness_dev_context --> pkg_session_projection + pkg_harness_dev_context --> pkg_system_prompt pkg_time_context --> pkg_agent pkg_time_context --> pkg_llm pkg_time_context --> pkg_session @@ -706,6 +711,7 @@ flowchart TD pkg_llm_deepseek_account --> pkg_launch_environment pkg_llm_deepseek_account --> pkg_llm pkg_llm_deepseek_account --> pkg_llm_deepseek + pkg_llm_deepseek_account --> pkg_timeout pkg_tools --> pkg_agent pkg_tools --> pkg_llm pkg_tools --> pkg_ptc_runtime @@ -719,10 +725,6 @@ flowchart TD pkg_command_goal --> pkg_commands pkg_command_goal --> pkg_goal pkg_command_goal --> pkg_llm - pkg_goal_round_driver --> pkg_agent - pkg_goal_round_driver --> pkg_goal - pkg_goal_round_driver --> pkg_llm - pkg_goal_round_driver --> pkg_session pkg_fs_sandbox --> pkg_fs pkg_fs_sandbox --> pkg_fs_local pkg_fs_sandbox --> pkg_sandbox @@ -1048,7 +1050,6 @@ flowchart TD pkg_api_settings_controller --> pkg_settings pkg_api_settings_controller --> pkg_typert_protocol pkg_web_app --> pkg_shell_env - pkg_web_app --> pkg_system_prompt pkg_experimental_auto_review --> pkg_agent pkg_experimental_auto_review --> pkg_agent_instructions pkg_experimental_auto_review --> pkg_llm @@ -1216,6 +1217,11 @@ flowchart TD pkg_mcp_manager --> pkg_tools pkg_mcp_manager --> pkg_typert_protocol pkg_mcp_manager --> pkg_util_values + pkg_goal_round_driver --> pkg_agent + pkg_goal_round_driver --> pkg_goal + pkg_goal_round_driver --> pkg_llm + pkg_goal_round_driver --> pkg_session + pkg_goal_round_driver --> pkg_subagent pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_llm pkg_subagent_acp --> pkg_session @@ -1237,6 +1243,7 @@ flowchart TD pkg_subagent_fork_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent pkg_tool_subagent --> pkg_agent + pkg_tool_subagent --> pkg_agent_preset_registry pkg_tool_subagent --> pkg_llm pkg_tool_subagent --> pkg_scope pkg_tool_subagent --> pkg_session @@ -1502,6 +1509,7 @@ flowchart TD | [`client-ui-reference`](../packages/client/ui-reference) | `client` | — | | [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | — | | [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | — | +| [`client-ui-secret-mask`](../packages/client/ui-secret-mask) | `client` | — | | [`client-ui-session`](../packages/client/ui-session) | `client` | — | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | — | | [`client-ui-settings-account`](../packages/client/ui-settings-account) | `client` | — | @@ -1569,6 +1577,7 @@ flowchart TD | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | +| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | @@ -1590,7 +1599,7 @@ flowchart TD | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | -| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | +| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | @@ -1611,7 +1620,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | -| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | +| [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) | | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) | | [`experimental-translator`](../packages/experimental/translator) | `experimental` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) | @@ -1630,7 +1639,6 @@ flowchart TD | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs) | | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`skill`](../packages/skill/skill) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | -| [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) | | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`subprocess`](../packages/subprocess/subprocess) | | [`hook-protocol`](../packages/experimental/hook-protocol) | `experimental` | [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | @@ -1642,6 +1650,7 @@ flowchart TD | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | +| [`harness-dev-context`](../packages/context/harness-dev-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | | [`deepseek-account`](../packages/credentials/deepseek-account) | `credentials` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | @@ -1656,10 +1665,9 @@ flowchart TD | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | +| [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`timeout`](../packages/util/timeout) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | -| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | @@ -1722,7 +1730,7 @@ flowchart TD | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | -| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env) | | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`webhook`](../packages/experimental/webhook) | `experimental` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | @@ -1747,12 +1755,13 @@ flowchart TD | [`tool-session-query`](../packages/experimental/tool-session-query) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`webhook-github`](../packages/experimental/webhook-github) | `experimental` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/experimental/webhook) | | [`mcp-manager`](../packages/mcp/mcp-manager) | `mcp` | [`mcp-client`](../packages/mcp/mcp-client), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) | +| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) | -| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index dab112d89b..ab62a37959 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 1387c4505dde2faf zh: 48570fa1c13d6910 /session-persistence-event-catalog/persistence-type-fingerprints: - en: d8542cb8987022e0 - zh: 38e135021cb21f20 + en: f6421cacbfe510db + zh: 6c24e981ac836d0c /session-persistence-event-catalog/event-envelope: en: 7ebb1a2e0dd1f916 zh: 8946f415dda5c1b8 @@ -27,8 +27,8 @@ en: 49891ca9be5ac123 zh: 49891ca9be5ac123 /session-persistence-event-catalog/events/agent-preset/agent-preset-selected-log-only: - en: 0cb4079706a5395b - zh: 9f1a0d8564c54d98 + en: 9eb4e8ec6b503d83 + zh: 6d96288a30d92b81 /session-persistence-event-catalog/events/approval: en: 9d2ec4c4c3664b9f zh: 9d2ec4c4c3664b9f @@ -119,6 +119,15 @@ /session-persistence-event-catalog/events/image/image-offload-log-only: en: 5b3bba4fc4261208 zh: 6d5f536c3ca92710 +/session-persistence-event-catalog/events/job: + en: 941358a5fef6a5b4 + zh: 941358a5fef6a5b4 +/session-persistence-event-catalog/events/job/job-registered-log-only: + en: 37ab7f962b199555 + zh: af05a3274adbe530 +/session-persistence-event-catalog/events/job/job-settled-log-only: + en: dde416e1c0d32dfb + zh: fa08197cad8acb9d /session-persistence-event-catalog/events/llm: en: 8a5c09e48ec0a6ae zh: 8a5c09e48ec0a6ae @@ -540,8 +549,8 @@ en: 028791baa24a6a0a zh: 028791baa24a6a0a /session-persistence-event-catalog/resolved-persistence-types/in-history: - en: 7ee8e3e9b3947c22 - zh: 035ceca2d5031800 + en: 8bda2ac68f50953b + zh: 190a69ddd097c445 /session-persistence-event-catalog/resolved-persistence-types/in-progress: en: 61de2b0c9de8c15b zh: 61de2b0c9de8c15b @@ -555,8 +564,20 @@ en: 759b0bea643a3f2b zh: 759b0bea643a3f2b /session-persistence-event-catalog/resolved-persistence-types/interrupted: - en: 69a7a30a4e43ff35 - zh: 69a7a30a4e43ff35 + en: 9f4bd316342f65ef + zh: 9f4bd316342f65ef +/session-persistence-event-catalog/resolved-persistence-types/job-registered: + en: 19aa7833c2187c48 + zh: 19aa7833c2187c48 +/session-persistence-event-catalog/resolved-persistence-types/job-settled: + en: 0a0b96a87bf8ad25 + zh: 0a0b96a87bf8ad25 +/session-persistence-event-catalog/resolved-persistence-types/kill: + en: 0401f4463ce25ee0 + zh: 0401f4463ce25ee0 +/session-persistence-event-catalog/resolved-persistence-types/killed: + en: cf74df062715315c + zh: cf74df062715315c /session-persistence-event-catalog/resolved-persistence-types/legacy: en: 1fc6c6420d6eb307 zh: 1fc6c6420d6eb307 @@ -624,8 +645,11 @@ en: de774562ec890a6c zh: de774562ec890a6c /session-persistence-event-catalog/resolved-persistence-types/positive: - en: 2a01e347d9604ac6 - zh: 2a01e347d9604ac6 + en: e048b2b8f8165011 + zh: e048b2b8f8165011 +/session-persistence-event-catalog/resolved-persistence-types/producer: + en: b829f105842b826c + zh: b829f105842b826c /session-persistence-event-catalog/resolved-persistence-types/product-interaction: en: 05fe435810456683 zh: 05fe435810456683 @@ -783,8 +807,11 @@ en: d683235390472024 zh: d683235390472024 /session-persistence-event-catalog/resolved-persistence-types/team-task: - en: f8a5fe2380e1a4c4 - zh: f8a5fe2380e1a4c4 + en: 7aa9286372341561 + zh: 7aa9286372341561 +/session-persistence-event-catalog/resolved-persistence-types/teardown: + en: 301cd78f24fe13c5 + zh: 301cd78f24fe13c5 /session-persistence-event-catalog/resolved-persistence-types/text: en: 20b696e15c9cf906 zh: 20b696e15c9cf906 @@ -1041,8 +1068,20 @@ en: a3087ab7bebe9371 zh: abf6afc2f9f5b692 /session-persistence-event-catalog/resolved-persistence-types/inboxtarget: - en: 49855060f429f597 - zh: 19d1db4ab892ba44 + en: 938113c1b01efad1 + zh: d6829dbe979e6819 +/session-persistence-event-catalog/resolved-persistence-types/jobregistereddata: + en: f604eebb973fe2cf + zh: 0c0aba1e17ecf66e +/session-persistence-event-catalog/resolved-persistence-types/jobsettlecause: + en: 748a4269d86e007a + zh: 4ce04c4451bdcfe8 +/session-persistence-event-catalog/resolved-persistence-types/jobsettleddata: + en: fc8caf270a7581f7 + zh: b467e1d4191106eb +/session-persistence-event-catalog/resolved-persistence-types/jobtoolcall: + en: 7ffee3cc96604a88 + zh: 026abfab0e46c21a /session-persistence-event-catalog/resolved-persistence-types/jsonvalue: en: c239aa9c90226c35 zh: 3561e77832c7c8b5 @@ -1128,8 +1167,8 @@ en: 07e04c3e336f880f zh: 6f8986ede9994fa1 /session-persistence-event-catalog/resolved-persistence-types/replayenvelope: - en: 9a80b6f5eb08eaea - zh: 061c632b8f3e4d87 + en: 3460f0c910a247fd + zh: 3990587ab01a1907 /session-persistence-event-catalog/resolved-persistence-types/requestcontext: en: cc8bb7075d546941 zh: 77209abf30744be0 @@ -1176,8 +1215,8 @@ en: ecc15ebadb77afdf zh: 97ac27d6a4175f6f /session-persistence-event-catalog/resolved-persistence-types/streamchunk: - en: 0d571899222eb04f - zh: de28c6d67d1763c1 + en: 12fe93a89508739b + zh: 03306c0f7b124f0d /session-persistence-event-catalog/resolved-persistence-types/subagentcatalogevent: en: 725539dcd5c335ed zh: 5ca08d3667f84ff9 @@ -1245,8 +1284,8 @@ en: 2258683c3cfa4ce3 zh: 5663aa76c911761b /session-persistence-event-catalog/resolved-persistence-types/toolschema: - en: 6e1e6ebc7c1c38c8 - zh: e47a3ca80c4c3b71 + en: eb76c5bd050ffccd + zh: cb24780d4b5f79ac /session-persistence-event-catalog/resolved-persistence-types/toolschema~2: en: 12c14727bb7834e2 zh: b0f3c877873c36db @@ -1296,11 +1335,11 @@ en: 5e7d82a493a1d4d8 zh: d2d785891dd766fc /session-persistence-event-catalog/resolved-persistence-types/number~2: - en: 386989934d1bb62e - zh: 69b8bc182a5acbd3 + en: 2786fef560851bb4 + zh: 5d787475356b2606 /session-persistence-event-catalog/resolved-persistence-types/string: - en: 90d3fd7202322ca4 - zh: 88cce532aed9f6d4 + en: 5fa180e9ba84fb2f + zh: ba6249510e691374 /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1329,9 +1368,12 @@ en: ab07ee7c506483d4 zh: 299e31a106d93dfe /session-persistence-event-catalog/resolved-persistence-types/union-3-variants~2: - en: faa507a889e1c476 - zh: b5f13f6f0590152f + en: 5095d352cbd90e21 + zh: e0bfa5c90e3917b6 /session-persistence-event-catalog/resolved-persistence-types/union-3-variants~3: + en: 9e296094df7462ba + zh: e7e3255a3ada9279 +/session-persistence-event-catalog/resolved-persistence-types/union-3-variants~4: en: 5fc89a7ec1cecd78 zh: dc66aa8e4aae1847 /session-persistence-event-catalog/resolved-persistence-types/union-6-variants: @@ -1350,8 +1392,8 @@ en: a3de6a6fe9672c7a zh: a357481e27b3b81f /session-persistence-event-catalog/resolved-persistence-types/agentpreset: - en: b8a0f8aac9708056 - zh: f5f004bd205b41e1 + en: 1afbe8196f161601 + zh: a320a36a8928817c /session-persistence-event-catalog/resolved-persistence-types/allowedmodels: en: 6178321110ceb69d zh: 0a9d94cab9d57487 @@ -1623,8 +1665,8 @@ en: c53129cf7214286c zh: 8c5183b6dabbf714 /session-persistence-event-catalog/resolved-persistence-types/kind-ptc-mode: - en: 26b3b9239392dcc9 - zh: f4c6c0195c9119fc + en: 8de77980e11a0562 + zh: 6de199e70a666a71 /session-persistence-event-catalog/resolved-persistence-types/kind-repeat-tool-reminder: en: e3a0ef98a197374e zh: 95e9363529a62cc3 @@ -1782,8 +1824,8 @@ en: 79db83dd2c430faf zh: 173f568241f5ce86 /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: cb5d3fbab4debac6 - zh: 72add55254356149 + en: d23abbfb20eb4fc0 + zh: 65207e79db74e2fe /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 @@ -1890,11 +1932,11 @@ en: bb507524bcc39c53 zh: 8314898562437473 /session-persistence-event-catalog/resolved-persistence-types/type-block-end: - en: 8c5a05fa6adf3e70 - zh: 66768b25d6fb3e07 + en: c5acb63761d0cbb8 + zh: 6caad01b80c6eb0d /session-persistence-event-catalog/resolved-persistence-types/type-block-start: - en: 5caed426cab416ee - zh: 86f47f90f8c440f2 + en: c52a22b7878bde2f + zh: 42f3fae52cf09ebb /session-persistence-event-catalog/resolved-persistence-types/type-chunk: en: 2186ede3e9917aea zh: 0cfef64045dce95b @@ -1932,8 +1974,8 @@ en: 14e4b48d856bcf57 zh: 72ba176535dc7f3f /session-persistence-event-catalog/resolved-persistence-types/type-finish: - en: 21627e72a24990ff - zh: d71375de325ee9f8 + en: 359385a004565fda + zh: 313f78d5906a3128 /session-persistence-event-catalog/resolved-persistence-types/type-goal-change: en: aad2516b62d3ee83 zh: e7204c4dc4e8e408 @@ -1944,8 +1986,14 @@ en: 8b523e0be5327519 zh: 86ec891087b8bf24 /session-persistence-event-catalog/resolved-persistence-types/type-image-offload: - en: 06230b6290fa9826 - zh: 11930bfa355b70fa + en: bf512693267d4262 + zh: 805710898b4e913a +/session-persistence-event-catalog/resolved-persistence-types/type-job-registered: + en: 734d3bb283cc6f66 + zh: 75bcb55f71673e34 +/session-persistence-event-catalog/resolved-persistence-types/type-job-settled: + en: 742c3a1543414ecb + zh: e256fb92d72b8184 /session-persistence-event-catalog/resolved-persistence-types/type-llm-retry: en: 6d026baeac30a0f1 zh: 55131683e3d5ed27 @@ -1965,8 +2013,8 @@ en: 34e5591d75322649 zh: a216839f305c9796 /session-persistence-event-catalog/resolved-persistence-types/type-reasoning-delta: - en: a18feb76be195fa0 - zh: 3db7147ee4c5c963 + en: 8b5e884e0c0ab11b + zh: 3b291d80028b6583 /session-persistence-event-catalog/resolved-persistence-types/type-request-context: en: f8addb1a01da9ca1 zh: 4ef11ebddd302c05 @@ -2025,8 +2073,8 @@ en: 1484738718de5312 zh: 3e6fbe9c64068225 /session-persistence-event-catalog/resolved-persistence-types/type-text-delta: - en: 47ae2d57be710450 - zh: b072753349ac505e + en: bac4775a1255bbbb + zh: 834b81d203e21b7c /session-persistence-event-catalog/resolved-persistence-types/type-todo-write: en: 2fa7ba77598a95b4 zh: 50972b51d77e9155 @@ -2034,8 +2082,8 @@ en: 32f732b97739e4d2 zh: f288e48e356969c0 /session-persistence-event-catalog/resolved-persistence-types/type-tool-call-delta: - en: 1df89f427ccac815 - zh: 1bec3d025ea1f7e3 + en: a172400ce8d6dbe7 + zh: 1d6cba18564d0230 /session-persistence-event-catalog/resolved-persistence-types/type-tool-workflow-agent-end: en: 8eb5e1eb95722bc7 zh: 8dd5980029600648 @@ -2067,8 +2115,8 @@ en: 1b045de96172be4b zh: 8a15c6f35b576b37 /session-persistence-event-catalog/resolved-persistence-types/type-usage: - en: fa542ccb91698e56 - zh: 7b725c813d69588d + en: a5579e3f96903381 + zh: deac8bf4949cf17a /session-persistence-event-catalog/resolved-persistence-types/type-user-message: en: fb49c375aec5952c zh: 19b80920c1068b86 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 6d4c694309..32bdc47a0a 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -44,6 +44,8 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:hook/invoked` | event | `8a6e1ec9e8db346b0e02f027db73c07a94f067a26d40c1aef1abd09c47ce7ba0` | [`{ type: "hook/invoked" }`](#persistence-type-sha256-8a6e1ec9e8db346b0e02f027db73c07a94f067a26d40c1aef1abd09c47ce7ba0) | | `event:hook/result` | event | `e75916628f3f10c2d50658bd143052a46285fbf1a9a700ba54947614603d26b4` | [`{ type: "hook/result" }`](#persistence-type-sha256-e75916628f3f10c2d50658bd143052a46285fbf1a9a700ba54947614603d26b4) | | `event:image/offload` | event | `b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e` | [`{ type: "image/offload" }`](#persistence-type-sha256-b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e) | +| `event:job/registered` | event | `f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1` | [`{ type: "job/registered" }`](#persistence-type-sha256-f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1) | +| `event:job/settled` | event | `edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf` | [`{ type: "job/settled" }`](#persistence-type-sha256-edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf) | | `event:llm/retry` | event | `525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336` | [`{ type: "llm/retry" }`](#persistence-type-sha256-525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336) | | `event:llm/retry-started` | event | `48e5c9861f16ac07e78cb7b5ae9dabdf7bb85c58baed5a51b4ad275050ea58e3` | [`{ type: "llm/retry-started" }`](#persistence-type-sha256-48e5c9861f16ac07e78cb7b5ae9dabdf7bb85c58baed5a51b4ad275050ea58e3) | | `event:model/selection` | event | `35203ba7ad5ef6f97d556b85df20ae98f04f09c65748cecdf8eefdb8b6405ffc` | [`{ type: "model/selection" }`](#persistence-type-sha256-35203ba7ad5ef6f97d556b85df20ae98f04f09c65748cecdf8eefdb8b6405ffc) | @@ -196,12 +198,15 @@ Source: [`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types * The session's agent preset was chosen after creation, while the session * was still blank. Log-only: it records the composition later turns ran * under, so a resumed or forked session rebuilds the same one instead of - * the header's creation-time value. + * the header's creation-time value. In a child Session, an occurrence + * before its first own `subagent/descriptor` marks an independent spawn + * selection that cold resume restores instead of joining the parent's + * composition (`dsh-subagent` README, "Restoring child composition"). */ 'agent-preset/selected': { agentPreset: string } ``` -Source: [`packages/preset/agent-preset-registry/src/session.ts:28`](../packages/preset/agent-preset-registry/src/session.ts) +Source: [`packages/preset/agent-preset-registry/src/session.ts:31`](../packages/preset/agent-preset-registry/src/session.ts) ### `approval/*` @@ -627,6 +632,36 @@ Source: [`packages/experimental/hook-protocol/src/types.ts:31`](../packages/expe Source: [`packages/compaction/compaction-image-offload/src/projection.ts:25`](../packages/compaction/compaction-image-offload/src/projection.ts) +### `job/*` + + + +#### `job/registered` — log-only + +```ts persistence-catalog +/** + * Log-only job registration; model output collection is not required. + * @param data - Execution identity, original owner, registration time, and creating tool call. + */ +'job/registered': JobRegisteredData +``` + +Source: [`packages/jobs/jobs/src/types.ts:59`](../packages/jobs/jobs/src/types.ts) + + + +#### `job/settled` — log-only + +```ts persistence-catalog +/** + * Log-only terminal record, paired by the same unique jobId used for live control. + * @param data - Registration facts plus settlement time, outcome, and producer acknowledgement. + */ +'job/settled': JobSettledData +``` + +Source: [`packages/jobs/jobs/src/types.ts:64`](../packages/jobs/jobs/src/types.ts) + ### `llm/*` @@ -1954,7 +1989,7 @@ SHA-256: `ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8` SHA-256: `f311671e07bc7fa645a0b6597de9966b97147956565296ba76a36543801b66c5` -Sources: [`packages/llm/llm/src/types.ts:396`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:412`](../packages/llm/llm/src/types.ts) `"in-history"` @@ -1998,6 +2033,38 @@ SHA-256: `69d238a6e9b08d67f601b1825962963d8d3523cb69ebf6208c697dc5d058c199` `"interrupted"` + + +### `"job/registered"` + +SHA-256: `24a8188c4d61b70a01af8cc576748765d36f132632ec55c0064f6fba482369fc` + +`"job/registered"` + + + +### `"job/settled"` + +SHA-256: `347de3380f2da308d0c74e08821f9e85559f55af4c5010795b110d00f7fc9dbd` + +`"job/settled"` + + + +### `"kill"` + +SHA-256: `bab68657940075483f086fffb711682a45bcc99c43d659fbe1c36af21ac05dd7` + +`"kill"` + + + +### `"killed"` + +SHA-256: `ef226a90ddac6cd8e631a3a337a3a3e27f9494be92befb7c718b404681d75e21` + +`"killed"` + ### `"legacy"` @@ -2182,6 +2249,14 @@ SHA-256: `df5d9709b7db2a72510dc52052a23f0c38f39730af1504bab8bccabc14fae528` `"positive"` + + +### `"producer"` + +SHA-256: `6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65` + +`"producer"` + ### `"product-interaction"` @@ -2606,6 +2681,14 @@ SHA-256: `b57ae1b37e52bee0a21cbdace168dc6a15582bcda8c306dad962c5dcc8a8e28b` `"team/task"` + + +### `"teardown"` + +SHA-256: `5cb63db9fe57dc09c3a1c2b84c3edf73ff5b62960155d8436a2377a5c43ce32c` + +`"teardown"` + ### `"text"` @@ -3696,6 +3779,88 @@ One of: - `"next-step"` - `"next-turn"` + + + + + + +### `JobRegisteredData` + +SHA-256: `e3056e0dda3a379e895386e368c3ce42131460ea65e410e1d091cca98a347305` + +Sources: [`packages/jobs/jobs/src/types.ts:24`](../packages/jobs/jobs/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `jobId` | required | `string` | +| `kind` | required | `string` | +| `label` | required | `string` | +| `owner` | required | `string` | +| `startedAt` | required | `number` | +| `toolCall` | optional | [`JobToolCall`](#persistence-type-sha256-39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f) | + + + + + + + +### `JobSettleCause` + +SHA-256: `750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5` + +Sources: [`packages/jobs/jobs/src/types.ts:247`](../packages/jobs/jobs/src/types.ts) + +One of: + +- `"kill"` +- `"producer"` +- `"teardown"` + + + + + + + +### `JobSettledData` + +SHA-256: `611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da` + +Sources: [`packages/jobs/jobs/src/types.ts:40`](../packages/jobs/jobs/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `cause` | required | [`JobSettleCause`](#persistence-type-sha256-750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5) | +| `detail` | optional | `string` | +| `finishedAt` | required | `number` | +| `jobId` | required | `string` | +| `kind` | required | `string` | +| `label` | required | `string` | +| `owner` | required | `string` | +| `producerReported` | required | `boolean` | +| `startedAt` | required | `number` | +| `status` | required | [`union (3 variants)`](#persistence-type-sha256-c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3) | +| `toolCall` | optional | [`JobToolCall`](#persistence-type-sha256-39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f) | + + + + + + + +### `JobToolCall` + +SHA-256: `39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f` + +Sources: [`packages/jobs/jobs/src/types.ts:16`](../packages/jobs/jobs/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `callId` | required | `string` | +| `rootCallId` | required | `string` | + @@ -4314,7 +4479,7 @@ Sources: [`packages/llm/llm/src/types.ts:68`](../packages/llm/llm/src/types.ts) SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555` -Sources: [`packages/llm/llm/src/types.ts:430`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:446`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4609,7 +4774,7 @@ Sources: [`packages/skill/skill/src/index.ts:146`](../packages/skill/skill/src/i SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` -Sources: [`packages/llm/llm/src/types.ts:452`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:468`](../packages/llm/llm/src/types.ts) One of: @@ -5034,7 +5199,7 @@ Sources: [`packages/llm/llm/src/message.ts:173`](../packages/llm/llm/src/message SHA-256: `66ba70f6f068da443f969a0b947a013c3addd776b9b60a0a393759c19825e9a2` -Sources: [`packages/llm/llm/src/types.ts:473`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:489`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5298,6 +5463,8 @@ Array of `number`. + + @@ -5330,6 +5497,8 @@ Array of `number`. + + @@ -5382,7 +5551,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:403`](../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:20`](../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/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:408`](../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:20`](../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` @@ -5491,6 +5660,18 @@ One of: - `"replace"` - `"set"` + + +### `union (3 variants)` + +SHA-256: `c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3` + +One of: + +- `"completed"` +- `"failed"` +- `"killed"` + ### `union (3 variants)` @@ -5568,7 +5749,7 @@ Sources: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/ SHA-256: `6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406` -Sources: [`packages/preset/agent-preset-registry/src/session.ts:28`](../packages/preset/agent-preset-registry/src/session.ts) +Sources: [`packages/preset/agent-preset-registry/src/session.ts:31`](../packages/preset/agent-preset-registry/src/session.ts) | Property | Presence | Type | |---|---|---| @@ -6747,7 +6928,7 @@ Sources: [`packages/session/session-title/src/types.ts:30`](../packages/session/ SHA-256: `dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd` -Sources: [`packages/core/tools/src/ptc.ts:14`](../packages/core/tools/src/ptc.ts) +Sources: [`packages/core/tools/src/ptc.ts:15`](../packages/core/tools/src/ptc.ts) | Property | Presence | Type | |---|---|---| @@ -7341,7 +7522,7 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -Sources: [`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:413`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7831,7 +8012,7 @@ SHA-256: `1033093edd0db80ff410e00830b523405e00bb0c7684948e531ff65095799625` SHA-256: `d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d` -Sources: [`packages/llm/llm/src/types.ts:457`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:473`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7845,7 +8026,7 @@ Sources: [`packages/llm/llm/src/types.ts:457`](../packages/llm/llm/src/types.ts) SHA-256: `3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90` -Sources: [`packages/llm/llm/src/types.ts:453`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:469`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8051,7 +8232,7 @@ SHA-256: `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` SHA-256: `a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1` -Sources: [`packages/llm/llm/src/types.ts:459`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:475`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8123,6 +8304,38 @@ SHA-256: `b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e` | `time` | required | `number` | | `type` | required | `"image/offload"` | + + + + +### `{ type: "job/registered" }` + +SHA-256: `f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1` + +| Property | Presence | Type | +|---|---|---| +| `data` | required | [`JobRegisteredData`](#persistence-type-sha256-e3056e0dda3a379e895386e368c3ce42131460ea65e410e1d091cca98a347305) | +| `ignorable` | optional | `true` | +| `seq` | required | `number` | +| `time` | required | `number` | +| `type` | required | `"job/registered"` | + + + + + +### `{ type: "job/settled" }` + +SHA-256: `edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf` + +| Property | Presence | Type | +|---|---|---| +| `data` | required | [`JobSettledData`](#persistence-type-sha256-611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da) | +| `ignorable` | optional | `true` | +| `seq` | required | `number` | +| `time` | required | `number` | +| `type` | required | `"job/settled"` | + @@ -8225,7 +8438,7 @@ Sources: [`packages/llm/llm/src/assistant-stream.ts:28`](../packages/llm/llm/src SHA-256: `2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095` -Sources: [`packages/llm/llm/src/types.ts:455`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:471`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8545,7 +8758,7 @@ Sources: [`packages/llm/llm/src/assistant-stream.ts:21`](../packages/llm/llm/src SHA-256: `95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65` -Sources: [`packages/llm/llm/src/types.ts:454`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:470`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8593,7 +8806,7 @@ Sources: [`packages/llm/llm/src/assistant-stream.ts:35`](../packages/llm/llm/src SHA-256: `88a610b831be6d92612a464be5c7d0b88ec24d97bd299837bf74bf7672183c9a` -Sources: [`packages/llm/llm/src/types.ts:456`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:472`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8771,7 +8984,7 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515` -Sources: [`packages/llm/llm/src/types.ts:458`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:474`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 92024676bd..991e8743f7 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -46,6 +46,8 @@ | `event:hook/invoked` | event | `8a6e1ec9e8db346b0e02f027db73c07a94f067a26d40c1aef1abd09c47ce7ba0` | [`{ type: "hook/invoked" }`](#persistence-type-sha256-8a6e1ec9e8db346b0e02f027db73c07a94f067a26d40c1aef1abd09c47ce7ba0) | | `event:hook/result` | event | `e75916628f3f10c2d50658bd143052a46285fbf1a9a700ba54947614603d26b4` | [`{ type: "hook/result" }`](#persistence-type-sha256-e75916628f3f10c2d50658bd143052a46285fbf1a9a700ba54947614603d26b4) | | `event:image/offload` | event | `b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e` | [`{ type: "image/offload" }`](#persistence-type-sha256-b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e) | +| `event:job/registered` | event | `f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1` | [`{ type: "job/registered" }`](#persistence-type-sha256-f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1) | +| `event:job/settled` | event | `edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf` | [`{ type: "job/settled" }`](#persistence-type-sha256-edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf) | | `event:llm/retry` | event | `525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336` | [`{ type: "llm/retry" }`](#persistence-type-sha256-525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336) | | `event:llm/retry-started` | event | `48e5c9861f16ac07e78cb7b5ae9dabdf7bb85c58baed5a51b4ad275050ea58e3` | [`{ type: "llm/retry-started" }`](#persistence-type-sha256-48e5c9861f16ac07e78cb7b5ae9dabdf7bb85c58baed5a51b4ad275050ea58e3) | | `event:model/selection` | event | `35203ba7ad5ef6f97d556b85df20ae98f04f09c65748cecdf8eefdb8b6405ffc` | [`{ type: "model/selection" }`](#persistence-type-sha256-35203ba7ad5ef6f97d556b85df20ae98f04f09c65748cecdf8eefdb8b6405ffc) | @@ -198,12 +200,15 @@ export type SessionEvent = { * The session's agent preset was chosen after creation, while the session * was still blank. Log-only: it records the composition later turns ran * under, so a resumed or forked session rebuilds the same one instead of - * the header's creation-time value. + * the header's creation-time value. In a child Session, an occurrence + * before its first own `subagent/descriptor` marks an independent spawn + * selection that cold resume restores instead of joining the parent's + * composition (`dsh-subagent` README, "Restoring child composition"). */ 'agent-preset/selected': { agentPreset: string } ``` -来源:[`packages/preset/agent-preset-registry/src/session.ts:28`](../packages/preset/agent-preset-registry/src/session.ts) +来源:[`packages/preset/agent-preset-registry/src/session.ts:31`](../packages/preset/agent-preset-registry/src/session.ts) ### `approval/*` @@ -629,6 +634,36 @@ export type SessionEvent = { 来源:[`packages/compaction/compaction-image-offload/src/projection.ts:25`](../packages/compaction/compaction-image-offload/src/projection.ts) +### `job/*` + + + +#### `job/registered` — log-only + +```ts persistence-catalog +/** + * Log-only job registration; model output collection is not required. + * @param data - Execution identity, original owner, registration time, and creating tool call. + */ +'job/registered': JobRegisteredData +``` + +来源:[`packages/jobs/jobs/src/types.ts:59`](../packages/jobs/jobs/src/types.ts) + + + +#### `job/settled` — log-only + +```ts persistence-catalog +/** + * Log-only terminal record, paired by the same unique jobId used for live control. + * @param data - Registration facts plus settlement time, outcome, and producer acknowledgement. + */ +'job/settled': JobSettledData +``` + +来源:[`packages/jobs/jobs/src/types.ts:64`](../packages/jobs/jobs/src/types.ts) + ### `llm/*` @@ -1956,7 +1991,7 @@ SHA-256: `ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8` SHA-256: `f311671e07bc7fa645a0b6597de9966b97147956565296ba76a36543801b66c5` -来源:[`packages/llm/llm/src/types.ts:396`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:412`](../packages/llm/llm/src/types.ts) `"in-history"` @@ -2000,6 +2035,38 @@ SHA-256: `69d238a6e9b08d67f601b1825962963d8d3523cb69ebf6208c697dc5d058c199` `"interrupted"` + + +### `"job/registered"` + +SHA-256: `24a8188c4d61b70a01af8cc576748765d36f132632ec55c0064f6fba482369fc` + +`"job/registered"` + + + +### `"job/settled"` + +SHA-256: `347de3380f2da308d0c74e08821f9e85559f55af4c5010795b110d00f7fc9dbd` + +`"job/settled"` + + + +### `"kill"` + +SHA-256: `bab68657940075483f086fffb711682a45bcc99c43d659fbe1c36af21ac05dd7` + +`"kill"` + + + +### `"killed"` + +SHA-256: `ef226a90ddac6cd8e631a3a337a3a3e27f9494be92befb7c718b404681d75e21` + +`"killed"` + ### `"legacy"` @@ -2184,6 +2251,14 @@ SHA-256: `df5d9709b7db2a72510dc52052a23f0c38f39730af1504bab8bccabc14fae528` `"positive"` + + +### `"producer"` + +SHA-256: `6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65` + +`"producer"` + ### `"product-interaction"` @@ -2608,6 +2683,14 @@ SHA-256: `b57ae1b37e52bee0a21cbdace168dc6a15582bcda8c306dad962c5dcc8a8e28b` `"team/task"` + + +### `"teardown"` + +SHA-256: `5cb63db9fe57dc09c3a1c2b84c3edf73ff5b62960155d8436a2377a5c43ce32c` + +`"teardown"` + ### `"text"` @@ -3698,6 +3781,88 @@ SHA-256: `96f4a9c81fc0f940ef71528a8cc66731ae4cf1f40d79680c5fe3c39f96d7723c` - `"next-step"` - `"next-turn"` + + + + + + +### `JobRegisteredData` + +SHA-256: `e3056e0dda3a379e895386e368c3ce42131460ea65e410e1d091cca98a347305` + +来源:[`packages/jobs/jobs/src/types.ts:24`](../packages/jobs/jobs/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `jobId` | 必需 | `string` | +| `kind` | 必需 | `string` | +| `label` | 必需 | `string` | +| `owner` | 必需 | `string` | +| `startedAt` | 必需 | `number` | +| `toolCall` | 可选 | [`JobToolCall`](#persistence-type-sha256-39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f) | + + + + + + + +### `JobSettleCause` + +SHA-256: `750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5` + +来源:[`packages/jobs/jobs/src/types.ts:247`](../packages/jobs/jobs/src/types.ts) + +以下类型之一: + +- `"kill"` +- `"producer"` +- `"teardown"` + + + + + + + +### `JobSettledData` + +SHA-256: `611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da` + +来源:[`packages/jobs/jobs/src/types.ts:40`](../packages/jobs/jobs/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `cause` | 必需 | [`JobSettleCause`](#persistence-type-sha256-750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5) | +| `detail` | 可选 | `string` | +| `finishedAt` | 必需 | `number` | +| `jobId` | 必需 | `string` | +| `kind` | 必需 | `string` | +| `label` | 必需 | `string` | +| `owner` | 必需 | `string` | +| `producerReported` | 必需 | `boolean` | +| `startedAt` | 必需 | `number` | +| `status` | 必需 | [`union (3 variants)`](#persistence-type-sha256-c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3) | +| `toolCall` | 可选 | [`JobToolCall`](#persistence-type-sha256-39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f) | + + + + + + + +### `JobToolCall` + +SHA-256: `39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f` + +来源:[`packages/jobs/jobs/src/types.ts:16`](../packages/jobs/jobs/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `callId` | 必需 | `string` | +| `rootCallId` | 必需 | `string` | + @@ -4316,7 +4481,7 @@ SHA-256: `814d9434e1ddf9078612b3b34322f26e9363b469213335007f7e9ba7fee9464e` SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555` -来源:[`packages/llm/llm/src/types.ts:430`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:446`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4611,7 +4776,7 @@ SHA-256: `672c4aa2a9b736e3d6e5da6346d2e2ed5e3573f2d0baafa18e7407c9f3fee3db` SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` -来源:[`packages/llm/llm/src/types.ts:452`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:468`](../packages/llm/llm/src/types.ts) 以下类型之一: @@ -5036,7 +5201,7 @@ SHA-256: `4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731` SHA-256: `66ba70f6f068da443f969a0b947a013c3addd776b9b60a0a393759c19825e9a2` -来源:[`packages/llm/llm/src/types.ts:473`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:489`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5300,6 +5465,8 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` + + @@ -5332,6 +5499,8 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` + + @@ -5384,7 +5553,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:403`](../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:20`](../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/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:408`](../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:20`](../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` @@ -5493,6 +5662,18 @@ SHA-256: `904bf499d98be4a3a5f836fcac533b6c4899569b018071e6604e1d32657d00fd` - `"replace"` - `"set"` + + +### `union (3 variants)` + +SHA-256: `c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3` + +以下类型之一: + +- `"completed"` +- `"failed"` +- `"killed"` + ### `union (3 variants)` @@ -5570,7 +5751,7 @@ SHA-256: `20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5` SHA-256: `6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406` -来源:[`packages/preset/agent-preset-registry/src/session.ts:28`](../packages/preset/agent-preset-registry/src/session.ts) +来源:[`packages/preset/agent-preset-registry/src/session.ts:31`](../packages/preset/agent-preset-registry/src/session.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6749,7 +6930,7 @@ SHA-256: `b81e9de81a481b24e1e1f9adf038bfc59e57b4f35ce62f8f1e45284186f4f812` SHA-256: `dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd` -来源:[`packages/core/tools/src/ptc.ts:14`](../packages/core/tools/src/ptc.ts) +来源:[`packages/core/tools/src/ptc.ts:15`](../packages/core/tools/src/ptc.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7343,7 +7524,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -来源:[`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:413`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7833,7 +8014,7 @@ SHA-256: `1033093edd0db80ff410e00830b523405e00bb0c7684948e531ff65095799625` SHA-256: `d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d` -来源:[`packages/llm/llm/src/types.ts:457`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:473`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7847,7 +8028,7 @@ SHA-256: `d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d` SHA-256: `3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90` -来源:[`packages/llm/llm/src/types.ts:453`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:469`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8053,7 +8234,7 @@ SHA-256: `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` SHA-256: `a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1` -来源:[`packages/llm/llm/src/types.ts:459`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:475`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8125,6 +8306,38 @@ SHA-256: `b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e` | `time` | 必需 | `number` | | `type` | 必需 | `"image/offload"` | + + + + +### `{ type: "job/registered" }` + +SHA-256: `f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `data` | 必需 | [`JobRegisteredData`](#persistence-type-sha256-e3056e0dda3a379e895386e368c3ce42131460ea65e410e1d091cca98a347305) | +| `ignorable` | 可选 | `true` | +| `seq` | 必需 | `number` | +| `time` | 必需 | `number` | +| `type` | 必需 | `"job/registered"` | + + + + + +### `{ type: "job/settled" }` + +SHA-256: `edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `data` | 必需 | [`JobSettledData`](#persistence-type-sha256-611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da) | +| `ignorable` | 可选 | `true` | +| `seq` | 必需 | `number` | +| `time` | 必需 | `number` | +| `type` | 必需 | `"job/settled"` | + @@ -8227,7 +8440,7 @@ SHA-256: `71bb3254ec8f1e491ce63b762a7b5f18709de23aa4e8abcc8b886f4b6ce051dd` SHA-256: `2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095` -来源:[`packages/llm/llm/src/types.ts:455`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:471`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8547,7 +8760,7 @@ SHA-256: `e72269f931559e901e5e8fe1cfcbd0669fb795f43cc038ff39fba62f60403c33` SHA-256: `95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65` -来源:[`packages/llm/llm/src/types.ts:454`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:470`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8595,7 +8808,7 @@ SHA-256: `3860b4757d512a2869be833acb0faf834335b1b056bf3f83df5474dce95f8fa6` SHA-256: `88a610b831be6d92612a464be5c7d0b88ec24d97bd299837bf74bf7672183c9a` -来源:[`packages/llm/llm/src/types.ts:456`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:472`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8773,7 +8986,7 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515` -来源:[`packages/llm/llm/src/types.ts:458`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:474`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-changes/2026-09-30-job-session-lifecycle.i18n.yaml b/docs/persistence-changes/2026-09-30-job-session-lifecycle.i18n.yaml new file mode 100644 index 0000000000..c8b13e6c47 --- /dev/null +++ b/docs/persistence-changes/2026-09-30-job-session-lifecycle.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-09-30-job-session-lifecycle.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-09-30-job-session-lifecycle.md +/: + en: 3d5a424ae7d7e46b + zh: c22a8e4a62d8cc1a +/2026-09-30-job-session-lifecycle: + en: 9be8dc8cb5ce646a + zh: 6ce0040813c36e92 +/2026-09-30-job-session-lifecycle/summary: + en: 7d98020db3395e2e + zh: abe692ef42327e49 +/2026-09-30-job-session-lifecycle/table-of-contents: + en: 6cf314a069ceb451 + zh: 9adff678e9ecab3b +/2026-09-30-job-session-lifecycle/declaration: + en: 6907136f28e48c90 + zh: 02f8298485f3d8e7 +/2026-09-30-job-session-lifecycle/compatibility: + en: 546fe003ad5293b6 + zh: 86d78c9704a03718 +/2026-09-30-job-session-lifecycle/verification: + en: e9660a31a43378fc + zh: e97fbe31ff47ea2e +/2026-09-30-job-session-lifecycle/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-09-30-job-session-lifecycle.md b/docs/persistence-changes/2026-09-30-job-session-lifecycle.md new file mode 100644 index 0000000000..d77ccc2c87 --- /dev/null +++ b/docs/persistence-changes/2026-09-30-job-session-lifecycle.md @@ -0,0 +1,52 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-09-30-job-session-lifecycle + +English | [中文](2026-09-30-job-session-lifecycle.zh.md) + +## Summary + +Records owned job registration and settlement with one unique JobId, original timestamps, creating tool calls, and producer completion reports. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-30-job-session-lifecycle +baseline: false +changes: + - root: "event:job/registered" + previous: null + after: "f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1" + decision: same-version + - root: "event:job/settled" + previous: null + after: "edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf" + decision: same-version +``` + + +## Compatibility + +Adds required job event kinds without changing the Session envelope. New JobIds use a kind-prefixed UUID for both live controls and durable pairing; historical sessions receive no fabricated job history. Older builds reject unknown required events. producerReported describes completion-promise fulfillment, not verified resource release. + + +## Verification + +Registry and snapshot-normalizer tests cover causal order and unique JobIds. CLI and SDK snapshots verify registration, settlement, and notices during execution; the [job lifecycle contract](../subsystems/jobs.md#session-lifecycle-records) allows incomplete terminal history during application teardown. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-09-30-job-session-lifecycle.schema.json b/docs/persistence-changes/2026-09-30-job-session-lifecycle.schema.json new file mode 100644 index 0000000000..f31a0696c3 --- /dev/null +++ b/docs/persistence-changes/2026-09-30-job-session-lifecycle.schema.json @@ -0,0 +1,297 @@ +{ + "formatVersion": 2, + "roots": [ + { + "key": "event:job/registered", + "kind": "event", + "event": "job/registered", + "surface": false, + "digest": "f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 5, + "optional": true + }, + { + "name": "seq", + "type": 3, + "optional": false + }, + { + "name": "time", + "type": 3, + "optional": false + }, + { + "name": "type", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "jobId", + "type": 2, + "optional": false + }, + { + "name": "kind", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": false + }, + { + "name": "owner", + "type": 2, + "optional": false + }, + { + "name": "startedAt", + "type": 3, + "optional": false + }, + { + "name": "toolCall", + "type": 4, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 2, + "optional": false + }, + { + "name": "rootCallId", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "job/registered" + } + ] + } + }, + { + "key": "event:job/settled", + "kind": "event", + "event": "job/settled", + "surface": false, + "digest": "edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 14, + "optional": true + }, + { + "name": "seq", + "type": 7, + "optional": false + }, + { + "name": "time", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "cause", + "type": 2, + "optional": false + }, + { + "name": "detail", + "type": 6, + "optional": true + }, + { + "name": "finishedAt", + "type": 7, + "optional": false + }, + { + "name": "jobId", + "type": 6, + "optional": false + }, + { + "name": "kind", + "type": 6, + "optional": false + }, + { + "name": "label", + "type": 6, + "optional": false + }, + { + "name": "owner", + "type": 6, + "optional": false + }, + { + "name": "producerReported", + "type": 8, + "optional": false + }, + { + "name": "startedAt", + "type": 7, + "optional": false + }, + { + "name": "status", + "type": 9, + "optional": false + }, + { + "name": "toolCall", + "type": 13, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 3, + 4, + 5 + ] + }, + { + "kind": "literal", + "value": "producer" + }, + { + "kind": "literal", + "value": "teardown" + }, + { + "kind": "literal", + "value": "kill" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "union", + "types": [ + 10, + 11, + 12 + ] + }, + { + "kind": "literal", + "value": "completed" + }, + { + "kind": "literal", + "value": "failed" + }, + { + "kind": "literal", + "value": "killed" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 6, + "optional": false + }, + { + "name": "rootCallId", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "job/settled" + } + ] + } + } + ], + "types": [] +} diff --git a/docs/persistence-changes/2026-09-30-job-session-lifecycle.zh.md b/docs/persistence-changes/2026-09-30-job-session-lifecycle.zh.md new file mode 100644 index 0000000000..b808a0e1d6 --- /dev/null +++ b/docs/persistence-changes/2026-09-30-job-session-lifecycle.zh.md @@ -0,0 +1,52 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-09-30-job-session-lifecycle + +[English](2026-09-30-job-session-lifecycle.md) | 中文 + +## 概述 + +以唯一 JobId 将有所有者 job 的注册与结算记录为 Session 事件,包含原始时间戳、创建工具调用和生产方完成报告。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-30-job-session-lifecycle +baseline: false +changes: + - root: "event:job/registered" + previous: null + after: "f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1" + decision: same-version + - root: "event:job/settled" + previous: null + after: "edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf" + decision: same-version +``` + + +## 兼容性 + +增加必需的 job 事件类型,不改变 Session 封装。新 JobId 用带 kind 前缀的 UUID 同时进行运行期控制与持久化配对;不为历史会话补造 job 事件。旧构建拒绝未知的必需事件。producerReported 表示完成 promise 兑现,不确认资源已释放。 + + +## 验证 + +注册表和快照归一化测试覆盖因果顺序与唯一 JobId。CLI 和 SDK 快照验证运行中的注册、结算和通知;[job 生命周期约定](../subsystems/jobs.zh.md#session-lifecycle-records)允许应用销毁时留下不完整的终态历史。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index a85d9e2a90..1835c6fe96 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: 553fd4963cf19abe - zh: 7ca3cd5d0a88d35a + en: 73b4caa341293dda + zh: 305a83174f20c790 /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 8f06d0e1ee..1f0274c047 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) | 63 / 597 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 65 / 610 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index f377a801aa..1dd81855c5 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) | 63 / 597 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 65 / 610 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index e9046242ff..97bc1ffe41 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -10339,6 +10339,297 @@ ] } }, + { + "key": "event:job/registered", + "kind": "event", + "event": "job/registered", + "surface": false, + "digest": "f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 5, + "optional": true + }, + { + "name": "seq", + "type": 3, + "optional": false + }, + { + "name": "time", + "type": 3, + "optional": false + }, + { + "name": "type", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "jobId", + "type": 2, + "optional": false + }, + { + "name": "kind", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": false + }, + { + "name": "owner", + "type": 2, + "optional": false + }, + { + "name": "startedAt", + "type": 3, + "optional": false + }, + { + "name": "toolCall", + "type": 4, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 2, + "optional": false + }, + { + "name": "rootCallId", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "job/registered" + } + ] + } + }, + { + "key": "event:job/settled", + "kind": "event", + "event": "job/settled", + "surface": false, + "digest": "edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 14, + "optional": true + }, + { + "name": "seq", + "type": 7, + "optional": false + }, + { + "name": "time", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "cause", + "type": 2, + "optional": false + }, + { + "name": "detail", + "type": 6, + "optional": true + }, + { + "name": "finishedAt", + "type": 7, + "optional": false + }, + { + "name": "jobId", + "type": 6, + "optional": false + }, + { + "name": "kind", + "type": 6, + "optional": false + }, + { + "name": "label", + "type": 6, + "optional": false + }, + { + "name": "owner", + "type": 6, + "optional": false + }, + { + "name": "producerReported", + "type": 8, + "optional": false + }, + { + "name": "startedAt", + "type": 7, + "optional": false + }, + { + "name": "status", + "type": 9, + "optional": false + }, + { + "name": "toolCall", + "type": 13, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 3, + 4, + 5 + ] + }, + { + "kind": "literal", + "value": "producer" + }, + { + "kind": "literal", + "value": "teardown" + }, + { + "kind": "literal", + "value": "kill" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "union", + "types": [ + 10, + 11, + 12 + ] + }, + { + "kind": "literal", + "value": "completed" + }, + { + "kind": "literal", + "value": "failed" + }, + { + "kind": "literal", + "value": "killed" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 6, + "optional": false + }, + { + "name": "rootCallId", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "job/settled" + } + ] + } + }, { "key": "event:llm/retry", "kind": "event", @@ -22131,7 +22422,7 @@ "packages/llm/llm/src/types.ts#ReplayEnvelope" ], "sources": [ - "packages/llm/llm/src/types.ts:430" + "packages/llm/llm/src/types.ts:446" ] }, { @@ -22567,6 +22858,11 @@ "names": [], "sources": [] }, + { + "digest": "24a8188c4d61b70a01af8cc576748765d36f132632ec55c0064f6fba482369fc", + "names": [], + "sources": [] + }, { "digest": "24b10f7cdae64928f89c49afc2eca0af9eb4a2f135e16f3416e6b8461fef1748", "names": [], @@ -22652,7 +22948,7 @@ "digest": "2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095", "names": [], "sources": [ - "packages/llm/llm/src/types.ts:455" + "packages/llm/llm/src/types.ts:471" ] }, { @@ -22793,6 +23089,11 @@ "packages/core/session/src/types.ts:462" ] }, + { + "digest": "347de3380f2da308d0c74e08821f9e85559f55af4c5010795b110d00f7fc9dbd", + "names": [], + "sources": [] + }, { "digest": "34d1ab5c5df378186d6054b1a1beea9a41e41965d6f336f0bc0025c441a82e66", "names": [], @@ -22851,6 +23152,15 @@ "packages/feedback/message-feedback/src/types.ts:17" ] }, + { + "digest": "39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f", + "names": [ + "packages/jobs/jobs/src/types.ts#JobToolCall" + ], + "sources": [ + "packages/jobs/jobs/src/types.ts:16" + ] + }, { "digest": "3b04790e08b35241ffa0d38f6d7e4f8788aa921341f6c0d7574a329397c59408", "names": [], @@ -22889,7 +23199,7 @@ "digest": "3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90", "names": [], "sources": [ - "packages/llm/llm/src/types.ts:453" + "packages/llm/llm/src/types.ts:469" ] }, { @@ -23025,7 +23335,7 @@ "packages/llm/llm/src/types.ts#StreamChunk" ], "sources": [ - "packages/llm/llm/src/types.ts:452" + "packages/llm/llm/src/types.ts:468" ] }, { @@ -23126,6 +23436,7 @@ "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", @@ -23137,7 +23448,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:403", + "packages/api/session-controller/src/types.ts:408", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:20", @@ -23151,6 +23462,7 @@ "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", @@ -23397,6 +23709,11 @@ "names": [], "sources": [] }, + { + "digest": "5cb63db9fe57dc09c3a1c2b84c3edf73ff5b62960155d8436a2377a5c43ce32c", + "names": [], + "sources": [] + }, { "digest": "5cdbf342b7748d152bc987b68f58e565e5bba4b227e076e2d2120d87662e45cd", "names": [], @@ -23472,6 +23789,15 @@ "names": [], "sources": [] }, + { + "digest": "611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da", + "names": [ + "packages/jobs/jobs/src/types.ts#JobSettledData" + ], + "sources": [ + "packages/jobs/jobs/src/types.ts:40" + ] + }, { "digest": "616f93a5047de668e1c30d57b1786abd76beeb4123599637ec1dc5290320ede5", "names": [], @@ -23525,7 +23851,7 @@ "packages/llm/llm/src/types.ts#ToolSchema" ], "sources": [ - "packages/llm/llm/src/types.ts:473" + "packages/llm/llm/src/types.ts:489" ] }, { @@ -23582,7 +23908,7 @@ "digest": "6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7", "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:408" + "packages/api/session-controller/src/types.ts:413" ] }, { @@ -23640,11 +23966,16 @@ "names": [], "sources": [] }, + { + "digest": "6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65", + "names": [], + "sources": [] + }, { "digest": "6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406", "names": [], "sources": [ - "packages/preset/agent-preset-registry/src/session.ts:28" + "packages/preset/agent-preset-registry/src/session.ts:31" ] }, { @@ -23731,6 +24062,15 @@ "names": [], "sources": [] }, + { + "digest": "750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5", + "names": [ + "packages/jobs/jobs/src/types.ts#JobSettleCause" + ], + "sources": [ + "packages/jobs/jobs/src/types.ts:247" + ] + }, { "digest": "7554565be416b0342053ee6a46256c9dab07c487349b4a5f10bab1edc151c619", "names": [], @@ -24035,7 +24375,7 @@ "digest": "88a610b831be6d92612a464be5c7d0b88ec24d97bd299837bf74bf7672183c9a", "names": [], "sources": [ - "packages/llm/llm/src/types.ts:456" + "packages/llm/llm/src/types.ts:472" ] }, { @@ -24202,7 +24542,7 @@ "digest": "95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65", "names": [], "sources": [ - "packages/llm/llm/src/types.ts:454" + "packages/llm/llm/src/types.ts:470" ] }, { @@ -24390,7 +24730,7 @@ "digest": "a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1", "names": [], "sources": [ - "packages/llm/llm/src/types.ts:459" + "packages/llm/llm/src/types.ts:475" ] }, { @@ -24851,6 +25191,11 @@ "names": [], "sources": [] }, + { + "digest": "bab68657940075483f086fffb711682a45bcc99c43d659fbe1c36af21ac05dd7", + "names": [], + "sources": [] + }, { "digest": "babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7", "names": [], @@ -24939,6 +25284,11 @@ "packages/workflow/workflow/src/types.ts:110" ] }, + { + "digest": "c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3", + "names": [], + "sources": [] + }, { "digest": "c43b119ce579b3e9983a0d3adc4ba402ebb9ba773d89738a2dab01ecf5813943", "names": [], @@ -25076,7 +25426,7 @@ "digest": "d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d", "names": [], "sources": [ - "packages/llm/llm/src/types.ts:457" + "packages/llm/llm/src/types.ts:473" ] }, { @@ -25173,7 +25523,7 @@ "digest": "dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd", "names": [], "sources": [ - "packages/core/tools/src/ptc.ts:14" + "packages/core/tools/src/ptc.ts:15" ] }, { @@ -25250,6 +25600,15 @@ "names": [], "sources": [] }, + { + "digest": "e3056e0dda3a379e895386e368c3ce42131460ea65e410e1d091cca98a347305", + "names": [ + "packages/jobs/jobs/src/types.ts#JobRegisteredData" + ], + "sources": [ + "packages/jobs/jobs/src/types.ts:24" + ] + }, { "digest": "e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72", "names": [], @@ -25391,6 +25750,11 @@ "names": [], "sources": [] }, + { + "digest": "edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf", + "names": [], + "sources": [] + }, { "digest": "ee577a0cc18664c9daee50e9455153321155604f8f1817362f8f4a4b6fb9e1ad", "names": [], @@ -25418,6 +25782,11 @@ "packages/core/session/src/types.ts:301" ] }, + { + "digest": "ef226a90ddac6cd8e631a3a337a3a3e27f9494be92befb7c718b404681d75e21", + "names": [], + "sources": [] + }, { "digest": "ef4f00923b9edbae15c7ef05320280bf61147ca17146cf59690359bfd5e5da45", "names": [], @@ -25464,7 +25833,7 @@ "digest": "f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515", "names": [], "sources": [ - "packages/llm/llm/src/types.ts:458" + "packages/llm/llm/src/types.ts:474" ] }, { @@ -25492,7 +25861,7 @@ "packages/llm/llm/src/types.ts#SystemPromptUpdate" ], "sources": [ - "packages/llm/llm/src/types.ts:396" + "packages/llm/llm/src/types.ts:412" ] }, { @@ -25500,6 +25869,11 @@ "names": [], "sources": [] }, + { + "digest": "f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1", + "names": [], + "sources": [] + }, { "digest": "f56c5b59ca0161310c338d31dd16cf14287ccd602ff4181058df178c1c286d7f", "names": [ diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml index b8f7380bbf..64ca728d7e 100644 --- a/docs/subsystems/conversation.i18n.yaml +++ b/docs/subsystems/conversation.i18n.yaml @@ -16,10 +16,10 @@ zh: 787ad4d04b79adaf /conversation-assembly/replayable-event-families: en: c216e2df128b8cb9 - zh: e61312f1f280332d + zh: c039cf84b0d86ad8 /conversation-assembly/definition-and-typed-chat-payload: - en: 5f24557a050d1df0 - zh: 47735627f802b6fa + en: d8f5e4773704c718 + zh: f3db8c0d01080dba /conversation-assembly/predecessor-reads: en: da849b3b5da81da8 zh: 37a647a439bc24b1 diff --git a/docs/subsystems/conversation.md b/docs/subsystems/conversation.md index ba4eb88102..ef6f6ffbdb 100644 --- a/docs/subsystems/conversation.md +++ b/docs/subsystems/conversation.md @@ -247,10 +247,24 @@ export function apply(ctx: ClientContext): void { `ConversationNodeDefinitionInput` accepts either this function or a read-only table from event type to matching function. Function-form registrations receive every event; table registrations receive only their listed types. The Registry copies table entries and precomputes ordered candidate Sets when registrations change, keeping candidate construction out of per-event dispatch. Replace a table by unregistering and registering again. Resolved `ConversationNodeDefinition` values retain a callable `match(event)` in both cases. +A match result with `exclusive: true` claims that event on the Definition's target. The assembler evaluates every match of the event before accepting any, so the outcome is independent of registration order: other Definitions of the claimed target build no Context for the event, and the target's unmatched-event fallback does not run. Definitions of other targets and Definitions without a target still receive it. Two claims on one target, or a claim from a Definition without a target, throw during dispatch; a claim returned by the fallback itself has no effect. A business Definition uses a claim to present one kind of message that a generic Definition of the same target would otherwise also present, such as a late question reply. + `buildLocationData(context, scope)` optionally publishes Definition-owned data onto an engine-owned Turn or Step. Use declaration merging to give each key a precise value type. Another Node in the same Location can consume that value through its constrained slot hook, such as `useTurnData(key)`, without receiving the Session or scanning `snapshot.chat.nodes`. `target` and `buildViewNode(context)` declare one target-owned rendering contribution and must appear together. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from the matched event ordering, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`. +A Chat Node also chooses where its row stands through `groupTree`. Positions are ordered from the outermost inward, and a Node at a position also has the effect of every inner one. Chat process grouping, whole-Turn folding, and Turn-input detection read only this position; [group-tree.ts](../../packages/client/ui-chat/src/client/contract/group-tree.ts) holds the complete contract. + +| The Node is | Declare | +|---|---| +| A person's input claimed in Step 1; its Turn opens an exchange | `exchange` | +| Another input of its Turn, such as later human input or a trigger notice; before the process it is the opening input, after it the whole Turn stays open | `input` | +| A Turn notice that stays outside whole-Turn folding, such as an error or the completed-turn footer | `turn` | +| An independent row that whole-Turn folding still hides, such as a model retry | `process` | +| Ordinary process content that joins the surrounding step run | omit `groupTree` | + +The [Chat business rules](../../packages/client/ui-chat/src/client/conversation-nodes/README.md#process-grouping) list the positions the built-in Nodes declare. When a generic Definition of the same target also matches the event your Node presents, claim the event with `exclusive: true` as described above. + ## Predecessor reads Some Definitions need the latest earlier State of another business kind. `start` receives a `ConversationContextReader`; call `reader.previous(kind)` there instead of accepting a Context collection or scanning events. The reader returns the nearest started Context before the current start `seq` as read-only data. diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md index 7115bb90b8..a3ef583db2 100644 --- a/docs/subsystems/conversation.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -71,6 +71,8 @@ Group 存储在安装前校验完整提交结果:根 Group 引用与记录一 实时 Assistant delta 作为 Client-only `assistant/live-chunk` event 到达。重连 baseline 会把活跃的进程内紧凑 stream 展开为相同的瞬态 event,持久 `assistant/message` 与 `assistant/attempt` event 则嵌入完整紧凑 stream 供历史回放。瞬态 event 可以初始化 Context。每条工具 delta 和后续 `tool/call` 都可以按同一个 callId 匹配为 start,即使名称尚未到达;只有最早的 Match 调用 `start()`,后续 Match 调用 `update()`。准备态 Tool 节点只有在名称可用后才显示。历史分页不把已结束消息展开为实时 delta。消费 Assistant 输出的 Definition 通过同一组匹配和更新接口处理 live chunk 与持久 settlement,其他 Definition 直接返回 `null`,无需展开 stream。 + + ## Definition 与类型化 Chat payload 为了完整展示关联关系,下面把生产方声明和 Client 贡献写在同一个代码块里。实际的包族中,branded id 与 `SessionEventMap` 声明留在事件生产方,Definition、Chat data 合并与 renderer 留在 Client 插件。 @@ -247,10 +249,24 @@ export function apply(ctx: ClientContext): void { `ConversationNodeDefinitionInput` 接受上述函数,或事件类型到匹配函数的只读表。函数形式的注册接收所有事件;表形式只接收列出的类型。Registry 在注册关系变化时复制表条目并预计算有序候选 Set,不在逐事件分发时构造候选集合。替换表时先注销,再重新注册。两种形式解析后的 `ConversationNodeDefinition` 都保留可调用的 `match(event)`。 +match 结果带 `exclusive: true` 时,即在该 Definition 的 target 上认领这个事件。Assembler 先求出该事件的全部 match,再逐个接收,因此结果与注册顺序无关:被认领 target 上的其他 Definition 不为该事件建立 Context,该 target 的未匹配事件 fallback 也不运行;其他 target 的 Definition 与没有 target 的 Definition 照常接收。同一 target 被认领两次,或没有 target 的 Definition 发起认领,都会在分发时抛错;fallback 自身返回的认领不起作用。业务 Definition 用认领来呈现某一类消息,而同一 target 上的通用 Definition 原本也会呈现它,例如迟到的问题回答。 + `buildLocationData(context, scope)` 可以把 Definition 拥有的数据发布到引擎拥有的 Turn 或 Step 上。通过 declaration merging 为每个 key 指定精确 value 类型。同一 Location 内的另一个 Node 可以使用受限 slot hook(例如 `useTurnData(key)`)读取该值,无须取得 Session,也无须扫描 `snapshot.chat.nodes`。 `target` 与 `buildViewNode(context)` 必须同时声明一项由 target 拥有的渲染贡献。把 `context.key` 保留为 React 侧身份,根据持久排序证据选择 `anchorSeq`,并且只返回 renderer 可以直接使用的数据。某个 target Node 一旦发布,就要继续返回同一个 key;需要暂时离开可见流时使用 `visibility: 'hidden'`,不要改为返回 `null` 撤回它。 +Chat Node 还通过 `groupTree` 选择所在行的位置。位置由外到内排列,站在某个位置的 Node 同时具有所有内层位置的效果。Chat 的过程分组、整轮折叠和轮次输入判断只读取这个位置;完整契约见 [group-tree.ts](../../packages/client/ui-chat/src/client/contract/group-tree.ts)。 + +| Node 是 | 声明 | +|---|---| +| Step 1 认领的人的输入,所在轮次开启一个交互段 | `exchange` | +| 所在轮次的其他输入,如之后的人的输入或触发通知;位于过程之前时是开场输入,之后出现时整个轮次保持展开 | `input` | +| 留在整轮折叠之外的轮次通知,如错误或已完成轮次页脚 | `turn` | +| 整轮折叠仍会隐藏的独立行,如模型重试 | `process` | +| 并入相邻步骤组的普通过程内容 | 不声明 `groupTree` | + +各内置 Node 声明的位置见 [Chat 业务规则](../../packages/client/ui-chat/src/client/conversation-nodes/README.zh.md#process-grouping)。如果同一 target 的通用 Definition 也会匹配你的 Node 所呈现的事件,按上文用 `exclusive: true` 认领该事件。 + ## Predecessor read 有些 Definition 需要另一个业务 kind 在当前位置之前的最新 State。`start` 会收到 `ConversationContextReader`;应在这里调用 `reader.previous(kind)`,不要接收 Context 集合或扫描事件。Reader 返回当前 start `seq` 之前最近一个已启动 Context 的只读数据。 diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index c4c106c066..94bed76054 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -25,7 +25,7 @@ zh: d0f66664ac85ae64 /core/tooldefinition: en: d926a9b80aada2e5 - zh: e34e6f43bdfd8920 + zh: 7e191ba5dab904df /core/repo-wide-type-patterns: en: 057db6d8bf356c0f zh: 1441b39e5142b0bf diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index c6c02c6c42..42f6d2e24a 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -557,7 +557,9 @@ serviceFor(agent: { ctx: Context }, name: K): */ async recompose(ctx: Context, id: string): Promise -/** Select a preset before a session starts its first turn. +/** Select a preset before a session starts its first turn. A Session whose + * log records the preset and whose Agent runs its current revision keeps its + * composition; nothing is logged or announced. * @param agent Target Agent. * @param agentPreset Requested identity. * @returns Committed preset identity. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 582a817f1f..9714c7c62f 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -363,6 +363,8 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' 其完整字段、`defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` 类型化 schema DSL、`ToolExecution`/`ToolExecutionResult` waterfall 类型,以及工具展示 UI 类型都在 **[tools.md](tools.zh.md)** 中。 + + ## 全仓通用类型模式 两个模式在每个子系统中反复出现,只在此处记录一次。 @@ -567,7 +569,9 @@ serviceFor(agent: { ctx: Context }, name: K): */ async recompose(ctx: Context, id: string): Promise -/** Select a preset before a session starts its first turn. +/** Select a preset before a session starts its first turn. A Session whose + * log records the preset and whose Agent runs its current revision keeps its + * composition; nothing is logged or announced. * @param agent Target Agent. * @param agentPreset Requested identity. * @returns Committed preset identity. diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index 64722160fa..a46cfbac32 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.i18n.yaml @@ -6,14 +6,14 @@ en: 4589c745baf092c7 zh: 3a782e6a06825201 /user-credentials/identity: - en: aba5a25c34e229cc - zh: 96493f925f60b533 + en: 0083032b9d19467d + zh: 15d7a7520bac094e /user-credentials/resolution: en: baaab4bca76bea6b zh: 54ccdc03ffd44f63 /user-credentials/description: - en: 17573160805d3014 - zh: 36a058a96f45df23 + en: f7b09588c0f34952 + zh: 1b93d673c5bf6a80 /user-credentials/change-commits: en: 464315a953330eb2 zh: d9815591f3abc2cb diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index ef0b5b04c3..70c705d94f 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -15,6 +15,7 @@ A reference names one credential as a POSIX-style environment-variable name. The type CredentialRef = Branded<'CredentialRef'> ``` + ## Resolution `resolve(ref)` returns the value with the provider-defined source layer that supplied it, or `undefined` while unconfigured. Consumers re-resolve at each operation and never cache across operations — that per-operation read is the hot-update mechanism. @@ -31,7 +32,7 @@ interface ResolvedCredential { ## Description -`describe(ref)` answers configuration surfaces without ever exposing a value: whether the reference resolves, from which layer, and whether `set` would currently succeed. The local provider reports a reference supplied by the live process environment as `writable: false` — a write would appear to succeed while resolution kept returning the shadowing value, so the seam rejects it and the UI can render the reference read-only up front. +`describe(ref)` answers configuration surfaces without ever exposing a value: whether the reference resolves, from which layer, and whether `set` would currently succeed. The local provider ranks its managed file above the launch environment, so saving a value always creates the effective writable layer; removing it reveals the launch environment or `.env` fallback again. ```ts type-equiv /** @@ -136,7 +137,7 @@ Source: [`packages/credentials/authorization/src/index.ts`](../../packages/crede Abstract credential service over two key spaces that answer two questions. -A CredentialRef answers "what is behind this environment-variable name", layered over the process environment, the provider-managed store, and `.env` files. One seam-wide rule binds that half: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret. +A CredentialRef answers "what is behind this environment-variable name": the provider-managed store first, then ambient fallbacks such as the process environment and `.env` files. One seam-wide rule binds that half: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret. A CredentialKey answers "what credential does this plugin hold for this id". Nothing can layer here — an authorization grant has no environment to be read from — so presence of the record is the whole fact, and modifyRecord is the only write path because a correct write depends on the current value (a token refresh is read-decide-replace under one lock). @@ -160,10 +161,9 @@ abstract resolve(ref: CredentialRef): Promise abstract describe(ref: CredentialRef): Promise /** - * Durably store one value in the provider-managed writable source. Rejects - * while a read-only source shadows the reference — the write would appear - * to succeed while resolution keeps returning the shadowing value — and - * rejects an empty value (use {@link unset}). + * Durably store one value in the provider-managed writable source. The stored + * value takes precedence over ambient fallbacks, and empty values are + * rejected (use {@link unset}). * @param ref - the reference to store. * @param value - the non-empty secret value. */ @@ -171,8 +171,8 @@ abstract set(ref: CredentialRef, value: string): Promise /** * Remove one reference from the provider-managed writable source; removing - * an absent reference is a no-op. Rejects while a read-only source shadows - * the reference, like {@link set}. + * an absent reference is a no-op. Ambient fallback layers may become + * effective again after removal. * @param ref - the reference to remove. */ abstract unset(ref: CredentialRef): Promise diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index d6a8d73938..14fb73bf13 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -15,6 +15,7 @@ type CredentialRef = Branded<'CredentialRef'> ``` + ## 解析 `resolve(ref)` 返回值及提供该值的来源层(由提供方定义);未配置期间返回 `undefined`。消费方在每个操作中重新解析,绝不跨操作缓存——这种按操作进行的读取正是热更新机制。 @@ -31,7 +32,7 @@ interface ResolvedCredential { ## 描述 -`describe(ref)` 在绝不暴露值的前提下回应配置界面:引用当前是否可解析、来自哪一层、`set` 当前能否成功。本地提供方把由当前进程环境供值的引用报告为 `writable: false`——那样的写入会表面成功而解析持续返回遮蔽值,因此 seam 直接拒绝,界面也得以提前把该引用渲染为只读。 +`describe(ref)` 在绝不暴露值的前提下回应配置界面:引用当前是否可解析、来自哪一层、`set` 当前能否成功。本地提供方将其管理的文件排在启动环境之前,因此保存值总会成为生效且可写的那一层;移除后会重新使用启动环境或 `.env` 后备值。 ```ts type-equiv /** @@ -136,7 +137,7 @@ Source: [`packages/credentials/authorization/src/index.ts`](../../packages/crede Abstract credential service over two key spaces that answer two questions. -A CredentialRef answers "what is behind this environment-variable name", layered over the process environment, the provider-managed store, and `.env` files. One seam-wide rule binds that half: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret. +A CredentialRef answers "what is behind this environment-variable name": the provider-managed store first, then ambient fallbacks such as the process environment and `.env` files. One seam-wide rule binds that half: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret. A CredentialKey answers "what credential does this plugin hold for this id". Nothing can layer here — an authorization grant has no environment to be read from — so presence of the record is the whole fact, and modifyRecord is the only write path because a correct write depends on the current value (a token refresh is read-decide-replace under one lock). @@ -160,10 +161,9 @@ abstract resolve(ref: CredentialRef): Promise abstract describe(ref: CredentialRef): Promise /** - * Durably store one value in the provider-managed writable source. Rejects - * while a read-only source shadows the reference — the write would appear - * to succeed while resolution keeps returning the shadowing value — and - * rejects an empty value (use {@link unset}). + * Durably store one value in the provider-managed writable source. The stored + * value takes precedence over ambient fallbacks, and empty values are + * rejected (use {@link unset}). * @param ref - the reference to store. * @param value - the non-empty secret value. */ @@ -171,8 +171,8 @@ abstract set(ref: CredentialRef, value: string): Promise /** * Remove one reference from the provider-managed writable source; removing - * an absent reference is a no-op. Rejects while a read-only source shadows - * the reference, like {@link set}. + * an absent reference is a no-op. Ambient fallback layers may become + * effective again after removal. * @param ref - the reference to remove. */ abstract unset(ref: CredentialRef): Promise diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index 17ea2f9f9e..61aa2da366 100644 --- a/docs/subsystems/jobs.i18n.yaml +++ b/docs/subsystems/jobs.i18n.yaml @@ -6,8 +6,11 @@ en: dd515d8548864a91 zh: 16a117d39989a373 /background-job-runtime/ids-and-status: - en: 529f401da4757a98 - zh: 041ff5e86b5dd8db + en: 4b286c70e4a83b99 + zh: 7edbf090e9cb6487 +/background-job-runtime/session-lifecycle-records: + en: 5fb99886e0ab2718 + zh: bdea51368c935895 /background-job-runtime/producer-contract: en: af0be338a0ba5e60 zh: fc0eefb8c31f3a98 diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index 6cc4e3e5a5..d00c691db2 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -6,7 +6,7 @@ Types shared by long-running producers, `ctx.jobs`, and job controls. The [seam ## Ids and status -`JobId` is a [branded id](core.md#branded-ids) generated as `-N`. Access control relies on owner authorization, not id secrecy. `JobKind` derives from a merge-extensible map; the registry treats kinds as opaque id namespaces. +`JobId` is a [branded id](core.md#branded-ids) generated as `-` with 16 lowercase UUID hex characters after the kind. Access control relies on owner authorization, not id secrecy. `JobKind` derives from a merge-extensible map; the registry treats kinds as opaque id namespaces. ```ts type-equiv /** @@ -20,10 +20,64 @@ interface JobKindMap { `JobStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `JobView.progress` while the job runs and in `JobView.detail` once it settled. + + +## Session lifecycle records + +Owned jobs publish `job/registered` and `job/settled` in their original owner Session. The globally unique `jobId` pairs those records and controls the live job. The SDK forwards these log-only records through `session.event`, independently of output reads. Timestamps describe registry commits, not CPU time or exact OS process boundaries; a missing settlement remains incomplete. `producerReported` means the producer fulfilled `done` with a terminal outcome; it is false for forced teardown or rejection and does not independently certify resource release. + +`job/settled` may follow `turn/end`. An SDK run result covers only its own observation window; consumers needing later settlements retain a Session subscription or read persisted history. Turn completion does not imply that every owned job stopped. + +Application teardown can stop Session event routing or close its writer before an owned job's final settlement reaches storage. This includes normal one-shot completion and signal shutdown, as well as hard process loss. A missing `job/settled` remains incomplete even if the live registry reported termination; consumers must not invent a finish timestamp. + +```ts type-equiv +/** Durable identity and registration time of one owned job execution. */ +interface JobRegisteredData { + /** Unique execution id accepted by job controls and retained in Session history. */ + readonly jobId: JobId + /** Original owner; copied history does not transfer execution ownership. */ + readonly owner: SessionId + /** Producer kind. */ + readonly kind: string + /** Producer-supplied label. */ + readonly label: string + /** Unix epoch milliseconds when registration committed. */ + readonly startedAt: number + /** Creating tool invocation, when the producer was called through a tool. */ + readonly toolCall?: JobToolCall +} +``` + +```ts type-equiv +/** Registry settlement, including whether the producer reported a terminal outcome. */ +interface JobSettledData extends JobRegisteredData { + /** Unix epoch milliseconds when the registry committed the terminal record. */ + readonly finishedAt: number + /** Recorded terminal outcome. */ + readonly status: JobOutcome['status'] + /** Source of the termination request, or producer completion. */ + readonly cause: JobSettleCause + /** True when done fulfilled; false for rejection or forced failure. Does not certify resource release. */ + readonly producerReported: boolean + /** Terminal reason, including a recorded kill reason when applicable. */ + readonly detail?: string +} +``` + ## Producer contract A `JobSpec` declares identity, the owning session, optional pull `output` sources, and a starter. The runtime finishes preflight before calling `run()` with the job's `JobHandle` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, lifecycle state, and the output ring. +```ts type-equiv +/** Tool dispatch that created a job; nested calls retain their root invocation. */ +interface JobToolCall { + /** Exact invocation, including a PTC sub-call when applicable. */ + readonly callId: SessionEventMap['tool/call']['callId'] + /** Root model-requested invocation owning this execution. */ + readonly rootCallId: SessionEventMap['tool/call']['callId'] +} +``` + ```ts type-equiv /** * Producer declaration passed to {@link JobRegistry.start}. The runtime @@ -43,6 +97,8 @@ interface JobSpec { * caller until service disposal. */ owner?: SessionId + /** Creating tool invocation, retained in the durable lifecycle records. */ + toolCall?: JobToolCall /** * Optional UTF-8 byte cap for each complete model-facing completion notice or * output read, including controller status metadata. Independent of ring @@ -76,7 +132,7 @@ interface JobSpec { * throwing, so a producer's trailing flush cannot break its own teardown path. */ interface JobHandle { - /** The registry-issued id (`-N`). */ + /** The registry-issued id (`-`). */ readonly id: JobId /** * Append one chunk to the output ring. Offsets advance by the chunk's UTF-8 @@ -219,7 +275,7 @@ interface JobOutputRead { * stream all consume this one shape. */ interface JobView { - /** The registry-issued id (`-N`). */ + /** The registry-issued id (`-`). */ readonly id: JobId /** * The producer kind the job was registered with: a Host-registered @@ -405,7 +461,7 @@ Implementations must honor these semantics: * rejection leaves no job id or execution resource. A throwing starter * leaves nothing registered; after it returns, registration cannot fail. * @param spec - job identity, owner, output sources, and synchronous starter. - * @returns the registry-issued `-N` id. + * @returns the registry-issued `-` id. */ abstract start(spec: JobSpec): JobId diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index b80f64fade..c592c7d996 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -6,7 +6,7 @@ ## ID 与状态 -`JobId` 是按 `-N` 生成的[品牌化 id](core.zh.md#branded-ids)。访问控制依赖拥有者授权,而非 id 的保密性。`JobKind` 派生自可合并扩展的 map;注册表将各个 kind 视为不透明的 id 命名空间。 +`JobId` 是按 `-` 生成的[品牌化 id](core.zh.md#branded-ids),kind 后是 UUID 的 16 位小写十六进制字符。访问控制依赖拥有者授权,而非 id 的保密性。`JobKind` 派生自可合并扩展的 map;注册表将各个 kind 视为不透明的 id 命名空间。 ```ts type-equiv /** @@ -20,10 +20,64 @@ interface JobKindMap { `JobStatus` 为 `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`;生产方特有的事实在运行期间归入 `JobView.progress`,结算后归入 `JobView.detail`。 + + +## Session 生命周期记录 + +有所有者的 job 在原始所有者 Session 中发布 `job/registered` 与 `job/settled`。全局唯一的 `jobId` 同时配对这两条记录并控制运行中的 job。SDK 通过 `session.event` 转发这些仅日志记录,不依赖输出读取。时间戳描述注册表提交时刻,不是 CPU 时间或精确的 OS 进程边界;缺少结算时保持不完整。`producerReported` 表示生产方通过兑现 `done` 报告了终态;强制销毁或 promise 拒绝时为 false,不独立确认资源已释放。 + +`job/settled` 可能出现在 `turn/end` 之后。SDK run 结果只覆盖自身观测窗口;需要较晚结算的消费方应保留 Session 订阅或读取持久化历史。轮次完成不表示所有有主 job 都已停止。 + +应用销毁可能在有主 job 的最后一条结算写入存储前停止 Session 事件路由或关闭写句柄。正常的一次性运行结束、信号关停和进程硬退出都可能出现这种情况。即使运行期注册表报告了终止,缺少 `job/settled` 的区间仍不完整;消费方不得虚构结束时间戳。 + +```ts type-equiv +/** Durable identity and registration time of one owned job execution. */ +interface JobRegisteredData { + /** Unique execution id accepted by job controls and retained in Session history. */ + readonly jobId: JobId + /** Original owner; copied history does not transfer execution ownership. */ + readonly owner: SessionId + /** Producer kind. */ + readonly kind: string + /** Producer-supplied label. */ + readonly label: string + /** Unix epoch milliseconds when registration committed. */ + readonly startedAt: number + /** Creating tool invocation, when the producer was called through a tool. */ + readonly toolCall?: JobToolCall +} +``` + +```ts type-equiv +/** Registry settlement, including whether the producer reported a terminal outcome. */ +interface JobSettledData extends JobRegisteredData { + /** Unix epoch milliseconds when the registry committed the terminal record. */ + readonly finishedAt: number + /** Recorded terminal outcome. */ + readonly status: JobOutcome['status'] + /** Source of the termination request, or producer completion. */ + readonly cause: JobSettleCause + /** True when done fulfilled; false for rejection or forced failure. Does not certify resource release. */ + readonly producerReported: boolean + /** Terminal reason, including a recorded kill reason when applicable. */ + readonly detail?: string +} +``` + ## 生产方约定 `JobSpec` 声明身份、拥有者会话、可选的拉取式 `output` 源与启动器。运行时会在完成预检后携带该 job 的 `JobHandle` 调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问、生命周期状态与输出环。 +```ts type-equiv +/** Tool dispatch that created a job; nested calls retain their root invocation. */ +interface JobToolCall { + /** Exact invocation, including a PTC sub-call when applicable. */ + readonly callId: SessionEventMap['tool/call']['callId'] + /** Root model-requested invocation owning this execution. */ + readonly rootCallId: SessionEventMap['tool/call']['callId'] +} +``` + ```ts type-equiv /** * Producer declaration passed to {@link JobRegistry.start}. The runtime @@ -43,6 +97,8 @@ interface JobSpec { * caller until service disposal. */ owner?: SessionId + /** Creating tool invocation, retained in the durable lifecycle records. */ + toolCall?: JobToolCall /** * Optional UTF-8 byte cap for each complete model-facing completion notice or * output read, including controller status metadata. Independent of ring @@ -76,7 +132,7 @@ interface JobSpec { * throwing, so a producer's trailing flush cannot break its own teardown path. */ interface JobHandle { - /** The registry-issued id (`-N`). */ + /** The registry-issued id (`-`). */ readonly id: JobId /** * Append one chunk to the output ring. Offsets advance by the chunk's UTF-8 @@ -219,7 +275,7 @@ interface JobOutputRead { * stream all consume this one shape. */ interface JobView { - /** The registry-issued id (`-N`). */ + /** The registry-issued id (`-`). */ readonly id: JobId /** * The producer kind the job was registered with: a Host-registered @@ -405,7 +461,7 @@ Implementations must honor these semantics: * rejection leaves no job id or execution resource. A throwing starter * leaves nothing registered; after it returns, registration cannot fail. * @param spec - job identity, owner, output sources, and synchronous starter. - * @returns the registry-issued `-N` id. + * @returns the registry-issued `-` id. */ abstract start(spec: JobSpec): JobId diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index c5426ddc70..f6fb858bef 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -36,8 +36,8 @@ en: 58dec2259b0b1c6f zh: fc99fe965acaabf3 /llm-streaming/the-model-request: - en: 824d18f76f990711 - zh: 8a2895513652e528 + en: 60c661bdcfec52dd + zh: c27a46f6c99efea1 /llm-streaming/the-model-request/the-request-envelope-llmcallconfig-and-the-logged-header: en: f2e6932e211f179b zh: 75112586a6f3f4aa diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 4936ba4ae8..cd780fd0ed 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -498,11 +498,31 @@ interface LlmConfigurableProvider { * from outside. */ declared?: boolean + /** + * Route endpoint the adapter uses when the profile sets no `baseURL`; the + * adapter may still send an individual model to that model's own endpoint. + * Absent when the route has no fixed endpoint. + */ + defaultBaseURL?: string + /** Authentication the adapter performs when the profile names no credential reference; absent when it reports none. */ + nativeAuth?: LlmNativeAuth /** Configuration diagnostic for repair; unaffected models may remain serviceable. */ error?: string } ``` +A settings editor shows `defaultBaseURL` and `nativeAuth` for a profile that leaves the endpoint or the credential reference unset. `nativeAuth` lists environment variable names, never their values. + +```ts type-equiv +/** Authentication a provider route performs without a credential reference in its profile. */ +interface LlmNativeAuth { + /** Environment variables the route's authentication reads, in lookup order; empty when it reads none. */ + env: readonly string[] + /** Whether a stored OAuth sign-in can authenticate the route. */ + oauth: boolean +} +``` + ```ts type-equiv /** One adapter-discovered model; catalog membership is advisory, not request validation. */ interface LlmModelInfo { diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 47d7c0df31..61ecc659bd 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -504,11 +504,31 @@ interface LlmConfigurableProvider { * from outside. */ declared?: boolean + /** + * Route endpoint the adapter uses when the profile sets no `baseURL`; the + * adapter may still send an individual model to that model's own endpoint. + * Absent when the route has no fixed endpoint. + */ + defaultBaseURL?: string + /** Authentication the adapter performs when the profile names no credential reference; absent when it reports none. */ + nativeAuth?: LlmNativeAuth /** Configuration diagnostic for repair; unaffected models may remain serviceable. */ error?: string } ``` +设置编辑器在提供方配置未设置端点或凭据引用时显示 `defaultBaseURL` 和 `nativeAuth`。`nativeAuth` 只列出环境变量名,不包含变量值。 + +```ts type-equiv +/** Authentication a provider route performs without a credential reference in its profile. */ +interface LlmNativeAuth { + /** Environment variables the route's authentication reads, in lookup order; empty when it reads none. */ + env: readonly string[] + /** Whether a stored OAuth sign-in can authenticate the route. */ + oauth: boolean +} +``` + ```ts type-equiv /** One adapter-discovered model; catalog membership is advisory, not request validation. */ interface LlmModelInfo { diff --git a/docs/subsystems/ptc-runtime.i18n.yaml b/docs/subsystems/ptc-runtime.i18n.yaml index 4ad1978544..7c6f634157 100644 --- a/docs/subsystems/ptc-runtime.i18n.yaml +++ b/docs/subsystems/ptc-runtime.i18n.yaml @@ -6,14 +6,14 @@ en: 337a6580d7571274 zh: f4ae98899120d122 /ptc-runtime/the-run-request-in-result-out: - en: b11e176bee0ca6bd - zh: 843d5d70c58dec42 + en: 0305ed1d4f8bc618 + zh: fcfeb156574b5779 /ptc-runtime/bindings-host-functions-as-program-globals: en: 182147ddf388c600 zh: c0a7d77602c78bcd /ptc-runtime/captured-output-and-the-failure-taxonomy: - en: e5431173436c00c2 - zh: 4ba58e259bbadbc8 + en: 8eb1607888ab6efc + zh: 580c852d17b9413f /ptc-runtime/the-service: en: a82f828fa006c7e9 zh: 2279d36867335b57 diff --git a/docs/subsystems/ptc-runtime.md b/docs/subsystems/ptc-runtime.md index efab5bcefa..d9ad8138a0 100644 --- a/docs/subsystems/ptc-runtime.md +++ b/docs/subsystems/ptc-runtime.md @@ -18,11 +18,18 @@ Source: [`packages/ptc-runtime/ptc-runtime/src/types.ts`](../../packages/ptc-run interface PtcRunRequest { /** * The program source, in the runtime's {@link ../index.ts | language}. It - * runs as the body of an async function: top-level `await` and `return` - * are available, and the completion value becomes + * follows the provider's programFormat: an async function body supports + * top-level `await` and `return`; a REPL supports top-level declarations, + * `await`, and a final expression. The completion value becomes * {@link PtcRunResult.value}. */ program: string + /** + * Object-identity owner for optional in-memory state. Omission requests a fresh + * environment. Consumers must await runtime.release(owner) when the owner ends. + * Stateless providers ignore this field; retained state is never durable. + */ + stateOwner?: object /** Host functions exposed to the program, one global object per namespace. */ bindings: PtcBindingNamespace[] /** Working directory in the mounted filesystem and subprocess execution world. */ @@ -77,7 +84,7 @@ interface PtcRunResult { /** Applied file policy and observed denial, when the provider enforces file policy. */ sandbox?: PtcRunSandbox /** - * The program's completion value (its top-level `return`), when it ran to + * The program's completion value (a return or final REPL expression), when it ran to * completion and the value crossed the runtime's lossless-JSON boundary. * Invalid or over-limit completions fail the run instead of substituting a * rendered string; a failed or value-less run leaves this absent. @@ -94,6 +101,8 @@ interface PtcRunResult { } ``` +`programFormat` distinguishes async function bodies from REPL statements. `stateOwner` is an in-process object identity for optional retained memory; distinct objects remain isolated. Consumers await `release(owner)` when that owner ends; cleanup failures reject that call. Omitted owners are one-shot, and stateless providers ignore ownership. The [persistent Node provider](../../packages/ptc-runtime/ptc-runtime-node/README.md#persistent-repl) defines reset and recovery behavior; no retained object enters the durable Session format. + ## Bindings: host functions as program globals Each `PtcBindingNamespace` becomes a global object of async callables; PTC passes `tools`. Arguments and resolutions must be lossless JSON. Providers enforce their own transport caps; the seam sets no uniform binding-byte limit. An optional error-class descriptor creates program-visible typed rejections without naming a consumer inside the runtime. Binding names are own properties, so `__proto__` cannot traverse a prototype: @@ -168,12 +177,12 @@ type PtcBindingFunction = (args: unknown) => Promise Logs are plain strings. Each source channel preserves emission order, while interleaving across independent channels is backend-dependent because channel metadata is not part of the seam. The runtime captures the program's console and stream output, and consumers render only the text. Implementations cap the serialized outer log-array plus completion-value or failure-message payload; fixed result-envelope syntax and consumer presentation whitespace are not part of that variable-payload ledger. Overflow is an explicit failure rather than in-band value substitution. -Failure kinds are **orthogonal outcomes reported independently** (per [defensive-patterns](../defensive-patterns.md)): a budget expiry is not an exception, an abort is not a timeout, and a substrate death (e.g. OOM) is neither: +Failure kinds are **orthogonal outcomes reported independently** (per [defensive-patterns](../defensive-patterns/general.md)): a budget expiry is not an exception, an abort is not a timeout, and a substrate death (e.g. OOM) is neither: ```ts type-equiv /** * Why a run failed. The kinds are orthogonal outcomes reported independently - * (per docs/defensive-patterns.md): a budget expiry is not an exception, an + * (per docs/defensive-patterns/general.md): a budget expiry is not an exception, an * abort is not a timeout, and a substrate death is neither. * * - `'exception'` — the program threw or failed to parse/transform. @@ -209,9 +218,18 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.ptcRuntime` — `PtcRuntime` (abstract seam) -Registers one `ctx.ptcRuntime` implementation. Program, budget, abort, and substrate failures resolve in PtcRunResult; only Service Definition contract misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal. +Registers one `ctx.ptcRuntime` implementation. Program, budget, abort, and substrate failures resolve in PtcRunResult; only Service Definition contract misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate distinct state owners, and terminate and await in-flight runs during disposal. ```ts cordis-catalog +/** + * Release an owner's retained environment and await its execution resources. + * Stateless providers have no retained resources to release. + * @param owner - The exact object supplied as the request's state owner. + * @returns Completion after all owned execution resources have been released. + * @throws When managed resource cleanup fails. + */ +release(owner: object): Promise + /** * Resolve supported options and provider defaults before execution. * @param request - Program, bindings, cancellation and optional execution choices. diff --git a/docs/subsystems/ptc-runtime.zh.md b/docs/subsystems/ptc-runtime.zh.md index f35c5bd84d..ba747b5369 100644 --- a/docs/subsystems/ptc-runtime.zh.md +++ b/docs/subsystems/ptc-runtime.zh.md @@ -18,11 +18,18 @@ PTC 执行[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13- interface PtcRunRequest { /** * The program source, in the runtime's {@link ../index.ts | language}. It - * runs as the body of an async function: top-level `await` and `return` - * are available, and the completion value becomes + * follows the provider's programFormat: an async function body supports + * top-level `await` and `return`; a REPL supports top-level declarations, + * `await`, and a final expression. The completion value becomes * {@link PtcRunResult.value}. */ program: string + /** + * Object-identity owner for optional in-memory state. Omission requests a fresh + * environment. Consumers must await runtime.release(owner) when the owner ends. + * Stateless providers ignore this field; retained state is never durable. + */ + stateOwner?: object /** Host functions exposed to the program, one global object per namespace. */ bindings: PtcBindingNamespace[] /** Working directory in the mounted filesystem and subprocess execution world. */ @@ -77,7 +84,7 @@ interface PtcRunResult { /** Applied file policy and observed denial, when the provider enforces file policy. */ sandbox?: PtcRunSandbox /** - * The program's completion value (its top-level `return`), when it ran to + * The program's completion value (a return or final REPL expression), when it ran to * completion and the value crossed the runtime's lossless-JSON boundary. * Invalid or over-limit completions fail the run instead of substituting a * rendered string; a failed or value-less run leaves this absent. @@ -94,6 +101,8 @@ interface PtcRunResult { } ``` +`programFormat` 区分异步函数体与 REPL 语句。`stateOwner` 是标识可选保留内存的进程内对象身份;不同对象相互隔离。消费者在所有者结束时等待 `release(owner)`;清理失败时该调用拒绝。省略所有者时仅执行一次,无状态提供方忽略所有权。[持久 Node 提供方](../../packages/ptc-runtime/ptc-runtime-node/README.zh.md#persistent-repl)定义重置和恢复行为;保留对象不会进入持久 Session 格式。 + ## 绑定:宿主函数作为程序全局变量 每个 `PtcBindingNamespace` 成为一个异步可调用函数的全局对象;PTC 传入 `tools`。参数与返回值必须是无损 JSON。提供方强制各自的传输上限;seam 不设统一的绑定字节上限。可选错误类描述符创建程序可见的类型化拒绝,无需在运行时内点名消费方。绑定名是自有属性,因此 `__proto__` 不能遍历原型: @@ -168,12 +177,12 @@ type PtcBindingFunction = (args: unknown) => Promise 日志是纯字符串。每个来源通道保留自身的发出顺序;由于通道元数据不属于 seam,相互独立的通道如何交错由后端决定。运行时捕获程序的 console 与流输出,Consumer 只渲染文本。实现会对序列化后的外层日志数组,以及完成值或失败消息的组合载荷设置上限;固定的结果封装语法与 Consumer 展示空白不计入这份可变载荷计量。超限会显式失败,而不会在值中插入替代内容。 -失败类型是**正交的结果,独立报告**(见 [defensive-patterns](../defensive-patterns.zh.md)):预算耗尽不是异常,中止不是超时,基底崩溃(如 OOM)也不是二者中的任何一个: +失败类型是**正交的结果,独立报告**(见 [defensive-patterns](../defensive-patterns/general.zh.md)):预算耗尽不是异常,中止不是超时,基底崩溃(如 OOM)也不是二者中的任何一个: ```ts type-equiv /** * Why a run failed. The kinds are orthogonal outcomes reported independently - * (per docs/defensive-patterns.md): a budget expiry is not an exception, an + * (per docs/defensive-patterns/general.md): a budget expiry is not an exception, an * abort is not a timeout, and a substrate death is neither. * * - `'exception'` — the program threw or failed to parse/transform. @@ -209,9 +218,18 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.ptcRuntime` — `PtcRuntime` (abstract seam) -Registers one `ctx.ptcRuntime` implementation. Program, budget, abort, and substrate failures resolve in PtcRunResult; only Service Definition contract misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal. +Registers one `ctx.ptcRuntime` implementation. Program, budget, abort, and substrate failures resolve in PtcRunResult; only Service Definition contract misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate distinct state owners, and terminate and await in-flight runs during disposal. ```ts cordis-catalog +/** + * Release an owner's retained environment and await its execution resources. + * Stateless providers have no retained resources to release. + * @param owner - The exact object supplied as the request's state owner. + * @returns Completion after all owned execution resources have been released. + * @throws When managed resource cleanup fails. + */ +release(owner: object): Promise + /** * Resolve supported options and provider defaults before execution. * @param request - Program, bindings, cancellation and optional execution choices. diff --git a/docs/subsystems/scope.i18n.yaml b/docs/subsystems/scope.i18n.yaml index bce762d85d..f5dd6f8a73 100644 --- a/docs/subsystems/scope.i18n.yaml +++ b/docs/subsystems/scope.i18n.yaml @@ -4,7 +4,7 @@ # pnpm run verify-translation-pairing --write docs/subsystems/scope.md /scoped-registration: en: b786b68190a93d3f - zh: 8ea0fb2fac9a1529 + zh: d487dc4263d61a53 /scoped-registration/identity-and-dispatch-carrier: en: f4e1bdd96c43a845 zh: c74409f7f81bd0e0 diff --git a/docs/subsystems/scope.zh.md b/docs/subsystems/scope.zh.md index db791a407f..c48858d6ba 100644 --- a/docs/subsystems/scope.zh.md +++ b/docs/subsystems/scope.zh.md @@ -6,6 +6,8 @@ 源码:[`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts) 与 [`packages/core/scope/src/store.ts`](../../packages/core/scope/src/store.ts)。 + + ## 身份标识与分发载体 `ScopeKey` 是一个不透明的对象身份标识。已交付的 agent loop(智能体循环)使用活跃的 `Agent` 对象作为自身的 key,但该原语从不检视该对象。 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 24d4833a81..bb40e7f93f 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -856,9 +856,9 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise /** - * Create or idempotently adopt one ordinary Session. - * @param request - requested identity, location, and Agent preset. - * @returns the Session identity and resolved preset when configured. + * Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable. + * @param request - optional blank Session to reuse, location, and Agent preset. + * @returns the created or reused Session identity and its preset when configured. */ @Remote('create') create(request: SessionCreateRequest): Promise diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index d214c4b471..298b704bde 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -860,9 +860,9 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise /** - * Create or idempotently adopt one ordinary Session. - * @param request - requested identity, location, and Agent preset. - * @returns the Session identity and resolved preset when configured. + * Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable. + * @param request - optional blank Session to reuse, location, and Agent preset. + * @returns the created or reused Session identity and its preset when configured. */ @Remote('create') create(request: SessionCreateRequest): Promise diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 385b99e972..83cd761e68 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -12,8 +12,8 @@ en: 465f0c330fc1054b zh: bd0b68a05790fff8 /right-sidebar/tab-type-registration: - en: 1e01c817c5879c40 - zh: a79abd92ee4c8f85 + en: b7b8a7b411aee94d + zh: 7298c20000945d97 /right-sidebar/navigation-ctx-sidebarright: en: 0f80b5364cbad804 zh: db81652a5374fad1 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 6315827c27..1342a7f938 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -46,6 +46,8 @@ Tab identity is the pair `(kind, address)`: the registry's claim uses the addres | `keepMounted` | Optional lazy retention of visited bodies across hiding, Session switches and docking; the owning View holds the Session reference. | | `guide` | Optional entry boxes for the guide page: `{ order, title(), description?(), icon? }`. Picking a box opens the contributing type as a page; omit to stay off the page. | +Whole-address patterns match the raw address: picomatch globstar (`**`) excludes standalone `.` and `..` segments even with `dot: true`, which includes dotfiles but does not change that exclusion. Resource-specific code parses addresses; the Host enforces file-read permissions. + Routing is a ranked claim. `candidates(address)` ranks the types whose patterns match and whose `canOpen` does not veto: by band, then by the length of the longest matched pattern, then by registration order. `claim(address, kind?)` picks the first candidate, or the named `kind` outright — its globs are skipped, its `canOpen` still applies — and returns `{ kind, contentId: address, title }`. An address no type claims throws: it is a wiring mistake, not a user error. One `kind` may carry one `builtin` and one `extension` registration at the same time. The extension is the one in force for claims, `get(kind)`, `openTab(kind)`, and the guide page, and the seat finds a tab's body and title under the definition in force's `id`, so no slot priority is involved; when the extension unregisters, the builtin resumes. Every other collision on a kind, and every duplicate `id`, throws. diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index 1df6007e12..e7df97d11c 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -46,6 +46,8 @@ tab 身份是 `(kind, address)` 二元组:注册表的认领把地址原文用 | `keepMounted` | 可选的懒保活:已访问正文跨隐藏、Session 切换与停靠切换保留,由所属 View 持有 Session reference。 | | `guide` | 可选的引导页入口框:`{ order, title(), description?(), icon? }`。点一框即把贡献它的类型作为页面打开;省略即不上引导页。 | +完整地址模式匹配地址原文:picomatch 的 globstar(`**`)即使启用 `dot: true` 也排除独立的 `.` 和 `..` 段;该选项允许匹配 dotfile,不改变点段排除规则。地址由资源业务代码解析,文件读取仍受 Host 文件系统权限限制。 + 路由是一次排序认领。`candidates(address)` 对模式命中且未被 `canOpen` 否决的类型排序:先按档,再按最长命中模式的长度,最后按注册顺序。`claim(address, kind?)` 取第一个候选,或直接用点名的 `kind`——跳过它的 glob,但 `canOpen` 仍生效——返回 `{ kind, contentId: address, title }`。没有任何类型认领的地址会抛错:这是接线错误,不是用户错误。 同一个 `kind` 可同时携带一个 `builtin` 与一个 `extension` 注册。extension 在认领、`get(kind)`、`openTab(kind)` 与引导页上生效,席位按生效定义的 `id` 找 tab 的正文与标题,不涉及任何 slot 优先级;extension 注销后 builtin 恢复。kind 上的其它任何撞名以及任何重复的 `id` 都抛错。 diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index aec206af8c..1dd04a5ab9 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -10,7 +10,7 @@ zh: 441ed13ff3f81d2a /web-client-slots/cardinality-and-scope: en: 5b0ada069239576b - zh: 7e71ced7f17d78a6 + zh: 84e1e6b253d1461e /web-client-slots/component-inputs: en: 9cb70abb2e39b958 zh: cd4b6f1705d90e17 @@ -18,11 +18,11 @@ en: 2fcfc55fbac80f72 zh: ea3016f394c56b07 /web-client-slots/developer-provided-injection: - en: 4fb5222bb2b064e7 - zh: b961f792052b4ce5 -/web-client-slots/current-hierarchy: - en: 8a4cc4b8608ce0f6 - zh: 21281dcf047503b2 + en: 3481dfa6cd3097da + zh: acbd6f06a7da1752 +/web-client-slots/conversation-hierarchy: + en: e1d572c42e91d663 + zh: 5a649dea2682be7f /web-client-slots/extension-rules: en: b2cce357141883ee zh: 278080a5fb941189 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index c0fa352a4f..30f8acedb4 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -108,92 +108,84 @@ The owner of a slot may put an `inject` face in the child declaration when every Use owner props for values already known at one render occurrence, registration `inject` for one entry's callbacks and private observables, slot-level `inject` for a capability controlled by the slot owner, and a declared store for mutable view state shared across entries or preserved across remounts. React nodes compose through child slots, not through injected values. -## Current hierarchy + -The hierarchy below is the shipped declaration tree. A child exists only while the named parent entry is mounted; optional feature entries can therefore make a subtree appear or disappear as one lifecycle unit. +## Conversation hierarchy + +The following Conversation paths distinguish declaration owners from reusable Factory roots; unrelated shell positions are omitted. An entry owns its declared children, while a Factory owns its child declarations for the lifetime of its registered definition. Bracketed names identify the default React components; a plain React descendant can render its owner's Slot. Factory calls are references to independent reusable definitions, not new Slot declarations. ```text -root -├─ sidebar -│ ├─ sidebar.brand.mark -│ ├─ sidebar.brand.name -│ ├─ sidebar.panellist -│ ├─ sidebar.footer.action -│ ├─ sidebar.workspaces -│ │ ├─ sidebar.workspaces.directoryFlow -│ │ ├─ sidebar.workspaces.session.menu.item -│ │ └─ sidebar.workspaces.session.row.action -│ └─ sidebar.settings -│ ├─ settings.trigger -│ ├─ settings.header -│ ├─ settings.action -│ ├─ settings.close -│ ├─ settings.onboarding -│ └─ settings.section -│ ├─ settings.general.item -│ ├─ settings.models.provider-card -│ ├─ settings.models.footer -│ └─ settings.plugins.tab -├─ main -│ ├─ plugins.add.actions -│ ├─ plugins.item -│ ├─ plugins.bundle.config -│ ├─ plugins.row.config -│ ├─ plugins.detail.actions -│ ├─ plugins.detail.badge -│ ├─ plugins.detail.section -│ └─ main.conversation -│ ├─ conversation.session -│ │ └─ conversation.view -│ │ ├─ conversation.chat.flow -│ │ │ ├─ conversation.chat.node -│ │ │ │ ├─ conversation.chat.assistant-actions -│ │ │ │ ├─ conversation.chat.reasoning.body -│ │ │ │ ├─ conversation.chat.commandview -│ │ │ │ ├─ conversation.chat.turnTail -│ │ │ │ └─ tool.call.toolview -│ │ │ │ ├─ tool.call.images -│ │ │ │ └─ tool.view.cordis -│ │ │ └─ conversation.message.images -│ │ └─ conversation.trajectory.images -│ ├─ conversation.header -│ │ ├─ conversation.header.leading -│ │ └─ conversation.session.header -│ │ ├─ conversation.session.header.lineage -│ │ ├─ conversation.session.header.actions -│ │ ├─ conversation.session.header.utilities -│ │ └─ conversation.session.header.corner -│ ├─ conversation.composer -│ │ ├─ conversation.approval.detail -│ │ └─ conversation.plan-review.actions -│ ├─ conversation.composer.bar -│ │ ├─ conversation.input.attachments -│ │ ├─ conversation.input.permission -│ │ ├─ conversation.input.plan -│ │ └─ conversation.input.model -│ ├─ conversation.input.overlay -│ ├─ conversation.input.dock -│ ├─ conversation.composer.dock -│ ├─ conversation.input.left -│ ├─ conversation.input.right -│ ├─ conversation.hero.brand.mark -│ ├─ conversation.hero.workspace -│ │ └─ conversation.hero.workspace.directoryFlow -│ └─ conversation.hero.agentPreset -├─ rightbar -│ └─ rightbar.session -│ ├─ sidebar.right.pane.tab -│ │ ├─ sidebar.right.tab.guide -│ │ └─ sidebar.right.tab.guide.entry -│ ├─ sidebar.right.pane.tab.title -│ └─ sidebar.right.tab.menu.item -├─ shell.bottom -├─ shell.leading -└─ shell.overlay - └─ shell.quota-notice +root [AppFrame] +└─ main[key=conversation] [ConversationPanel] + └─ main.conversation [ConversationRoot / ConversationMainPanel] + ├─ conversation.header [ConversationHeader] + │ ├─ conversation.header.leading + │ └─ conversation.session.header [ConversationSessionHeader] + │ ├─ conversation.session.header.lineage + │ ├─ conversation.session.header.actions + │ ├─ conversation.session.header.utilities + │ └─ conversation.session.header.corner + └─ calls Factory: conversation.content + +Factory: conversation.content [ConversationContent] +├─ conversation.session [ConversationSession / DefaultConversationViews] +│ └─ conversation.view +│ ├─ id=chat [ChatView] +│ │ └─ conversation.chat.flow [ChatFlow] +│ │ └─ conversation.chat.node [ChatNodeSeat dispatch] +│ │ ├─ key=user,steering [UserMessageNodeView] +│ │ │ └─ calls Factory: conversation.chat.user.content +│ │ ├─ key=assistant-step [AssistantNodeView / AssistantMarkdown] +│ │ │ ├─ conversation.chat.message.images [MessageImages] +│ │ │ └─ conversation.chat.reasoning.body [DefaultReasoningBody] +│ │ │ └─ calls Factory: conversation.chat.reasoning.content +│ │ ├─ key=tool-call [ToolCallTree] +│ │ │ └─ tool.call.toolview +│ │ │ └─ key=read_image [ReadImageRow / ToolRow] +│ │ │ └─ tool.call.images [MessageImages] +│ │ ├─ key=command-input [GoalCommandInputView] +│ │ │ └─ calls Factory: conversation.user.text +│ │ ├─ key=command [CommandNodeView] +│ │ │ └─ conversation.chat.commandview +│ │ └─ key=turn-tail [TurnTailNodeView] +│ │ ├─ conversation.chat.turnTail +│ │ └─ conversation.chat.assistant-actions +│ └─ id=trajectory [TrajectoryView / TrajectoryTable] +│ └─ conversation.trajectory.images [MessageImages] +├─ conversation.composer +│ ├─ conversation.approval.detail +│ └─ conversation.plan-review.actions +├─ conversation.composer.bar [InputBar] +│ ├─ conversation.input.attachments +│ ├─ conversation.input.permission +│ ├─ conversation.input.plan +│ ├─ conversation.input.model +│ ├─ conversation.input.overlay +│ ├─ conversation.composer.dock +│ ├─ conversation.input.left +│ └─ conversation.input.right +├─ conversation.input.dock +│ └─ id=queue [QueueDock] +│ └─ calls Factory: conversation.user.text +├─ conversation.hero.brand.mark +├─ conversation.hero.workspace +│ └─ conversation.hero.workspace.directoryFlow +└─ conversation.hero.agentPreset + +Factory: conversation.chat.user.content [UserContent] +├─ conversation.chat.user.images [MessageImages] +└─ calls Factory: conversation.user.text + +Factory: conversation.user.text [UserText] +└─ conversation.user.text.node [keyed, inline] + +Factory: conversation.images.content [MessageImagesContent / ImageGallery] +Factory: conversation.chat.reasoning.content [ReasoningContent / MarkdownText] ``` -The generated Client inspect catalog is the exhaustive contract for each key: cardinality, scope, owner props, standard props, current occupants, declaration owner, and replacement risk. A running dynamic package can query the live tree and an exact key with `cordis_inspect what:"client"`; the source catalog is generated from `SlotMap` declarations and `slots.register()` call sites by `pnpm run gen-client-catalog`. +All four image Slots default to `MessageImages`, which calls `conversation.images.content` with the caller's image references and authorized loader. User and output image replacements are independent. The `conversation.user.text` Factory is shared by user content, Queue, and Goal; its `inline: true` keyed Slot renders span anchors and inline error placeholders rather than block wrappers. + +The generated [Client inspect catalog](../../packages/extensions/cordis-client-runner/src/client/slot-catalog.ts) covers all shell branches and is the exhaustive contract for each key: cardinality, scope, owner props, standard props, current occupants, declaration owner, and replacement risk. A running dynamic package can query the live tree and an exact key with `cordis_inspect what:"client"`; the source catalog is generated from `SlotMap` declarations and `slots.register()` call sites by `pnpm run gen-client-catalog`. ## Extension rules diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index 3689583543..a660f720e4 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -61,6 +61,8 @@ Slot 声明固定两个相互独立的维度。 对于 `single`、`list` 和 `keyed` cell,`priority` 是遮蔽优先级;对于 `chain`,它是选举顺序。数值越小越先运行或渲染。普通增量贡献应选用新的 list `id` 或 keyed `key`;复用已有 cell 表示有意替换其展示。 + + ## 组件输入 注册组件会在 binding 位置收到组装后的输入。组件应从这些类型推导 props,不要重新抄写成员。 @@ -108,92 +110,86 @@ Renderer 还会根据声明的 store 创建 `useStore`,并根据声明的 loca 一次渲染时 owner 已知的值走 owner props;单个 entry 的 callback 与私有 observable 走注册项 `inject`;由 slot owner 控制、所有 occupant 共享的能力走 slot 级 `inject`;需要跨 entry 共享或跨重新挂载保留的可变视图状态走声明的 store。React node 通过 child slot 组合,不通过注入值传递。 -## 当前层级 + -下图是当前发布组合的声明树。只有具名 parent entry 已挂载时,其 child 才存在;因此可选功能 entry 可以作为一个生命周期单元让整棵子树出现或消失。 +## Conversation 层级 + +下面的 Conversation 路径区分声明所有者与可复用 Factory 根,省略无关的外壳位置。条目拥有其声明的子 Slot;Factory 在其定义注册期间拥有子 Slot 声明。方括号标出默认 React 组件,普通 React 后代组件可以渲染其所有者的 Slot。Factory 调用指向独立的可复用定义,不是新的 Slot 声明。 ```text -root -├─ sidebar -│ ├─ sidebar.brand.mark -│ ├─ sidebar.brand.name -│ ├─ sidebar.panellist -│ ├─ sidebar.footer.action -│ ├─ sidebar.workspaces -│ │ ├─ sidebar.workspaces.directoryFlow -│ │ ├─ sidebar.workspaces.session.menu.item -│ │ └─ sidebar.workspaces.session.row.action -│ └─ sidebar.settings -│ ├─ settings.trigger -│ ├─ settings.header -│ ├─ settings.action -│ ├─ settings.close -│ ├─ settings.onboarding -│ └─ settings.section -│ ├─ settings.general.item -│ ├─ settings.models.provider-card -│ ├─ settings.models.footer -│ └─ settings.plugins.tab -├─ main -│ ├─ plugins.add.actions -│ ├─ plugins.item -│ ├─ plugins.bundle.config -│ ├─ plugins.row.config -│ ├─ plugins.detail.actions -│ ├─ plugins.detail.badge -│ ├─ plugins.detail.section -│ └─ main.conversation -│ ├─ conversation.session -│ │ └─ conversation.view -│ │ ├─ conversation.chat.flow -│ │ │ ├─ conversation.chat.node -│ │ │ │ ├─ conversation.chat.assistant-actions -│ │ │ │ ├─ conversation.chat.reasoning.body -│ │ │ │ ├─ conversation.chat.commandview -│ │ │ │ ├─ conversation.chat.turnTail -│ │ │ │ └─ tool.call.toolview -│ │ │ │ ├─ tool.call.images -│ │ │ │ └─ tool.view.cordis -│ │ │ └─ conversation.message.images -│ │ └─ conversation.trajectory.images -│ ├─ conversation.header -│ │ ├─ conversation.header.leading -│ │ └─ conversation.session.header -│ │ ├─ conversation.session.header.lineage -│ │ ├─ conversation.session.header.actions -│ │ ├─ conversation.session.header.utilities -│ │ └─ conversation.session.header.corner -│ ├─ conversation.composer -│ │ ├─ conversation.approval.detail -│ │ └─ conversation.plan-review.actions -│ ├─ conversation.composer.bar -│ │ ├─ conversation.input.attachments -│ │ ├─ conversation.input.permission -│ │ ├─ conversation.input.plan -│ │ └─ conversation.input.model -│ ├─ conversation.input.overlay -│ ├─ conversation.input.dock -│ ├─ conversation.composer.dock -│ ├─ conversation.input.left -│ ├─ conversation.input.right -│ ├─ conversation.hero.brand.mark -│ ├─ conversation.hero.workspace -│ │ └─ conversation.hero.workspace.directoryFlow -│ └─ conversation.hero.agentPreset -├─ rightbar -│ └─ rightbar.session -│ ├─ sidebar.right.pane.tab -│ │ ├─ sidebar.right.tab.guide -│ │ └─ sidebar.right.tab.guide.entry -│ ├─ sidebar.right.pane.tab.title -│ └─ sidebar.right.tab.menu.item -├─ shell.bottom -├─ shell.leading -└─ shell.overlay - └─ shell.quota-notice +root [AppFrame] +└─ main[key=conversation] [ConversationPanel] + └─ main.conversation [ConversationRoot / ConversationMainPanel] + ├─ conversation.header [ConversationHeader] + │ ├─ conversation.header.leading + │ └─ conversation.session.header [ConversationSessionHeader] + │ ├─ conversation.session.header.lineage + │ ├─ conversation.session.header.actions + │ ├─ conversation.session.header.utilities + │ └─ conversation.session.header.corner + └─ calls Factory: conversation.content + +Factory: conversation.content [ConversationContent] +├─ conversation.session [ConversationSession / DefaultConversationViews] +│ └─ conversation.view +│ ├─ id=chat [ChatView] +│ │ └─ conversation.chat.flow [ChatFlow] +│ │ └─ conversation.chat.node [ChatNodeSeat dispatch] +│ │ ├─ key=user,steering [UserMessageNodeView] +│ │ │ └─ calls Factory: conversation.chat.user.content +│ │ ├─ key=assistant-step [AssistantNodeView / AssistantMarkdown] +│ │ │ ├─ conversation.chat.message.images [MessageImages] +│ │ │ └─ conversation.chat.reasoning.body [DefaultReasoningBody] +│ │ │ └─ calls Factory: conversation.chat.reasoning.content +│ │ ├─ key=tool-call [ToolCallTree] +│ │ │ └─ tool.call.toolview +│ │ │ └─ key=read_image [ReadImageRow / ToolRow] +│ │ │ └─ tool.call.images [MessageImages] +│ │ ├─ key=command-input [GoalCommandInputView] +│ │ │ └─ calls Factory: conversation.user.text +│ │ ├─ key=command [CommandNodeView] +│ │ │ └─ conversation.chat.commandview +│ │ └─ key=turn-tail [TurnTailNodeView] +│ │ ├─ conversation.chat.turnTail +│ │ └─ conversation.chat.assistant-actions +│ └─ id=trajectory [TrajectoryView / TrajectoryTable] +│ └─ conversation.trajectory.images [MessageImages] +├─ conversation.composer +│ ├─ conversation.approval.detail +│ └─ conversation.plan-review.actions +├─ conversation.composer.bar [InputBar] +│ ├─ conversation.input.attachments +│ ├─ conversation.input.permission +│ ├─ conversation.input.plan +│ ├─ conversation.input.model +│ ├─ conversation.input.overlay +│ ├─ conversation.composer.dock +│ ├─ conversation.input.left +│ └─ conversation.input.right +├─ conversation.input.dock +│ └─ id=queue [QueueDock] +│ └─ calls Factory: conversation.user.text +├─ conversation.hero.brand.mark +├─ conversation.hero.workspace +│ └─ conversation.hero.workspace.directoryFlow +└─ conversation.hero.agentPreset + +Factory: conversation.chat.user.content [UserContent] +├─ conversation.chat.user.images [MessageImages] +└─ calls Factory: conversation.user.text + +Factory: conversation.user.text [UserText] +└─ conversation.user.text.node [keyed, inline] + +Factory: conversation.images.content [MessageImagesContent / ImageGallery] +Factory: conversation.chat.reasoning.content [ReasoningContent / MarkdownText] ``` -生成的 Client inspect catalog 是每个 key 的完整参考,包含 cardinality、scope、owner props、标准 props、当前 occupant、声明 owner 与替换风险。运行中的动态包可以用 `cordis_inspect what:"client"` 查询实时树与某个精确 key;源码 catalog 由 `pnpm run gen-client-catalog` 根据 `SlotMap` 声明和 `slots.register()` 调用点生成。 +四个图片 Slot 默认均填入 `MessageImages`,由它携带调用者的图片引用和授权 loader 调用 `conversation.images.content`。用户与输出图片的替换互不影响。`conversation.user.text` Factory 由用户内容、Queue 和 Goal 共用;其 `inline: true` keyed Slot 使用 span 锚点及行内错误占位,而不是块级包装。 + +生成的 [Client inspect catalog](../../packages/extensions/cordis-client-runner/src/client/slot-catalog.ts) 是包含所有外壳分支及每个 key 的完整参考,包含 cardinality、scope、owner props、标准 props、当前 occupant、声明 owner 与替换风险。运行中的动态包可以用 `cordis_inspect what:"client"` 查询实时树与某个精确 key;源码 catalog 由 `pnpm run gen-client-catalog` 根据 `SlotMap` 声明和 `slots.register()` 调用点生成。 + + ## 扩展规则 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 104548c425..21fa5dbbde 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -9,8 +9,8 @@ en: 8c92b131f67997f6 zh: c2dc606a0859420c /subagents/local-children-and-activations: - en: d926d4777ec0f11e - zh: 0ae6076e24b66401 + en: 6f87231d18d82916 + zh: 9f064b23058f8766 /subagents/messages-and-interruption: en: 623b06b3c12540bc zh: f56c6341e1c62768 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 5647fab452..30e064f25c 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -19,10 +19,10 @@ Optional `request.cwd` selects the child's initial directory; relative paths res */ interface SubagentCapabilities { readonly agentOptions: boolean + /** Supports selecting a preset independently of the parent's composition. */ + readonly agentPreset: boolean readonly outputSchema: boolean readonly depthLimit: boolean - readonly toolFilter: boolean - readonly persona: boolean } ``` @@ -70,6 +70,12 @@ interface SubagentActivation { interface SubagentStartRequest { /** Initial child directory; relative paths resolve against the parent's current directory. Omitted inherits that directory at start. */ readonly cwd?: string + /** + * Independent preset id; omission joins the parent's current generation. + * Requires a provider with `capabilities.agentPreset` that does not inherit + * parent context. + */ + readonly agentPreset?: string /** Optional short display label persisted with a session-backed child. */ readonly label?: string /** Content delivered as the child's user message. */ @@ -108,22 +114,6 @@ interface SubagentStartRequest { * start otherwise. */ readonly maxDepth?: number - /** - * Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter}; - * rejected at start otherwise. In-process backends apply it as a scoped - * `tools.restrict()` in the child's creation window: the named tools vanish - * from the child's prompt AND refuse to execute (one visibility), with loud - * unknown-name validation. - */ - readonly toolFilter?: ToolRestriction - /** - * Optional per-child persona. Requires {@link SubagentCapabilities.persona}; - * rejected at start otherwise. In-process backends register it as a scoped - * `deployment:persona-prefix` section on the child, SHADOWING the deployment's - * persona for this child alone — same template semantics as the deployment - * persona (strict `{{…}}` interpolation against the registered variables). - */ - readonly persona?: string } ``` @@ -137,6 +127,8 @@ interface ResolvedSubagentStartRequest extends SubagentStartRequest { ## Local children and activations +Spawn alone supports an independent `agentPreset`; omission and fork inherit the parent's active composition. The [child preset rules](../../packages/subagent/subagent/README.md#restoring-child-composition) define explicit selection, event ordering, and historical restoration. + A local child has a durable Session and at most one live activation. The manager reserves identity and capacity, prepares the child through its backend, and owns input admission, cold resume, and child-first disposal. The activation result settles after resource release and completion notification. Disposal reports cleanup failure separately without replacing a captured result. Structured capture closes input for that activation; a later cold resume does not inherit the output schema. ```ts type-equiv diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 30262036b7..c6d7119a25 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -19,10 +19,10 @@ */ interface SubagentCapabilities { readonly agentOptions: boolean + /** Supports selecting a preset independently of the parent's composition. */ + readonly agentPreset: boolean readonly outputSchema: boolean readonly depthLimit: boolean - readonly toolFilter: boolean - readonly persona: boolean } ``` @@ -70,6 +70,12 @@ interface SubagentActivation { interface SubagentStartRequest { /** Initial child directory; relative paths resolve against the parent's current directory. Omitted inherits that directory at start. */ readonly cwd?: string + /** + * Independent preset id; omission joins the parent's current generation. + * Requires a provider with `capabilities.agentPreset` that does not inherit + * parent context. + */ + readonly agentPreset?: string /** Optional short display label persisted with a session-backed child. */ readonly label?: string /** Content delivered as the child's user message. */ @@ -108,22 +114,6 @@ interface SubagentStartRequest { * start otherwise. */ readonly maxDepth?: number - /** - * Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter}; - * rejected at start otherwise. In-process backends apply it as a scoped - * `tools.restrict()` in the child's creation window: the named tools vanish - * from the child's prompt AND refuse to execute (one visibility), with loud - * unknown-name validation. - */ - readonly toolFilter?: ToolRestriction - /** - * Optional per-child persona. Requires {@link SubagentCapabilities.persona}; - * rejected at start otherwise. In-process backends register it as a scoped - * `deployment:persona-prefix` section on the child, SHADOWING the deployment's - * persona for this child alone — same template semantics as the deployment - * persona (strict `{{…}}` interpolation against the registered variables). - */ - readonly persona?: string } ``` @@ -137,6 +127,8 @@ interface ResolvedSubagentStartRequest extends SubagentStartRequest { ## 本地子代理与 activation +只有 spawn 支持独立的 `agentPreset`;省略选择与 fork 都继承父方的活跃组装。[子级 preset 规则](../../packages/subagent/subagent/README.zh.md#restoring-child-composition)定义显式选择、事件顺序和历史恢复行为。 + 本地子代理拥有持久 Session,最多有一个在线 activation。Manager 预留身份和容量,通过后端准备子代理,并管理输入准入、冷恢复以及先子后父的释放顺序。activation 结果在资源释放和完成通知之后结算。释放操作单独报告清理失败,不会覆盖已捕获的结果。结构化结果提交会关闭该 activation 的输入;之后的冷恢复不会继承输出 schema。 ```ts type-equiv diff --git a/docs/subsystems/translation.i18n.yaml b/docs/subsystems/translation.i18n.yaml index 51904d7464..58d834341f 100644 --- a/docs/subsystems/translation.i18n.yaml +++ b/docs/subsystems/translation.i18n.yaml @@ -9,8 +9,8 @@ en: 485abf6107c73e68 zh: 78a274d488274035 /text-translation/failures-and-data-handling: - en: cf92a1920650888c - zh: 3e8d3aa244cc8d46 + en: 436e55e924e3ad1e + zh: c8548fda1e86b078 /text-translation/persistent-translations: en: 40d175fbe66fd4cd zh: bff18511a8d386e9 diff --git a/docs/subsystems/translation.md b/docs/subsystems/translation.md index 8cbf6d3ac8..306b5baf84 100644 --- a/docs/subsystems/translation.md +++ b/docs/subsystems/translation.md @@ -14,7 +14,7 @@ The service exposes its configured `maxTextChars`, measured in UTF-16 code units `TranslationError` carries a `TranslationErrorCode` distinguishing text limits, HTTP failures, invalid responses, response limits, owned deadlines, and request failures. `TRANSLATION_SESSION_REQUIRED` rejects a missing persistence backend or Session; `TRANSLATION_SESSION_INACTIVE` rejects an uncached inactive Session before provider dispatch; `TRANSLATION_STORAGE_ERROR` rejects invalid saved records or failed durability. Provider diagnostics omit submitted text and response bodies. Failed calls do not select another provider. Responses are validated as external JSON and redirects are rejected. -`TRANSLATION_UNAVAILABLE` rejects a missing active built-in owner or registered route, unconfigured credentials, absent exact `deepseek-flash` catalog membership, an owner/model-list/official credential-reference change before dispatch, provider withdrawal, or a prepared call whose reasoning effort is not `off`. A native `finish` with reason `error` maps to `TRANSLATION_REQUEST_FAILED`; a missing or other non-`stop` finish maps to `TRANSLATION_INVALID_RESPONSE`. +`TRANSLATION_UNAVAILABLE` rejects a missing active built-in owner or registered route, unconfigured credentials, absent exact `deepseek-flash` catalog membership, an owner, model-configuration (Official `models` or Account `modelOverrides`), or Official credential-reference change before dispatch, provider withdrawal, or a prepared call whose reasoning effort is not `off`. A native `finish` with reason `error` maps to `TRANSLATION_REQUEST_FAILED`; a missing or other non-`stop` finish maps to `TRANSLATION_INVALID_RESPONSE`. Google receives source text in a form POST body; Bing receives it in a JSON POST body. Both are remote services. Their anonymous endpoints provide no supported developer availability or quota guarantee. Regional connectivity must be verified on the user's network. diff --git a/docs/subsystems/translation.zh.md b/docs/subsystems/translation.zh.md index 4600d369e4..a9ef52df83 100644 --- a/docs/subsystems/translation.zh.md +++ b/docs/subsystems/translation.zh.md @@ -14,7 +14,7 @@ `TranslationError` 的 `TranslationErrorCode` 区分文本超限、HTTP 失败、无效响应、响应超限、服务自身超时与请求失败。`TRANSLATION_SESSION_REQUIRED` 拒绝缺失的持久化后端或 Session,`TRANSLATION_SESSION_INACTIVE` 在调用 Provider 前拒绝未激活且缓存未命中的 Session,`TRANSLATION_STORAGE_ERROR` 拒绝无效保存记录或持久化失败。Provider 错误信息省略提交的文本与响应体。失败时不选择其他 Provider。响应作为外部 JSON 校验,重定向被拒绝。 -`TRANSLATION_UNAVAILABLE` 拒绝缺失的活跃内置所有者或已注册路由、未配置的凭据、目录中不存在精确的 `deepseek-flash`、派发前所有者/模型列表/官方凭据引用变化、提供方卸载,以及准备后的调用推理强度不是 `off` 的情况。原生 `finish` 的原因为 `error` 时映射为 `TRANSLATION_REQUEST_FAILED`;缺失 finish 或其他非 `stop` 原因映射为 `TRANSLATION_INVALID_RESPONSE`。 +`TRANSLATION_UNAVAILABLE` 拒绝缺失的活跃内置所有者或已注册路由、未配置的凭据、目录中不存在精确的 `deepseek-flash`、派发前所有者、模型配置(官方路由的 `models` 或账号路由的 `modelOverrides`)或官方凭据引用变化、提供方卸载,以及准备后的调用推理强度不是 `off` 的情况。原生 `finish` 的原因为 `error` 时映射为 `TRANSLATION_REQUEST_FAILED`;缺失 finish 或其他非 `stop` 原因映射为 `TRANSLATION_INVALID_RESPONSE`。 Google 在 form POST 请求体中接收原文,Bing 在 JSON POST 请求体中接收原文。两者都是远端服务。免登录端点不保证开发者 API 的可用性或免费额度。区域连通性须在用户网络上验证。 diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index a74d05084d..0030b50111 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.i18n.yaml @@ -9,8 +9,8 @@ en: 8725fa62f36364d7 zh: 0f2ff4bdf13ff55b /typert-remote-calls/invocation-descriptors: - en: 6417ffb792fd5385 - zh: c012707075a0b21f + en: 21e88d68154c2c92 + zh: 8c9e05c65ef0f34d /typert-remote-calls/typert-registry: en: 29f578ba733cfd5a zh: c486ff4ac2d1e3b4 @@ -18,5 +18,5 @@ en: 90fab88fe51d17bb zh: 2d4c3849cf2dc7d3 /typert-remote-calls/consumer-remote: - en: 4836f8f44fef8fd3 - zh: 51f3ab2462a08ff4 + en: 324386c600a5fdf5 + zh: 7963501eb663e228 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index f0ef75f223..e9cabd714b 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -40,27 +40,31 @@ interface TypertLookupDefinition { An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schema factories, while SRC codecs enforce JSON-safe inputs without structural type recovery. Unary result codecs can supply `encode()` for byte-containing subtrees and `decode()` for recursive native-byte validation; Client declarations narrow every `Uint8Array` to `ArrayBuffer` backing. Pure JSON results need neither byte detection nor Client decoding. Cancellation is an out-of-band carrier signal injected after business parameters and never enters `args`. +`createTypertRemoteInfo` returns a lazy class instance implementing `TypertRemoteInfo`, which extends the mountable `TypertRemoteContribution`. Its `methods` index aliases the direct objects in `descriptors`: each `TypertRemoteMethod` adds named `input(options?)` and `invoke`, retains documentation on the method and its parameters, and uses `result` as its only output codec. Unary `mode` is absent; stream `mode` is `'stream'`. Context-only receivers remain in descriptors and scoped declarations. Registry queries return base descriptors; `isTypertRemoteMethod` narrows generated direct capabilities without recovering endpoint-specific generics. + +`TypertSchema` guarantees parsing, not arbitrary Zod operations, although the raw factory spec requires Zod factories. Missing documentation or an unsupported optional JSON Schema projection does not invalidate an ordinary method. The info index and each input-policy view, parser, and derived projection are independently lazy and cached; no metadata or schema access invokes the network. The [protocol reference](<../../packages/typert/protocol/README.md>) defines these APIs, and the [generator reference](<../../packages/typert/generator/README.md#ordinary-remote-metadata>) defines their separate Host `./typert`, consumer `./remote`, and Client `./client/typert` artifacts. + +`TypertRemoteSpec` holds a package and endpoint-keyed `TypertRemoteMethodSpec` values. Each ordered `TypertRemoteParameterSpec` contains normal `codec` and `strictCodec` factories from the same checker-resolved type model; the runtime derives identities, wire-name defaults, and named-call views. `TypertRemoteInputOptions` has only `strict?: boolean`; `method.input(options?)` selects a cached `TypertRemoteValue` with matching parsing and optional documentation/projection, without changing Host dispatch or `result`. `RemoteDecoded` describes carrier-reconstructed unary results without narrowing arbitrary parser outputs. + ```ts type-equiv /** Codec attached to one invocation parameter or result. */ -type TypertCodec = - | { +type TypertCodec = + | TypertRemoteValue & { readonly mode: 'strict' readonly typeSymbol: string - /** Materialize and return the process-realm schema on first boundary use. */ - readonly create: () => TypertSchema /** * Decode a unary result whose fields require type-specific handling. * @param value - result reconstructed by the RPC carrier. * @returns the validated result, retaining native byte views. */ - readonly decode?: (value: unknown) => unknown + readonly decode?: ((value: unknown) => unknown) | undefined /** * Project typed binary fields into RPC result attachments. * @param value - native unary result. * @param writeBytes - records a byte view at its result-relative path and returns its JSON placeholder. * @returns JSON metadata with untouched JSON subtrees retained. */ - readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown + readonly encode?: ((value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown) | undefined } | { readonly mode: 'src-json' @@ -72,6 +76,8 @@ type TypertCodec = interface InvocationParameterDescriptor { /** Source-level parameter name. */ readonly name: string + /** Source parameter documentation, independent of JSON Schema support. */ + readonly description?: string /** Required key in the wire `args` object. */ readonly wire: string /** Whether the value is JSON or requires a registered Host lookup. */ @@ -135,6 +141,8 @@ interface InvocationDescriptor { } /** Codec for the unary result or each yielded stream item. */ readonly result: TypertCodec + /** Source-owned method documentation; ordinary methods need not have JSDoc. */ + readonly description?: string /** Source declaration used only for diagnostics. */ readonly sourceLocation?: InvocationSourceLocation } @@ -207,6 +215,16 @@ interface PeerScope { } ``` +```ts type-equiv +/** Carrier capacity for buffered uplink frames of one logical stream. */ +interface RemoteUplinkWindow { + /** Maximum complete encoded frame bytes waiting for the Host consumer. */ + readonly maxBytes: number + /** UTF-8 envelope bytes added to a compact JSON item with a present value field. */ + readonly frameOverheadBytes: number +} +``` + ```ts type-equiv /** * The context of one Remote call, reachable inside the receiving method as @@ -225,6 +243,8 @@ interface RemoteInvocation { readonly peer: PeerScope /** Carrier cancellation: Client cancel, socket close, or an uplink failure. */ readonly signal: AbortSignal + /** Carrier frame capacity; absent when the carrier imposes no encoded-frame inbox limit. */ + readonly uplinkWindow?: RemoteUplinkWindow | undefined /** * The Client's uplink items for this call. Available once; a second call * throws. With an uplink codec on the descriptor every item is decoded to @@ -279,6 +299,8 @@ interface InvokeRemoteRequest { * `invocation.uplink()`; absent means an immediately ended iterable. */ readonly uplink?: AsyncIterable + /** Carrier-provided frame capacity; absent for carriers without an encoded-frame inbox limit. */ + readonly uplinkWindow?: RemoteUplinkWindow | undefined /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ readonly peer?: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ @@ -350,6 +372,8 @@ interface TypertGateway { `ctx.remote` exposes only namespaces contributed by imported `/remote` artifacts. `$mount()` installs generated descriptors and concrete methods as one fiber-owned operation. Each namespace is a traced `remote.` Cordis child Service whose lifetime spans its mounted methods; no JavaScript Proxy or Host business Service type enters the consumer. +Metadata ownership is separate from connection ownership; the [registry reference](<../../packages/typert/registry/README.md>) covers offline registration and mount lifetimes. + ```ts type-equiv /** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ interface TypertClientRemote extends TypertRemoteNamespaceMap { diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index e2414dfc5e..287e81a5fa 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -40,27 +40,31 @@ interface TypertLookupDefinition { `InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema factory,SRC codec 则在不恢复结构类型的前提下强制要求输入为 JSON 安全值。一元结果 codec 可提供 `encode()` 处理含字节的子树,并通过 `decode()` 递归校验原生字节;Client 声明将每个 `Uint8Array` 限定为以 `ArrayBuffer` 为底层缓冲区。纯 JSON 结果无需字节识别或 Client 解码。取消通过带外 carrier signal 表达:它在业务参数之后注入,绝不进入 `args`。 +`createTypertRemoteInfo` 返回实现 `TypertRemoteInfo` 的惰性类实例,后者扩展可挂载的 `TypertRemoteContribution`。其 `methods` 索引引用 `descriptors` 中的直接方法对象:每个 `TypertRemoteMethod` 增加具名 `input(options?)` 和 `invoke`,在方法及参数上保留文档,并以 `result` 作为唯一输出 codec。一元 `mode` 缺省;流式 `mode` 为 `'stream'`。仅使用 Context 接收器的方法保留在描述符和作用域声明中。Registry 查询返回基础描述符;`isTypertRemoteMethod` 收窄生成的直接调用能力,但不会恢复各端点的精确泛型。 + +`TypertSchema` 保证解析能力,不保证任意 Zod 操作,尽管原始 factory spec 要求 Zod factory。缺少文档或不支持可选的 JSON Schema 投影,不会使普通方法失效。info 索引与每种输入策略的视图、解析器和派生投影都独立惰性求值并缓存;读取元数据或 schema 绝不发起网络调用。[协议参考](<../../packages/typert/protocol/README.zh.md>)定义这些 API,[生成器参考](<../../packages/typert/generator/README.zh.md#ordinary-remote-metadata>)定义它们彼此分离的 Host `./typert`、消费方 `./remote` 和 Client `./client/typert` 产物。 + +`TypertRemoteSpec` 保存包名与按端点键控的 `TypertRemoteMethodSpec`。每个有序 `TypertRemoteParameterSpec` 条目都包含从同一 checker 解析后类型模型生成的普通 `codec` 和 `strictCodec` factory;运行时派生标识、默认 wire 名称和具名调用视图。`TypertRemoteInputOptions` 仅包含 `strict?: boolean`;`method.input(options?)` 选择缓存的 `TypertRemoteValue`,提供匹配的解析及可选文档/投影,而不改变 Host 分发或 `result`。`RemoteDecoded` 则描述 carrier 重建的一元结果,不会收窄任意解析器输出。 + ```ts type-equiv /** Codec attached to one invocation parameter or result. */ -type TypertCodec = - | { +type TypertCodec = + | TypertRemoteValue & { readonly mode: 'strict' readonly typeSymbol: string - /** Materialize and return the process-realm schema on first boundary use. */ - readonly create: () => TypertSchema /** * Decode a unary result whose fields require type-specific handling. * @param value - result reconstructed by the RPC carrier. * @returns the validated result, retaining native byte views. */ - readonly decode?: (value: unknown) => unknown + readonly decode?: ((value: unknown) => unknown) | undefined /** * Project typed binary fields into RPC result attachments. * @param value - native unary result. * @param writeBytes - records a byte view at its result-relative path and returns its JSON placeholder. * @returns JSON metadata with untouched JSON subtrees retained. */ - readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown + readonly encode?: ((value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown) | undefined } | { readonly mode: 'src-json' @@ -72,6 +76,8 @@ type TypertCodec = interface InvocationParameterDescriptor { /** Source-level parameter name. */ readonly name: string + /** Source parameter documentation, independent of JSON Schema support. */ + readonly description?: string /** Required key in the wire `args` object. */ readonly wire: string /** Whether the value is JSON or requires a registered Host lookup. */ @@ -135,6 +141,8 @@ interface InvocationDescriptor { } /** Codec for the unary result or each yielded stream item. */ readonly result: TypertCodec + /** Source-owned method documentation; ordinary methods need not have JSDoc. */ + readonly description?: string /** Source declaration used only for diagnostics. */ readonly sourceLocation?: InvocationSourceLocation } @@ -207,6 +215,16 @@ interface PeerScope { } ``` +```ts type-equiv +/** Carrier capacity for buffered uplink frames of one logical stream. */ +interface RemoteUplinkWindow { + /** Maximum complete encoded frame bytes waiting for the Host consumer. */ + readonly maxBytes: number + /** UTF-8 envelope bytes added to a compact JSON item with a present value field. */ + readonly frameOverheadBytes: number +} +``` + ```ts type-equiv /** * The context of one Remote call, reachable inside the receiving method as @@ -225,6 +243,8 @@ interface RemoteInvocation { readonly peer: PeerScope /** Carrier cancellation: Client cancel, socket close, or an uplink failure. */ readonly signal: AbortSignal + /** Carrier frame capacity; absent when the carrier imposes no encoded-frame inbox limit. */ + readonly uplinkWindow?: RemoteUplinkWindow | undefined /** * The Client's uplink items for this call. Available once; a second call * throws. With an uplink codec on the descriptor every item is decoded to @@ -279,6 +299,8 @@ interface InvokeRemoteRequest { * `invocation.uplink()`; absent means an immediately ended iterable. */ readonly uplink?: AsyncIterable + /** Carrier-provided frame capacity; absent for carriers without an encoded-frame inbox limit. */ + readonly uplinkWindow?: RemoteUplinkWindow | undefined /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ readonly peer?: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ @@ -350,6 +372,8 @@ interface TypertGateway { `ctx.remote` 只暴露由已导入 `/remote` 产物贡献的 namespace。`$mount()` 会把生成的 descriptor 与具体方法作为一项由 fiber 持有的操作统一注册。每个 namespace 都是可追踪的 `remote.` Cordis 子服务,其生命周期覆盖已挂载的方法;JavaScript Proxy 与 Host 业务服务类型都不会进入消费方。 +元数据所有权与连接所有权相互独立;[注册表参考](<../../packages/typert/registry/README.zh.md>)说明离线注册与挂载生命周期。 + ```ts type-equiv /** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ interface TypertClientRemote extends TypertRemoteNamespaceMap { diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index 5150ddd01f..4fcb42f3cc 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -143,6 +143,22 @@ authenticatedUrl(baseUrl: string): string Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connection/src/rpc.ts) + + +### `ctx.webApp` — `WebApp` + +The Web GUI this process serves, provided by the `web-runtime` row. + +```ts cordis-catalog +/** + * The URL this GUI advertises, also used for `DSH_WEB_URL` and the startup URL line. + * @returns the configured `publicUrl`, otherwise the listener origin. + */ +getAdvertisedUrl(): string +``` + +Source: [`packages/bundle/web-app/src/index.ts`](../../packages/bundle/web-app/src/index.ts) + ### `ctx.webServer` — `WebServer` @@ -150,6 +166,14 @@ Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connecti The browser HTTP carrier service. Activation loads any configured TLS material, then listens immediately; a material or listen failure rejects initialization, and the boot process reports the failed fiber. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. ```ts cordis-catalog +/** + * The root URL local clients use to reach the bound listener: the protocol, + * the {@link bindUrlHost} form of the bind address, and the listening port. + * @returns the origin, for example `http://127.0.0.1:3080`. + * @throws before `listen` binds a port, or when the bind address has no URL form. + */ +getOrigin(): string + /** * Register a named route. Duplicate (kind, path) throws — route patterns are * a composition-level contract, so a collision is a misconfiguration. diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index f91bfdcdbd..67d67b15de 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -143,6 +143,22 @@ authenticatedUrl(baseUrl: string): string Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connection/src/rpc.ts) + + +### `ctx.webApp` — `WebApp` + +The Web GUI this process serves, provided by the `web-runtime` row. + +```ts cordis-catalog +/** + * The URL this GUI advertises, also used for `DSH_WEB_URL` and the startup URL line. + * @returns the configured `publicUrl`, otherwise the listener origin. + */ +getAdvertisedUrl(): string +``` + +Source: [`packages/bundle/web-app/src/index.ts`](../../packages/bundle/web-app/src/index.ts) + ### `ctx.webServer` — `WebServer` @@ -150,6 +166,14 @@ Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connecti The browser HTTP carrier service. Activation loads any configured TLS material, then listens immediately; a material or listen failure rejects initialization, and the boot process reports the failed fiber. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. ```ts cordis-catalog +/** + * The root URL local clients use to reach the bound listener: the protocol, + * the {@link bindUrlHost} form of the bind address, and the listening port. + * @returns the origin, for example `http://127.0.0.1:3080`. + * @throws before `listen` binds a port, or when the bind address has no URL form. + */ +getOrigin(): string + /** * Register a named route. Duplicate (kind, path) throws — route patterns are * a composition-level contract, so a collision is a misconfiguration. diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index 7701312154..bf01196282 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -9,8 +9,8 @@ en: d61a7e78bbc1fb56 zh: 8397869f70cd5b28 /workspaces/the-workspace-entity: - en: 697698bc46adc477 - zh: 73d073933ed68c53 + en: ce5b65fb4d0b0c7b + zh: aa332a17810d583a /workspaces/the-registry-ctx-workspaceregistry: en: 73b54ccf836b7f46 zh: 7ee93ba8bb0a616a diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index e501d7b7bb..0736c86fe7 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -25,9 +25,10 @@ Consumers see only the `Workspace` interface; the implementation stays package-p ```ts type-equiv /** * One workspace: a stable id over an existing directory, a display title, and - * an ordered candidate account of sessions. Membership requires both an id in - * that account and a session header whose canonical cwd equals the workspace - * path. Consumers only see this interface; the implementation stays private. + * an ordered candidate account of sessions. Membership validates header cwd + * against the workspace path, but preserves an existing account when its + * fully qualified cwd is missing. Consumers only see this interface; the + * implementation stays private. */ interface Workspace { /** Stable record id (generated uuid). */ @@ -54,8 +55,11 @@ interface Workspace { * prepended at attach, explicit reordering goes through * `insertSessionBefore`, and activity never reorders. The durable candidate * account is filtered synchronously: missing headers, invalid cwd values, - * and canonical cwd mismatches are never returned. A subsequent workspace - * mutation prunes those filtered candidates durably. + * and canonical cwd mismatches are never returned. A fully qualified cwd + * whose realpath or directory stat fails with ENOENT preserves an existing + * candidate, including a dangling symlink; other filesystem failures do not. + * A subsequent mutation prunes filtered candidates, not missing-directory + * candidates. This does not admit new sessions or recreate directories. */ readonly sessionIds: readonly SessionId[] @@ -113,7 +117,7 @@ interface Workspace { } ``` -Ownership truth is the record's ordered `sessionIds`, never derived from session cwd — but membership requires both: an id on the account and a header whose canonical cwd equals the workspace path, so one session structurally belongs to at most one workspace. Failed writes reject (`insertSessionBefore` account errors as `WorkspaceMoveInvalidError`, storage failures as plain errors); every accepted mutation stamps `updatedAt` and durably prunes candidates that no longer pass the membership check. +Ownership truth is the record's ordered `sessionIds`, never derived from session cwd. Membership validates the recorded header cwd against the workspace path; an existing candidate remains owned when its fully qualified cwd fails resolution or directory stat with `ENOENT`. This preserves missing-directory and dangling-symlink accounts without admitting new sessions. Failed writes reject (`insertSessionBefore` account errors as `WorkspaceMoveInvalidError`, storage failures as plain errors); every accepted mutation stamps `updatedAt` and durably prunes candidates that no longer pass the membership check. ## The registry: `ctx.workspaceRegistry` @@ -273,35 +277,13 @@ Typed Remote control of transient Session-owned terminal processes. @Remote({ mode: 'stream' }) retain(sessionId: SessionId, id: WebTerminalId, signal: AbortSignal): AsyncIterable /** - * Attach to a terminal without binding its process lifetime to the transport. + * Attach terminal input and output to one physical Remote stream. * @param agent - Session owner supplied by the Gateway. * @param id - terminal identity. - * @param attachmentId - new exclusive input attachment. - * @param signal - physical stream cancellation. - * @returns screen recovery followed by output and metadata changes. + * @param signal - attachment cancellation; the process remains alive and input is never replayed. + * @returns screen recovery, output, writable state and provider-acceptance acknowledgements. */ -@Remote({ mode: 'stream' }) follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable - -/** - * Deliver raw input, including Tab completion and control characters. - * @param agent - Session owner supplied by the Gateway. - * @param id - terminal identity. - * @param attachmentId - current writable attachment. - * @param data - input bytes represented as UTF-8 text. - * @returns after provider input acceptance. - */ -@Remote async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise - -/** - * Update the dimensions of the PTY and recovery screen. - * @param agent - Session owner supplied by the Gateway. - * @param id - terminal identity. - * @param attachmentId - current writable attachment. - * @param cols - column count. - * @param rows - row count. - * @returns after the resize completes. - */ -@Remote async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise +@Remote({ mode: 'stream' }) attach(agent: Agent, id: WebTerminalId, signal: AbortSignal): RemoteStream /** * Rename a terminal without changing its shell. @@ -320,7 +302,7 @@ Typed Remote control of transient Session-owned terminal processes. @Remote async close(agent: Agent, id: WebTerminalId): Promise ``` -Types: [Agent](core.md) · [SessionId](core.md) +Types: [Agent](core.md) · [RemoteStream](typert.md) · [SessionId](core.md) Source: [`packages/api/terminal-controller/src/index.ts`](../../packages/api/terminal-controller/src/index.ts) diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 868712b4c9..1a366370e5 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -25,9 +25,10 @@ type WorkspaceId = Branded<'WorkspaceId'> ```ts type-equiv /** * One workspace: a stable id over an existing directory, a display title, and - * an ordered candidate account of sessions. Membership requires both an id in - * that account and a session header whose canonical cwd equals the workspace - * path. Consumers only see this interface; the implementation stays private. + * an ordered candidate account of sessions. Membership validates header cwd + * against the workspace path, but preserves an existing account when its + * fully qualified cwd is missing. Consumers only see this interface; the + * implementation stays private. */ interface Workspace { /** Stable record id (generated uuid). */ @@ -54,8 +55,11 @@ interface Workspace { * prepended at attach, explicit reordering goes through * `insertSessionBefore`, and activity never reorders. The durable candidate * account is filtered synchronously: missing headers, invalid cwd values, - * and canonical cwd mismatches are never returned. A subsequent workspace - * mutation prunes those filtered candidates durably. + * and canonical cwd mismatches are never returned. A fully qualified cwd + * whose realpath or directory stat fails with ENOENT preserves an existing + * candidate, including a dangling symlink; other filesystem failures do not. + * A subsequent mutation prunes filtered candidates, not missing-directory + * candidates. This does not admit new sessions or recreate directories. */ readonly sessionIds: readonly SessionId[] @@ -113,7 +117,7 @@ interface Workspace { } ``` -所有权的真源是记录中有序的 `sessionIds`,绝不从会话 cwd 派生——但成员资格要求两者同时成立:账本上有其 id,且 header 的规范 cwd 等于工作区路径,因此一个会话在结构上至多属于一个工作区。失败的写入会拒绝(`insertSessionBefore` 的账本错误以 `WorkspaceMoveInvalidError` 拒绝,存储失败以普通错误拒绝);每次被接受的变更都盖上 `updatedAt` 时间戳,并持久修剪不再通过成员资格检查的候选项。 +所有权的真源是记录中有序的 `sessionIds`,绝不从会话 cwd 派生。成员资格将记录的 header cwd 与工作区路径校验;已有候选的完全限定 cwd 在路径解析或目录 stat 时返回 `ENOENT`,仍保留其归属。这会保留目录缺失或符号链接失效时的记账关系,但不会接纳新会话。失败的写入会拒绝(`insertSessionBefore` 的账本错误以 `WorkspaceMoveInvalidError` 拒绝,存储失败以普通错误拒绝);每次被接受的变更都盖上 `updatedAt` 时间戳,并持久修剪不再通过成员资格检查的候选项。 ## 注册表:`ctx.workspaceRegistry` @@ -273,35 +277,13 @@ Typed Remote control of transient Session-owned terminal processes. @Remote({ mode: 'stream' }) retain(sessionId: SessionId, id: WebTerminalId, signal: AbortSignal): AsyncIterable /** - * Attach to a terminal without binding its process lifetime to the transport. + * Attach terminal input and output to one physical Remote stream. * @param agent - Session owner supplied by the Gateway. * @param id - terminal identity. - * @param attachmentId - new exclusive input attachment. - * @param signal - physical stream cancellation. - * @returns screen recovery followed by output and metadata changes. + * @param signal - attachment cancellation; the process remains alive and input is never replayed. + * @returns screen recovery, output, writable state and provider-acceptance acknowledgements. */ -@Remote({ mode: 'stream' }) follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable - -/** - * Deliver raw input, including Tab completion and control characters. - * @param agent - Session owner supplied by the Gateway. - * @param id - terminal identity. - * @param attachmentId - current writable attachment. - * @param data - input bytes represented as UTF-8 text. - * @returns after provider input acceptance. - */ -@Remote async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise - -/** - * Update the dimensions of the PTY and recovery screen. - * @param agent - Session owner supplied by the Gateway. - * @param id - terminal identity. - * @param attachmentId - current writable attachment. - * @param cols - column count. - * @param rows - row count. - * @returns after the resize completes. - */ -@Remote async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise +@Remote({ mode: 'stream' }) attach(agent: Agent, id: WebTerminalId, signal: AbortSignal): RemoteStream /** * Rename a terminal without changing its shell. @@ -320,7 +302,7 @@ Typed Remote control of transient Session-owned terminal processes. @Remote async close(agent: Agent, id: WebTerminalId): Promise ``` -Types: [Agent](core.zh.md) · [SessionId](core.zh.md) +Types: [Agent](core.zh.md) · [RemoteStream](typert.zh.md) · [SessionId](core.zh.md) Source: [`packages/api/terminal-controller/src/index.ts`](../../packages/api/terminal-controller/src/index.ts) diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index d7936c0fef..4a45861506 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -9,8 +9,8 @@ en: c024da7e8d9cb056 zh: c4cc1a66d2903656 /testing-policy/how-specs-execute: - en: 5d8eb65958da096d - zh: 4a40f1c5fe5b2d14 + en: 022c534e72e23142 + zh: c07a20994224f37c /testing-policy/the-with-key-policy-inference-is-cheap-here: en: d555af53068bf523 zh: ddc4265e5de5b3be diff --git a/docs/testing.md b/docs/testing.md index 9ea6db1501..1ba63f9c35 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -18,7 +18,7 @@ Session fixtures retain headers and payloads but omit body sequence/time envelop ## How specs execute -Forked workers run several spec files at once, the coverage gate splits into concurrent partitions beside the other gates in its job, and the self-hosted runners share one host and one volume. Only the process is isolated: ports, predictable paths, external namespaces, and inherited children are not. Own each acquired resource through teardown. A spec passing only alone has a test defect, not an unstable runner. [dsh-ci-test-reliability](../.agents/skills/dsh-ci-test-reliability/SKILL.md) owns the allocation, restoration, synchronization, timeout-budget, platform, and teardown rules; its [flake diagnosis workflow](../.agents/skills/dsh-ci-test-reliability/references/ci-flake-diagnosis.md) classifies an existing probabilistic failure. [Unexpected fork exits](../scripts/coverage-fork-diagnostics.ts) report PID, file, code, and signal. +Forked workers run several spec files at once, the coverage gate splits into [concurrent partitions](../scripts/coverage-partitions.ts) beside the other gates in its job, and the self-hosted runners share one host and one volume. Only the process is isolated: ports, predictable paths, external namespaces, and inherited children are not. Own each acquired resource through teardown. A spec passing only alone has a test defect, not an unstable runner. [dsh-ci-test-reliability](../.agents/skills/dsh-ci-test-reliability/SKILL.md) owns the allocation, restoration, synchronization, timeout-budget, platform, and teardown rules; its [flake diagnosis workflow](../.agents/skills/dsh-ci-test-reliability/references/ci-flake-diagnosis.md) classifies an existing probabilistic failure. [Unexpected fork exits](../scripts/coverage-fork-diagnostics.ts) report PID, file, code, and signal. ## The with-key policy: inference is cheap here diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 81c16c60ab..c7545a9686 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -18,7 +18,7 @@ Session fixture 保留 header 与 payload,但省略正文 seq/time envelope; ## spec 如何被执行 -fork 出的 worker 会同时运行多个 spec 文件,coverage gate 会拆成并发的 partition,与同一个 job 中的其它 gate 并排运行,而自托管 runner 共用同一台宿主机和同一个卷。被隔离的只有进程:端口、可预测路径、外部命名空间和继承而来的子进程都不隔离。为每个占用的资源负责到 teardown。只在单独运行时通过的 spec 存在测试缺陷,而非 runner 不稳定。[dsh-ci-test-reliability](../.agents/skills/dsh-ci-test-reliability/SKILL.md) 负责资源分配、状态恢复、同步、超时预算、平台差异与 teardown 规则;它的 [flake 诊断流程](../.agents/skills/dsh-ci-test-reliability/references/ci-flake-diagnosis.md)用于归类已经存在的概率性失败。 [fork 意外退出](../scripts/coverage-fork-diagnostics.ts)时会报告 PID、文件、退出码和信号。 +fork 出的 worker 会同时运行多个 spec 文件,coverage gate 会拆成[并发的 partition](../scripts/coverage-partitions.ts),与同一个 job 中的其它 gate 并排运行,而自托管 runner 共用同一台宿主机和同一个卷。被隔离的只有进程:端口、可预测路径、外部命名空间和继承而来的子进程都不隔离。为每个占用的资源负责到 teardown。只在单独运行时通过的 spec 存在测试缺陷,而非 runner 不稳定。[dsh-ci-test-reliability](../.agents/skills/dsh-ci-test-reliability/SKILL.md) 负责资源分配、状态恢复、同步、超时预算、平台差异与 teardown 规则;它的 [flake 诊断流程](../.agents/skills/dsh-ci-test-reliability/references/ci-flake-diagnosis.md)用于归类已经存在的概率性失败。 [fork 意外退出](../scripts/coverage-fork-diagnostics.ts)时会报告 PID、文件、退出码和信号。 ## 带密钥策略:推理(inference)在这里很便宜 diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 2ab2f56624..639b01a6e4 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -60,8 +60,8 @@ en: 76fbbf0fe38aef10 zh: 76fbbf0fe38aef10 /tool-schema-catalog/deepseek-ai-dsh-tools/run-code: - en: 2cfa8042a920b191 - zh: bbd0aae394fb3806 + en: d03bca78d96757a7 + zh: 0e7cd389c38e2d9f /tool-schema-catalog/deepseek-ai-dsh-plan-mode: en: b500b530845e26a9 zh: b500b530845e26a9 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 8aa9fb0096..0d415975c6 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -525,7 +525,7 @@ ask_user_question keeps the original blocking behavior by default; set `mode: ti ### `run_code` -Execute a TypeScript program against the available tools. Takes two required arguments: `description`, a short summary of what the program does, and `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work). Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run. +Execute a TypeScript program against the available tools. Takes two required arguments: `description`, a short summary of what the program does, and `code`, the program to execute. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. ```json { @@ -537,7 +537,7 @@ Execute a TypeScript program against the available tools. Takes two required arg }, "code": { "type": "string", - "description": "The program: the body of an async TypeScript function." + "description": "The TypeScript program to execute, following this tool's execution instructions." }, "timeoutMs": { "type": "number", diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index b505568dde..1622182f0f 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -529,7 +529,7 @@ ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才 ### `run_code` -针对可用工具执行 TypeScript 程序。接受两个必填参数:`description`,简要说明该程序做什么;以及 `code`,即异步函数的**函数体**(仅使用可擦除语法;支持顶层 `await` 和 `return`)。请根据系统提示词中的声明,以 `await tools.name(args)` 形式调用工具。只有打印或返回的内容属于程序输出,请谨慎筛选。含图片的子工具结果会在运行结束后附加。 +针对可用工具执行 TypeScript 程序。接受两个必填参数:`description`,简要说明该程序做什么;以及 `code`,要执行的程序。请根据系统提示词中的声明,以 `await tools.name(args)` 形式调用工具。请筛选程序输出。含图片的子工具结果会在运行结束后附加。 ```json { @@ -541,7 +541,7 @@ ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才 }, "code": { "type": "string", - "description": "The program: the body of an async TypeScript function." + "description": "The TypeScript program to execute, following this tool's execution instructions." }, "timeoutMs": { "type": "number", diff --git a/docs/ui-radius.i18n.yaml b/docs/ui-radius.i18n.yaml index a5015aa946..0c8d58fcd9 100644 --- a/docs/ui-radius.i18n.yaml +++ b/docs/ui-radius.i18n.yaml @@ -15,14 +15,14 @@ en: cd8120cba5566e19 zh: 65359436088c8bc5 /dsh-unified-corner-radius-standard/select-the-radius: - en: 06ddd6bf9ef1e48c - zh: 4c42d0c9266a00e8 + en: e736416ec85d6e16 + zh: 0edf60fccc867295 /dsh-unified-corner-radius-standard/reuse-tokens-and-preserve-deliberate-shapes: en: fb6d50f3139ad186 zh: 668cb0c02bd22167 /dsh-unified-corner-radius-standard/nested-geometry-and-hover: - en: 3920921700d8ad1c - zh: fc99da8a5e905a27 + en: 7fe4eb7707e6a9f3 + zh: be7530a05c205e27 /dsh-unified-corner-radius-standard/settings-card-materials: en: 85e7e63183df06d1 zh: 1da2b44083d7ebe2 diff --git a/docs/ui-radius.md b/docs/ui-radius.md index d6d28568ca..303e7c9382 100644 --- a/docs/ui-radius.md +++ b/docs/ui-radius.md @@ -38,9 +38,9 @@ H means designed outer height in CSS pixels, including borders. R means CSS `bor Prefer an existing shared size variant when a proposed control falls between these bands. A taller card remains a card: do not promote it to R28 merely because it exceeds a button's height. A multiline cell intentionally designed for multiple lines uses R16; an ordinary button whose label wraps retains its button variant. -Concrete defaults: `Button` uses H28/R8 for `sm` and H36/R12 for `md`, including outline variants. Standard H32 inputs and H36 selectors use R12. The Workspace files and New terminal guide entries both use R20, including title-only and described variants. Reserve R28 for their enclosing panel or dialog, not those entries. +Concrete defaults: `Button` uses H28/R8 for `sm` and H36/R12 for `md`, including outline variants. Standard H32 inputs and H36 selectors use R12. The Workspace files and New terminal guide entries both use R20, including title-only and described variants. Reserve R28 for their enclosing panel or dialog, not those entries. Composer statistics and the context-occupancy trigger use compact-control R8 with the theme curve, including hover, expanded, and focus states. -Code blocks, diffs, and file previews share R16: changed-file entry cards, delivered-file cards, generic file attachment cards, and hover preview panels. The 40px file-icon tiles inside changed-file and delivery cards use R10 on all four corners as a component-specific exception. Their hover fills keep the same contour. +Code blocks, diffs, and preview cards share R16: changed-file entry cards, delivered-file cards, submitted-plan cards, created-task cards, generic file attachment cards before and after sending, and hover preview panels. The 40px icon tiles inside changed-file, delivery, submitted-plan, and created-task cards use R10 on all four corners as a component-specific exception and share the same neutral tile fill and border in both themes. Their hover fills keep the same contour. ## Reuse tokens and preserve deliberate shapes @@ -64,11 +64,13 @@ Distinguish these three arrangements before changing a child radius: | Arrangement | Rule | Example | |---|---|---| -| Concentric inset | Inner R = max(0, outer R − inset); use the same curve | Outer R16, inset 4, inner R12 | +| Circular concentric inset | Inner R = max(0, outer R − inset); both arcs share a center and use `round` | Outer R16, inset 4, inner R12 | | Fill flush against an enclosing edge | The enclosure owns the visible corner; clip the fill or inherit the matching corner | A split-button card with two independent hover regions | | Separate card or form group inside another surface | Choose the child's semantic tier; do not subtract all parent padding mechanically | R16 editor within an R20 settings card | -Standard menus use outer R16, 4px padding, and R12 items. Compact menus use outer R12, 4px padding, and R8 items. Shared `SegmentedControl` uses outer R12, 4px inset, and R8 segments/indicator; `SegmentedTabs` uses outer R16, 4px inset, and R12 segments/indicator. Keep indicator offsets, width calculations, submenu alignment, and pointer bridges consistent when changing padding. +Standard menus use outer R16, 4px padding, and R12 items, including command selection, task menus, and the exposed highlights in subagent and background-job menus. Tree disclosure gutters retain their indentation; controls inside a filled job row keep their own compact geometry. Compact menus use outer R12, 4px padding, and R8 items. Shared `SegmentedControl` uses outer R12, 4px inset, and R8 segments/indicator; `SegmentedTabs` uses outer R16, 4px inset, and R12 segments/indicator. Keep indicator offsets, width calculations, submenu alignment, and pointer bridges consistent when changing padding. + +The radius subtraction is also the nominal sizing rule for nested superellipses. Equal exponents do not make them exact offset curves, so their diagonal gap may differ from the side inset. Keep the shared superellipse menu and segmented-control treatments; use circular arcs when strict equal radial spacing is required. A single clickable surface paints hover on itself. A flush pseudo-element or overlay uses the same radius and curve as the visible enclosure. Avoid a separately rounded fill that leaves a crescent between the background and the border. diff --git a/docs/ui-radius.zh.md b/docs/ui-radius.zh.md index 657684ebc7..80edb753c6 100644 --- a/docs/ui-radius.zh.md +++ b/docs/ui-radius.zh.md @@ -38,9 +38,9 @@ H 表示以 CSS 像素计、包含边框的设计外部高度。R 表示浏览 拟定控件尺寸落在档位之间时,优先复用已有的公共尺寸变体。高卡片仍是卡片,不因高度超过按钮就升到 R28。专门设计为多行的 cell 使用 R16;普通按钮的文字发生换行时,仍保留其按钮变体。 -具体默认值:`Button` 的 `sm` 使用 H28/R8,`md` 使用 H36/R12,包含描边变体。标准 H32 输入框和 H36 选择器使用 R12。“工作区文件”和“新建终端”引导入口统一使用 R20,包含仅标题与带描述的变体。R28 留给包围它们的面板或对话框,不用于这些入口。 +具体默认值:`Button` 的 `sm` 使用 H28/R8,`md` 使用 H36/R12,包含描边变体。标准 H32 输入框和 H36 选择器使用 R12。“工作区文件”和“新建终端”引导入口统一使用 R20,包含仅标题与带描述的变体。R28 留给包围它们的面板或对话框,不用于这些入口。输入框统计控件与上下文占用率入口使用紧凑控件的 R8 和主题曲线,包含 hover、展开与焦点状态。 -代码块、diff 与文件预览共用 R16:包括改动文件入口卡片、交付文件卡片、通用附件文件卡片和悬停预览浮层。改动文件与交付文件卡片内部的 40px 文件图标底框作为组件特例,四角统一使用 R10。各自的 hover 保持同一轮廓。 +代码块、diff 与预览卡片共用 R16:包括改动文件入口卡片、交付文件卡片、已提交计划卡片、已创建任务卡片、发送前后的通用附件文件卡片和悬停预览浮层。改动文件、交付文件、已提交计划与已创建任务卡片内部的 40px 图标底框作为组件特例,四角统一使用 R10,并在明暗两种主题下分别共用相同的中性底色与边框。各自的 hover 保持同一轮廓。 ## 复用 token,保留有意设计的形状 @@ -64,11 +64,13 @@ H 表示以 CSS 像素计、包含边框的设计外部高度。R 表示浏览 | 关系 | 规则 | 示例 | |---|---|---| -| 同心内缩 | 内层 R = max(0, 外层 R − 内缩距离);使用相同曲线 | 外层 R16,内缩 4,内层 R12 | +| 圆弧同心内缩 | 内层 R = max(0, 外层 R − 内缩距离);两段圆弧同心且使用 `round` | 外层 R16,内缩 4,内层 R12 | | 底色紧贴容器边缘 | 由外框决定可见圆角;裁切底色或继承相应圆角 | 两个独立 hover 区域组成的分体按钮卡片 | | 容器内独立的卡片或表单组 | 按子元素用途选择档位;不机械减去全部父级内边距 | R20 设置卡片内的 R16 编辑器 | -标准菜单使用外层 R16、4px 内边距、R12 菜单项。紧凑菜单使用外层 R12、4px 内边距、R8 菜单项。公共 `SegmentedControl` 使用外层 R12、4px 内缩、R8 分段和指示器;`SegmentedTabs` 使用外层 R16、4px 内缩、R12 分段和指示器。修改内边距时,同步保持指示器偏移、宽度计算、子菜单对齐与指针桥接区域一致。 +标准菜单使用外层 R16、4px 内边距、R12 菜单项,包含命令选择、任务菜单,以及子会话和后台任务菜单的外露高亮区域。树形展开操作保留缩进;带底色任务行内的控件保留各自的紧凑几何。紧凑菜单使用外层 R12、4px 内边距、R8 菜单项。公共 `SegmentedControl` 使用外层 R12、4px 内缩、R8 分段和指示器;`SegmentedTabs` 使用外层 R16、4px 内缩、R12 分段和指示器。修改内边距时,同步保持指示器偏移、宽度计算、子菜单对齐与指针桥接区域一致。 + +半径相减也用于嵌套超级椭圆的名义尺寸关系。相同指数不意味着两条曲线严格等距,因此对角线间隙可能不同于侧边内缩。菜单与分段控件保留公共的超级椭圆样式;要求径向严格等距时使用圆弧。 单个可点击表面在自身绘制 hover。贴边的伪元素或覆盖层使用与可见外框相同的圆角和曲线。避免单独给底色设置圆角,导致背景与边框之间出现月牙状空隙。 diff --git a/docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/guide.i18n.yaml b/docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/guide.i18n.yaml new file mode 100644 index 0000000000..5d121913f3 --- /dev/null +++ b/docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/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.0-rc.2/bundled-python-tests/guide.md +/: + en: 985656162a55b14a + zh: c32bda15938879e5 +/numpy-and-pandas-upstream-tests-require-a-separate-environment: + en: ec0ae29022f8ce8c + zh: f5e49682b842544d +/numpy-and-pandas-upstream-tests-require-a-separate-environment/change: + en: 41410ee875be7975 + zh: b052e7b3c8fb096b +/numpy-and-pandas-upstream-tests-require-a-separate-environment/migration: + en: 00c4b10f8df805e7 + zh: 06ef0fbda56c9acf diff --git a/docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/guide.md b/docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/guide.md new file mode 100644 index 0000000000..0c3978d20b --- /dev/null +++ b/docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Bundled NumPy and pandas omit their upstream test suites." +--- + +# NumPy and pandas upstream tests require a separate environment + +English | [中文](guide.zh.md) + +## Change + +Desktop and Python SDK runtime distributions previously included NumPy and pandas test directories. Bundled distributions now omit those directories, so scripts importing their test modules or running their complete upstream suites need a separate installation. Data processing, Office authoring, `numpy.testing`, `pandas.testing`, and `pandas._testing` remain available. + +## Migration + +1. If your scripts import `numpy.*.tests` or `pandas.tests`, install the original NumPy and pandas distributions and their upstream testing dependencies in a separate Python environment. Keep that environment outside the managed Harness runtime directory, which upgrades replace. +2. Run the affected scripts or upstream suites with that environment's interpreter. Scripts that use only library functionality or retained testing helpers require no changes. +3. Confirm that the previously failing test-module imports and affected scripts succeed with the separate interpreter. The bundled versions are recorded in the [runtime lock](../../../../scripts/primary-runtime/lock.json). diff --git a/docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/guide.zh.md b/docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/guide.zh.md new file mode 100644 index 0000000000..9e36c71dc6 --- /dev/null +++ b/docs/upgrade-guide/v0.2.0-rc.2/bundled-python-tests/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "内置 NumPy 和 pandas 不包含其上游测试套件。" +--- + +# NumPy 和 pandas 上游测试需要独立环境 + +[English](guide.md) | 中文 + +## 变更 + +Desktop 和 Python SDK 运行时分发包此前包含 NumPy 和 pandas 测试目录。内置分发包现在裁剪这些目录,因此导入其测试模块或运行其完整上游测试套件的脚本需要独立安装环境。数据处理、Office 创作、`numpy.testing`、`pandas.testing` 和 `pandas._testing` 仍然可用。 + +## 迁移 + +1. 如果脚本导入 `numpy.*.tests` 或 `pandas.tests`,请在独立 Python 环境中安装原始 NumPy 和 pandas 分发包及其上游测试依赖。请将该环境放在 Harness 托管运行时目录之外,升级会替换托管目录。 +2. 使用该环境的解释器运行受影响的脚本或上游测试套件。仅使用库功能或保留的 testing 辅助 API 的脚本无需修改。 +3. 确认先前失败的测试模块导入及相关脚本均可在独立解释器中成功运行。随包版本记录在[运行时锁文件](../../../../scripts/primary-runtime/lock.json)中。 diff --git a/docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/guide.i18n.yaml b/docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/guide.i18n.yaml new file mode 100644 index 0000000000..5fae57fcd3 --- /dev/null +++ b/docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/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.0-rc.2/primary-runtime-python-aliases/guide.md +/: + en: a4f973e3aed3cbb1 + zh: e33e5eb33838f82e +/custom-python-runtime-aliases-must-target-a-sibling-interpreter: + en: c38fad48cd870c4c + zh: 49fff1636ca8c8fd +/custom-python-runtime-aliases-must-target-a-sibling-interpreter/change: + en: 55f21ac0b9190e37 + zh: 2d1c7aaa89ddfe19 +/custom-python-runtime-aliases-must-target-a-sibling-interpreter/migration: + en: 2839866a08be236d + zh: 11a01d35a7f396ce diff --git a/docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/guide.md b/docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/guide.md new file mode 100644 index 0000000000..1a3545ee9b --- /dev/null +++ b/docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Custom primary-runtime payloads require relocatable Python interpreter aliases." +--- + +# Custom Python runtime aliases must target a sibling interpreter + +English | [中文](guide.zh.md) + +## Change + +On macOS and Linux, `DSH_PRIMARY_RUNTIME` previously accepted interpreter links that resolved outside their payload or through other links. Payload validation now requires each linked `dependencies/python/bin/python` or `python3` to point directly to the regular sibling interpreter named for `runtime.json`'s Python major and minor version, such as `python3.12`. Regular command files remain supported, including those distributed in Python wheels. Windows is unchanged. + +## Migration + +1. For a custom payload, keep the versioned interpreter in `dependencies/python/bin/` and make `python` and `python3` relative links to its filename. The [runtime preparation instructions](../../../../packages/skill/tool-workspace-dependencies/README.md) list the required target and output arguments. +2. Preserve relative links when copying prepared resources. With Node `fs.cp`, use `verbatimSymlinks: true`; wheel builders must materialize the command files because pip does not restore links. The native build smoke requires the prepared resource tree to retain both aliases. +3. Confirm that `readlink` reports only the sibling filename for both aliases, then move the payload away from its build directory, point `DSH_PRIMARY_RUNTIME` at its new directory, and verify that `load_workspace_dependencies` and the returned Python command succeed. diff --git a/docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/guide.zh.md b/docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/guide.zh.md new file mode 100644 index 0000000000..e623f41857 --- /dev/null +++ b/docs/upgrade-guide/v0.2.0-rc.2/primary-runtime-python-aliases/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "自定义 primary-runtime payload 的 Python 解释器别名必须支持目录迁移。" +--- + +# 自定义 Python 运行时别名必须指向同目录解释器 + +[English](guide.md) | 中文 + +## 变更 + +在 macOS 和 Linux 上,`DSH_PRIMARY_RUNTIME` 之前接受指向 payload 外部或经过其他链接的解释器链接。现在,payload 校验要求 `dependencies/python/bin/python` 或 `python3` 的链接直接指向同目录的普通解释器文件,文件名由 `runtime.json` 中 Python 的主版本号和次版本号决定,例如 `python3.12`。普通命令文件仍受支持,包括 Python wheel 中分发的文件。Windows 不变。 + +## 迁移 + +1. 对于自定义 payload,将版本化解释器保留在 `dependencies/python/bin/` 中,并让 `python` 和 `python3` 以该文件名为目标建立相对链接。[运行时准备说明](../../../../packages/skill/tool-workspace-dependencies/README.zh.md)列出了必需的目标与输出参数。 +2. 复制准备好的资源时保留相对链接。使用 Node `fs.cp` 时设置 `verbatimSymlinks: true`;wheel 构建器必须将命令展开为普通文件,因为 pip 不会还原链接。本机构建冒烟检查要求准备好的资源目录保留两个别名。 +3. 确认 `readlink` 对两个别名都只返回同目录解释器的文件名,再将 payload 移离构建目录,将 `DSH_PRIMARY_RUNTIME` 指向新目录,验证 `load_workspace_dependencies` 和返回的 Python 命令均可成功执行。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.i18n.yaml new file mode 100644 index 0000000000..8e8646abe0 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/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-alpha.1/deepseek-model-discovery/guide.md +/: + en: 059ab5482d52d8b8 + zh: 006b65489bc4ec70 +/deepseek-api-key-model-visibility: + en: 7b14dc7133967bfd + zh: 4f338ae1e8151feb +/deepseek-api-key-model-visibility/change: + en: ab0b141fbb3167ab + zh: 918f4202223abe44 +/deepseek-api-key-model-visibility/migration: + en: 2fda36f7e543f1d0 + zh: e8522f256de5adaa diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.md new file mode 100644 index 0000000000..a5fa635ccd --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "DeepSeek API-key model discovery requires a configured credential." +--- + +# DeepSeek API-key model visibility + +English | [中文](guide.zh.md) + +## Change + +The `deepseek-official` route previously listed its configured models without an API key. It now returns an empty catalog when its credential is missing, so Desktop and Web hide the DeepSeek group. Model discovery reports malformed credentials and credential lookup failures. A saved or default selection absent from the catalog displays “Select model” without changing the stored selection. The independent DeepSeek account route still uses account login. + +## Migration + +1. To use the API-key route, configure its key in Settings → Models or supply the reference named by `apiKeyEnv` (default `DEEPSEEK_API_KEY`) through the launch environment. +2. Reopen the model selector and confirm the DeepSeek group appears. Custom discovery consumers must handle an empty catalog before credentials are configured; listing models does not verify that the remote API accepts the key. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.zh.md new file mode 100644 index 0000000000..46ff3452ff --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.zh.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "DeepSeek API Key 模型发现要求已配置凭据。" +--- + +# DeepSeek API Key 模型可见性 + +[English](guide.md) | 中文 + +## 变更 + +`deepseek-official` 路由此前在没有 API Key 时也会列出配置的模型。现在凭据缺失时返回空目录,因此桌面端和 Web 隐藏 DeepSeek 分组。模型发现会报告凭据格式错误和凭据读取失败。已保存或默认的选择不在目录中时,界面显示“请选择模型”,保存的选择保持不变。独立的 DeepSeek 账号路由仍使用账号登录。 + +## 迁移 + +1. 要使用 API Key 路由,请在设置 → 模型中配置密钥,或通过启动环境提供 `apiKeyEnv` 指定的凭据引用(默认为 `DEEPSEEK_API_KEY`)。 +2. 重新打开模型选择器,确认 DeepSeek 分组出现。自定义模型发现消费者必须处理配置凭据前的空目录;模型列出不代表远端 API 已接受密钥。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.i18n.yaml new file mode 100644 index 0000000000..6e120f29bd --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/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-alpha.1/job-id-identity/guide.md +/: + en: d6fdce6b58c67691 + zh: a103e5028f5442d0 +/background-job-ids-identify-one-execution: + en: 9b06780be69e93f6 + zh: c33945fd584c6a23 +/background-job-ids-identify-one-execution/change: + en: 047ec9d0337bfc0b + zh: 1218472855e81189 +/background-job-ids-identify-one-execution/migration: + en: 08921c9c25218c83 + zh: 7ff1d3ac63ae40ef diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.md new file mode 100644 index 0000000000..16ded54d20 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "JobId now identifies one background execution across registry lifetimes." +--- + +# Background job ids identify one execution + +English | [中文](guide.zh.md) + +## Change + +The job registry returns `-` instead of `-N`; the suffix is 16 lowercase UUID hex characters. The same `JobId` appears in live controls, `job/registered`, and `job/settled`; lifecycle records no longer carry `executionId`. The identifier remains owner-authorized. Existing Session histories remain unchanged and do not resume their jobs. + +## Migration + +1. Pass the exact id returned by a start result or `jobs.list()` to `job_output` and `job_kill`; use `job_output` with `wait: true` to wait. Remove code that constructs an id from the kind and an ordinal. +2. Pair SDK lifecycle events by `data.jobId`. Remove imports of `JobExecutionId` and code that maps it to a process-local `jobId`. +3. Verify a new job's registration and settlement contain the same `jobId`, and a later registry instance issues a different one. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.zh.md new file mode 100644 index 0000000000..372a3b6c9f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "JobId 现在跨注册表生命周期标识一次后台执行。" +--- + +# 后台 job id 标识一次执行 + +[English](guide.md) | 中文 + +## 变更 + +job 注册表返回 `-`,不再返回 `-N`;后缀是 UUID 的 16 位小写十六进制字符。运行期控制、`job/registered` 和 `job/settled` 使用同一个 `JobId`;生命周期记录不再携带 `executionId`。访问仍按所有者授权。已有 Session 历史保持原样,不会恢复其中的 job。 + +## 迁移 + +1. 把启动结果或 `jobs.list()` 返回的原始 id 传给 `job_output` 和 `job_kill`;等待时为 `job_output` 设置 `wait: true`。移除按 kind 和序号构造 id 的代码。 +2. 使用 `data.jobId` 配对 SDK 生命周期事件。移除 `JobExecutionId` 导入,以及将它映射为进程内 `jobId` 的代码。 +3. 核对新 job 的注册与结算含有相同 `jobId`,后续注册表实例发放不同 id。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.i18n.yaml new file mode 100644 index 0000000000..57d0ff470e --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/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-alpha.1/jsonl-persistence-plugin/guide.md +/: + en: eff30513d0de970b + zh: c16a32f374bd07e1 +/mount-jsonl-persistence-through-its-plugin: + en: ae3ff407a497cf5c + zh: 1b71e651967b8255 +/mount-jsonl-persistence-through-its-plugin/change: + en: ea5defddc1a9b989 + zh: 97902f22a4196b12 +/mount-jsonl-persistence-through-its-plugin/migration: + en: 93d9f202c59303e9 + zh: c5ced26ed819d7c5 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.md new file mode 100644 index 0000000000..a05bdef3eb --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "JSONL persistence uses a namespace plugin import; direct service-class construction and class type annotations must use the persistence service instead." +--- + +# Mount JSONL persistence through its plugin + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-session-persistence-jsonl` exports a function plugin through named `name`, `Config`, and `apply` exports, with no default export. Default imports, direct construction, subclassing, `instanceof`, and class type annotations are affected. The plugin retains listeners and handles through the consumer unloads triggered by removing this provider; concurrent root disposal remains unordered. The `sessionPersistence` service API, YAML configuration, and stored files remain unchanged. + +## Migration + +1. Replace the default import with `import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'`. Existing `ctx.plugin(JsonlSessionPersistence, config)` calls remain valid; replace direct construction with this mounting call. For dynamic imports, use the module namespace returned by `await import(...)` instead of its `.default`. Consumers declare `inject: ['sessionPersistence']` and access `ctx.sessionPersistence`. +2. Use the `SessionPersistence` type from `@deepseek-ai/dsh-session-persistence` for service annotations. Replace concrete-class `instanceof` checks and subclasses with consumers of that service; the concrete JSONL class is private. +3. Await plugin startup and confirm a session can be created, flushed, and reopened. Register asynchronous consumer cleanup in the scope that injects `sessionPersistence` so backend unloading waits for it. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.zh.md new file mode 100644 index 0000000000..0cf8323713 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "JSONL 持久化使用插件命名空间导入;直接构造服务类和类类型标注需要改用持久化服务。" +--- + +# 通过插件挂载 JSONL 持久化 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-session-persistence-jsonl` 通过命名导出的 `name`、`Config` 和 `apply` 提供函数插件,没有默认导出。默认导入、直接构造、继承、`instanceof` 和类类型标注会受到影响。插件会保留监听器和句柄,直到移除该提供者所触发的消费者卸载完成;根作用域的并发卸载仍不保证顺序。`sessionPersistence` 服务 API、YAML 配置和存储文件保持不变。 + +## 迁移 + +1. 将默认导入替换为 `import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'`。现有的 `ctx.plugin(JsonlSessionPersistence, config)` 调用仍然有效;将直接构造改为该挂载调用。动态导入直接使用 `await import(...)` 返回的模块命名空间,不再取其 `.default`。消费者声明 `inject: ['sessionPersistence']` 并访问 `ctx.sessionPersistence`。 +2. 服务类型标注使用 `@deepseek-ai/dsh-session-persistence` 的 `SessionPersistence` 类型。将针对具体类的 `instanceof` 检查和子类改为消费该服务;具体 JSONL 类是私有的。 +3. 等待插件启动完成,确认能够创建、flush 并重新打开会话。将消费者的异步清理注册到注入 `sessionPersistence` 的作用域中,以便后端卸载时等待它完成。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.i18n.yaml new file mode 100644 index 0000000000..d7f903cbe4 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/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-alpha.1/python-optional-resources/guide.md +/: + en: d6baeff3354ba733 + zh: 54e736a9718228f8 +/download-optional-python-runtime-resources-explicitly: + en: b9a46f870d69cf79 + zh: 1ed1da9167fad3b1 +/download-optional-python-runtime-resources-explicitly/change: + en: 92d45d6f2a499e9b + zh: d4560da85fc5cfbf +/download-optional-python-runtime-resources-explicitly/migration: + en: 0f0b15be52301aed + zh: 646d7b47069714d4 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.md new file mode 100644 index 0000000000..21c5a025e5 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Python runtime wheels require explicit downloads for Office conversion and bundled authoring environments." +--- + +# Download optional Python runtime resources explicitly + +English | [中文](guide.zh.md) + +## Change + +Python runtime wheels omit the Office sidecar and authoring interpreters and libraries. SDK startup does not download them. Office operations require an explicit download; the authoring environment and conversion engine can be selected independently. The SDK still packages the normal `dsh` CLI and requires no system Node.js. + +## Migration + +1. Before processing Office files, call `deepseek_harness_runtime.download_office()`. Before using the bundled authoring environment, call `deepseek_harness_runtime.download_primary_runtime()`. Both reuse completed caches and return absolute paths. +2. For offline deployment, prepare the cache with the matching runtime release on the target platform and copy it to that platform's deployment. Set `DSH_RESOURCE_CACHE` to its absolute root before downloads and SDK startup. See the [runtime reference](../../../../python/sdk-runtime/README.md). +3. Start the SDK and confirm the required resources are available. `deepseek_harness_runtime.resolve_office_launch_args()` checks the Office CLI paths without downloading. Existing `DSH_PRIMARY_RUNTIME` paths remain supported; an empty value disables the authoring query. Profile patches can independently disable `skill-office`. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.zh.md new file mode 100644 index 0000000000..9c804c6d34 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Python 运行时 wheel 的 Office 转换与内置创作环境需要显式下载。" +--- + +# 显式下载可选 Python 运行时资源 + +[English](guide.md) | 中文 + +## 变更 + +Python 运行时 wheel 不包含 Office sidecar、创作解释器和库。SDK 启动不会下载这些资源。Office 操作需要显式下载;创作环境和转换引擎可以独立选择。SDK 仍打包常规 `dsh` CLI,无需系统 Node.js。 + +## 迁移 + +1. 处理 Office 文件前调用 `deepseek_harness_runtime.download_office()`。使用内置创作环境前调用 `deepseek_harness_runtime.download_primary_runtime()`。两者复用完整缓存并返回绝对路径。 +2. 离线部署时,在目标平台使用匹配的运行时发布版本准备缓存,并将其复制到该平台的部署环境。下载和启动 SDK 前,将 `DSH_RESOURCE_CACHE` 设为缓存的绝对根目录。详见[运行时参考](../../../../python/sdk-runtime/README.zh.md)。 +3. 启动 SDK 并确认所需资源可用。`deepseek_harness_runtime.resolve_office_launch_args()` 检查 Office CLI 路径,不执行下载。既有 `DSH_PRIMARY_RUNTIME` 路径仍受支持;空值禁用创作环境查询。Profile patch 可独立禁用 `skill-office`。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.i18n.yaml new file mode 100644 index 0000000000..3a047980af --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/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-alpha.1/subagent-preset-composition/guide.md +/: + en: 0351f79f0f7acc90 + zh: c6815e557ffa1a2f +/configure-subagent-composition-through-presets: + en: aab975172f278acf + zh: 4e6dad4ef487cc52 +/configure-subagent-composition-through-presets/change: + en: e758e1d0c34846db + zh: 93675aa1b3f7be4e +/configure-subagent-composition-through-presets/migration: + en: 0ae5b3e77d8c4b83 + zh: 746b44875e23dea6 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.md new file mode 100644 index 0000000000..c41f6f4c26 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Subagent persona and toolFilter configuration is replaced by Agent Preset composition." +--- + +# Configure subagent composition through presets + +English | [中文](guide.zh.md) + +## Change + +`dsh-tool-subagent` rejects `persona` and `toolFilter` configuration. New `SubagentStartRequest` values and provider capabilities no longer expose either field. In-process spawn selects a preset through `agentPreset`; its persona and tools come from that preset's plugins. Fork inherits the parent's preset and cannot independently change persona or restrict tools. Model and reasoning-effort options are unchanged. + +The shipped `standard`, `ptc`, and `cordis` delegation tools no longer hide `schedule_*` tools. Delegated callers still receive the existing execution refusal. Historical child Session descriptors retain their persona and tool restrictions when resumed. + +## Migration + +1. Remove `persona` and `toolFilter` from `dsh-tool-subagent` rows in your profile patches and custom presets. Update programmatic callers and provider capability declarations to remove the same fields. +2. For spawn, define an Agent Preset containing `dsh-persona` and the required tool plugins, then set the delegation tool's `agentPreset` to its id. Use `allowedAgentPresets` to offer several declared presets to the parent model. Presets select plugin compositions; there is no replacement per-child tool-name filter. +3. For fork, remove the overrides and accept the inherited composition. If the task needs a different composition, use spawn with a preset; it does not inherit the parent's conversation. +4. Start a new Session and delegate a task. Confirm that the child uses the selected preset's persona and tools. Existing persisted child Sessions need no manual edits. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.zh.md new file mode 100644 index 0000000000..dd84e38eda --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Subagent 的 persona 与 toolFilter 配置由 Agent Preset 组装替代。" +--- + +# 通过 preset 配置子 Agent 组装 + +[English](guide.md) | 中文 + +## 变更 + +`dsh-tool-subagent` 拒绝 `persona` 与 `toolFilter` 配置。新的 `SubagentStartRequest` 和提供方能力声明也不再暴露这两个字段。进程内 spawn 通过 `agentPreset` 选择 preset,其 persona 与工具来自该 preset 的插件。fork 继承父方 preset,不能独立改变 persona 或限制工具。模型与推理强度选项保持不变。 + +出货的 `standard`、`ptc` 和 `cordis` 委派工具不再隐藏 `schedule_*` 工具。委派调用方仍会收到已有的执行拒绝。历史子 Session 的 descriptor 在恢复时仍应用其 persona 与工具限制。 + +## 迁移 + +1. 从 profile patch 和自定义 preset 的 `dsh-tool-subagent` 行中移除 `persona` 与 `toolFilter`。同步从程序化调用方和提供方能力声明中移除这两个字段。 +2. 对于 spawn,定义包含 `dsh-persona` 和所需工具插件的 Agent Preset,再将委派工具的 `agentPreset` 设为其 id。使用 `allowedAgentPresets` 向父模型开放多个已声明 preset。preset 选择插件组合,不提供替代的逐子级工具名过滤器。 +3. 对于 fork,移除覆盖并接受继承的组合。如果任务需要不同组合,使用带 preset 的 spawn;它不继承父方对话。 +4. 启动新 Session 并委派任务,确认子级使用所选 preset 的 persona 与工具。已有持久化子 Session 无须手动修改。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/guide.i18n.yaml new file mode 100644 index 0000000000..5d77befdf5 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/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-alpha.2/deepseek-account-model-catalog/guide.md +/: + en: 60a8441ae7ea6841 + zh: 3715b44e11c5131b +/deepseek-account-models-come-from-the-account-listing: + en: 2208c82c24d75a53 + zh: fa6c53c925aae6f2 +/deepseek-account-models-come-from-the-account-listing/change: + en: bf4c84372b8b1316 + zh: af1623701d19b62e +/deepseek-account-models-come-from-the-account-listing/migration: + en: 7ce26a5a1926b292 + zh: 35dfc17997834e4d diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/guide.md new file mode 100644 index 0000000000..9bedcf6c07 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/guide.md @@ -0,0 +1,27 @@ +--- +kind: upgrade-guide +description: "The DeepSeek account route no longer accepts a models catalog; the account listing supplies its models and modelOverrides adjusts them." +--- + +# DeepSeek account models come from the account listing + +English | [中文](guide.zh.md) + +## Change + +The `deepseek-account` route previously read its model catalog from the `models` field of the `llm-deepseek-account` entry, which the Models page could edit for the account. The route now lists models from the account's `GET /models` endpoint, serves the built-in catalog until a listing loads, and accepts per-model adjustments only through `modelOverrides`; the Models page no longer offers an editor for this route. A listed model can require a newer DeepSeek Harness; such a model is hidden, and a request for it fails with `CLIENT_UPGRADE_REQUIRED`. A `models` field left in the entry, including one saved by the earlier Models page editor, stops the plugin at load with `llm-deepseek-account: models is no longer configurable`, and Desktop and Web show no account models until the field is removed. Signed-in accounts without a saved catalog need no action. + +## Migration + +1. Open `$DSH_HOME/profiles//cordis.patch.yml` and find the entry with `id: llm-deepseek-account`. +2. Delete its `models` field. For each model whose fields you changed, move only the changed fields under `modelOverrides.`: + + ```yaml + - id: llm-deepseek-account + config: + modelOverrides: + deepseek-flash: + maxTokens: 131072 + ``` + +3. Restart DeepSeek Harness, sign in to the DeepSeek account, and confirm that the model selector lists the account models and the Models page shows the account as signed in. An override the listed model rejects, or one that names an unknown model, appears as the account's diagnostic on the Models page. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/guide.zh.md new file mode 100644 index 0000000000..a95a72d633 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/guide.zh.md @@ -0,0 +1,27 @@ +--- +kind: upgrade-guide +description: "DeepSeek 账号路由不再接受 models 目录;模型由账号的模型列表提供,单个模型通过 modelOverrides 调整。" +--- + +# DeepSeek 账号模型改由账号的模型列表提供 + +[English](guide.md) | 中文 + +## 变更 + +`deepseek-account` 路由此前从 `llm-deepseek-account` 条目的 `models` 字段读取模型目录,模型页可以为账号编辑这份目录。现在该路由从账号的 `GET /models` 端点列出模型,在列表加载之前使用内置目录,单个模型只能通过 `modelOverrides` 调整;模型页不再为该路由提供编辑器。列表中的模型可以要求更新版本的 DeepSeek Harness,这样的模型会被隐藏,请求它时以 `CLIENT_UPGRADE_REQUIRED` 失败。条目中遗留的 `models` 字段,包括早先模型页编辑器保存的,会让插件在加载时报 `llm-deepseek-account: models is no longer configurable` 并停止,删除该字段之前桌面端和 Web 都不显示账号模型。没有保存过账号目录的已登录账号无需操作。 + +## 迁移 + +1. 打开 `$DSH_HOME/profiles//cordis.patch.yml`,找到 `id: llm-deepseek-account` 的条目。 +2. 删除其中的 `models` 字段。对改过字段的模型,只把改过的字段移到 `modelOverrides.<模型 id>` 下: + + ```yaml + - id: llm-deepseek-account + config: + modelOverrides: + deepseek-flash: + maxTokens: 131072 + ``` + +3. 重启 DeepSeek Harness 并登录 DeepSeek 账号,确认模型选择器列出账号模型、模型页显示账号已登录。被列出的模型拒绝的覆盖,或指向未知模型的覆盖,会作为该账号的诊断信息显示在模型页上。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/guide.i18n.yaml new file mode 100644 index 0000000000..a4a47e22a2 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/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-alpha.2/desktop-font-size-shortcuts/guide.md +/: + en: 0863e2ef201c6e98 + zh: e7042cab4852e9c8 +/desktop-font-size-shortcuts-reserve-custom-key-combinations: + en: 7c82d9faadaec1e0 + zh: 91d60eb7a660e1b3 +/desktop-font-size-shortcuts-reserve-custom-key-combinations/change: + en: cb025eb9ae5e18ae + zh: 094ec608c1ce4dad +/desktop-font-size-shortcuts-reserve-custom-key-combinations/migration: + en: bc66077939ee3df3 + zh: f8460bf5ec20ac58 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/guide.md new file mode 100644 index 0000000000..f674031611 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Desktop font-size shortcuts reserve key combinations previously available to custom shortcuts." +--- + +# Desktop font-size shortcuts reserve custom key combinations + +English | [中文](guide.zh.md) + +## Change + +Desktop reserves `Cmd`/`Ctrl` + `=` (including `Shift` + `=` for `+`) and `Cmd`/`Ctrl` + `-` to adjust text, code, and terminal font sizes together. Use `Cmd` on macOS and `Ctrl` on Windows and Linux. Custom bindings using these combinations, including overlapping two-key chords, remain saved but no longer execute. Web shortcut behavior is unchanged. + +## Migration + +1. Open Settings → General → Keyboard shortcuts in Desktop and find any custom binding marked as conflicting with a font-size shortcut. +2. Record a nonconflicting combination for that action, or choose **Remove** to disable its keyboard shortcut. **Restore default** is available when the action's default does not conflict. +3. Try the reassigned action, then press `Cmd`/`Ctrl` + `=` and `Cmd`/`Ctrl` + `-`. Confirm that all three font sizes change in Settings and remain saved after reopening Desktop. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/guide.zh.md new file mode 100644 index 0000000000..a9d052744f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "桌面端字号快捷键占用了原先可用于自定义快捷键的组合键。" +--- + +# 桌面端字号快捷键占用部分自定义组合键 + +[English](guide.md) | 中文 + +## 变更 + +桌面端保留 `Cmd`/`Ctrl` + `=`(包括输入 `+` 的 `Shift` + `=`)和 `Cmd`/`Ctrl` + `-`,用于一起调整正文、代码和终端字号。macOS 使用 `Cmd`,Windows 和 Linux 使用 `Ctrl`。使用这些组合键的自定义绑定,包括与其重叠的双键序列,仍会保存,但不再执行。Web 端快捷键行为不变。 + +## 迁移 + +1. 在桌面端打开「设置 → 通用 → 键盘快捷键」,找到标记为与字号快捷键冲突的自定义绑定。 +2. 为该操作录制不冲突的组合键,或选择「移除」禁用其快捷键。操作的默认快捷键不冲突时,也可以选择「恢复默认」。 +3. 尝试重新绑定的操作,再按 `Cmd`/`Ctrl` + `=` 和 `Cmd`/`Ctrl` + `-`。确认设置中的三组字号一起变化,并在重新打开桌面端后仍保留。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.i18n.yaml new file mode 100644 index 0000000000..46b8b3fcd7 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/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-alpha.2/pi-ai-azure-provider/guide.md +/: + en: 51d5b5b4d2fb1b8a + zh: 4dc9af2458a8fcac +/azure-provider-route-rename: + en: 105c73820d057e28 + zh: 91a7bfc5c282e164 +/azure-provider-route-rename/change: + en: d5ce9089d8aa7119 + zh: f337926f85b01bc1 +/azure-provider-route-rename/migration: + en: 8fb23fbb8184fa00 + zh: 8c44fb293a8dd70a diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.md new file mode 100644 index 0000000000..1b4119f4d7 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "The pi-ai Azure provider route is renamed from azure-openai-responses to azure." +--- + +# Azure provider route rename + +English | [中文](guide.zh.md) + +## Change + +The installed pi-ai catalog identifies the Azure provider as `azure` instead of `azure-openai-responses`. The `azure` route includes both Responses and Chat Completions models. The Responses API identifier remains `azure-openai-responses`, and the `AZURE_OPENAI_*` environment variables keep their names. Configurations and model selections that use the old provider key no longer resolve the Azure catalog. + +## Migration + +1. In the `llm-pi-ai` configuration in `$DSH_HOME/profiles//cordis.patch.yml` or another supplying layer, rename `config.providers.azure-openai-responses` to `config.providers.azure`. Preserve its existing fields and any `apiKeyEnv` reference. +2. Use catalog model IDs under `models`; map a custom Azure deployment name with `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`. The mixed catalog cannot infer a protocol for a model ID absent from the catalog. An explicit route-level `api` overrides every model on that route; it does not select the Azure provider. +3. Update explicit request/default provider selections to `azure`, and reselect an Azure model when continuing an existing Session. Configure the new Azure entry in Settings → Models if its key was stored for the old entry. Do not edit historical Session events. +4. Confirm that Settings → Models offers `azure` and lists the expected models, then send a short request using the configured deployment. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.zh.md new file mode 100644 index 0000000000..ad62892268 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "pi-ai 的 Azure 提供方路由从 azure-openai-responses 更名为 azure。" +--- + +# Azure 提供方路由更名 + +[English](guide.md) | 中文 + +## 变更 + +已安装的 pi-ai 目录使用 `azure` 标识 Azure 提供方,替代 `azure-openai-responses`。`azure` 路由同时包含 Responses 和 Chat Completions 模型。Responses API 标识仍为 `azure-openai-responses`,`AZURE_OPENAI_*` 环境变量名称也保持不变。使用旧提供方键的配置和模型选择无法再解析 Azure 目录。 + +## 迁移 + +1. 在 `$DSH_HOME/profiles//cordis.patch.yml` 或其他提供配置的层中,找到 `llm-pi-ai` 配置,将 `config.providers.azure-openai-responses` 改为 `config.providers.azure`。保留原有字段及任何 `apiKeyEnv` 引用。 +2. 在 `models` 中使用目录模型 ID;自定义 Azure 部署名称通过 `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` 映射。混合协议目录无法为目录外的模型 ID 推断协议。显式设置路由级 `api` 会覆盖该路由所有模型的协议,并不表示选择 Azure 提供方。 +3. 将显式请求及默认模型配置中的提供方改为 `azure`,继续现有 Session 时重新选择 Azure 模型。若密钥存储在旧条目下,请在“设置 → 模型”中配置新的 Azure 条目。不要修改历史 Session 事件。 +4. 确认“设置 → 模型”提供 `azure` 且列出预期模型,再向所配置的部署发送一条简短请求。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/guide.i18n.yaml new file mode 100644 index 0000000000..4412c0cf22 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/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-alpha.2/pi-ai-model-discovery/guide.md +/: + en: 40afcce4dbdcb337 + zh: 33c5647441bd1dcb +/pi-ai-model-visibility-without-a-credential: + en: 1bc74c0ce4085c3c + zh: 0a2be91b04c069c8 +/pi-ai-model-visibility-without-a-credential/change: + en: f92005c2a3d4645c + zh: 5bfb1685cdf3437c +/pi-ai-model-visibility-without-a-credential/migration: + en: 26d72c82e5c60031 + zh: 4b582d3e4d022dd8 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/guide.md new file mode 100644 index 0000000000..0427fda13a --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/guide.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "pi-ai routes that name a credential hide their models until the credential is configured." +--- + +# pi-ai model visibility without a credential + +English | [中文](guide.zh.md) + +## Change + +A pi-ai route that names a credential through `apiKeyEnv` previously listed its configured models while that credential was missing, and requests then failed with `MISSING_CREDENTIAL`. Model discovery now returns an empty catalog for such a route until the credential resolves, so Desktop and Web hide its group. Other credential errors, such as a malformed key, still fail model discovery. Routes that use the provider's native authentication or name no credential keep their configured catalog. A saved or default selection absent from the catalog displays “Select model” without changing the stored selection. + +## Migration + +1. To use such a route, configure its key in Settings → Models, or supply the reference its `apiKeyEnv` names through the launch environment. +2. Reopen the model selector and confirm that the route's group appears. Custom model discovery consumers must handle an empty catalog before the credential is configured; a listed catalog does not mean that the remote API accepts the key. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/guide.zh.md new file mode 100644 index 0000000000..b4855afad2 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/guide.zh.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "指定了凭据的 pi-ai 路由在凭据配置之前隐藏其模型。" +--- + +# pi-ai 路由缺少凭据时的模型可见性 + +[English](guide.md) | 中文 + +## 变更 + +通过 `apiKeyEnv` 指定凭据的 pi-ai 路由,此前在凭据缺失时也会列出配置的模型,请求随后以 `MISSING_CREDENTIAL` 失败。现在凭据解析到之前,模型发现对这样的路由返回空目录,因此桌面端和 Web 隐藏它的分组。其他凭据错误(例如密钥格式错误)仍会让模型发现失败。使用提供方原生认证或未指定凭据的路由保留配置的目录。已保存或默认的选择不在目录中时,界面显示“请选择模型”,保存的选择保持不变。 + +## 迁移 + +1. 要使用这样的路由,请在设置 → 模型中配置密钥,或通过启动环境提供 `apiKeyEnv` 指定的凭据引用。 +2. 重新打开模型选择器,确认该路由的分组出现。自定义模型发现消费者必须处理配置凭据前的空目录;列出目录不代表远端 API 已接受密钥。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.i18n.yaml new file mode 100644 index 0000000000..7855124a09 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/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-alpha.2/sdk-minimal-image-compaction/guide.md +/: + en: 5fc67054b45f1734 + zh: 0cb92b2e036dfb67 +/sdk-minimal-image-reading-and-automatic-compaction: + en: 1b0161d44c885284 + zh: 5babee2ad7dc2571 +/sdk-minimal-image-reading-and-automatic-compaction/change: + en: 42d90c20c9549611 + zh: f904303bea2a60a8 +/sdk-minimal-image-reading-and-automatic-compaction/migration: + en: e944f28ce8c2ef8d + zh: 3bd5fd84a02e5866 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.md new file mode 100644 index 0000000000..a1cd0795fa --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.md @@ -0,0 +1,32 @@ +--- +kind: upgrade-guide +description: "The sdk-minimal default tool roster adds read_image and automatically summarizes older context." +--- + +# SDK-minimal image reading and automatic compaction + +English | [中文](guide.zh.md) + +## Change + +`sdk-minimal` advertises `read_image` alongside its persistent shell and `working_directory`. Automatic compaction sends an additional model request to summarize older history when context pressure reaches the configured threshold. The complete Session log remains stored as uncompressed JSONL. Images that exceed the route budget are permanently replaced by attachment/path placeholders so the request can retry. SDK prompt text does not execute `/compact`. + +Clients that pin tool names, benchmark prompt size, or count model requests must account for these defaults. Image reading requires an adapter route that declares image input. See the [bundle reference](../../../../packages/bundle/sdk-minimal/README.md) for configuration and model effects. + +## Migration + +1. Accept the new defaults, or place this patch in `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` to retain the previous default tools and disable automatic compaction: + +```yaml +- id: tool-fs + config: + read: false + readImage: false + write: false + edit: false +- id: compaction-basic + config: + auto: false +``` + +2. Launch a fresh SDK runtime. Without additional user plugins, its model requests contain the persistent `bash` or `pwsh` tool and `working_directory`; it sends no automatic summarization requests. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.zh.md new file mode 100644 index 0000000000..6d4d43a100 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.zh.md @@ -0,0 +1,32 @@ +--- +kind: upgrade-guide +description: "sdk-minimal 默认工具清单新增 read_image,并自动生成较早上下文的摘要。" +--- + +# SDK-minimal 图片读取与自动压缩 + +[English](guide.md) | 中文 + +## 变更 + +`sdk-minimal` 在持久 shell 和 `working_directory` 之外公布 `read_image`。上下文压力达到配置阈值时,自动压缩会额外发起模型请求,生成较早历史的摘要。完整 Session 日志仍存储为未压缩 JSONL。图片超出路由预算时会被永久替换为包含附件与路径的占位文本,以便重试请求。SDK 提示词文本不会执行 `/compact`。 + +固定工具名称、基准提示词大小或统计模型请求数的客户端必须考虑这些默认行为。图片读取需要适配器路由声明支持图片输入。配置与模型影响见[组合包参考](../../../../packages/bundle/sdk-minimal/README.zh.md)。 + +## 迁移 + +1. 接受新增默认能力,或将以下 patch 写入 `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml`,保留原有默认工具并关闭自动压缩: + +```yaml +- id: tool-fs + config: + read: false + readImage: false + write: false + edit: false +- id: compaction-basic + config: + auto: false +``` + +2. 启动新的 SDK 运行时。未额外配置用户插件时,模型请求包含持久 `bash` 或 `pwsh` 工具及 `working_directory`;不会发起自动摘要请求。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.i18n.yaml new file mode 100644 index 0000000000..60dff113e1 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/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-alpha.2/stored-credentials-precedence/guide.md +/: + en: 9fc8377bd2d6e38c + zh: b77fd76aeee0f592 +/a-stored-key-takes-precedence-over-an-exported-variable: + en: 0d002196a3f19850 + zh: 7dcbbb6a44216f01 +/a-stored-key-takes-precedence-over-an-exported-variable/change: + en: 4ac6ffc5e782f765 + zh: 51a69db953708bd3 +/a-stored-key-takes-precedence-over-an-exported-variable/migration: + en: d4f534f82476b5e9 + zh: 5371eaaae96a3759 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.md new file mode 100644 index 0000000000..5726a62e23 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.md @@ -0,0 +1,25 @@ +--- +kind: upgrade-guide +description: "Stored credentials now precede environment fallbacks when the store is mounted; the shipped headless profile omits stored authentication." +--- + +# A stored key takes precedence over an exported variable + +English | [中文](guide.zh.md) + +## Change + +Earlier releases used an exported environment variable before the `refs` entry with the same name in `$DSH_HOME/.credentials.yaml` (`~/.dsh/.credentials.yaml` by default). This applied to `DEEPSEEK_API_KEY=… dsh`, a shell profile export, a CI secret, and a container `-e` value. While the name was exported, the Models page could not save a key for it. + +Now the stored entry wins. The exported variable applies only while the file stores no value for that name, and `$cwd/.env` and `$DSH_HOME/.env` still apply only after both. The Models page can save and replace a key while the name is exported. + +The shipped `headless` profile instead disables the credential store, authorization and DeepSeek account provider. It uses the launch environment, project `.env`, then Harness-home `.env`, and leaves `.credentials.yaml` untouched. Saved API keys and sign-ins no longer authenticate headless jobs by default. Web, SDK and ACP retain the store. + +You are affected when a harness home stores a value under `refs` and the process that launches `dsh` exports the same name. This includes a container that mounts such a home. Requests now use the stored key instead of the exported one. A CI job or container that starts with a fresh harness home, without `.credentials.yaml`, is not affected. The [stored credential precedence](../../../../.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.md) Agent Note records the reason. + +## Migration + +1. Compare the names under `refs` in `$DSH_HOME/.credentials.yaml` with the variables that your shell, CI job, or container exports. A name that appears in only one place needs no action. +2. For each name that appears in both places, keep the stored key if it is the account you want. Otherwise, delete the name's line under `refs`. A running harness reloads the file, so the next request reads the exported variable without a restart. +3. For a headless job, export the required key and run `dsh headless ""`. To keep using a saved key or sign-in, apply the three-row [headless restoration patch](../../../../packages/bundle/headless/README.md#credentials); restored storage also restores saved-first precedence. Existing profile, home or command overlays can override the shipped rows, so inspect `dsh headless --dump-config` when checking the effective composition. +4. Confirm the intended request account and that the launching process exports a non-empty value where needed. Check names and presence without printing credential values. A headless run relying only on a saved credential now fails; restoring storage or supplying supported environment authentication resolves it. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.zh.md new file mode 100644 index 0000000000..239d0367ac --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.zh.md @@ -0,0 +1,25 @@ +--- +kind: upgrade-guide +description: "挂载存储时,已保存凭据现在优先于环境后备;随附 headless profile 不挂载存储认证。" +--- + +# 存储的密钥优先于导出的变量 + +[English](guide.md) | 中文 + +## 变更 + +早期版本会先使用导出的环境变量,再使用 `$DSH_HOME/.credentials.yaml`(默认为 `~/.dsh/.credentials.yaml`)中同名的 `refs` 条目。这适用于 `DEEPSEEK_API_KEY=… dsh`、shell 配置文件中的导出、CI 机密和容器 `-e` 值。该名字被导出期间,Models 页面无法为其保存密钥。 + +现在存储的条目优先。只有当文件没有为该名字存储值时,导出的变量才会生效;`$cwd/.env` 和 `$DSH_HOME/.env` 仍排在两者之后。即使该名字已被导出,Models 页面也可以保存和替换密钥。 + +随附的 `headless` profile 则禁用凭据存储、authorization 和 DeepSeek account 提供方。它依次使用启动环境、项目 `.env` 和 Harness home `.env`,并保持 `.credentials.yaml` 原样。已保存 API 密钥和登录凭据默认不再为 headless 任务提供认证。Web、SDK 和 ACP 保留存储。 + +如果某个 harness home 在 `refs` 下存储了值,而启动 `dsh` 的进程导出了同名变量,你就会受到影响。挂载了这类 home 的容器也包括在内。请求现在使用存储的密钥,而不是导出的密钥。以全新 harness home 启动、没有 `.credentials.yaml` 的 CI 作业或容器不受影响。[存储凭据优先级](../../../../.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.zh.md) Agent Note 记录了原因。 + +## 迁移 + +1. 将 `$DSH_HOME/.credentials.yaml` 中 `refs` 下的名字,与 shell、CI 作业或容器导出的变量进行比较。只出现在其中一处的名字无需处理。 +2. 对同时出现在两处的名字,如果存储的密钥正是你要使用的账户,就保留它。否则,删除 `refs` 下该名字所在的行。运行中的 harness 会重新加载该文件,因此下一次请求无需重启即可读取导出的变量。 +3. 对 headless 任务,导出所需密钥并运行 `dsh headless ""`。要继续使用已保存密钥或登录凭据,请应用三行配置的 [headless 恢复 patch](../../../../packages/bundle/headless/README.zh.md#credentials);恢复存储也恢复已保存凭据优先。现有 profile、home 或命令覆盖可以覆盖随附行,因此核对有效组合时请查看 `dsh headless --dump-config`。 +4. 确认请求使用预期账户,并在需要时确认启动进程导出了非空值。只检查名字和是否存在,不要打印凭据值。仅依赖已保存凭据的 headless 运行现在会失败;恢复存储或提供受支持的环境认证即可解决。 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 6f9b6e315e..85cf647be1 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -9,8 +9,8 @@ en: 2c6b665adc256b4e zh: 2240e4c1573ebe9c /configure-models/add-a-third-party-provider: - en: 0fe5f88e692f618c - zh: 4f2e493818d8934f + en: 4b99a9b34d117eea + zh: dddeb5a08c0d3cc6 /configure-models/add-a-custom-model-api: en: 17867305a3e1b52a zh: 876fedf1f6f563e4 @@ -26,6 +26,9 @@ /configure-models/advanced-configuration/image-input: en: 75350cb615da03a4 zh: 6f8b3510085e4ac4 +/configure-models/advanced-configuration/deepseek-account-models: + en: fc09d02a89b211a7 + zh: e0f23a8912a24178 /configure-models/advanced-configuration/reasoning-effort: en: f7581a0760fdfd2c zh: dc6eaa16f74a0e9f diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 773ff6722a..1fd84e9f1a 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -16,7 +16,9 @@ Keys are write-only. The page receives a redacted descriptor after saving, never Choose **Add model provider**. The card opens on **Third-party model provider**: pick a provider dsh ships with — the list shows provider ids such as `anthropic`, `openai`, `moonshotai` for Kimi, or `zai` for GLM — enter its API key, and save. The installed catalog supplies the endpoint, protocol, and model list. -Providers that sign in with OAuth, such as Codex, are not supported here yet. +The authentication **?** explains saved-credential priority and the environment fallbacks displayed for a key field. Leaving the key blank preserves existing authentication. Providers without native key authentication, such as `openai-codex`, show no key field unless the profile explicitly names a credential reference. + +For an existing profile that names a credential reference, **API key source** shows the layer currently supplying that reference: saved credentials, launch environment, `$cwd/.env`, or `$DSH_HOME/.env`. Native authentication without a named reference has no source label. ## Add a custom model API @@ -108,6 +110,21 @@ In pi-ai configuration, every list must name at least one modality except a mode Both fields state a claim about your endpoint rather than checking it. A model that declares images its endpoint does not serve is not caught here; the provider rejects the request instead. +### DeepSeek account models + +A signed-in DeepSeek account lists its models from the account service, so the Models page offers no model editor for it. Adjust a single model with `modelOverrides` in `$DSH_HOME/profiles//cordis.patch.yml`; the fields match a DeepSeek `models` entry, and unset fields keep the listed values: + +```yaml +- id: llm-deepseek-account + config: + modelOverrides: + deepseek-flash: + contextWindow: 524288 + maxTokens: 131072 +``` + +A model that needs a newer DeepSeek Harness fails with `CLIENT_UPGRADE_REQUIRED`, which names the version to install. The [account provider reference](../../../packages/llm/llm-deepseek-account/README.md) owns the listing, fallback, and gating rules. + ### Reasoning effort The model picker offers an **Effort** menu for a model that declares reasoning levels. A built-in provider's models inherit their levels from the installed catalog. A model you enter by hand declares none, so the Effort entry does not appear in the menu and the endpoint's own default decides whether the model thinks. Declare the levels with `reasoningEfforts` in `$DSH_HOME/profiles//cordis.patch.yml`: diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 6d618ed1e9..340f6b8b97 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -16,7 +16,9 @@ 选择**添加模型提供商**。卡片默认打开在**第三方模型提供商**:选取 dsh 自带的提供商——列表显示的是提供商 id,例如 `anthropic`、`openai`、Kimi 对应的 `moonshotai`、GLM 对应的 `zai`——输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。 -通过 OAuth 登录的提供商(例如 Codex)暂不支持。 +认证 **?** 说明已保存凭据的优先级,以及密钥字段所显示的环境后备。密钥留空会保留现有认证。没有原生密钥认证的提供商(例如 `openai-codex`)不显示密钥字段,除非 profile 显式指定了凭据引用。 + +已有 profile 指定凭据引用时,**API 密钥来源**显示当前为该引用供值的层:已保存凭据、启动环境、`$cwd/.env` 或 `$DSH_HOME/.env`。未指定引用的原生认证不显示来源标签。 ## 添加自定义模型 API @@ -108,6 +110,21 @@ DeepSeek 将省略的 `inputModalities` 视为纯文本,并拒绝空列表。 这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供商拒绝该请求。 +### DeepSeek 账号的模型 + +已登录 DeepSeek 账号的模型列表来自账号服务,因此模型页不为它提供模型编辑器。要调整单个模型,在 `$DSH_HOME/profiles//cordis.patch.yml` 中使用 `modelOverrides`;字段与 DeepSeek 的 `models` 条目相同,未设置的字段沿用列表中的值: + +```yaml +- id: llm-deepseek-account + config: + modelOverrides: + deepseek-flash: + contextWindow: 524288 + maxTokens: 131072 +``` + +需要更新版 DeepSeek Harness 的模型会以 `CLIENT_UPGRADE_REQUIRED` 失败,并写明需要安装的版本。列表、兜底和门控规则见[账号提供方参考](../../../packages/llm/llm-deepseek-account/README.zh.md)。 + ### 推理等级 对于声明了推理等级的模型,模型选择器会提供**推理等级**菜单。内置提供商的模型从已安装目录继承其等级。手动录入的模型不声明任何等级,因此模型菜单里不会出现推理等级项,由端点自身的默认值决定模型是否思考。请在 `$DSH_HOME/profiles//cordis.patch.yml` 中用 `reasoningEfforts` 声明等级: diff --git a/docs/user/guide/public-deployments.i18n.yaml b/docs/user/guide/public-deployments.i18n.yaml index cba21115ff..757429dcc0 100644 --- a/docs/user/guide/public-deployments.i18n.yaml +++ b/docs/user/guide/public-deployments.i18n.yaml @@ -6,8 +6,8 @@ en: 867565aa675f3fb3 zh: c60606b5e5bdb778 /publish-the-web-ui-behind-a-reverse-proxy/what-public-url-advertises: - en: 576292b93ccf299a - zh: ded5adc55396869c + en: 924b28b211478f01 + zh: b098d5a53a29c883 /publish-the-web-ui-behind-a-reverse-proxy/what-the-proxy-must-do: en: 2f93051763125365 zh: 1dd699cb84ecf034 diff --git a/docs/user/guide/public-deployments.md b/docs/user/guide/public-deployments.md index b6b4efc0ca..70e107fe6d 100644 --- a/docs/user/guide/public-deployments.md +++ b/docs/user/guide/public-deployments.md @@ -10,7 +10,7 @@ dsh --profile web --public-url https://app.example/ui/ --trusted-host app.exampl ## What `--public-url` advertises -`--public-url` accepts an `http://` or `https://` root with an optional mount prefix and normalizes it to end in `/`. It supplies the printed and opened startup URL, `DSH_WEB_URL`, and the web-surface orientation. The webserver keeps serving origin-root routes and never learns the mount. The `publicUrl` config field publishes the same advertisement. +`--public-url` accepts an `http://` or `https://` root with an optional mount prefix and normalizes it to end in `/`. It supplies the printed and opened startup URL, `DSH_WEB_URL`, and the Web GUI URL in the agent's runtime context. The webserver keeps serving origin-root routes and never learns the mount. The `publicUrl` config field publishes the same advertisement. ## What the proxy must do diff --git a/docs/user/guide/public-deployments.zh.md b/docs/user/guide/public-deployments.zh.md index 2cb0e8fd4a..93572f0db1 100644 --- a/docs/user/guide/public-deployments.zh.md +++ b/docs/user/guide/public-deployments.zh.md @@ -10,7 +10,7 @@ dsh --profile web --public-url https://app.example/ui/ --trusted-host app.exampl ## `--public-url` 公告什么 -`--public-url` 接受可带挂载前缀的 `http://` 或 `https://` 根,并把它归一化为以 `/` 结尾。它提供打印与打开的启动 URL、`DSH_WEB_URL` 与 web 表层定位。webserver 继续提供 origin-root 路由,且从不了解挂载。`publicUrl` 配置字段发布同样的公告。 +`--public-url` 接受可带挂载前缀的 `http://` 或 `https://` 根,并把它归一化为以 `/` 结尾。它提供打印与打开的启动 URL、`DSH_WEB_URL`,以及 agent 运行时上下文中的 Web GUI URL。webserver 继续提供 origin-root 路由,且从不了解挂载。`publicUrl` 配置字段发布同样的公告。 ## 代理必须做什么 diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index 1eac40d8fb..f911334f40 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.i18n.yaml @@ -21,8 +21,8 @@ en: 592dd306b920cd83 zh: 8c22110621c12ba5 /get-started-with-the-python-sdk/install-or-define-plugins/opt-in-to-str-replace-editor: - en: c63ca1e3a99e6fe1 - zh: 2ceddea3f30d92d3 + en: 450d54b6e9e38469 + zh: f0dc06a08c073571 /get-started-with-the-python-sdk/understand-the-minimal-profile: - en: 5eaacc1bc52853c4 - zh: 5b94bfc79aac04d8 + en: 8ed0bfd3cc9cc967 + zh: a52dab4cb3dd09ea diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 837e97aa27..438598c378 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -152,19 +152,15 @@ Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or anothe ### Opt in to `str_replace_editor` -The bundled runtime includes `str_replace_editor`, but `sdk-minimal` omits it from the default Cordis tree. To use it, save this configuration as `editor.patch.yml`; `insert` adds both the editor and the filesystem provider that the minimal profile lacks: +The bundled runtime includes `str_replace_editor`, but `sdk-minimal` omits it from the default Cordis tree. To use it, save this configuration as `editor.patch.yml`; `insert` adds the editor using the existing filesystem provider: ```yaml - insert: - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - id: tool-str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' ``` -Pass `patches=("/absolute/path/to/editor.patch.yml",)` when constructing `DeepSeekHarness(profile="sdk-minimal", ...)`, or put the patch in `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` for persistent configuration. On the next runtime launch, model requests include `str_replace_editor` beside the persistent shell. The local filesystem provider uses the runtime working directory for relative paths; like the minimal shell, it does not confine access to that directory. For the standard `sdk` profile, insert only the editor row so it uses the existing filesystem provider and policies. +Pass `patches=("/absolute/path/to/editor.patch.yml",)` when constructing `DeepSeekHarness(profile="sdk-minimal", ...)`, or put the patch in `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` for persistent configuration. On the next runtime launch, model requests include `str_replace_editor` beside the default tools. The local filesystem provider uses the runtime working directory for relative paths; like the minimal shell, it does not confine access to that directory. The same patch works with the standard `sdk` profile. ## Understand the minimal profile @@ -172,12 +168,15 @@ Pass `patches=("/absolute/path/to/editor.patch.yml",)` when constructing `DeepSe |---|---| | System prompt | `DSH_SYSTEM_PROMPT`, falling back to `You are a helpful software engineer assistant.` | | Model in `minimal.py` | `--model`, then `DSH_MODEL`, then `deepseek-v4-flash` | -| Model-facing tool | Persistent `bash` on Linux/macOS or `pwsh` on Windows | +| Model-facing tools | Persistent `bash` on Linux/macOS or `pwsh` on Windows, `read_image`, and `working_directory` | | Shell timeout | 300 seconds | -| Runtime context and compaction | Absent | +| Optional runtime context | Absent | +| Context compaction | Automatic; summarizes older history through an additional model request | | Session persistence | Uncompressed JSONL under `/sessions` | -The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while filesystem tools, settings, managed credentials, OTel telemetry, Web tools, subagents, local instruction discovery, and compaction are absent. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) uploads unaccepted log events with DeepSeek requests by default, at most `maxBytes` (8 MiB) per request; set `session-log-deepseek.enabled: false` in a profile patch to disable it. It pins `danger-full-access`, so the platform-selected persistent shell can modify any path visible to the runtime; use a disposable checkout or container. +The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while text file tools, settings, managed credentials, OTel telemetry, Web tools, subagents, and local instruction discovery are absent. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) uploads unaccepted log events with DeepSeek requests by default, at most `maxBytes` (8 MiB) per request; set `session-log-deepseek.enabled: false` in a profile patch to disable it. It pins `danger-full-access`, so the platform-selected persistent shell can modify any path visible to the runtime; use a disposable checkout or container. + +Image reading requires an image-capable route. Automatic compaction preserves the complete Session log; see the [bundle reference](../../../packages/bundle/sdk-minimal/README.md) for model and storage effects. SDK prompt text does not execute `/compact` or other slash commands. The installed wheel still packages the full `web` profile and frontend assets. Run `dsh web` against an explicit `DSH_HOME` when a Python SDK deployment also needs the browser application; `web` is a separate CLI application and cannot serve a Python SDK client. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index d7742bec58..b7a86e9860 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -152,19 +152,15 @@ dsh plugin --profile sdk-minimal add file:C:/work/my-plugin-bundle ### 显式启用 `str_replace_editor` -随附运行时包含 `str_replace_editor`,但 `sdk-minimal` 的默认 Cordis tree 不挂载它。要使用该工具,请将以下配置保存为 `editor.patch.yml`;`insert` 会添加 editor,以及极简 profile 缺少的文件系统后端: +随附运行时包含 `str_replace_editor`,但 `sdk-minimal` 的默认 Cordis tree 不挂载它。要使用该工具,请将以下配置保存为 `editor.patch.yml`;`insert` 会添加 editor,并使用已有的文件系统后端: ```yaml - insert: - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - id: tool-str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' ``` -构造 `DeepSeekHarness(profile="sdk-minimal", ...)` 时传入 `patches=("/absolute/path/to/editor.patch.yml",)`,或将 patch 写入 `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` 以持久保存配置。下次运行时启动后,模型请求会在持久 shell 之外包含 `str_replace_editor`。本地文件系统后端以运行时工作目录解析相对路径;与极简 shell 一样,它不会将访问限制在该目录内。对于标准 `sdk` profile,只插入 editor 配置项,让它使用已有的文件系统后端与策略。 +构造 `DeepSeekHarness(profile="sdk-minimal", ...)` 时传入 `patches=("/absolute/path/to/editor.patch.yml",)`,或将 patch 写入 `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` 以持久保存配置。下次运行时启动后,模型请求会在默认工具之外包含 `str_replace_editor`。本地文件系统后端以运行时工作目录解析相对路径;与极简 shell 一样,它不会将访问限制在该目录内。该 patch 也适用于标准 `sdk` profile。 ## 理解极简 profile @@ -172,12 +168,15 @@ dsh plugin --profile sdk-minimal add file:C:/work/my-plugin-bundle |---|---| | 系统提示词 | `DSH_SYSTEM_PROMPT`,未设置时为 `You are a helpful software engineer assistant.` | | `minimal.py` 的模型 | `--model`,然后是 `DSH_MODEL`,最后是 `deepseek-v4-flash` | -| 面向模型的工具 | Linux/macOS 上的持久 `bash` 或 Windows 上的 `pwsh` | +| 面向模型的工具 | Linux/macOS 上的持久 `bash` 或 Windows 上的 `pwsh`、`read_image` 和 `working_directory` | | Shell 超时 | 300 秒 | -| 运行时上下文与 compaction | 不存在 | +| 可选运行时上下文 | 不存在 | +| 上下文压缩 | 自动执行;通过额外模型请求生成较早历史的摘要 | | 会话持久化 | `/sessions` 下的未压缩 JSONL | -该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;文件系统工具、settings、托管凭据、OTel 遥测、Web 工具、subagent、本地指令发现和 compaction 均不存在。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随 DeepSeek 请求上传未接受的日志事件,每次请求最多 `maxBytes`(8 MiB);在 profile patch 中设置 `session-log-deepseek.enabled: false` 可将其关闭。它固定使用 `danger-full-access`,因此按平台选择的持久 shell 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。 +该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;文本文件工具、settings、托管凭据、OTel 遥测、Web 工具、subagent、本地指令发现均不存在。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随 DeepSeek 请求上传未接受的日志事件,每次请求最多 `maxBytes`(8 MiB);在 profile patch 中设置 `session-log-deepseek.enabled: false` 可将其关闭。它固定使用 `danger-full-access`,因此按平台选择的持久 shell 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。 + +图片读取需要支持图片输入的路由。自动压缩保留完整 Session 日志;模型与存储影响见[组合包参考](../../../packages/bundle/sdk-minimal/README.zh.md)。SDK 提示词文本不会执行 `/compact` 或其他斜杠命令。 已安装 wheel 仍会打包完整 `web` profile 与前端产物。如果 Python SDK 部署还需要浏览器应用,请针对显式 `DSH_HOME` 运行 `dsh web`;`web` 是独立 CLI 应用,不能为 Python SDK client 提供服务。 diff --git a/lefthook.yml b/lefthook.yml index 1d5c8898c6..75c03f9a26 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -21,16 +21,6 @@ pre-commit: run: node_modules/.bin/tsx scripts/run-oxlint.ts --config .oxlintrc.staged.json --fix --no-error-on-unmatched-pattern {staged_files} stage_fixed: true - # Regenerate rather than reject: a dependency edit that forgot the notices - # would otherwise fail the test lane long after the commit. The glob matches - # every input the generator reads, including the generator itself and the - # build-time pin source. Deleting a manifest cannot trigger this job — - # lefthook only inspects files present on disk — so that one case still - # falls through to the freshness assertion in the test lane. - - name: third-party notices (staged) - glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/primary-runtime/lock.json,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,scripts/build-exe-for-python-sdk.ts,tsconfig.base*.json,packages/*/*/src/**/*,packages/*/*/tsdown.config.ts,packages/client/tsdown.client.ts,apps/*/src/**/*,apps/*/vite.config.ts}' - run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md - - name: whitespace (staged) run: git diff --cached --check diff --git a/package.json b/package.json index 26a5e5b305..3b9e5f2b36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "license": "MIT", "private": true, "type": "module", @@ -19,6 +19,8 @@ "scripts": { "prepare:primary-runtime": "tsx scripts/primary-runtime/prepare.ts", "build": "tsx scripts/build.ts", + "build:artifacts": "tsx scripts/build.ts --scope artifacts", + "build:check": "tsx scripts/build.ts --scope check", "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", @@ -48,11 +50,11 @@ "upload:latest:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:win:x64", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", - "typecheck": "npm run build:lib:host && npm run typecheck:contracts-ready", + "typecheck": "tsx scripts/build.ts --scope typecheck", "typecheck:contracts-ready": "tsc -b tsconfig.client.json", - "lint": "npm run build:lib:host && npm run lint:contracts-ready", + "lint": "tsx scripts/compile-referenced-projects.ts host-contracts && npm run lint:contracts-ready", "lint:contracts-ready": "tsx scripts/run-oxlint.ts .", - "lint:fix": "npm run build:lib:host && npm run lint:fix:contracts-ready", + "lint:fix": "tsx scripts/compile-referenced-projects.ts host-contracts && npm run lint:fix:contracts-ready", "lint:fix:contracts-ready": "tsx scripts/run-oxlint.ts --config .oxlintrc.staged.json packages/typert/generator/tests/fixtures/type-model --fix && tsx scripts/run-oxlint.ts . --fix", "duplication": "jscpd --config .jscpd.json packages scripts", "test": "pnpm run build:native-system && vitest run", @@ -99,7 +101,7 @@ "check:ci:windows-observational-ready": "tsx scripts/run-gates.ts ci-windows-observational-ready", "check:node-compat": "tsx scripts/run-gates.ts node-compat", "publint": "tsx scripts/publint-all.ts", - "doc-typecheck": "npm run build:lib:host && npm run doc-typecheck:contracts-ready", + "doc-typecheck": "tsx scripts/compile-referenced-projects.ts host-contracts && npm run doc-typecheck:contracts-ready", "doc-typecheck:contracts-ready": "tsx scripts/doc-typecheck.ts", "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", "verify-md-links": "tsx scripts/verify-md-links.ts", @@ -238,7 +240,10 @@ "@types/js-yaml": "^4.0.9", "@types/jsdom": "^28.0.3", "@types/mdast": "^4.0.4", + "@types/papaparse": "^5.5.2", "@types/node": "^22.20.1", + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", "@types/semver": "^7.8.0", "@types/spdx-expression-parse": "^4.0.0", "@vitest/coverage-v8": "^4.1.11", @@ -262,6 +267,7 @@ "micromark-extension-gfm": "^3.0.0", "oxlint": "1.81.0", "oxlint-tsgolint": "7.0.2001", + "papaparse": "5.5.3", "pnpm": "11.28.5", "publint": "^0.3.24", "semver": "^7.8.5", diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 8c071dac2e..50f203586c 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -10,6 +10,8 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Design Service Definitions for all current Consumers.** Keep tool-schema, Loader, UI, transport, and provider-specific behavior in the Consumer or provider; do not let one Consumer dictate the service contract ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`). - **Require a current owner and need.** Tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer, and keep behavior in its owning plugin or service. - **Require evidence for public choices.** Configurability does not justify an unsupported default, public operation set, format, or imported external concept. Use current-consumer evidence or relevant prior art; otherwise require an explicit value or defer the choice. +- **Give shared state an owner.** Hang shared state on a service or instance. A module-level cache, a singleton, a global `WeakMap`, or a `Symbol.for` / `globalThis` / `window.__X__` bypass hides that owner; add no new global `current`, `active`, or `selection` singleton, and derive context from the current scope instead. +- **Keep business rules in their own package.** A runtime, controller, manager, gateway, or API dispatch layer carries generic orchestration; a business branch belongs to an independent package and attaches through a registration surface or projected data ([where new behavior goes](../docs/architecture.md#where-new-behavior-goes)). - **Write model-facing contracts from the model's perspective.** Prompts, tool schemas, results, and diagnostics contain only task-relevant concepts, not UI, transport, or implementation vocabulary. Pin stable model-visible text verbatim and dynamic behavior through snapshots or end-to-end coverage. - **Enforce a decision in the operation that makes it.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor. - **Publish state only at its commit point.** Emit each notification and update derived state only after the operation succeeds; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 5a9e332ce9..ee019fe922 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/acp/acp/tests/harness.ts b/packages/acp/acp/tests/harness.ts index 51d89dab25..12ee80b95b 100644 --- a/packages/acp/acp/tests/harness.ts +++ b/packages/acp/acp/tests/harness.ts @@ -23,7 +23,7 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, St import { type GenerateOptions, LlmAdapter, ReasoningEffortId, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import TokenMeter from '@deepseek-ai/dsh-token-meter' import * as AcpPlugin from '../src/index.ts' import type { AcpConfig } from '../src/index.ts' diff --git a/packages/api/account-controller/locale/en.json b/packages/api/account-controller/locale/en.json new file mode 100644 index 0000000000..4c7e6dd036 --- /dev/null +++ b/packages/api/account-controller/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "api-account-controller" + } +} diff --git a/packages/api/account-controller/locale/zh.json b/packages/api/account-controller/locale/zh.json new file mode 100644 index 0000000000..f404257eaa --- /dev/null +++ b/packages/api/account-controller/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "账号操作接口", + "description": "向已认证的客户端提供账号操作能力。" + } +} diff --git a/packages/api/account-controller/package.json b/packages/api/account-controller/package.json index ae3bfe24a1..fd3a002a5d 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -31,9 +31,11 @@ "./remote": { "types": "./lib/typert.remote-client.d.ts", "default": "./lib/typert.remote-client.js" - } + }, + "./locale/*.json": "./locale/*.json" }, "files": [ + "locale/*.json", "lib/index.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", @@ -44,7 +46,7 @@ ], "license": "MIT", "dependencies": { - "zod": "^4.4.3" + "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index e8cf3b7f4b..3c5a28123f 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: 5179435c09004549 - zh: c904462396531947 + en: 2aabd26516a45771 + zh: 5907f472a4d536df /deepseek-ai-dsh-api-gateway/client-service-clientremote-ctx-key-remote: en: c106f0ca3e547f84 zh: 833e30d7151c41c7 @@ -27,8 +27,8 @@ en: af5abeee32f2d93b zh: b320bb727a223a13 /deepseek-ai-dsh-api-gateway/known-limitations-and-deferred-work: - en: 3ade2903f40a0a98 - zh: 9af1d557fefdbd89 + en: f817580f28d30dc2 + zh: 5262b8fbcf9d65e8 /deepseek-ai-dsh-api-gateway/known-limitations-and-deferred-work/dev-note: en: 10727471030aee01 zh: e6bee82abd12127d diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 41cfc3a324..c212ec01ff 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -32,7 +32,7 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api 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. -A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A socket that has not answered the previous Ping is terminated at the next interval. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. +A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A Pong, any received frame, or a downlink write that waited in the full local send buffer and then completed counts as peer activity; a socket with no activity across two Ping intervals is terminated, so a congested link that delays the Pong behind data stays connected. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. When the launcher supplies `ctx.appReady`, Gateway registers the WebSocket upgrade route only after successful application startup. Earlier connection attempts remain carrier failures handled by Connection's retry policy, so a restarting Host cannot accept streams while its controllers are still initializing. Unloading cancels a pending readiness subscription and closes any registered carrier. Embedded Hosts without `appReady` register immediately and own their startup ordering; in-process invocation is unchanged. @@ -86,8 +86,9 @@ No direct effect; invoked business Services own any model-visible result. - `$stream()` supervises carrier replacement but does not infer replay semantics; each domain owns its resume cursor or replacement-baseline validation and normal-end classification. Connection generations reopen the internal `$events` stream; one-way notifications are not replayed, while pending scoped waterfalls retain their event id across replay. - Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key. - Forwarded events reach `$on` without business-payload projection or redaction. Ordinary notifications are not replayed after reconnect; Agent-scoped waterfalls project only the top-level Agent identity needed to select the Client Context and carry their own pending lifetime. -- `websocketHeartbeatIntervalMs` is both the Ping cadence and the Pong deadline. The Host terminates a peer that does not answer before the next interval, so a deployment whose event loop or network can stall longer than this interval must raise it. -- Uplinks have no flow control beyond the bounded Host inbox: a Client that sends faster than the method reads, or that sends to a method never taking its uplink, fails its stream with `gateway/uplink-overflow`, and no uplink item is replayed across carrier generations; a domain that must resume an uplink carries its own acknowledgement cursor in the reopened request. +- `websocketHeartbeatIntervalMs` is both the Ping cadence and the activity deadline. The Host terminates a peer that shows no activity for two intervals, so a deployment whose event loop or network can stall longer than this interval must raise it. +- A completed queued write shows that the TCP peer acknowledged data. Behind a buffering reverse proxy that peer is the proxy, so a vanished Client during sustained downlink is detected only after the proxy stops accepting data. +- The Gateway exposes each WebSocket stream's inbox capacity and JSON envelope cost through `invocation.uplinkWindow`; carriers without an encoded-frame inbox limit leave it absent. Domain protocols can advertise this window and use acknowledgements to bound in-flight bytes. The Gateway itself does not issue credits: a Client that sends faster than the method reads, or that sends to a method never taking its uplink, fails its stream with `gateway/uplink-overflow`, and no uplink item is replayed across carrier generations; a domain that must resume an uplink carries its own acknowledgement cursor in the reopened request. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index c73b0f45b6..1c3a9a2048 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -32,7 +32,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 -流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。若 socket 尚未回复上一次 Ping,Host 会在下一间隔终止它。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 +流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。Pong、收到的任意帧,或曾在已满的本机发送缓冲区中等待后完成的下行写入,都视为对端活动;连续两个 Ping 间隔没有活动的 socket 会被终止,因此 Pong 被数据阻塞在拥塞链路上时连接仍会保持。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 启动器提供 `ctx.appReady` 时,Gateway 仅在应用成功启动后注册 WebSocket 升级路由。此前的连接尝试仍属于载体故障,由 Connection 的重试策略处理,因此重启中的 Host 不会在控制器仍在初始化时接受流。卸载会取消尚未触发的就绪订阅,并关闭已注册的载体。不提供 `appReady` 的嵌入式 Host 会立即注册,并自行负责启动顺序;进程内调用不变。 @@ -86,8 +86,9 @@ Client waterfall 的 Context 解析保持同步。解析器可以返回借用的 - `$stream()` 监督载体替换,但不推断回放语义;各领域自行拥有恢复 cursor 或替换 baseline 的校验,以及正常结束的分类。Connection generation 会重开内部 `$events` 流;单向通知不会重放,仍处于 pending 的 scoped waterfall 则沿用同一个 event id 重放。 - lookup 解析器按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 - 被转发的事件到达 `$on` 时不做业务载荷投影或脱敏。普通通知在重连后不重放;Agent-scoped waterfall 只投影选择 Client Context 所需的顶层 Agent 身份,并自行携带 pending 生命周期。 -- `websocketHeartbeatIntervalMs` 同时是 Ping 周期和 Pong 截止时间。对端未在下一周期前回复时,Host 会终止连接;如果部署的事件循环或网络可能停顿超过该间隔,必须调大此配置。 -- 上行除了有界的 Host inbox 之外没有流控:Client 发送快于方法读取,或发给从未取用 uplink 的方法时,其流以 `gateway/uplink-overflow` 失败;上行项不会跨载体代际重放,需要恢复上行的领域在重开的请求里自带确认游标。 +- `websocketHeartbeatIntervalMs` 同时是 Ping 周期和活动截止时间。对端连续两个周期没有活动时,Host 会终止连接;如果部署的事件循环或网络可能停顿超过该间隔,必须调大此配置。 +- 排队写入完成只说明 TCP 对端确认了数据。经过带缓冲的反向代理时,该对端是代理,因此持续下行期间 Client 消失,要等代理不再接收数据后才会被检测到。 +- Gateway 通过 `invocation.uplinkWindow` 提供每条 WebSocket 流的 inbox 容量与 JSON 封装开销;没有编码帧 inbox 限额的载体省略该字段。领域协议可以公布该窗口并通过确认约束在途字节数。Gateway 本身不发放额度:Client 发送快于方法读取,或发给从未取用 uplink 的方法时,其流以 `gateway/uplink-overflow` 失败;上行项不会跨载体代际重放,需要恢复上行的领域在重开的请求里自带确认游标。 diff --git a/packages/api/gateway/locale/en.json b/packages/api/gateway/locale/en.json new file mode 100644 index 0000000000..3aaa997318 --- /dev/null +++ b/packages/api/gateway/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "api-gateway" + } +} diff --git a/packages/api/gateway/locale/zh.json b/packages/api/gateway/locale/zh.json new file mode 100644 index 0000000000..616ee05866 --- /dev/null +++ b/packages/api/gateway/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "远程调用网关", + "description": "接收客户端的接口调用,并分发给对应的服务。" + } +} diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 0ee56662b0..d4469cf8cd 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -31,7 +31,8 @@ "./stream-protocol": { "types": "./lib/types/stream-protocol.d.ts", "default": "./lib/types/stream-protocol.js" - } + }, + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -48,6 +49,7 @@ "watch": "tsdown --watch" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.js", @@ -75,6 +77,6 @@ "@deepseek-ai/dsh-util-crypto": "workspace:*", "@types/ws": "^8.18.1", "@deepseek-ai/cordis": "workspace:~", - "zod": "^4.4.3" + "zod": "4.4.3" } } diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index e97a065076..877dd83194 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -100,6 +100,7 @@ interface PreparedInvocation { /** Carrier inputs `GatewayInvocation.uplink()` decodes on first use. */ interface UplinkSource { + readonly window: RemoteInvocation['uplinkWindow'] /** Carrier items; an immediately ended iterable when the carrier has none. */ readonly source: AsyncIterable /** Descriptor codec, or the JSON-safety codec when the descriptor declares no uplink. */ @@ -240,8 +241,8 @@ export class TypertGatewayService extends Service implements TypertGateway { ctx.inject(['connection', 'webServer'], (webCtx) => { const listen = (): void => { const mux = new RemoteStreamMuxServer( - (endpoint, payload, uplink, peer, control) => - this.openWireStream(endpoint, payload, uplink, peer, control.signal, control), + (endpoint, payload, uplink, peer, control, window) => + this.openWireStream(endpoint, payload, uplink, peer, control.signal, control, window), this.wireStream.failure, resolved.websocketHeartbeatIntervalMs, resolved.streamInboxBytes, @@ -450,13 +451,14 @@ export class TypertGatewayService extends Service implements TypertGateway { peer: PeerScope | undefined, signal: AbortSignal, control: AbortController, + uplinkWindow?: RemoteInvocation['uplinkWindow'], ): Promise> { if (endpoint === REMOTE_EVENT_STREAM_ENDPOINT) { // A Gateway-owned stream reads no uplink: releasing it now keeps its items out of the bounded inbox. releaseUplink(uplink) return this.openRemoteEvents(payload, signal) } - return this.openStream({ ...remoteRequest(endpoint, payload, signal, peer), uplink }, control) + return this.openStream({ ...remoteRequest(endpoint, payload, signal, peer), uplink, uplinkWindow }, control) } /** @@ -723,6 +725,7 @@ export class TypertGatewayService extends Service implements TypertGateway { signal, { source: request.uplink ?? EMPTY_ASYNC_ITERABLE, + window: request.uplinkWindow, codec: descriptor.uplink?.codec ?? SRC_JSON_CODEC, endpoint, abort: (reason) => { control.abort(reason) }, @@ -1309,6 +1312,8 @@ class GatewayInvocation implements RemoteInvocation { private decoder: UplinkDecoder | undefined private taken = false + get uplinkWindow(): RemoteInvocation['uplinkWindow'] { return this.uplink_.window } + /** * @param request - decoded endpoint and wire arguments. * @param service - Cordis service key of the receiver. diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts index 9454f03f2d..2473f1a8e6 100644 --- a/packages/api/gateway/src/stream-server.ts +++ b/packages/api/gateway/src/stream-server.ts @@ -3,7 +3,7 @@ import type { IncomingMessage } from 'node:http' import type { Duplex } from 'node:stream' import { Deque } from '@deepseek-ai/dsh-deque' -import { RemoteError, remoteErrorOf, type PeerScope } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, remoteErrorOf, type PeerScope, type RemoteUplinkWindow } from '@deepseek-ai/dsh-typert-protocol' import WebSocket, { WebSocketServer, type RawData } from 'ws' import { parseRemoteStreamClientMessage, @@ -16,7 +16,7 @@ import { * Open one validated Remote stream for a decoded wire request on behalf of one * Peer. `uplink` carries the Client's items; `control` cancels the logical * stream, and aborting it with a Remote failure as the reason delivers that - * failure to the Client. + * failure to the Client. `window` describes this stream's complete-frame inbox budget. */ export type RemoteStreamOpener = ( endpoint: string, @@ -24,6 +24,7 @@ export type RemoteStreamOpener = ( uplink: AsyncIterable, peer: PeerScope, control: AbortController, + window: RemoteUplinkWindow, ) => Promise> /** The opener one socket uses: its Peer is fixed at upgrade time. */ @@ -32,6 +33,7 @@ type BoundStreamOpener = ( payload: unknown, uplink: AsyncIterable, control: AbortController, + window: RemoteUplinkWindow, ) => Promise> /** Convert an invocation or carrier failure to a stable wire value. */ @@ -72,12 +74,12 @@ export class RemoteStreamMuxServer { this.server.handleUpgrade(req, socket, head, (websocket) => { const release = bindPeer(websocket, peer) if (release === undefined) return - this.missedHeartbeats.set(websocket, 0) - websocket.on('pong', () => { this.missedHeartbeats.set(websocket, 0) }) + const alive = (): void => { this.missedHeartbeats.set(websocket, 0) } + alive() this.startHeartbeat() - const bound: BoundStreamOpener = (endpoint, payload, uplink, control) => - this.open(endpoint, payload, uplink, peer, control) - const connection = new RemoteStreamMuxConnection(websocket, bound, this.failure, this.streamInboxBytes) + const bound: BoundStreamOpener = (endpoint, payload, uplink, control, window) => + this.open(endpoint, payload, uplink, peer, control, window) + const connection = new RemoteStreamMuxConnection(websocket, bound, this.failure, this.streamInboxBytes, alive) const done = connection.run() this.connections.add(done) void done.then(() => { @@ -141,13 +143,23 @@ class RemoteStreamMuxConnection { private readonly open: BoundStreamOpener, private readonly failure: RemoteStreamFailureMapper, private readonly streamInboxBytes: number, + private readonly alive: () => void, ) {} + /** + * Serve frames until the socket closes. A Pong, any received frame, and a + * downlink write that waited in the local send buffer and then completed + * count as peer activity, so a congested link that delays the Pong behind + * data keeps its socket. A write that completes at once proves nothing + * about the peer and does not count. + */ async run(): Promise { const closed = new Promise((resolve) => { this.socket.once('close', resolve) this.socket.once('error', () => { this.socket.terminate() }) + this.socket.on('pong', this.alive) this.socket.on('message', (data, isBinary) => { + this.alive() if (isBinary) { this.socket.close(1003, 'text messages required') return @@ -230,7 +242,10 @@ class RemoteStreamMuxConnection { ): Promise { let outcome: { readonly failed: false } | { readonly failed: true; readonly error: unknown } try { - const source = await this.open(endpoint, payload, active.inbox, active.abort) + const source = await this.open(endpoint, payload, active.inbox, active.abort, { + maxBytes: this.streamInboxBytes, + frameOverheadBytes: Buffer.byteLength(JSON.stringify({ type: 'item', streamId, value: null }), 'utf8') - 'null'.length, + }) for await (const value of source) { await this.send({ type: 'item', streamId, value }) } @@ -283,10 +298,18 @@ class RemoteStreamMuxConnection { reject(new Error('api gateway: Remote stream socket is closed')) return } + let queued = false this.socket.send(text, (error) => { - if (error) reject(error) - else resolve() + if (error) { + reject(error) + return + } + if (queued) this.alive() + resolve() }) + // Read after send(): bytes still buffered mean the local send buffer was + // full, so their completion requires the peer to acknowledge data. + queued = this.socket.bufferedAmount > 0 })) this.writes = delivery.catch(() => undefined) return delivery diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index 9fa3a012b5..fd7c6a4a96 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -4,7 +4,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' +import type { PeerScope, RemoteUplinkWindow } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteEventHostInfo } from './stream-protocol.ts' /** One Remote method request after a carrier has decoded its envelope. */ @@ -20,6 +20,8 @@ export interface InvokeRemoteRequest { * `invocation.uplink()`; absent means an immediately ended iterable. */ readonly uplink?: AsyncIterable + /** Carrier-provided frame capacity; absent for carriers without an encoded-frame inbox limit. */ + readonly uplinkWindow?: RemoteUplinkWindow | undefined /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ readonly peer?: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 012f1b73fa..2efcb950e0 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -56,6 +56,7 @@ class FeedService extends TypertRemoteService { readonly signals: AbortSignal[] = [] readonly peeked: unknown[] = [] readonly peers: PeerScope[] = [] + readonly windows: RemoteInvocation['uplinkWindow'][] = [] leftover: AsyncIterator | undefined source: Iterable = [] returns = 0 @@ -130,6 +131,7 @@ class FeedService extends TypertRemoteService { this.signals.push(signal) const invocation = this.invocation() this.peers.push(invocation.peer) + this.windows.push(invocation.uplinkWindow) try { for await (const item of invocation.uplink()) yield `${prefix}${item}` } finally { @@ -749,6 +751,37 @@ describe('Typert Remote streams', () => { }))).resolves.toEqual(['s:a', 's:2']) }) + it('passes carrier capacity to the invocation and leaves unlimited carriers without a window', async () => { + const { ctx, service } = await setup(false) + const window = { maxBytes: 512, frameOverheadBytes: 40 } + for (const uplinkWindow of [undefined, window]) { + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync(['a']), uplinkWindow, + }))).resolves.toEqual(['a']) + } + expect(service.windows).toEqual([undefined, window]) + }) + + it('reports configured frame capacity and the encoded envelope size for the current stream id', async () => { + const { ctx, service } = await setup(true, { streamInboxBytes: 512 }) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { headers: { cookie: browserCookie(ctx) } }) + await once(socket, 'open') + try { + const streamId = '界"window' + sendOpen(socket, streamId, 'feed/echo', { prefix: '' }) + socket.send(JSON.stringify({ type: 'end', streamId })) + await vi.waitFor(() => { expect(service.windows).toHaveLength(1) }) + const value = 'line\n界' + const complete = JSON.stringify({ type: 'item', streamId, value }) + expect(service.windows[0]).toEqual({ + maxBytes: 512, frameOverheadBytes: Buffer.byteLength(complete, 'utf8') - Buffer.byteLength(JSON.stringify(value), 'utf8'), + }) + } finally { + socket.close() + await once(socket, 'close') + } + }) + it('echoes uplink frames over the WebSocket carrier and fails misused uplinks per stream', async () => { const { ctx, service } = await setup(true) const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts index 469aa25b0c..965cbb7989 100644 --- a/packages/api/gateway/tests/stream-server.host.spec.ts +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -67,6 +67,54 @@ describe('Remote stream mux server carrier lifecycle', () => { await closed }) + it('counts inbound frames as liveness while Pong is delayed', async () => { + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal), 20) + const client = await connect(entry.url, false) + const terminated = vi.spyOn(acceptedSocket(entry.mux), 'terminate') + const traffic = setInterval(() => { client.send(JSON.stringify({ type: 'cancel', streamId: 'absent' })) }, 5) + try { + await pings(client, 4) + expect(terminated).not.toHaveBeenCalled() + } finally { + clearInterval(traffic) + } + await vi.waitFor(() => { expect(terminated).toHaveBeenCalledOnce() }) + }) + + it('counts a downlink write that waited in the send buffer as liveness', async () => { + const stop = Promise.withResolvers() + const entry = await startMux(async () => steadyItems(stop.promise), 20) + const client = await connect(entry.url, false) + const serverSocket = acceptedSocket(entry.mux) + const terminated = vi.spyOn(serverSocket, 'terminate') + Object.defineProperty(serverSocket, 'bufferedAmount', { configurable: true, value: 1 }) + client.send(openFrame('congested')) + try { + await pings(client, 4) + expect(terminated).not.toHaveBeenCalled() + } finally { + stop.resolve(undefined) + Reflect.deleteProperty(serverSocket, 'bufferedAmount') + } + await vi.waitFor(() => { expect(terminated).toHaveBeenCalledOnce() }) + }) + + it('does not count a downlink write that completed at once as liveness', async () => { + const stop = Promise.withResolvers() + const entry = await startMux(async () => steadyItems(stop.promise), 20) + const client = await connect(entry.url, false) + const terminated = vi.spyOn(acceptedSocket(entry.mux), 'terminate') + const items = vi.fn() + client.on('message', items) + client.send(openFrame('flowing')) + try { + await vi.waitFor(() => { expect(terminated).toHaveBeenCalledOnce() }) + expect(items).toHaveBeenCalled() + } finally { + stop.resolve(undefined) + } + }) + it('keeps the socket when a delayed Pong arrives before the final check', async () => { const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal), 20) const client = await connect(entry.url, false) @@ -585,6 +633,19 @@ async function *echoUplink(uplink: AsyncIterable): AsyncIterable): AsyncIterable { + const stopped = stop.then(() => true) + while (!await Promise.race([stopped, new Promise((resolve) => { setTimeout(() => { resolve(false) }, 5) })])) { + yield 'item' + } +} + +/** Resolve after the client has received `count` Ping frames. */ +async function pings(client: WebSocket, count: number): Promise { + for (let received = 0; received < count; received++) await once(client, 'ping') +} + async function *waitForAbort(signal: AbortSignal): AsyncIterable { await new Promise((resolve) => { if (signal.aborted) resolve() diff --git a/packages/api/gateway/tests/strict-source.host.spec.ts b/packages/api/gateway/tests/strict-source.host.spec.ts new file mode 100644 index 0000000000..2bd9c35a17 --- /dev/null +++ b/packages/api/gateway/tests/strict-source.host.spec.ts @@ -0,0 +1,77 @@ +import { Context, Service } from '@deepseek-ai/cordis' +import { afterEach, expect, it } from 'vitest' +import { z } from 'zod' +import { bindTypertRemote, Remote } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry' +import TypertGateway from '../src/index.ts' + +const contexts: Context[] = [] +afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) }) + +const contribution: TypertContribution = { + package: '@fixture/strict', face: 'host', schemas: [], + model: { services: [], events: [], objects: [] }, + invocations: [{ + id: '@fixture/strict#items/read', service: 'items', namespace: 'items', method: 'read', + invocation: { kind: 'direct' }, + parameters: [{ name: 'request', wire: 'request', source: 'json', codec: { + mode: 'strict', typeSymbol: 'Request', create: () => z.strictObject({ title: z.string() }), + } }], + result: { mode: 'strict', typeSymbol: 'string', create: () => z.string() }, + }], +} + +class ItemsService extends Service { + readonly typertRemote + calls = 0 + constructor(ctx: Context) { + super(ctx, 'items') + this.typertRemote = bindTypertRemote(this, 'items') + } + @Remote + read(request: { title: string }): string { + this.calls++ + return request.title + } +} + +async function setup() { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(TypertRegistry) + await ctx.plugin(TypertGateway) + const service = new ItemsService(ctx) + return { ctx, service } +} + +it('validates source dispatch with the ordinary local Registry before invoking the Service', async () => { + const { ctx, service } = await setup() + ctx.typert.register(contribution) + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } } })) + .resolves.toBe('ready') + for (const args of [{}, { request: { title: 'ready', extra: true } }, { request: { title: 1 } }]) { + const code: unknown = expect.stringMatching(/^gateway\/(arguments|input)-invalid$/) + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args })) + .rejects.toMatchObject({ code }) + } + expect(service.calls).toBe(1) +}) + +it('allows limited source fallback without recovering erased object constraints', async () => { + const { ctx, service } = await setup() + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready', extra: true } } })) + .resolves.toBe('ready') + expect(service.calls).toBe(1) +}) + +it('refuses source fallback after the strict definition owner unloads', async () => { + const { ctx, service } = await setup() + const owner = ctx.plugin({ inject: ['typert'], apply(ctx) { ctx.typert.register(contribution) } }) + await owner + expect(ctx.typert.local.get('items/read')).toBe(contribution.invocations[0]) + await owner.dispose() + expect(ctx.typert.local.get('items/read')).toBeUndefined() + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } } })) + .rejects.toMatchObject({ code: 'gateway/definition-unavailable' }) + expect(service.calls).toBe(0) +}) diff --git a/packages/api/gateway/tsdown.config.ts b/packages/api/gateway/tsdown.config.ts index 2f199f0d1f..71d5bd77f8 100644 --- a/packages/api/gateway/tsdown.config.ts +++ b/packages/api/gateway/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../../client/tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-api-gateway', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/api/job-controller/locale/en.json b/packages/api/job-controller/locale/en.json new file mode 100644 index 0000000000..87cdb55a70 --- /dev/null +++ b/packages/api/job-controller/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "api-job-controller" + } +} diff --git a/packages/api/job-controller/locale/zh.json b/packages/api/job-controller/locale/zh.json new file mode 100644 index 0000000000..1d7b6c6e65 --- /dev/null +++ b/packages/api/job-controller/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "后台任务观察接口", + "description": "向客户端持续提供后台任务的状态和输出。" + } +} diff --git a/packages/api/job-controller/package.json b/packages/api/job-controller/package.json index f9fd59429b..a82912d8bf 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -35,7 +35,8 @@ "default": "./lib/typert.remote-client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -54,6 +55,7 @@ "watch": "tsdown --watch" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.js", @@ -67,7 +69,7 @@ "dependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "^4.4.3" + "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" diff --git a/packages/api/job-controller/tsdown.config.ts b/packages/api/job-controller/tsdown.config.ts index 0391bc61bf..0e42a1163e 100644 --- a/packages/api/job-controller/tsdown.config.ts +++ b/packages/api/job-controller/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-api-job-controller', + import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/api/remotes/locale/en.json b/packages/api/remotes/locale/en.json new file mode 100644 index 0000000000..cc9009975d --- /dev/null +++ b/packages/api/remotes/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "api-remotes" + } +} diff --git a/packages/api/remotes/locale/zh.json b/packages/api/remotes/locale/zh.json new file mode 100644 index 0000000000..222afebaf9 --- /dev/null +++ b/packages/api/remotes/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "客户端 API 汇总", + "description": "汇总应用向客户端开放的服务端能力。" + } +} diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index d59bd331db..5fa6364777 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -27,7 +27,8 @@ "default": "./lib/types/types.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -44,6 +45,7 @@ }, "license": "MIT", "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.js", @@ -86,7 +88,7 @@ "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", "@deepseek-ai/cordis": "workspace:~", - "zod": "^4.4.3", + "zod": "4.4.3", "@deepseek-ai/dsh-command-feedback": "workspace:*", "@deepseek-ai/dsh-api-terminal-controller": "workspace:*", "@deepseek-ai/dsh-plugin-manager": "workspace:*", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 1416804110..c58ee4babb 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -147,7 +147,7 @@ export type { // Provider registry and discovery vocabulary for the llm namespace. export type { LlmConfigurableProvider, LlmDiscoveredModel, - LlmModelDiscoveryRequest, LlmProviderInfo, + LlmModelDiscoveryRequest, LlmNativeAuth, LlmProviderInfo, } from '@deepseek-ai/dsh-llm/types' // Reference-discovery result vocabulary for the fileReferences and // sessionReferenceResolver namespaces. diff --git a/packages/api/remotes/tsdown.config.ts b/packages/api/remotes/tsdown.config.ts index 29349148d6..0e42a1163e 100644 --- a/packages/api/remotes/tsdown.config.ts +++ b/packages/api/remotes/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-api-remotes', + import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 94dd4e518f..3d1a5b274d 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: d26a58a8f2406cd5 - zh: 82135eceb9adb628 + en: d8c0e2b2e45fa75d + zh: 025b50be4204580c /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 ca922877f8..ed01827a70 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -39,7 +39,7 @@ Host list generation yields between complete rows only when its configured work Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank. -Explicit-id `session.create` adopts a live Session or resumes a persisted Session while retaining its writer lock. Writer contention returns `session/writer-held`; callers may try another blank without suppressing unrelated failures. `session.list` includes persisted blanks using cached metadata, without opening cold log bodies. +`session.create` returns a Session for a new conversation, and its response names the Session actually used. A named `sessionId` is reused while it is blank (no turn has started), belongs to the requested directory, is an unarchived member of the requested Workspace, and is not written by another owner; a live Session is reused in place, and a persisted one is resumed while retaining its writer lock. The reused Session runs the requested `agentPreset`, or the configured default when the request names none: its recorded preset is not resolved, and a change is logged as `agent-preset/selected` before the Agent is published. A named Session that has started, fails the Workspace placement, or is written by another owner stays unchanged, and a new Session is created instead. A `sessionId` with no stored Session becomes the new Session's identity. A named Session in another directory fails with `session/conflict`, and one owned by subagent routing with `session/agent-busy`; both checks precede preset resolution. Creations naming one identity run in arrival order, including their preset resolution, and each response reports the preset its own request left the Session running. `session.list` includes persisted blanks using cached metadata, without opening cold log bodies. Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Each row's `retainedBy` contains positive local reference-source counts; Host metadata refreshes cannot overwrite them. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes. Host summary updates replace running and Agent availability; local create/fork responses only fill missing metadata on existing rows. Removed ordinary Sessions retain projection stores only when their catalogs contain children. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 585c4e0363..cc5a4ce4b2 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -39,7 +39,7 @@ Host 仅在配置的工作时间片耗尽时,于完整条目之间让出事件 Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。 -显式 ID 的 `session.create` 会收养活动 Session,或恢复持久化 Session 并持续持有其写锁。写锁争用返回 `session/writer-held`;调用方可以尝试其他空白会话,同时保留其他失败。`session.list` 根据缓存元数据列出持久化空白会话,不打开冷日志正文。 +`session.create` 返回一个用于新对话的 Session,响应指明实际使用的 Session。请求指明的 `sessionId` 在满足以下条件时被复用:仍为空白(尚未开始任何轮次)、属于请求的目录、是请求的工作区中未归档的成员,且未被其他写者占用;活动 Session 原地复用,持久化 Session 被恢复并持续持有其写锁。被复用的 Session 运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset:不解析它记录的 preset,变更在 Agent 发布前记录为 `agent-preset/selected`。指明的 Session 若已开始、不满足工作区归属,或被其他写者占用,则保持不变,改为新建 Session。没有对应已存储 Session 的 `sessionId` 成为新 Session 的标识。指明其他目录中的 Session 返回 `session/conflict`,指明由 subagent 路由拥有的 Session 返回 `session/agent-busy`;这两项检查先于 preset 解析。指明同一标识的创建请求按到达顺序执行,preset 解析也包含在内,每个响应报告其自身请求完成时该 Session 运行的 preset。`session.list` 根据缓存元数据列出持久化空白会话,不打开冷日志正文。 Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含正数的本地引用来源计数,Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Host 摘要更新会替换运行状态与 Agent 可用性;本地 create/fork 响应只补充已有行缺失的元数据。普通 Session 被移除后,仅在目录仍有子项时保留其投影 store。 diff --git a/packages/api/session-controller/locale/en.json b/packages/api/session-controller/locale/en.json new file mode 100644 index 0000000000..3be8df2f2f --- /dev/null +++ b/packages/api/session-controller/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "api-session-controller" + } +} diff --git a/packages/api/session-controller/locale/zh.json b/packages/api/session-controller/locale/zh.json new file mode 100644 index 0000000000..48389bed43 --- /dev/null +++ b/packages/api/session-controller/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "会话控制接口", + "description": "向客户端提供会话读取、运行控制和状态更新能力。" + } +} diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index b18ac92fbe..436b151bbb 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -39,7 +39,8 @@ "default": "./lib/typert.remote-client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -58,6 +59,7 @@ "watch": "tsdown --watch" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.js", @@ -73,7 +75,7 @@ "@deepseek-ai/dsh-deque": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "mime-types": "^3.0.2", - "zod": "^4.4.3" + "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 3f0fa77944..aaacc886e7 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -1,5 +1,6 @@ /** Agent activation, composition, and model-selection policy owned by API Session. */ +import { randomUUID } from 'node:crypto' import { mkdir } from 'node:fs/promises' import type { Context } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' @@ -8,11 +9,12 @@ import type { } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' import type {} from '@deepseek-ai/dsh-agent-preset-registry' +import { brandString } from '@deepseek-ai/dsh-brand' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' 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 } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-typert-registry' import type { ModelSelection } from './types.ts' @@ -42,21 +44,49 @@ export class ApiSessionCwdConflict extends Error { } } -/** Explicit-id creation attempted to adopt a Session under another preset. */ +/** Reuse of a Session named an Agent preset while the deployment composes no preset roster. */ export class ApiSessionPresetConflict extends Error { constructor( readonly sessionId: SessionId, readonly requestedPreset: string, - readonly existingPreset: string | undefined, ) { - super( - existingPreset === undefined - ? `session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"` - : `session "${sessionId}" runs agent preset "${existingPreset}", not "${requestedPreset}"`, - ) + super(`the deployment composes no agent preset roster, so session "${sessionId}" cannot run preset "${requestedPreset}"`) } } +/** A blank Session chosen for reuse started a turn before this Host acquired its writer. */ +class ApiSessionStartedSinceObserved extends Error {} + +/** Placement and Agent preset for one Session created for a new conversation. */ +export interface ApiSessionCreateRequest { + /** Blank Session to reuse, or the identity of a new Session when none is stored under it. */ + readonly sessionId?: SessionId + /** Directory the Session owns. */ + readonly cwd: string + /** Preset the Session runs; the configured default when omitted. */ + readonly agentPreset?: string + /** + * Placement rule the caller owns, such as Workspace membership and archive state. + * @param sessionId - existing Session named by the request. + * @returns whether that Session may be reused. + */ + readonly reusable: (sessionId: SessionId) => boolean +} + +/** The Session one creation request created or reused. */ +export interface ApiSessionCreated { + /** Live Agent of the Session. */ + readonly agent: Agent + /** Preset the Session ran when the request finished, before a later request for the same identity; absent without a roster. */ + readonly agentPreset: string | undefined +} + +/** Resolved preset and pre-publication Agent setup for one create or resume. */ +interface ApiSessionComposition { + readonly agentPreset?: string + readonly setup: AgentSetup +} + /** Failures produced while resolving one ordinary Session identity to its live Agent. */ export type ApiSessionAgentError = RemoteError<'session/not-found' | 'session/agent-busy' | 'session/writer-held' | 'gateway/internal'> @@ -139,7 +169,7 @@ export async function inspectApiSession( /** Owns every operation that may create, resume, or configure a Web Agent. */ export class ApiSessionAgentController { private readonly resumes = new Map>() - private readonly creations = new Map>() + private readonly claims = new Map>() private readonly selections = new WeakMap() private readonly imageAdmissionChains = new WeakMap>() @@ -229,50 +259,26 @@ export class ApiSessionAgentController { } /** - * Resolve one requested identity, creating or resuming it once. - * @param sessionId - requested Session identity. - * @param cwd - directory the Session must own. - * @param checkPersistedIdentity - whether to inspect a cold identity before creation. - * @param presetId - optional Agent preset the Session must own. - * @returns the matching live ordinary Agent. + * Create a Session for a new conversation, reusing the named blank Session + * while it is still reusable. A reused Session runs the requested preset, or + * the configured default when the request names none; the preset it recorded + * is not resolved, and a change is logged as `agent-preset/selected` before + * the Agent is published. A named Session that has started a turn, fails the + * caller's placement rule, or is written by another owner stays unchanged, + * and a new Session is created instead. Requests naming one identity run in + * arrival order, including their preset resolution, and check the named + * Session's directory and ownership before resolving the preset. + * @param request - optional Session to reuse, directory, preset, and placement rule. + * @returns the reused or created Session and the preset it ran when this request finished. + * @throws ApiSessionCwdConflict when the named Session belongs to another directory. + * @throws ApiSessionSubagentOwnership when subagent routing owns the named Session. + * @throws RemoteError `agent-preset/not-found` or `agent-preset/invalid` for the requested or default preset. + * @throws ApiSessionPresetConflict when no roster is composed and the request names a preset for a reused Session. */ - async ensureSession( - sessionId: SessionId, - cwd: string, - checkPersistedIdentity: boolean, - presetId?: string, - ): Promise { - let creation = this.creations.get(sessionId) - if (creation === undefined) { - creation = this.createOrAdopt(sessionId, cwd, checkPersistedIdentity, presetId) - .catch((error: unknown) => { - const live = this.ctx.agents.get(sessionId) - if (live !== undefined) { - if (hasApiSessionSubagentOwner(this.ctx, live.session, live)) { - throw new ApiSessionSubagentOwnership(sessionId) - } - return live - } - const attached = this.ctx.sessions.get(sessionId) - if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) { - throw new ApiSessionSubagentOwnership(sessionId) - } - throw error - }) - .finally(() => { this.creations.delete(sessionId) }) - this.creations.set(sessionId, creation) - } - const agent = await creation - if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { - throw new ApiSessionSubagentOwnership(sessionId) - } - if (presetId !== undefined) { - this.assertPresetUnchanged(sessionId, presetId, this.presetForSession(agent.session)) - } - if (agent.session.header.cwd !== cwd) { - throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd) - } - return agent + async createSession(request: ApiSessionCreateRequest): Promise { + const named = request.sessionId + if (named === undefined) return this.created(await this.createMinted(request.cwd, await this.composeAgent(request.agentPreset))) + return this.inOrder(named, async () => this.created(await this.claim(named, request))) } /** @@ -378,10 +384,7 @@ export class ApiSessionAgentController { * @param presetId - requested preset or the configured default when omitted. * @returns the resolved preset identity and Agent setup callback. */ - async composeAgent(presetId: string | undefined): Promise<{ - readonly agentPreset?: string - readonly setup: AgentSetup - }> { + async composeAgent(presetId: string | undefined): Promise { const presets = this.ctx.get('agentPresets') if (presets === undefined) { return { setup: (_agentCtx, agent) => { this.installSelection(agent) } } @@ -441,48 +444,128 @@ export class ApiSessionAgentController { })).agent } - private async createOrAdopt( - sessionId: SessionId, - cwd: string, - checkPersistedIdentity: boolean, - presetId: string | undefined, - ): Promise { + private async inOrder(sessionId: SessionId, operation: () => Promise): Promise { + const result = (this.claims.get(sessionId) ?? Promise.resolve()).then(operation) + const settled = result.then(() => undefined, () => undefined) + this.claims.set(sessionId, settled) + try { + return await result + } finally { + if (this.claims.get(sessionId) === settled) this.claims.delete(sessionId) + } + } + + /** @returns the reused Session, a new Session under `sessionId` when none is stored, or a new Session under a minted identity. */ + private async claim(sessionId: SessionId, request: ApiSessionCreateRequest): Promise { const attached = this.ctx.sessions.get(sessionId) const live = this.ctx.agents.get(sessionId) if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) { throw new ApiSessionSubagentOwnership(sessionId) } - if (live !== undefined) return live - - if (checkPersistedIdentity) { - try { - using observation = await this.ctx.sessionQuery.observeSession(sessionId) - if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) { - throw new ApiSessionSubagentOwnership(sessionId) - } - if (observation.header.cwd !== cwd) { - throw new ApiSessionCwdConflict(sessionId, cwd, observation.header.cwd) - } - const storedPreset = this.presetForObservation(observation) - this.assertPresetUnchanged(sessionId, presetId, storedPreset) - const composition = await this.composeAgent(storedPreset) - return (await this.ctx.agents.resume({ - resumeSessionId: sessionId, - agentOptions: this.agentOptions(), - setup: composition.setup, - })).agent - } catch (error: unknown) { - if (!(error instanceof SessionQueryError) - || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error + if (live !== undefined) { + if (live.session.header.cwd !== request.cwd) { + throw new ApiSessionCwdConflict(live.id, request.cwd, live.session.header.cwd) } + const composition = await this.composeAgent(request.agentPreset) + return await this.reuseLive(live, request, composition) ?? this.createMinted(request.cwd, composition) } + let observation: SessionObservation + try { + observation = await this.ctx.sessionQuery.observeSession(sessionId) + } catch (error: unknown) { + if (!(error instanceof SessionQueryError) || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error + return this.createAgent(sessionId, request.cwd, await this.composeAgent(request.agentPreset)) + } + let reusable: boolean + { + using observed = observation + if (hasApiSessionSubagentOwner(this.ctx, { header: observed.header }, undefined)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + if (observed.header.cwd !== request.cwd) { + throw new ApiSessionCwdConflict(sessionId, request.cwd, observed.header.cwd) + } + reusable = request.reusable(sessionId) && !observed.events.some(event => event.type === 'turn/start') + } + const composition = await this.composeAgent(request.agentPreset) + if (!reusable) return this.createMinted(request.cwd, composition) + this.assertPresetApplicable(sessionId, request.agentPreset) + try { + return (await this.ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: this.agentOptions(), + setup: this.reuseSetup(composition), + })).agent + } catch (error: unknown) { + if (error instanceof ApiSessionStartedSinceObserved) return this.createMinted(request.cwd, composition) + if (!(error instanceof Error) || error.name !== 'SessionAlreadyOwnedError') throw error + // Another resume in this process may hold the writer for the Agent it published. + return this.ctx.agents.get(sessionId) === undefined + ? this.createMinted(request.cwd, composition) + : this.claim(sessionId, request) + } + } + /** @returns the live blank Session under the requested preset, or undefined when it cannot be reused. */ + private async reuseLive( + live: Agent, + request: ApiSessionCreateRequest, + composition: ApiSessionComposition, + ): Promise { + if (!request.reusable(live.id) || !this.isBlank(live.session)) return undefined + this.assertPresetApplicable(live.id, request.agentPreset) + const current = this.presetForSession(live.session) + const presets = this.ctx.get('agentPresets') + const target = composition.agentPreset + if (presets === undefined || target === undefined || target === current) return live + try { + await presets.select(live, target) + } catch (error: unknown) { + if (remoteErrorOf(error)?.code !== 'agent-preset/locked') throw error + return undefined + } + return live + } + + /** Setup that re-checks blankness under the acquired writer, then records a preset change before publication. */ + private reuseSetup(composition: ApiSessionComposition): AgentSetup { + return async (agentCtx, agent) => { + if (!this.isBlank(agent.session)) throw new ApiSessionStartedSinceObserved() + const commit = await composition.setup(agentCtx, agent) + const target = composition.agentPreset + if (target !== undefined && this.presetForSession(agent.session) !== target) { + agent.session.append('agent-preset/selected', { agentPreset: target }) + } + return commit + } + } + + /** Whether no turn has started, by the predicate the preset registry's `select` enforces. */ + private isBlank(session: Session): boolean { + const boundary = this.ctx.sessionProjections.stateOf(session, 'turnBoundary') + return boundary === undefined || (boundary.openTurnStartSeq === null && boundary.lastTurn === 0) + } + + /** Without a roster, reuse cannot apply a named preset. */ + private assertPresetApplicable(sessionId: SessionId, requested: string | undefined): void { + if (requested === undefined || this.ctx.get('agentPresets') !== undefined) return + throw new ApiSessionPresetConflict(sessionId, requested) + } + + private created(agent: Agent): ApiSessionCreated { + return { agent, agentPreset: this.presetForSession(agent.session) } + } + + private createMinted(cwd: string, composition: ApiSessionComposition): Promise { + return this.createAgent(brandString(`session-${randomUUID()}`), cwd, composition) + } + + private async createAgent(sessionId: SessionId, cwd: string, composition: ApiSessionComposition): Promise { try { await mkdir(cwd, { recursive: true }) } catch (error: unknown) { throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error }) } - const composition = await this.composeAgent(presetId) return (await this.ctx.agents.create({ sessionId, agentOptions: this.agentOptions(), @@ -514,15 +597,6 @@ export class ApiSessionAgentController { } return observation.projections.values.agentPreset ?? undefined } - - private assertPresetUnchanged( - sessionId: SessionId, - requested: string | undefined, - existing: string | undefined, - ): void { - if (requested === undefined || requested === existing) return - throw new ApiSessionPresetConflict(sessionId, requested, existing) - } } function agentModelSelection(selection: ModelSelection): AgentModelSelection { diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts index 08d6ad2367..a52d2c155a 100644 --- a/packages/api/session-controller/src/client/contract/sessions.ts +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -79,14 +79,17 @@ export interface ISessions { */ readonly searchResultLimit: number /** - * Create or adopt a Session on the Host. - * @param opts - target workspace, directory, and optional preallocated identity. - * @returns the catalogued identity; retain it before borrowing its binding. + * Create a Session for a new conversation on the Host. The Host reuses the + * named blank Session while it is reusable and otherwise creates another; the + * Session runs the named preset, or the Host default when none is named. + * @param opts - target workspace or directory, an optional blank Session to reuse, and an optional Agent preset. + * @returns the catalogued identity the Host created or reused; retain it before borrowing its binding. */ create(opts?: { workspaceId?: WorkspaceId cwd?: string sessionId?: SessionId + agentPreset?: string }): Promise /** * Resolve an already discovered direct-parent address without opening it. diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 9c2df4ba0e..b8bec9be8a 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -502,17 +502,21 @@ export class SessionManager { * Contract session.create; on success merge into summaries immediately (no * wait for the next refresh). A created session is blank by definition * (entity birth precedes the first message). - * @param opts - target workspace or working directory, plus an optional caller-owned id. - * @returns the create result. + * @param opts - target workspace or working directory, an optional blank Session to reuse, and an optional Agent preset. + * @returns the create result, naming the Session the Host created or reused. */ async create( opts: { workspaceId?: WorkspaceId cwd?: string sessionId?: SessionId + agentPreset?: string } = {}, ): Promise> { - const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId } + const shared = { + ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }), + ...(opts.agentPreset === undefined ? {} : { agentPreset: opts.agentPreset }), + } const payload = opts.workspaceId !== undefined ? { workspaceId: opts.workspaceId, ...shared } : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared } diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index dd86e7cac2..b992a34920 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -427,11 +427,16 @@ export class ClientSessions implements ISessions { /** * Create a Host Session and publish its catalog row before resolving. * Callers retain the returned identity before borrowing its binding. - * @param opts - target workspace or directory and an optional preallocated id. - * @returns the new session id. + * @param opts - target workspace or directory, an optional blank Session to reuse, and an optional Agent preset. + * @returns the created or reused session id. * @throws {SessionCreateError} with the requested id. */ - async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise { + async create(opts: { + workspaceId?: WorkspaceId + cwd?: string + sessionId?: SessionId + agentPreset?: string + } = {}): Promise { const result = await this.manager.create(opts) if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId) this.projectList() diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 7e900b92ae..5bb0a7039d 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -33,6 +33,7 @@ import { apiSessionSubagentOwnershipError, hasApiSessionSubagentOwner, inspectApiSession, + type ApiSessionCreated, } from './agent.ts' import type { SessionAttachmentRequest, @@ -98,15 +99,15 @@ export class SessionCommandController { ) {} /** - * Create or idempotently adopt one ordinary Session. - * @param request - requested identity, location, and Agent preset. - * @returns the Session identity and resolved preset when configured. + * Create one ordinary Session for a new conversation, reusing the named + * blank Session while it is an unarchived member of the requested Workspace. + * @param request - optional blank Session to reuse, location, and Agent preset. + * @returns the created or reused Session identity and its preset when configured. */ async create(request: SessionCreateRequest): Promise { if (request.workspaceId !== undefined && request.cwd !== undefined) { throw new RemoteError('gateway/bad-request', 'session.create accepts workspaceId or cwd, not both', {}) } - const sessionId = request.sessionId ?? brandString(`session-${randomUUID()}`) let workspace: Workspace | undefined if (request.workspaceId !== undefined) { workspace = this.ctx.workspaceRegistry.get(request.workspaceId) @@ -117,17 +118,19 @@ export class SessionCommandController { } } const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd - let adopted: Agent + let created: ApiSessionCreated try { - adopted = await this.agents.ensureSession( - sessionId, + created = await this.agents.createSession({ + ...(request.sessionId === undefined ? {} : { sessionId: request.sessionId }), cwd, - request.sessionId !== undefined, - request.agentPreset, - ) + ...(request.agentPreset === undefined ? {} : { agentPreset: request.agentPreset }), + reusable: sessionId => !this.ctx.workspaceRegistry.archivedSessionIds.includes(sessionId) + && (workspace === undefined || workspace.sessionIds.includes(sessionId)), + }) } catch (error) { - this.rejectCreation(sessionId, error) + this.rejectCreation(error) } + const sessionId = created.agent.id if (workspace !== undefined) { try { await workspace.attachSession(sessionId) @@ -139,8 +142,7 @@ export class SessionCommandController { ) } } - const agentPreset = this.agents.presetForSession(adopted.session) - return { sessionId, ...(agentPreset === undefined ? {} : { agentPreset }) } + return { sessionId, ...(created.agentPreset === undefined ? {} : { agentPreset: created.agentPreset }) } } /** @@ -530,16 +532,12 @@ export class SessionCommandController { return found.agent } - private rejectCreation(sessionId: SessionId, error: unknown): never { + private rejectCreation(error: unknown): never { if (remoteErrorOf(error) !== undefined) throw error - if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') { - throw new RemoteError('session/writer-held', error.message, { sessionId }) - } if (error instanceof ApiSessionPresetConflict) { throw new RemoteError('agent-preset/conflict', error.message, { sessionId: error.sessionId, requestedPreset: error.requestedPreset, - ...(error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }), }) } if (error instanceof ApiSessionCwdConflict) { @@ -552,7 +550,7 @@ export class SessionCommandController { if (error instanceof ApiSessionSubagentOwnership) { throw apiSessionSubagentOwnershipError(error.sessionId) } - throw new RemoteError('gateway/internal', `failed to create session "${sessionId}": ${String(error)}`, {}) + throw new RemoteError('gateway/internal', `failed to create session: ${String(error)}`, {}) } private async readSessionState(sessionId: SessionId): Promise { diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 0ba5bf8ff0..b28be14073 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -273,9 +273,9 @@ export class SessionController extends TypertRemoteService { } /** - * Create or idempotently adopt one ordinary Session. - * @param request - requested identity, location, and Agent preset. - * @returns the Session identity and resolved preset when configured. + * Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable. + * @param request - optional blank Session to reuse, location, and Agent preset. + * @returns the created or reused Session identity and its preset when configured. */ @Remote('create') create(request: SessionCreateRequest): Promise { diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 3455db1b9f..03878bfae7 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -219,7 +219,6 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'agent-preset/conflict': { readonly sessionId: SessionId readonly requestedPreset: string - readonly existingPreset?: string } 'session/attachment-invalid': { readonly reason: string } 'session/queue-item-not-found': { readonly itemId: MessageId } @@ -284,17 +283,23 @@ export interface SessionSearchValue { readonly hasMore: boolean } -/** Session creation or explicit-id adoption request. */ +/** Request for a Session for a new conversation. */ export interface SessionCreateRequest { + /** Workspace the Session joins; exclusive with `cwd`. */ readonly workspaceId?: WorkspaceId + /** Directory the Session owns when no Workspace is named. */ readonly cwd?: string + /** Blank Session to reuse while it is reusable, or the identity of a new Session when none is stored under it. */ readonly sessionId?: SessionId + /** Preset the Session runs, replacing a reused Session's recorded preset; the configured default when omitted. */ readonly agentPreset?: string } /** Session creation response value. */ export interface SessionCreateValue { + /** The created or reused Session, which may differ from the requested `sessionId`. */ readonly sessionId: SessionId + /** Preset the Session runs, when the deployment composes a preset roster. */ readonly agentPreset?: string } diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 4756ae45b0..40dceeefb9 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 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 type { SessionEvent, SessionHeader } 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' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' @@ -15,7 +15,6 @@ import { ApiSessionAgentController, ApiSessionCwdConflict, ApiSessionNotFound, - ApiSessionSubagentOwnership, inspectApiSession, } from '../src/agent.ts' import { installModelSelectionProjection } from '../src/model-selection-projection.ts' @@ -329,123 +328,8 @@ describe('ApiSession model selection', () => { }) }) -describe('ApiSession create or adoption', () => { - it('shares one in-flight creation between concurrent callers', async () => { - const { ctx, agents } = await harness() - const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-concurrent-')) - tempDirs.push(cwd) - const meta = header('concurrent-create', cwd) - const created = unpublishedAgent(ctx, meta) - let release!: () => void - const gate = new Promise((resolve) => { release = resolve }) - const create = vi.spyOn(ctx.agents, 'create').mockImplementation(async () => { - await gate - return { agent: created, dispose: () => Promise.resolve() } - }) - - const first = agents.ensureSession(meta.id, cwd, false) - const second = agents.ensureSession(meta.id, cwd, false) - release() - - await expect(Promise.all([first, second])).resolves.toEqual([created, created]) - expect(create).toHaveBeenCalledOnce() - }) - - it('accepts a raced ordinary creation and rejects a raced attached child', async () => { - const ordinary = await harness() - const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-create-')) - tempDirs.push(cwd) - const ordinaryMeta = header('create-race', cwd) - const winner = agent(ordinary.ctx, ordinaryMeta) - vi.spyOn(ordinary.ctx.agents, 'create').mockImplementation(async () => { - await ordinary.ctx.agents.register(winner) - throw new Error('raced creation') - }) - await expect(ordinary.agents.ensureSession(ordinaryMeta.id, cwd, false)) - .resolves.toBe(winner) - - const child = await harness() - const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-child-')) - tempDirs.push(childCwd) - const childId = SessionId('create-child-race') - vi.spyOn(child.ctx.agents, 'create').mockImplementation(async () => { - child.ctx.sessions.create(childId, { - meta: { cwd: childCwd, parentSession: SessionId('parent'), origin: 'subagent' }, - }) - throw new Error('raced child creation') - }) - await expect(child.agents.ensureSession(childId, childCwd, false)) - .rejects.toBeInstanceOf(ApiSessionSubagentOwnership) - }) - - it('validates ownership and cwd on the Agent returned by creation', async () => { - const child = await harness() - const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-returned-child-')) - tempDirs.push(childCwd) - const childMeta = { - ...header('returned-child', childCwd), - parentSession: SessionId('parent'), - origin: 'subagent' as const, - } - const childAgent = unpublishedAgent(child.ctx, childMeta) - vi.spyOn(child.ctx.agents, 'create').mockResolvedValue({ - agent: childAgent, - dispose: () => Promise.resolve(), - }) - await expect(child.agents.ensureSession(childMeta.id, childCwd, false)) - .rejects.toBeInstanceOf(ApiSessionSubagentOwnership) - - const wrong = await harness() - const requestedCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-wrong-cwd-')) - tempDirs.push(requestedCwd) - const wrongAgent = unpublishedAgent(wrong.ctx, header('wrong-returned-cwd', '/other')) - vi.spyOn(wrong.ctx.agents, 'create').mockResolvedValue({ - agent: wrongAgent, - dispose: () => Promise.resolve(), - }) - await expect(wrong.agents.ensureSession(wrongAgent.id, requestedCwd, false)) - .rejects.toBeInstanceOf(ApiSessionCwdConflict) - }) - - it('resumes a matching persisted identity and preserves its selected preset', async () => { - const { ctx, agents } = await harness() - const meta = { ...header('stored'), agentPreset: 'minimal' } - const events = [{ - type: 'agent-preset/selected', - seq: 0, - time: 1, - data: { agentPreset: 'minimal' }, - }] as SessionEvent[] - providePersistence(ctx, { - list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ meta, events }), - }) - ctx.provide('agentPresets', { - resolve: (id?: string) => Promise.resolve({ id: id ?? 'minimal' }), - mount: () => Promise.resolve(), - } as never) - const resumed = { - id: meta.id, - session: { - id: meta.id, - header: meta, - snapshotEvents: () => events, - eventAt: (seq: number) => events[seq], - seq: events.length, - }, - status: 'idle', - ctx, - } as unknown as Agent - const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({ - agent: resumed, - dispose: () => Promise.resolve(), - }) - - await expect(agents.ensureSession(meta.id, '/workspace', true, 'minimal')).resolves.toBe(resumed) - expect(resume).toHaveBeenCalledWith(expect.objectContaining({ resumeSessionId: meta.id })) - }) - - it('rejects an ownership race before resume and a persisted cwd conflict', async () => { +describe('ApiSession creation', () => { + it('rejects a resume that subagent routing adopts before publication', async () => { const child = await harness() const childMeta = header('resume-child-race') providePersistence(child.ctx, { @@ -464,15 +348,6 @@ describe('ApiSession create or adoption', () => { await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({ error: { code: 'session/agent-busy' }, }) - - const conflict = await harness() - const stored = header('stored-cwd-conflict', '/stored') - providePersistence(conflict.ctx, { - list: () => Promise.resolve([stored]), - inspect: () => Promise.resolve({ meta: stored, events: [] }), - }) - await expect(conflict.agents.ensureSession(stored.id, '/requested', true)) - .rejects.toBeInstanceOf(ApiSessionCwdConflict) }) it('surfaces directory creation failure', async () => { @@ -481,7 +356,7 @@ describe('ApiSession create or adoption', () => { tempDirs.push(parent) const file = join(parent, 'file') writeFileSync(file, 'not a directory') - await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false)) + await expect(agents.createSession({ cwd: join(file, 'child'), reusable: () => true })) .rejects.toThrow('failed to ensure project directory') }) }) diff --git a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts index 87bdc5a399..f43ce3f116 100644 --- a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts +++ b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts @@ -5,11 +5,14 @@ import type {} from '@deepseek-ai/dsh-agent-preset-registry' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { Workspace, WorkspaceId } from '@deepseek-ai/dsh-workspace' import { describe, expect, it, vi } from 'vitest' import { ApiSessionAgentController, ApiSessionCwdConflict, + type ApiSessionCreated, + type ApiSessionCreateRequest, } from '../src/agent.ts' import { SessionCommandController } from '../src/commands.ts' import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' @@ -18,18 +21,31 @@ async function expectFailure(operation: Promise, code: string): Promise await expect(operation).rejects.toMatchObject({ code }) } -function controllerAgents(overrides: object = {}): ApiSessionAgentController { - return { - ensureSession: () => Promise.resolve(), - composeAgent: () => Promise.resolve({ setup: () => {} }), - presetForSession: () => undefined, - presetForObservation: () => undefined, - ...overrides, - } as unknown as ApiSessionAgentController +/** The Agent controller with a fixed Agent composition, and optionally a fixed creation outcome. */ +function controllerAgents( + ctx: Context, + fixed: { + readonly composeAgent?: ApiSessionAgentController['composeAgent'] + readonly createSession?: ApiSessionAgentController['createSession'] + } = {}, +): ApiSessionAgentController { + const agents = new ApiSessionAgentController(ctx) + vi.spyOn(agents, 'composeAgent').mockImplementation(fixed.composeAgent ?? (() => Promise.resolve({ setup: () => {} }))) + if (fixed.createSession !== undefined) vi.spyOn(agents, 'createSession').mockImplementation(fixed.createSession) + return agents +} + +/** A creation outcome that publishes `sessionId` in `cwd` as a stand-in Agent. */ +function publishedSession(ctx: Context, sessionId: SessionId) { + return (request: ApiSessionCreateRequest): Promise => { + const session = ctx.sessions.create(sessionId, { meta: { cwd: request.cwd } }) + return Promise.resolve({ agent: { id: session.id, session, status: 'idle', ctx } as Agent, agentPreset: undefined }) + } } async function baseContext(): Promise { const ctx = new Context() + await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) @@ -41,29 +57,19 @@ async function baseContext(): Promise { } describe('Session creation failures', () => { - it('mints an identity with the default cwd when no explicit target is supplied', async () => { + it('creates in the default cwd without a named identity when no explicit target is supplied', async () => { const ctx = await baseContext() - ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) - const ensureSession = vi.fn((sessionId: SessionId, cwd: string) => { - const session = ctx.sessions.create(sessionId, { meta: { cwd } }) - return Promise.resolve({ id: sessionId, session } as Agent) - }) - const controller = new SessionCommandController( - ctx, - controllerAgents({ ensureSession }), - '/default-workspace', - ) + ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [], archivedSessionIds: [] } as never) + const createSession = vi.fn(publishedSession(ctx, SessionId('session-minted'))) + const controller = new SessionCommandController(ctx, controllerAgents(ctx, { createSession }), '/default-workspace') const created = await controller.create({}) - expect(created.sessionId).toMatch(/^session-/) - expect(created).not.toHaveProperty('agentPreset') - expect(ensureSession).toHaveBeenCalledWith( - created.sessionId, - '/default-workspace', - false, - undefined, - ) + expect(created).toEqual({ sessionId: 'session-minted' }) + expect(createSession).toHaveBeenCalledOnce() + const request = createSession.mock.calls[0]?.[0] + expect(request?.cwd).toBe('/default-workspace') + expect(Object.keys(request ?? {}).sort()).toEqual(['cwd', 'reusable']) await ctx.fiber.dispose() }) @@ -72,7 +78,7 @@ describe('Session creation failures', () => { missing.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) const missingController = new SessionCommandController( missing, - controllerAgents(), + controllerAgents(missing), '/default', ) await expectFailure(missingController.create({ @@ -92,7 +98,7 @@ describe('Session creation failures', () => { } as never) const failedController = new SessionCommandController( failed, - controllerAgents(), + controllerAgents(failed, { createSession: publishedSession(failed, SessionId('workspace-session')) }), '/default', ) await expectFailure(failedController.create({ @@ -119,10 +125,6 @@ describe('Session creation failures', () => { error: new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/requested', '/stored'), code: 'session/conflict', }, - { - error: Object.assign(new Error('writer already held'), { name: 'SessionAlreadyOwnedError' }), - code: 'session/writer-held', - }, { error: new Error('factory unavailable'), code: 'gateway/internal', @@ -132,7 +134,7 @@ describe('Session creation failures', () => { ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) const controller = new SessionCommandController( ctx, - controllerAgents({ ensureSession: () => Promise.reject(error) }), + controllerAgents(ctx, { createSession: () => Promise.reject(error) }), '/default', ) @@ -144,7 +146,7 @@ describe('Session creation failures', () => { it('rejects contradictory create targets', async () => { const ctx = await baseContext() - const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default') await expectFailure(controller.create({ workspaceId: 'workspace-1' as WorkspaceId, @@ -184,7 +186,7 @@ describe('Session fork failures', () => { const withoutPersistence = await baseContext() withoutPersistence.provide('workspaceRegistry', { list: () => [] } as never) const unavailableController = new SessionCommandController( - withoutPersistence, controllerAgents(), '/default', + withoutPersistence, controllerAgents(withoutPersistence), '/default', ) await expectFailure(unavailableController.fork({ sessionId: SessionId('missing'), @@ -197,7 +199,7 @@ describe('Session fork failures', () => { list: () => Promise.resolve([]), inspect: vi.fn(), }) as never) - const missingController = new SessionCommandController(missing, controllerAgents(), '/default') + const missingController = new SessionCommandController(missing, controllerAgents(missing), '/default') await expectFailure(missingController.fork({ sessionId: SessionId('missing'), }), 'session/not-found') @@ -208,7 +210,7 @@ describe('Session fork failures', () => { const ctx = await baseContext() ctx.provide('workspaceRegistry', { list: () => [] } as never) vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline')) - const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default') await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'gateway/internal') await ctx.fiber.dispose() @@ -218,7 +220,7 @@ describe('Session fork failures', () => { const ctx = await baseContext() ctx.provide('workspaceRegistry', { list: () => [] } as never) const source = ctx.sessions.create(SessionId('empty-source')) - const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default') await expectFailure(controller.fork({ sessionId: source.id }), 'session/fork-unavailable') await expect(controller.fork({ sessionId: source.id, atSeq: 0 })).rejects.toMatchObject({ @@ -237,7 +239,7 @@ describe('Session fork failures', () => { parentSession: SessionId('parent'), origin: 'subagent', }) - const lineageController = new SessionCommandController(lineage, controllerAgents(), '/default') + const lineageController = new SessionCommandController(lineage, controllerAgents(lineage), '/default') await expectFailure(lineageController.fork({ sessionId: child.id }), 'gateway/internal') await lineage.fiber.dispose() @@ -245,7 +247,7 @@ describe('Session fork failures', () => { creation.provide('workspaceRegistry', { list: () => [] } as never) const source = completedSession(creation, 'creation-source', '/workspace') vi.spyOn(creation.agents, 'create').mockRejectedValue(new Error('factory failed')) - const creationController = new SessionCommandController(creation, controllerAgents(), '/default') + const creationController = new SessionCommandController(creation, controllerAgents(creation), '/default') await expectFailure(creationController.fork({ sessionId: source.id }), 'gateway/internal') await creation.fiber.dispose() }) @@ -262,7 +264,7 @@ describe('Session fork failures', () => { const create = vi.spyOn(ctx.agents, 'create').mockImplementation( (options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)), ) - const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default') await expectFailure(controller.fork({ sessionId: source.id }), 'session/workspace-attach-failed') const options = create.mock.calls[0]?.[0] @@ -279,7 +281,7 @@ describe('Session fork failures', () => { const create = vi.spyOn(ctx.agents, 'create').mockImplementation( (options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)), ) - const controller = new SessionCommandController(ctx, controllerAgents({ + const controller = new SessionCommandController(ctx, controllerAgents(ctx, { composeAgent: () => Promise.resolve({ agentPreset: 'minimal', setup: () => {} }), }), '/default') diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 2097854412..bcf4a033be 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -1194,6 +1194,17 @@ describe('remaining branches', () => { expect(await manager.create()).toMatchObject({ ok: false }) }) + it('create forwards the requested Agent preset and catalogs the identity the Host returns', async ({ mock, remote }) => { + remote.session.create.mockResolvedValue(ok({ sessionId: S2, agentPreset: 'minimal' })) + const manager = makeManager(mock, remote) + + const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1, agentPreset: 'minimal' }) + + expect(remote.session.create).toHaveBeenCalledExactlyOnceWith({ workspaceId: 'w1', sessionId: S1, agentPreset: 'minimal' }) + expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } }) + expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2]) + }) + it.for(['create', 'fork'] as const)('preserves pushed running state when a %s response arrives later', async (operation, { mock, remote }) => { const response = Promise.withResolvers>>() remote.session[operation].mockImplementation(() => response.promise) 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 0a14c90f0a..fcfe1a4820 100644 --- a/packages/api/session-controller/tests/projection-discovery.host.spec.ts +++ b/packages/api/session-controller/tests/projection-discovery.host.spec.ts @@ -9,7 +9,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionProjectionCache, { checkpointRecord, diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index 5406d235f8..0fc3132fe7 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -1,4 +1,4 @@ -/** Session creation and adoption rules for Agent preset identity. */ +/** Session creation, blank-Session reuse, and Agent preset identity through `session.create`. */ import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -6,12 +6,14 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import { afterEach, describe, expect, it } from 'vitest' -import { createSessionTestRemote } from './test-remote.ts' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createSessionTestRemote, testSessionPersistence } from './test-remote.ts' /** Booted contexts and their temp roots, torn down after each test. */ const contexts: Context[] = [] @@ -25,53 +27,137 @@ function stubAgent(session: Session): Agent { return { id: session.id, session, status: 'idle' } as unknown as Agent } -function roster(ids: readonly string[]): unknown { - const presetOf = (id: string): object => ({ - id, - trust: 'system', - }) - return { - defaultId: ids[0], - resolve: (id?: string) => { - const wanted = id ?? ids[0] ?? '' - if (!ids.includes(wanted)) { - return Promise.reject(new RemoteError( - 'agent-preset/not-found', - `agent-presets: preset "${wanted}" not found (available: ${ids.join(', ') || 'none'})`, - { agentPreset: wanted, available: ids }, - )) - } - return Promise.resolve(presetOf(wanted)) - }, - mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')), - } +/** A stored Session the persistence double serves until an Agent resumes it. */ +interface StoredSession { + readonly header: SessionHeader + readonly events: readonly SessionEvent[] } -async function harness(presets?: readonly string[]) { +interface HarnessOptions { + /** Declared presets; the first is the default. Omitted, no roster is composed. */ + readonly presets?: readonly string[] + /** Stored Sessions keyed by id. */ + readonly stored?: ReadonlyMap + /** Stored Sessions whose writer another owner holds. */ + readonly held?: ReadonlySet + /** Stored Sessions another resume in this process publishes while this one waits for the writer. */ + readonly publishedByOtherResume?: ReadonlySet + /** Stored Sessions whose storage fails to open for writing. */ + readonly unreadable?: ReadonlySet + /** Events a resume finds beyond the stored log, as when another Host wrote after the observation. */ + readonly writtenSinceObservation?: ReadonlyMap + /** Live Sessions whose preset switch the roster refuses as already started. */ + readonly locked?: ReadonlySet + /** Declared presets whose activation failed. */ + readonly broken?: ReadonlySet + /** Requested presets whose resolution waits for the paired promise. */ + readonly resolving?: ReadonlyMap> + /** Workspace placement served to `workspaceId` requests. */ + readonly workspace?: { readonly members: readonly SessionId[]; readonly archived: readonly SessionId[] } +} + +const WORKSPACE = 'workspace-1' as WorkspaceId + +async function harness(options: HarnessOptions = {}) { const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-'))) tempDirs.push(cwd) const ctx = new Context() contexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + const presets = options.presets + const selections: { sessionId: SessionId; agentPreset: string }[] = [] + const resumed: SessionId[] = [] if (presets !== undefined) { - ctx.provide('agentPresets', roster(presets) as never) + const known = (id?: string): string => { + const wanted = id ?? presets[0] ?? '' + if (!presets.includes(wanted)) { + throw new RemoteError('agent-preset/not-found', `Unknown agent preset: ${wanted}`, { + agentPreset: wanted, available: [...presets], + }) + } + return wanted + } + const usable = (id?: string): string => { + const wanted = known(id) + if (options.broken?.has(wanted) === true) { + throw new RemoteError('agent-preset/invalid', 'activation failed', { agentPreset: wanted, reason: 'activation failed' }) + } + return wanted + } + ctx.provide('agentPresets', { + defaultId: presets[0], + resolve: (id?: string) => Promise.resolve(id === undefined ? undefined : options.resolving?.get(id)) + .then(() => ({ id: known(id) })), + mount: (_ctx: Context, id?: string) => Promise.resolve().then(() => ({ id: usable(id) })), + select: async (agent: Agent, agentPreset: string) => { + usable(agentPreset) + if (options.locked?.has(agent.id) === true) { + throw new RemoteError('agent-preset/locked', 'This session has already started', { + sessionId: agent.id, agentPreset, + }) + } + selections.push({ sessionId: agent.id, agentPreset }) + agent.session.append('agent-preset/selected', { agentPreset }) + return agentPreset + }, + } as never) } - - const factory: AgentFactory = { - async createAgent(_ownerCtx, options) { - const session = ctx.sessions.create( - options.sessionId, - options.meta === undefined ? {} : { meta: options.meta }, - ) - const agent = stubAgent(session) - ;(agent as { ctx?: Context }).ctx = ctx - await options.setup?.(ctx, agent) - const unregister = await ctx.agents.register(agent) - return { agent, dispose: async () => { await unregister() } } + const stored = options.stored ?? new Map() + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([...stored.values()].map(record => record.header)), + inspect: (id: SessionId) => { + const record = stored.get(id) + return Promise.resolve(record === undefined ? undefined : { meta: record.header, events: [...record.events] }) }, - async resume() { - throw new Error('test harness has no persisted sessions') + }) as never) + const { members, archived } = options.workspace ?? { members: [], archived: [] } + const workspace = { id: WORKSPACE, path: cwd, sessionIds: members, attachSession: () => Promise.resolve() } + ctx.provide('workspaceRegistry', { + get: (id: WorkspaceId) => id === WORKSPACE ? workspace : undefined, + list: () => [workspace], + archivedSessionIds: archived, + } as never) + + /** Run setup on the unpublished Session, then publish it and its Agent, as the Agent factory does. */ + const publish = async ( + session: Session, + setup: Parameters[1]['setup'], + ): Promise<{ agent: Agent; dispose: () => Promise }> => { + const agent = stubAgent(session) + ;(agent as { ctx?: Context }).ctx = ctx + await setup?.(ctx, agent) + ctx.effect(function* () { + yield ctx.sessions.enter(session) + ctx.sessions.announce(session) + }, 'test: published Session') + const unregister = await ctx.agents.register(agent) + return { agent, dispose: async () => { await unregister() } } + } + const factory: AgentFactory = { + async createAgent(_ownerCtx, create) { + const session = ctx.sessions.prepare(create.sessionId, create.meta === undefined ? {} : { meta: create.meta }) + return publish(session, create.setup) + }, + async resume(_ownerCtx, resume) { + const id = resume.resumeSessionId + if (options.held?.has(id) === true) { + throw Object.assign(new Error(`session "${id}" is written by another owner`), { name: 'SessionAlreadyOwnedError' }) + } + const record = stored.get(id) + if (record === undefined || options.unreadable?.has(id) === true) { + throw new Error(`test persistence cannot open Session "${id}" for writing`) + } + if (options.publishedByOtherResume?.has(id) === true) { + await publish(ctx.sessions.prepare(id, { meta: record.header, seed: [...record.events] }), undefined) + throw Object.assign(new Error(`session "${id}" is written by this process`), { name: 'SessionAlreadyOwnedError' }) + } + resumed.push(id) + const session = ctx.sessions.prepare(id, { + meta: record.header, + seed: [...record.events, ...options.writtenSinceObservation?.get(id) ?? []], + }) + return publish(session, resume.setup) }, } ctx.agents.setFactory(factory) @@ -79,13 +165,43 @@ async function harness(presets?: readonly string[]) { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd, }) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) if (presets !== undefined) ctx.sessionProjections.register(agentPresetProjectionDefinition) - return { ctx, remote } + const presetOf = (id: SessionId): string | null | undefined => { + const session = ctx.sessions.get(id) + return session === undefined ? undefined : ctx.sessionProjections.stateOf(session, 'agentPreset') + } + const selectedEvents = (id: SessionId): unknown[] => { + const session = ctx.sessions.get(id) + return session === undefined ? [] : session.snapshotEvents() + .filter(event => event.type === 'agent-preset/selected') + .map(event => event.data) + } + return { ctx, remote, cwd, selections, resumed, presetOf, selectedEvents } +} + +function storedHeader(id: SessionId, cwd: string, extra: Partial = {}): SessionHeader { + return { version: SESSION_FORMAT_VERSION, id, createdAt: 1, isSeeded: false, cwd, ...extra } +} + +function modelSelection(seq: number): SessionEvent { + return { type: 'model/selection', seq: SessionSeq(seq), time: 1, data: { provider: 'test', model: 'test-model' } } +} + +function presetSelection(seq: number, agentPreset: string): SessionEvent { + return { type: 'agent-preset/selected', seq: SessionSeq(seq), time: 1, data: { agentPreset } } +} + +function completedTurn(seq: number): SessionEvent[] { + return [ + { type: 'turn/start', seq: SessionSeq(seq), time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: SessionSeq(seq + 1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] } describe('session.create Agent preset identity', () => { it('records the requested preset on the Session header', async () => { - const { ctx, remote } = await harness(['standard', 'minimal']) + const { ctx, remote } = await harness({ presets: ['standard', 'minimal'] }) const created = await remote.create({ sessionId: SessionId('s1'), agentPreset: 'minimal' }) @@ -94,7 +210,7 @@ describe('session.create Agent preset identity', () => { }) it('records the roster default when the caller names no preset', async () => { - const { ctx, remote } = await harness(['standard', 'minimal']) + const { ctx, remote } = await harness({ presets: ['standard', 'minimal'] }) await remote.create({ sessionId: SessionId('s2') }) @@ -102,55 +218,13 @@ describe('session.create Agent preset identity', () => { }) it('rejects an unknown preset', async () => { - const { remote } = await harness(['standard']) + const { remote } = await harness({ presets: ['standard'] }) const response = await remote.create({ sessionId: SessionId('s3'), agentPreset: 'nope' }) expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/not-found' } }) }) - it('refuses to adopt a live Session under a different preset', async () => { - const { remote } = await harness(['standard', 'minimal']) - await remote.create({ sessionId: SessionId('s4'), agentPreset: 'minimal' }) - - const response = await remote.create({ sessionId: SessionId('s4'), agentPreset: 'standard' }) - - expect(response).toMatchObject({ - ok: false, - error: { - code: 'agent-preset/conflict', - details: { - sessionId: 's4', - requestedPreset: 'standard', - existingPreset: 'minimal', - }, - }, - }) - }) - - it('adopts a live Session under the preset selected in its log', async () => { - const { ctx, remote } = await harness(['standard', 'minimal']) - await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'standard' }) - ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' }) - - const adopted = await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'minimal' }) - const stale = await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'standard' }) - - expect(adopted).toMatchObject({ ok: true, value: { agentPreset: 'minimal' } }) - expect(stale).toMatchObject({ - ok: false, - error: { details: { existingPreset: 'minimal' } }, - }) - }) - - it('adopts a live Session unchanged when the caller names no preset', async () => { - const { remote } = await harness(['standard', 'minimal']) - await remote.create({ sessionId: SessionId('s5'), agentPreset: 'minimal' }) - - await expect(remote.create({ sessionId: SessionId('s5') })) - .resolves.toMatchObject({ ok: true }) - }) - it('leaves the header preset-less when no roster is composed', async () => { const { ctx, remote } = await harness() @@ -158,6 +232,195 @@ describe('session.create Agent preset identity', () => { expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined() }) +}) + +describe('session.create reuse of a live blank Session', () => { + it('recomposes a blank Session under the default when the caller names no preset', async () => { + const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + + const reused = await remote.create({ sessionId: SessionId('draft') }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'standard' } }) + expect(selections).toEqual([{ sessionId: 'draft', agentPreset: 'standard' }]) + expect(presetOf(SessionId('draft'))).toBe('standard') + }) + + it('recomposes a blank Session under the named preset', async () => { + const { remote, selections } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft') }) + + const reused = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'minimal' } }) + expect(selections).toEqual([{ sessionId: 'draft', agentPreset: 'minimal' }]) + }) + + it('keeps a blank Session that already runs the requested preset', async () => { + const { ctx, remote, selections } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft') }) + ctx.sessions.get(SessionId('draft'))?.append('agent-preset/selected', { agentPreset: 'minimal' }) + + const reused = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'minimal' } }) + expect(selections).toEqual([]) + }) + + it('validates the requested preset before touching the blank Session', async () => { + const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + + const response = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'deleted' }) + + expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/not-found' } }) + expect(selections).toEqual([]) + expect(presetOf(SessionId('draft'))).toBe('minimal') + }) + + it('keeps the blank Session unchanged when the requested preset fails to activate', async () => { + const { remote, selections, presetOf } = await harness({ + presets: ['standard', 'broken'], + broken: new Set(['broken']), + }) + await remote.create({ sessionId: SessionId('draft') }) + + const response = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'broken' }) + + expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/invalid' } }) + expect(selections).toEqual([]) + expect(presetOf(SessionId('draft'))).toBe('standard') + }) + + it('creates a new Session instead of reusing one that has started', async () => { + const { ctx, remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('talk'), agentPreset: 'minimal' }) + const talk = ctx.sessions.get(SessionId('talk')) + talk?.append('turn/start', { turn: 1 }) + talk?.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const created = await remote.create({ sessionId: SessionId('talk') }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('talk') + expect(created.value.agentPreset).toBe('standard') + expect(selections).toEqual([]) + expect(presetOf(SessionId('talk'))).toBe('minimal') + }) + + it('creates a new Session when a turn starts before the preset switch', async () => { + const { remote } = await harness({ + presets: ['standard', 'minimal'], + locked: new Set([SessionId('racing')]), + }) + await remote.create({ sessionId: SessionId('racing'), agentPreset: 'minimal' }) + + const created = await remote.create({ sessionId: SessionId('racing') }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('racing') + }) + + it('serializes concurrent reuse of one Session in request order', async () => { + const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft') }) + + const [first, second] = await Promise.all([ + remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }), + remote.create({ sessionId: SessionId('draft') }), + ]) + + expect(first).toMatchObject({ ok: true, value: { sessionId: 'draft' } }) + expect(second).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'standard' } }) + expect(selections).toEqual([ + { sessionId: 'draft', agentPreset: 'minimal' }, + { sessionId: 'draft', agentPreset: 'standard' }, + ]) + expect(presetOf(SessionId('draft'))).toBe('standard') + }) + + it('claims one Session in arrival order when an earlier preset resolves later', async () => { + const minimal = Promise.withResolvers() + const { remote, selections, presetOf } = await harness({ + presets: ['standard', 'minimal'], resolving: new Map([['minimal', minimal.promise]]), + }) + await remote.create({ sessionId: SessionId('draft') }) + + const first = remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + const second = remote.create({ sessionId: SessionId('draft') }) + await new Promise(resolve => setTimeout(resolve, 0)) + minimal.resolve(undefined) + + expect(await first).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'minimal' } }) + expect(await second).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'standard' } }) + expect(selections).toEqual([ + { sessionId: 'draft', agentPreset: 'minimal' }, + { sessionId: 'draft', agentPreset: 'standard' }, + ]) + expect(presetOf(SessionId('draft'))).toBe('standard') + }) + + it('shares one creation between concurrent callers of a new identity', async () => { + const { ctx, remote } = await harness({ presets: ['standard'] }) + + const results = await Promise.all([ + remote.create({ sessionId: SessionId('fresh') }), + remote.create({ sessionId: SessionId('fresh') }), + ]) + + expect(results).toMatchObject([ + { ok: true, value: { sessionId: 'fresh' } }, + { ok: true, value: { sessionId: 'fresh' } }, + ]) + expect(ctx.agents.list().map(agent => agent.id)).toEqual(['fresh']) + }) + + it('refuses a Session in another directory', async () => { + const { ctx, remote } = await harness({ presets: ['standard'] }) + const elsewhere = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-elsewhere-'))) + tempDirs.push(elsewhere) + await remote.create({ sessionId: SessionId('other'), cwd: elsewhere }) + + const response = await remote.create({ sessionId: SessionId('other') }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'session/conflict', details: { sessionId: 'other', existingCwd: elsewhere } }, + }) + expect(ctx.agents.list().map(agent => agent.id)).toEqual(['other']) + }) + + it('refuses a Session in another directory before resolving the requested preset', async () => { + const { remote } = await harness({ presets: ['standard'] }) + const elsewhere = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-elsewhere-'))) + tempDirs.push(elsewhere) + await remote.create({ sessionId: SessionId('other'), cwd: elsewhere }) + + const response = await remote.create({ sessionId: SessionId('other'), agentPreset: 'nope' }) + + expect(response).toMatchObject({ ok: false, error: { code: 'session/conflict', details: { sessionId: 'other' } } }) + }) + + it('leaves a subagent identity to subagent routing', async () => { + const { ctx, remote, cwd } = await harness({ presets: ['standard'] }) + ctx.sessions.create(SessionId('child'), { + meta: { cwd, parentSession: SessionId('parent'), origin: 'subagent' }, + }) + + const response = await remote.create({ sessionId: SessionId('child') }) + + expect(response).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } }) + }) + + it('reuses a preset-less blank Session unchanged when no roster is composed', async () => { + const { ctx, remote } = await harness() + await remote.create({ sessionId: SessionId('s5') }) + + const reused = await remote.create({ sessionId: SessionId('s5') }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 's5' } }) + expect(ctx.sessions.get(SessionId('s5'))?.header.agentPreset).toBeUndefined() + }) it('explains why a preset-less Session cannot be adopted under one', async () => { const { remote } = await harness() @@ -177,6 +440,235 @@ describe('session.create Agent preset identity', () => { }) if (response.ok) throw new Error('unreachable') expect('existingPreset' in response.error.details).toBe(false) - expect(response.error.message).toContain('records no agent preset') + expect(response.error.message).toBe('the deployment composes no agent preset roster, so session "s7" cannot run preset "standard"') + }) +}) + +describe('session.create reuse of a stored blank Session', () => { + it('resumes a blank Session whose recorded preset is no longer declared under the default', async () => { + const draft = SessionId('stale-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, resumed, presetOf, selectedEvents } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'liangshen' }), events: [modelSelection(0)] }]]), + }) + + const reused = await remote.create({ sessionId: draft, cwd }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'stale-draft', agentPreset: 'standard' } }) + expect(resumed).toEqual(['stale-draft']) + expect(presetOf(draft)).toBe('standard') + expect(selectedEvents(draft)).toEqual([{ agentPreset: 'standard' }]) + }) + + it('resumes a blank Session under the default even when its previous choice is still declared', async () => { + const draft = SessionId('picked-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, presetOf, selectedEvents } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [presetSelection(0, 'minimal')] }]]), + }) + + const reused = await remote.create({ sessionId: draft, cwd }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'picked-draft', agentPreset: 'standard' } }) + expect(presetOf(draft)).toBe('standard') + expect(selectedEvents(draft)).toEqual([{ agentPreset: 'minimal' }, { agentPreset: 'standard' }]) + }) + + it('resumes a blank Session under its recorded preset when the caller names it', async () => { + const draft = SessionId('restored-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, presetOf, selectedEvents } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [presetSelection(0, 'minimal')] }]]), + }) + + const reused = await remote.create({ sessionId: draft, cwd, agentPreset: 'minimal' }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'restored-draft', agentPreset: 'minimal' } }) + expect(presetOf(draft)).toBe('minimal') + expect(selectedEvents(draft)).toEqual([{ agentPreset: 'minimal' }]) + }) + + it('rejects a named preset that is no longer declared without resuming the Session', async () => { + const draft = SessionId('stale-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { ctx, remote, resumed } = await harness({ + presets: ['standard'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'liangshen' }), events: [modelSelection(0)] }]]), + }) + + const response = await remote.create({ sessionId: draft, cwd, agentPreset: 'liangshen' }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'agent-preset/not-found', details: { agentPreset: 'liangshen', available: ['standard'] } }, + }) + expect(resumed).toEqual([]) + expect(ctx.agents.list()).toEqual([]) + }) + + it('creates a new Session instead of resuming one that has started', async () => { + const talk = SessionId('stored-talk') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, resumed } = await harness({ + presets: ['standard'], + stored: new Map([[talk, { header: storedHeader(talk, cwd, { agentPreset: 'liangshen' }), events: completedTurn(0) }]]), + }) + + const created = await remote.create({ sessionId: talk, cwd }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('stored-talk') + expect(created.value.agentPreset).toBe('standard') + expect(resumed).toEqual([]) + }) + + it('creates a new Session when another owner holds the blank Session', async () => { + const draft = SessionId('held-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { ctx, remote } = await harness({ + presets: ['standard'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [modelSelection(0)] }]]), + held: new Set([draft]), + }) + + const created = await remote.create({ sessionId: draft, cwd }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('held-draft') + expect(ctx.sessions.get(draft)).toBeUndefined() + }) + + it('reuses the Agent another resume published while holding the writer', async () => { + const draft = SessionId('contended-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, selections, presetOf } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'minimal' }), events: [modelSelection(0)] }]]), + publishedByOtherResume: new Set([draft]), + }) + + const reused = await remote.create({ sessionId: draft, cwd }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'contended-draft', agentPreset: 'standard' } }) + expect(selections).toEqual([{ sessionId: 'contended-draft', agentPreset: 'standard' }]) + expect(presetOf(draft)).toBe('standard') + }) + + it('reports an unreadable Session instead of creating another one', async () => { + const { ctx, remote } = await harness({ presets: ['standard'] }) + vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline')) + + const response = await remote.create({ sessionId: SessionId('unreadable') }) + + expect(response).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) + expect(ctx.agents.list()).toEqual([]) + }) + + it('reports a storage failure instead of creating another Session', async () => { + const draft = SessionId('unreadable-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { ctx, remote } = await harness({ + presets: ['standard'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [modelSelection(0)] }]]), + unreadable: new Set([draft]), + }) + + const response = await remote.create({ sessionId: draft, cwd }) + + expect(response).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) + expect(ctx.agents.list()).toEqual([]) + }) + + it('creates a new Session when the blank Session started after it was observed', async () => { + const draft = SessionId('overtaken-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { ctx, remote, resumed } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'minimal' }), events: [modelSelection(0)] }]]), + writtenSinceObservation: new Map([[draft, completedTurn(1)]]), + }) + + const created = await remote.create({ sessionId: draft, cwd }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('overtaken-draft') + expect(resumed).toEqual(['overtaken-draft']) + expect(ctx.sessions.get(draft)).toBeUndefined() + }) + + it('refuses a stored Session in another directory', async () => { + const other = SessionId('stored-other') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, resumed } = await harness({ + presets: ['standard'], + stored: new Map([[other, { header: storedHeader(other, '/elsewhere'), events: [modelSelection(0)] }]]), + }) + + const response = await remote.create({ sessionId: other, cwd }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'session/conflict', details: { sessionId: 'stored-other', existingCwd: '/elsewhere' } }, + }) + expect(resumed).toEqual([]) + }) + + it('leaves a stored subagent identity to subagent routing', async () => { + const child = SessionId('stored-child') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, resumed } = await harness({ + presets: ['standard'], + stored: new Map([[child, { + header: storedHeader(child, cwd, { parentSession: SessionId('parent'), origin: 'subagent' }), + events: [modelSelection(0)], + }]]), + }) + + const response = await remote.create({ sessionId: child, cwd }) + + expect(response).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } }) + expect(resumed).toEqual([]) + }) +}) + +describe('session.create Workspace placement', () => { + it('reuses a blank member of the requested Workspace', async () => { + const draft = SessionId('member-draft') + const { remote } = await harness({ presets: ['standard'], workspace: { members: [draft], archived: [] } }) + await remote.create({ sessionId: draft }) + + const reused = await remote.create({ sessionId: draft, workspaceId: WORKSPACE }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'member-draft' } }) + }) + + it.each([ + { name: 'outside the Workspace', members: [], archived: [] }, + { name: 'archived', members: [SessionId('placed-draft')], archived: [SessionId('placed-draft')] }, + ])('creates a new Session instead of reusing a blank Session $name', async ({ members, archived }) => { + const draft = SessionId('placed-draft') + const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'], workspace: { members, archived } }) + await remote.create({ sessionId: draft, agentPreset: 'minimal' }) + + const created = await remote.create({ sessionId: draft, workspaceId: WORKSPACE }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('placed-draft') + expect(selections).toEqual([]) + expect(presetOf(draft)).toBe('minimal') }) }) diff --git a/packages/api/session-controller/tsdown.config.ts b/packages/api/session-controller/tsdown.config.ts index bc2abfa538..0e42a1163e 100644 --- a/packages/api/session-controller/tsdown.config.ts +++ b/packages/api/session-controller/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-api-session-controller', + import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/api/settings-controller/locale/en.json b/packages/api/settings-controller/locale/en.json new file mode 100644 index 0000000000..ccd029abde --- /dev/null +++ b/packages/api/settings-controller/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "api-settings-controller" + } +} diff --git a/packages/api/settings-controller/locale/zh.json b/packages/api/settings-controller/locale/zh.json new file mode 100644 index 0000000000..5e1155ba4d --- /dev/null +++ b/packages/api/settings-controller/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "设置读写接口", + "description": "向客户端提供各项设置的读取与修改能力。" + } +} diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index ded8c8bf22..6c72102b61 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -31,9 +31,11 @@ "default": "./lib/typert.remote-client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "files": [ + "locale/*.json", "lib/index.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", @@ -45,7 +47,7 @@ "license": "MIT", "dependencies": { "@deepseek-ai/schemastery": "workspace:~", - "zod": "^4.4.3" + "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/api/settings-controller/src/credentials.ts b/packages/api/settings-controller/src/credentials.ts index 99c42a4cf0..3889669048 100644 --- a/packages/api/settings-controller/src/credentials.ts +++ b/packages/api/settings-controller/src/credentials.ts @@ -132,11 +132,12 @@ export class CredentialsController extends TypertRemoteService { /** * Run one remote write and report every refusal as `credential/rejected` - * carrying the seam's own message: a read-only source shadowing the reference - * is what a configuration surface must show verbatim. Callers brand the - * reference before entering, so a name outside the grammar never reaches this - * path and fails the same way it does on the read side. The details name only - * the reference, so no failure path can carry the value back out. + * carrying the seam's own message: a provider refusal, such as a stored + * document that no longer parses, is what a settings page must show + * verbatim. Callers brand the reference before entering, so a name outside + * the grammar never reaches this path and fails the same way it does on the + * read side. The details name only the reference, so no failure path can + * carry the value back out. */ private async write(ref: string, write: () => Promise): Promise { try { diff --git a/packages/api/settings-controller/src/types.ts b/packages/api/settings-controller/src/types.ts index 2110184363..3d11c6ab0c 100644 --- a/packages/api/settings-controller/src/types.ts +++ b/packages/api/settings-controller/src/types.ts @@ -20,9 +20,9 @@ declare module '@deepseek-ai/dsh-typert-protocol' { */ 'settings/conflict': { readonly ns: string; readonly expected: number; readonly actual: number } /** - * The provider refused a valid credential write, for example because a - * read-only source shadows the reference. The details name only the - * reference, never the value. + * The provider refused a valid credential write, for example because the + * stored document no longer parses. The details name only the reference, + * never the value. */ 'credential/rejected': { readonly ref: string } } diff --git a/packages/api/settings-controller/tests/credentials-controller.host.spec.ts b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts index 9ac14d9eb8..5eeb190385 100644 --- a/packages/api/settings-controller/tests/credentials-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts @@ -24,7 +24,7 @@ class LiteralRejectingCredentials extends MemoryCredentials { /** A store whose provider-owned policy rejects an otherwise valid write. */ class RejectingCredentials extends MemoryCredentials { override set(): Promise { - return Promise.reject(new Error('a read-only source shadows this reference')) + return Promise.reject(new Error('the stored document no longer parses')) } } @@ -120,7 +120,7 @@ describe('the credentials Remote namespace a configuration surface calls', () => const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error) const { code, message, details } = remoteErrorOf(failure) ?? {} expect(code).toBe('credential/rejected') - expect(message).toContain('read-only source') + expect(message).toContain('no longer parses') expect(details).toEqual({ ref: 'DEEPSEEK_API_KEY' }) }) diff --git a/packages/api/terminal-controller/README.i18n.yaml b/packages/api/terminal-controller/README.i18n.yaml index 167264cbde..e25664e9e3 100644 --- a/packages/api/terminal-controller/README.i18n.yaml +++ b/packages/api/terminal-controller/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: 1fa9a72d1ee045cc /deepseek-ai-dsh-api-terminal-controller/use-this-package: - en: abda92ff99530baa - zh: 67aa726392ae8e05 + en: 056c8b23819be51f + zh: d4525149ad4c32ce /deepseek-ai-dsh-api-terminal-controller/understand-the-implementation: - en: 94dcd1835c55acc2 - zh: cbfeb2da8cb60395 + en: b34684c82aa5fe5f + zh: 07638715c8708546 /deepseek-ai-dsh-api-terminal-controller/further-exploration: en: b1b5d2a2bfb8cc6a zh: aa0ea568aa63fda5 diff --git a/packages/api/terminal-controller/README.md b/packages/api/terminal-controller/README.md index 2709674163..856ebc96de 100644 --- a/packages/api/terminal-controller/README.md +++ b/packages/api/terminal-controller/README.md @@ -25,7 +25,7 @@ Open the execution environment's default shell in the Session's current working ## Use this package -The Web bundle mounts this package with the subprocess provider, working-directory service, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `retain`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error. +The Web bundle mounts this package with the subprocess provider, working-directory service, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `retain`, `attach`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error. Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits confirmed lookup misses. It lists each executable name once, ignoring letter case and `.exe`, and keeps the earliest entry, because PATH lookup can reach the default through another directory, such as `/usr/bin/bash` for `/bin/bash` on merged-`/usr` systems. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup reads the current directory and limits without filesystem validation or shell resolution, so unavailable directories or default shells do not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. New terminals validate the current directory through the [working-directory service](../../session/working-directory/README.md) before spawning, including its recovery behavior. Existing terminals keep their process directories. User terminals run with the execution environment’s system-user permissions, independently of the Agent’s sandbox mode and approval policy. Operating-system and container restrictions still apply; DSH does not elevate the user. The subprocess provider retains its credential-environment scrubbing. @@ -36,8 +36,8 @@ Shell discovery lists the execution environment's declared default shell first. | `maxTerminals` | `8` | Retained terminals and pending allocations per Session | | `maxCols`, `maxRows` | `500`, `200` | Maximum PTY dimensions | | `scrollback` | `1000` | Retained screen history rows | -| `maxBufferedBytes` | `2097152` | Output queued for one follower | -| `maxInputBytes` | `65536` | Maximum input request bytes | +| `maxBufferedBytes` | `2097152` | Encoded output per follower and buffered Client input | +| `maxInputBytes` | `65536` | Maximum raw UTF-8 bytes per input command and across unacknowledged Client input | | `disposeGraceMs` | `1000` | Provider termination grace in milliseconds | | `unattendedTimeoutMs` | `7200000` | Continuous confirmed idle time without window holds before cleanup; `0` disables automatic reclamation | | `activityPollIntervalMs` | `30000` | Interval between unattended activity observations | @@ -51,11 +51,13 @@ An open tab in any connected window retains its terminal, including hidden tabs
Implementation internals — click to expand -The Host uses `ctx.subprocess.spawnTerminal` with `TERM=xterm-256color`; it never launches a desktop terminal application. Streaming UTF-8 decoding preserves split characters and leading BOMs, and replaces incomplete trailing bytes at EOF. Unary control uses the Gateway, and `follow` uses its multiplexed Remote stream transport. Headless xterm and its serializer produce each opening screen after all preceding output writes, then monotone output sequences identify subsequent frames. Slow followers fail explicitly; a new attachment restores the current screen. +The Host uses `ctx.subprocess.spawnTerminal` with `TERM=xterm-256color`; it never launches a desktop terminal application. Streaming UTF-8 decoding preserves split characters and leading BOMs, and replaces incomplete trailing bytes at EOF. The duplex `attach` Remote stream carries output and ordered `write`/`resize` commands on the Gateway mux. Each command has a consecutive attachment-local sequence; an `ack` confirms provider acceptance or reports control refusal. Snapshots and state frames carry the receiving attachment's `writable` flag. Headless xterm and its serializer produce each opening screen after all preceding output writes, then monotone output sequences identify subsequent frames. Slow followers fail explicitly; a new attachment restores the current screen. -The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. Changing the Session’s sandbox mode leaves user terminals running with the same permissions. Input or resize refused after control transfer or process exit leaves the output attachment intact and disables input; rejected input is not replayed. +The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. Changing the Session’s sandbox mode leaves user terminals running with the same permissions. Input or resize refused after control transfer or process exit leaves the output attachment intact and disables input; rejected input is not replayed. Provider completion disables input before output drainage finishes, and late input failures cannot discard a gracefully finished follower's final frames. -The Client saves each Session/content-to-terminal association before allocation under its own `dsh.terminal.binding.v1.*` localStorage key. The content identity is globally unique; layout-local tab ids only identify live view occurrences. Independent record writes and deletes preserve other windows' bindings. Restored views reuse that identity; the sidebar terminal provider restores its views before querying unrepresented Host terminals. A new view may create a process, while a recovered view reports a missing target without creating a replacement. Explicit close removes the association after saving its cleanup request. The Host supplies current process metadata and screen contents; neither is saved in the browser. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes. +The opening snapshot advertises the carrier's complete-frame `inputWindow`; in-process carriers without an inbox limit omit it. The Client counts JSON escaping, command fields, and the carrier envelope for both writes and resizes. Commands wait locally while unacknowledged frames fill that window; provider acknowledgements release capacity. `maxInputBytes` bounds raw UTF-8 input, while `maxBufferedBytes` bounds all queued and unacknowledged encoded input frames. A single frame that cannot fit the carrier window, or an exhausted Client buffer, reports `inputFull` and cancels the attachment without replaying queued commands. + +The Client saves each Session/content-to-terminal association before allocation under its own `dsh.terminal.binding.v1.*` localStorage key. The content identity is globally unique; layout-local tab ids only identify live view occurrences. Independent record writes and deletes preserve other windows' bindings. Restored views reuse that identity; the sidebar terminal provider restores its views before querying unrepresented Host terminals. A new view may create a process, while a recovered view reports a missing target without creating a replacement. Explicit close removes the association after saving its cleanup request. The Host supplies current process metadata and screen contents; neither is saved in the browser. The Client model acknowledges screen writes after the browser emulator processes them, pipelines input within its unacknowledged UTF-8 byte budget and ignores stale attachment responses. Reconnection creates a fresh command sequence and never replays unacknowledged input. Provider input failures end the attachment; its process and recovery screen remain available for explicit reconnection. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes. The separate `retain(sessionId, id, signal)` Remote stream acknowledges a window hold without Agent activation, screen output, input transfer or process creation. The terminal provider supplies the sidebar's complete open-tab inventory, and the Client intersects it with its own saved identities. Duplicate occurrences share one hold per window; stale associations alone retain nothing. Restored output attachments wait for an acknowledged current hold. Transport cancellation releases exactly that physical generation; plugin disposal joins all hold streams. Failed cleanup retains ownership and retries without reopening admission or restarting the idle grace. diff --git a/packages/api/terminal-controller/README.zh.md b/packages/api/terminal-controller/README.zh.md index 49fc7ab343..220d88190d 100644 --- a/packages/api/terminal-controller/README.zh.md +++ b/packages/api/terminal-controller/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -Web bundle 将此包与 subprocess provider、working-directory 服务、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`retain`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。 +Web bundle 将此包与 subprocess provider、working-directory 服务、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`retain`、`attach`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。 Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,并省略确定未找到的候选。同一可执行文件名(不区分大小写,忽略 `.exe`)只列出最早的一项,因为 PATH 查找可能经由其他目录找到默认 shell,例如 merged-`/usr` 系统上 `/bin/bash` 对应的 `/usr/bin/bash`。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询读取当前目录和限制,不校验文件系统或解析 shell,因此目录或默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。新终端启动前通过[工作目录服务](../../session/working-directory/README.zh.md)校验当前目录,并采用该服务的恢复行为。已有终端保留其进程目录。用户终端使用执行环境中系统用户的权限,独立于 Agent 的沙箱模式和审批策略。操作系统和容器的限制仍然生效;DSH 不提升用户权限。Subprocess provider 继续清除环境中的凭据变量。 @@ -36,8 +36,8 @@ Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provide | `maxTerminals` | `8` | 每个 Session 保留的终端和创建请求上限 | | `maxCols`、`maxRows` | `500`、`200` | PTY 最大尺寸 | | `scrollback` | `1000` | 屏幕历史行数 | -| `maxBufferedBytes` | `2097152` | 单个订阅者的输出排队上限 | -| `maxInputBytes` | `65536` | 单次输入请求的字节上限 | +| `maxBufferedBytes` | `2097152` | 单个订阅者输出及 Client 输入帧的缓存上限 | +| `maxInputBytes` | `65536` | 每条输入命令及 Client 未确认输入总量的原始 UTF-8 字节上限 | | `disposeGraceMs` | `1000` | provider 终止宽限期,单位毫秒 | | `unattendedTimeoutMs` | `7200000` | 无窗口持有且持续确认空闲后开始清理的时长;`0` 禁用自动回收 | | `activityPollIntervalMs` | `30000` | 无窗口持有时的活动观察间隔 | @@ -51,11 +51,13 @@ Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provide
实现细节 -Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终端,不启动桌面终端应用。流式 UTF-8 解码保留跨块字符和开头的 BOM,并在 EOF 将不完整的尾部字节替换为替代字符。控制请求走 Gateway,`follow` 使用其复用的 Remote stream。Headless xterm 和序列化 addon 在此前输出写入后生成初始屏幕,后续增量携带单调序号。过慢的订阅者明确失败;重新连接恢复当前屏幕。 +Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终端,不启动桌面终端应用。流式 UTF-8 解码保留跨块字符和开头的 BOM,并在 EOF 将不完整的尾部字节替换为替代字符。双工 `attach` Remote 流通过 Gateway mux 传输输出和有序的 `write`/`resize` 命令。每条命令使用附件内连续递增的序号;`ack` 确认提供者已接受操作,或报告控制权拒绝。快照和状态帧携带接收附件的 `writable` 标记。Headless xterm 和序列化 addon 在此前输出写入后生成初始屏幕,后续增量携带单调序号。过慢的订阅者明确失败;重新连接恢复当前屏幕。 -最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。改变 Session 的沙箱模式时,用户终端继续以原有权限运行。控制权转移或进程退出后被拒绝的输入和尺寸请求保留输出连接并禁用输入,不重发被拒绝的输入。 +最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。改变 Session 的沙箱模式时,用户终端继续以原有权限运行。控制权转移或进程退出后被拒绝的输入和尺寸请求保留输出连接并禁用输入,不重发被拒绝的输入。提供者完成时即禁用输入,无须等待输出排空;迟到的输入失败不能丢弃已正常结束的订阅者所保留的尾部帧。 -Client 在分配前将每条 Session/内容与终端身份的关联保存到独立的 localStorage key `dsh.terminal.binding.v1.*`。内容身份全局唯一;布局内的 tab id 只标识活动视图 occurrence。逐条记录的写入和删除会保留其他窗口的关联。恢复视图复用该身份;侧栏 terminal provider 先恢复自己的视图,再查询尚无视图的 Host 终端。新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。显式关闭先保存清理请求,再删除关联。当前进程元数据和屏幕内容由 Host 提供,不保存在浏览器中。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。 +初始快照公布载体的完整帧 `inputWindow`;没有 inbox 限额的进程内载体省略该字段。Client 对输入和尺寸调整都计入 JSON 转义、命令字段及载体封装的开销。未确认帧占满窗口时,命令在本地等待;提供者确认后释放额度。`maxInputBytes` 限制原始 UTF-8 输入,`maxBufferedBytes` 限制全部排队及未确认的编码输入帧。单帧无法容纳于载体窗口或 Client 缓存耗尽时,报告 `inputFull` 并取消附件,不重放排队命令。 + +Client 在分配前将每条 Session/内容与终端身份的关联保存到独立的 localStorage key `dsh.terminal.binding.v1.*`。内容身份全局唯一;布局内的 tab id 只标识活动视图 occurrence。逐条记录的写入和删除会保留其他窗口的关联。恢复视图复用该身份;侧栏 terminal provider 先恢复自己的视图,再查询尚无视图的 Host 终端。新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。显式关闭先保存清理请求,再删除关联。当前进程元数据和屏幕内容由 Host 提供,不保存在浏览器中。Client 模型在浏览器完成屏幕解析后确认帧,在未确认 UTF-8 字节预算内连续发送输入,并忽略旧连接迟到的响应。重连建立新的命令序列,绝不重放未确认输入。提供者输入失败会结束附件;进程和恢复屏幕仍可供显式重连使用。Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。 独立的 `retain(sessionId, id, signal)` Remote 流确认窗口持有关系,不激活 Agent、发送屏幕输出、转移输入权或创建进程。终端 provider 提供侧栏的完整打开标签清单,Client 将其与自己保存的身份取交集。同一窗口中的重复 occurrence 共用一个持有流;孤立的旧关联不会保活任何终端。恢复输出连接前必须等待当前持有关系确认。传输取消只释放对应的物理流代次;插件卸载等待全部持有流结束。清理失败时保留所有权并重试,不重新接受连接,也不重新计算空闲宽限期。 diff --git a/packages/api/terminal-controller/locale/en.json b/packages/api/terminal-controller/locale/en.json new file mode 100644 index 0000000000..a505798bad --- /dev/null +++ b/packages/api/terminal-controller/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "api-terminal-controller" + } +} diff --git a/packages/api/terminal-controller/locale/zh.json b/packages/api/terminal-controller/locale/zh.json new file mode 100644 index 0000000000..777c03f31c --- /dev/null +++ b/packages/api/terminal-controller/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "终端控制接口", + "description": "向客户端提供会话终端的创建、输入、读取和恢复能力。" + } +} diff --git a/packages/api/terminal-controller/package.json b/packages/api/terminal-controller/package.json index 9b67bd26cc..56f8ef8671 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -35,7 +35,8 @@ "default": "./lib/typert.remote-client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -57,10 +58,11 @@ "@deepseek-ai/dsh-deque": "workspace:*", "@deepseek-ai/dsh-lazy-require": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "@xterm/headless": "^6.0.0", "@xterm/addon-serialize": "^0.14.0", - "zod": "^4.4.3" + "@xterm/headless": "^6.0.0", + "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/dsh-subprocess": "workspace:*", @@ -84,6 +86,7 @@ "@deepseek-ai/dsh-working-directory": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.js", diff --git a/packages/api/terminal-controller/src/client/model.ts b/packages/api/terminal-controller/src/client/model.ts index 64bea4906e..d143e9c57a 100644 --- a/packages/api/terminal-controller/src/client/model.ts +++ b/packages/api/terminal-controller/src/client/model.ts @@ -1,13 +1,13 @@ /** React-free browser terminal state and reconnecting Remote-stream ownership. */ import { preferredShell, rememberShell } from './shell-preference.ts' -import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { Deque } from '@deepseek-ai/dsh-deque' import { RemoteStreamCarrierError, type ClientRemote, type RemoteStream } from '@deepseek-ai/dsh-api-gateway/client' -import { RemoteError, remoteErrorOf, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, remoteErrorOf, type RemoteResult, type RemoteStreamHandle, type RemoteUplinkWindow } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { - TerminalAttachmentId, TerminalEnvironment, TerminalFrame, + TerminalInput, TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo, } from '../types.ts' @@ -46,6 +46,24 @@ export interface TerminalViewState { readonly issue?: TerminalViewIssue | undefined } +interface InputCost { + readonly inputBytes: number + readonly frameBytes: number +} + +interface TerminalInputAttachment { + readonly handle: RemoteStreamHandle + readonly signal: AbortSignal + sequence: number + acknowledged: number + readonly pending: Map + readonly queued: Deque + window: RemoteUplinkWindow | undefined + bytes: number + bufferedBytes: number + inFlightBytes: number +} + /** A view survives DOM unmount; its process only ends on explicit close. */ export class TerminalView { /** Observable controls, process metadata and the next screen update awaiting acknowledgement. */ @@ -53,14 +71,12 @@ export class TerminalView { private readonly lifetime = new AbortController() private stream: RemoteStream | undefined private mounted = false - private attachmentId: TerminalAttachmentId | undefined + private attachment: TerminalInputAttachment | undefined private pendingRender: { revision: number; resolve: () => void } | undefined private revision = 0 private creation: Promise | undefined private loading: Promise | undefined private closing: Promise | undefined - private writes: Promise = Promise.resolve() - private queuedInput = 0 private readonly detaching = new Set>() /** @@ -164,9 +180,20 @@ export class TerminalView { open: async function* (this: TerminalView, signal: AbortSignal) { await this.retain?.(signal) signal.throwIfAborted() - const attachmentId = randomUUID() as TerminalAttachmentId - this.attachmentId = attachmentId - yield* this.remote.follow(this.sessionId, info.id, attachmentId, signal) + const handle = this.remote.attach(this.sessionId, info.id, signal) + const attachment: TerminalInputAttachment = { + handle, signal, sequence: 0, acknowledged: 0, + pending: new Map(), queued: new Deque(), window: undefined, + bytes: 0, bufferedBytes: 0, inFlightBytes: 0, + } + this.attachment = attachment + try { + for await (const frame of handle) { + if (frame.type === 'snapshot') attachment.window = frame.inputWindow + yield frame + } + } + finally { if (this.attachment === attachment) this.attachment = undefined } }.bind(this), ended: () => new TerminalViewError('attachmentEnded'), carrierFailed: () => { if (this.stream === stream) this.patch({ phase: 'disconnected', writable: false }) }, @@ -187,40 +214,66 @@ export class TerminalView { } /** - * Serialize raw input so concurrent RPC requests cannot reorder keystrokes. - * @param data - input from the terminal emulator. + * Queue input within the raw UTF-8 and encoded-frame budgets, waiting for carrier capacity. + * @param data - input from the terminal emulator; never replayed after disconnection. */ write(data: string): void { const state = this.state.getSnapshot() - const attachmentId = this.attachmentId - if (!state.writable || state.info === undefined || attachmentId === undefined) return + const attachment = this.attachment + if (!state.writable || state.info === undefined || attachment === undefined || attachment.signal.aborted) return + if (data.length === 0) return const bytes = new TextEncoder().encode(data).byteLength - if (this.queuedInput + bytes > (state.environment?.maxInputBytes ?? 0)) { this.fail(new TerminalViewError('inputFull')); return } - this.queuedInput += bytes - const id = state.info.id - this.writes = this.writes.then(async () => { - if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return - valueOf(await this.remote.write(this.sessionId, id, attachmentId, data)) - }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }).finally(() => { this.queuedInput -= bytes }) + if (attachment.bytes + bytes > (state.environment?.maxInputBytes ?? 0)) { this.failInput(new TerminalViewError('inputFull')); return } + this.send(attachment, { type: 'write', sequence: attachment.sequence + 1, data }, bytes) } /** - * Resize only from the currently writable view. + * Resize only from the currently writable view, ordered with keyboard input. * @param cols - measured column count. * @param rows - measured row count. */ resize(cols: number, rows: number): void { const state = this.state.getSnapshot() - const attachmentId = this.attachmentId - if (!state.writable || state.info === undefined || attachmentId === undefined) return + const attachment = this.attachment + if (!state.writable || state.info === undefined || attachment === undefined || attachment.signal.aborted) return if (state.info.cols === cols && state.info.rows === rows) return - const id = state.info.id cols = Math.min(cols, state.environment?.maxCols ?? cols) rows = Math.min(rows, state.environment?.maxRows ?? rows) - this.writes = this.writes.then(async () => { - if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return - valueOf(await this.remote.resize(this.sessionId, id, attachmentId, cols, rows)) - }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }) + this.send(attachment, { type: 'resize', sequence: attachment.sequence + 1, cols, rows }, 0) + } + + private send(attachment: TerminalInputAttachment, command: TerminalInput, bytes: number): void { + const frameBytes = new TextEncoder().encode(JSON.stringify(command)).byteLength + (attachment.window?.frameOverheadBytes ?? 0) + if (attachment.bufferedBytes + frameBytes > (this.state.getSnapshot().environment?.maxBufferedBytes ?? 0) + || (attachment.window !== undefined && frameBytes > attachment.window.maxBytes)) { + this.failInput(new TerminalViewError('inputFull')) + return + } + attachment.queued.pushBack({ command, inputBytes: bytes, frameBytes }) + attachment.sequence = command.sequence + attachment.bytes += bytes + attachment.bufferedBytes += frameBytes + this.flushInput(attachment) + } + + private flushInput(attachment: TerminalInputAttachment): void { + while (this.attachment === attachment && !attachment.signal.aborted && this.state.getSnapshot().writable) { + const next = attachment.queued.popFront() + if (next === undefined) return + if (attachment.window !== undefined && attachment.inFlightBytes + next.frameBytes > attachment.window.maxBytes) { + attachment.queued.pushFront(next) + return + } + try { attachment.handle.send(next.command) } + catch (error) { this.failInput(error); return } + attachment.pending.set(next.command.sequence, { inputBytes: next.inputBytes, frameBytes: next.frameBytes }) + attachment.inFlightBytes += next.frameBytes + } + } + + private failInput(error: unknown): void { + this.fail(error) + this.detach() } /** @@ -269,7 +322,7 @@ export class TerminalView { private detach(): void { const previous = this.stream this.stream = undefined - this.attachmentId = undefined + this.attachment = undefined this.pendingRender?.resolve() this.pendingRender = undefined if (previous !== undefined) { @@ -294,8 +347,23 @@ export class TerminalView { if (frame.sequence !== sequence + 1) throw new TerminalViewError('invalidOutput', 'Terminal output sequence has a gap') sequence = frame.sequence } else if (frame.type === 'snapshot') throw new TerminalViewError('invalidOutput', 'Unexpected terminal screen snapshot') + if (frame.type === 'ack') { + const attachment = this.attachment + /* v8 ignore next -- the physical opener retains its handle until this consumer advances. */ + if (attachment === undefined) throw new TerminalViewError('invalidOutput', 'Terminal acknowledgement has no attachment') + const pending = attachment.pending.get(frame.sequence) + if (frame.sequence !== attachment.acknowledged + 1 || pending === undefined) throw new TerminalViewError('invalidOutput', 'Terminal input acknowledgement is out of order') + attachment.acknowledged = frame.sequence + attachment.bytes -= pending.inputBytes + attachment.bufferedBytes -= pending.frameBytes + attachment.inFlightBytes -= pending.frameBytes + attachment.pending.delete(frame.sequence) + if (frame.refusal !== undefined) this.patch({ writable: false }) + this.flushInput(attachment) + continue + } if (frame.type !== 'output') { - this.patch({ info: frame.info, title: frame.info.title, phase: 'connected', writable: frame.info.state === 'running' && frame.info.controllerId === this.attachmentId }) + this.patch({ info: frame.info, title: frame.info.title, phase: 'connected', writable: frame.info.state === 'running' && frame.writable }) } if (frame.type !== 'state') { const revision = ++this.revision @@ -324,10 +392,6 @@ export class TerminalView { private fail(error: unknown): void { const failure = remoteErrorOf(error) - if (failure?.code === 'terminal/control-unavailable') { - this.patch({ writable: false, error: undefined, issue: undefined }) - return - } const issue = failure?.code === 'terminal/view' ? failure.details.issue : failure?.code === 'terminal/limit-reached' ? 'terminalLimit' : failure?.code === 'terminal/unavailable' ? 'missingTerminal' : undefined this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, issue, error: error instanceof Error ? error.message : String(error) }) } diff --git a/packages/api/terminal-controller/src/index.ts b/packages/api/terminal-controller/src/index.ts index 953c34a9fc..75f454d77d 100644 --- a/packages/api/terminal-controller/src/index.ts +++ b/packages/api/terminal-controller/src/index.ts @@ -5,12 +5,12 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-working-directory' -import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService, type RemoteStream } from '@deepseek-ai/dsh-typert-protocol' import { discoverShells, resolveShell } from './shells.ts' import { BrowserTerminal } from './terminal.ts' import { TerminalRetention } from './retention.ts' import type { - TerminalShell, TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame, TerminalRetentionFrame, + TerminalShell, TerminalInput, TerminalCreateRequest, TerminalEnvironment, TerminalFrame, TerminalRetentionFrame, WebTerminalId, WebTerminalInfo, } from './types.ts' @@ -44,9 +44,9 @@ export interface Config { readonly maxRows: number /** Screen history rows retained for reconnecting clients. */ readonly scrollback: number - /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */ + /** Maximum queued UTF-8 frame bytes per output follower and in the Client input buffer. */ readonly maxBufferedBytes: number - /** Maximum UTF-8 bytes in one input request. */ + /** Maximum raw UTF-8 bytes per input command and across unacknowledged Client input. */ readonly maxInputBytes: number /** Provider process-termination grace period in milliseconds. */ readonly disposeGraceMs: number @@ -120,7 +120,7 @@ export class TerminalController extends TypertRemoteService { signal.throwIfAborted() const { workingDirectory } = this.execution(agent) return { cwd: workingDirectory.get(agent.session), - maxInputBytes: this.config.maxInputBytes, maxCols: this.config.maxCols, + maxInputBytes: this.config.maxInputBytes, maxBufferedBytes: this.config.maxBufferedBytes, maxCols: this.config.maxCols, maxRows: this.config.maxRows, scrollback: this.config.scrollback } } @@ -206,46 +206,17 @@ export class TerminalController extends TypertRemoteService { } /** - * Attach to a terminal without binding its process lifetime to the transport. + * Attach terminal input and output to one physical Remote stream. * @param agent - Session owner supplied by the Gateway. * @param id - terminal identity. - * @param attachmentId - new exclusive input attachment. - * @param signal - physical stream cancellation. - * @returns screen recovery followed by output and metadata changes. + * @param signal - attachment cancellation; the process remains alive and input is never replayed. + * @returns screen recovery, output, writable state and provider-acceptance acknowledgements. */ @Remote({ mode: 'stream' }) - follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable { - if (!/^[\w-]{1,128}$/u.test(attachmentId)) throw new Error('Invalid terminal attachment identity') - return this.terminal(agent, id).follow(attachmentId, signal) - } - - /** - * Deliver raw input, including Tab completion and control characters. - * @param agent - Session owner supplied by the Gateway. - * @param id - terminal identity. - * @param attachmentId - current writable attachment. - * @param data - input bytes represented as UTF-8 text. - * @returns after provider input acceptance. - */ - @Remote - async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise { - if (Buffer.byteLength(data, 'utf8') > this.config.maxInputBytes) throw new Error('Terminal input exceeds the configured limit') - await this.terminal(agent, id).write(attachmentId, data) - } - - /** - * Update the dimensions of the PTY and recovery screen. - * @param agent - Session owner supplied by the Gateway. - * @param id - terminal identity. - * @param attachmentId - current writable attachment. - * @param cols - column count. - * @param rows - row count. - * @returns after the resize completes. - */ - @Remote - async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise { - this.dimensions(cols, rows) - await this.terminal(agent, id).resize(attachmentId, cols, rows) + attach(agent: Agent, id: WebTerminalId, signal: AbortSignal): RemoteStream { + const invocation = this.ctx.invocation + if (invocation === undefined) throw new Error('Terminal attachment requires a Remote invocation') + return this.terminal(agent, id).attach(invocation.uplink(), this.config, signal, invocation.uplinkWindow) } /** diff --git a/packages/api/terminal-controller/src/stream.ts b/packages/api/terminal-controller/src/stream.ts index 4ee89f254f..d3b33dc922 100644 --- a/packages/api/terminal-controller/src/stream.ts +++ b/packages/api/terminal-controller/src/stream.ts @@ -11,6 +11,9 @@ export class TerminalFollower { private finished = false private failure: Error | undefined + /** Whether this attachment still accepts input and output. */ + get active(): boolean { return !this.closed && !this.finished } + /** @param maxBytes - maximum queued UTF-8 bytes for this follower. */ constructor(private readonly maxBytes: number) {} @@ -22,8 +25,7 @@ export class TerminalFollower { if (this.closed || this.finished) return const bytes = Buffer.byteLength(JSON.stringify(frame), 'utf8') if (this.bytes + bytes > this.maxBytes) { - this.failure = new Error('Terminal output consumer exceeded its buffer; reconnect to recover the current screen') - this.close() + this.fail(new Error('Terminal output consumer exceeded its buffer; reconnect to recover the current screen')) return } this.queue.pushBack({ frame, bytes }) @@ -31,6 +33,16 @@ export class TerminalFollower { this.wake?.() } + /** + * Fail this attachment without stopping its terminal; failures after finish or close are ignored. + * @param error - input or output failure reported to the attached consumer. + */ + fail(error: Error): void { + if (!this.active) return + this.failure = error + this.close() + } + /** Finish after delivering every queued frame, including the final exit state. */ finish(): void { this.finished = true diff --git a/packages/api/terminal-controller/src/terminal.ts b/packages/api/terminal-controller/src/terminal.ts index b4f6623037..7268092b97 100644 --- a/packages/api/terminal-controller/src/terminal.ts +++ b/packages/api/terminal-controller/src/terminal.ts @@ -1,12 +1,13 @@ /** One PTY, a bounded terminal emulator and its detachable browser followers. */ -import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import { RemoteError, remoteErrorOf, type RemoteUplinkWindow } from '@deepseek-ai/dsh-typert-protocol' import type { Terminal as HeadlessTerminal } from '@xterm/headless' import type { SerializeAddon as Serializer } from '@xterm/addon-serialize' import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' import { createLazyRequire } from '@deepseek-ai/dsh-lazy-require' import { TerminalFollower } from './stream.ts' import { TerminalRetention, type TerminalRetentionPolicy } from './retention.ts' -import type { TerminalAttachmentId, TerminalFrame, TerminalRetentionFrame, WebTerminalInfo } from './types.ts' +import type { TerminalEnvironment, TerminalInput, TerminalFrame, TerminalRetentionFrame, WebTerminalInfo } from './types.ts' const requireHeadless = createLazyRequire('@xterm/headless', import.meta.url) const requireSerialize = createLazyRequire('@xterm/addon-serialize', import.meta.url) @@ -19,9 +20,11 @@ export class BrowserTerminal { private sequence = 0 private operations: Promise = Promise.resolve() private readonly drained: Promise + /** Provider completion precedes output drainage and the published exit state. */ + private exited = false private closing: Promise | undefined private retention: TerminalRetention | undefined - private controller: { id: TerminalAttachmentId; follower: TerminalFollower } | undefined + private controller: TerminalFollower | undefined /** * @param handle - allocated terminal process range. @@ -69,64 +72,91 @@ export class BrowserTerminal { } /** - * Attach with exclusive input control; an older attachment becomes read-only. - * @param id - browser attachment identity. + * Attach with exclusive input control; older attachments keep receiving output. + * @param input - Gateway-decoded commands; returning its iterator must interrupt pending reads. + * @param limits - input byte and PTY dimension limits validated at the Remote boundary. * @param signal - attachment cancellation; never terminates the process. - * @returns a consistent screen followed by ordered output and state changes. + * @param inputWindow - carrier frame capacity; absent for a carrier without an encoded-frame inbox limit. + * @returns a screen baseline, output, per-attachment state and ordered command acknowledgements. */ - async *follow(id: TerminalAttachmentId, signal: AbortSignal): AsyncIterable { + async *attach(input: AsyncIterable, limits: Pick, signal: AbortSignal, inputWindow?: RemoteUplinkWindow): AsyncIterable { signal.throwIfAborted() const follower = new TerminalFollower(this.maxBufferedBytes) const baseline = await this.enqueue(() => { signal.throwIfAborted() - this.controller = { id, follower } - this.info = { ...this.info, controllerId: id } - this.broadcast({ type: 'state', info: this.info }) - const snapshot: TerminalFrame = { type: 'snapshot', sequence: this.sequence, screen: this.serializer.serialize(), info: this.info } + this.controller = follower + this.broadcastState() + const snapshot: TerminalFrame = { type: 'snapshot', sequence: this.sequence, screen: this.serializer.serialize(), info: this.info, writable: this.writable(follower), ...inputWindow === undefined ? {} : { inputWindow } } this.followers.add(follower) return snapshot }) + const uplink = input[Symbol.asyncIterator]() + const commands = this.consumeInput(uplink, follower, limits, signal).catch((error: unknown) => { + follower.fail(error instanceof Error ? error : new Error(String(error))) + }) try { yield baseline yield* follower.read(signal) } finally { this.followers.delete(follower) follower.close() - if (this.controller?.follower === follower) { + if (this.controller === follower) { this.controller = undefined - const { controllerId: _controllerId, ...info } = this.info - this.info = info - this.broadcast({ type: 'state', info }) + this.broadcastState() + } + // Gateway's uplink return wakes pending reads before attachment cleanup joins the input pump. + await uplink.return?.() + await commands + } + } + + private async consumeInput(input: AsyncIterator, follower: TerminalFollower, limits: Pick, signal: AbortSignal): Promise { + let sequence = 0 + while (true) { + const next = await input.next() + if (next.done === true || signal.aborted || !follower.active) return + const command = next.value + if (!Number.isSafeInteger(command.sequence) || command.sequence !== ++sequence) throw new Error('Terminal input sequence has a gap') + try { + await this.enqueue(async () => { + signal.throwIfAborted() + this.requireController(follower) + switch (command.type) { + case 'write': + if (Buffer.byteLength(command.data, 'utf8') > limits.maxInputBytes) throw new Error('Terminal input exceeds the configured limit') + this.retention?.invalidate() + await this.handle.write(command.data) + break + case 'resize': + if (!Number.isSafeInteger(command.cols) || command.cols < 2 || command.cols > limits.maxCols + || !Number.isSafeInteger(command.rows) || command.rows < 1 || command.rows > limits.maxRows) throw new Error('Terminal dimensions exceed the configured limits') + await this.handle.resize(command.cols, command.rows) + this.screen.resize(command.cols, command.rows) + this.info = { ...this.info, cols: command.cols, rows: command.rows } + this.broadcastState() + break + /* v8 ignore next -- the Gateway codec validates this closed union. */ + default: assertNever(command) + } + }) + follower.push({ type: 'ack', sequence }) + } catch (error) { + const failure = remoteErrorOf(error) + if (failure?.code === 'terminal/control-unavailable') { + follower.push({ type: 'ack', sequence, refusal: failure.details.reason }) + } else if (this.exited) { + follower.push({ type: 'ack', sequence, refusal: 'not-running' }) + } else throw error } } } - /** - * Send raw terminal input without command interpretation. - * @param id - current writable attachment. - * @param data - UTF-8 input, including shell completion/control keys. - * @returns when the provider accepts the input. - */ - write(id: TerminalAttachmentId, data: string): Promise { - this.retention?.invalidate() - return this.enqueue(async () => { this.requireController(id); await this.handle.write(data) }) + private writable(follower: TerminalFollower): boolean { + return !this.exited && this.closing === undefined && this.info.state === 'running' && this.controller === follower } - /** - * Resize the PTY and recovery screen in the same operation order as output. - * @param id - current writable attachment. - * @param cols - validated column count. - * @param rows - validated row count. - * @returns when the provider and emulator use the new dimensions. - */ - resize(id: TerminalAttachmentId, cols: number, rows: number): Promise { - return this.enqueue(async () => { - this.requireController(id) - await this.handle.resize(cols, rows) - this.screen.resize(cols, rows) - this.info = { ...this.info, cols, rows } - this.broadcast({ type: 'state', info: this.info }) - }) + private broadcastState(): void { + for (const follower of this.followers) follower.push({ type: 'state', info: this.info, writable: this.writable(follower) }) } /** @@ -135,7 +165,7 @@ export class BrowserTerminal { */ rename(title: string): void { this.info = { ...this.info, title } - this.broadcast({ type: 'state', info: this.info }) + this.broadcastState() } /** @@ -164,9 +194,9 @@ export class BrowserTerminal { return this.closing } - private requireController(id: TerminalAttachmentId): void { - if (this.closing !== undefined || this.info.state !== 'running') throw new RemoteError('terminal/control-unavailable', 'Terminal is not running', { reason: 'not-running' }) - if (this.controller?.id !== id) throw new RemoteError('terminal/control-unavailable', 'Terminal input is controlled by another attachment', { reason: 'read-only' }) + private requireController(follower: TerminalFollower): void { + if (this.exited || this.closing !== undefined || this.info.state !== 'running') throw new RemoteError('terminal/control-unavailable', 'Terminal is not running', { reason: 'not-running' }) + if (this.controller !== follower || !follower.active) throw new RemoteError('terminal/control-unavailable', 'Terminal input is controlled by another attachment', { reason: 'read-only' }) } private broadcast(frame: TerminalFrame): void { @@ -181,7 +211,7 @@ export class BrowserTerminal { private async consume(): Promise { const decoder = new TextDecoder('utf-8', { ignoreBOM: true }) - const outcome = this.handle.done.then(value => ({ value }), (error: unknown) => ({ error })) + const outcome = this.handle.done.then((value) => { this.exited = true; return { value } }, (error: unknown) => ({ error })) try { for await (const chunk of this.handle.output) { // Node Readable's iterator is untyped; this provider explicitly emits Buffer chunks. @@ -195,7 +225,7 @@ export class BrowserTerminal { } catch (error) { this.info = { ...this.info, state: 'failed', error: error instanceof Error ? error.message : String(error) } } - this.broadcast({ type: 'state', info: this.info }) + this.broadcastState() } private async output(data: string): Promise { diff --git a/packages/api/terminal-controller/src/types.ts b/packages/api/terminal-controller/src/types.ts index 8490ea1a7f..7c0eba8ff2 100644 --- a/packages/api/terminal-controller/src/types.ts +++ b/packages/api/terminal-controller/src/types.ts @@ -1,6 +1,6 @@ /** Browser terminal identities, metadata and screen-stream frames. */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type {} from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteUplinkWindow } from '@deepseek-ai/dsh-typert-protocol' declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { @@ -15,8 +15,6 @@ declare module '@deepseek-ai/dsh-typert-protocol' { /** A terminal identity scoped to one Session and one Host lifetime. */ export type WebTerminalId = Branded<'WebTerminalId'> -/** An attachment allowed to write and resize one terminal. */ -export type TerminalAttachmentId = Branded<'TerminalAttachmentId'> /** Acknowledges one physical window hold without taking screen or input control. */ export interface TerminalRetentionFrame { @@ -35,6 +33,8 @@ export interface TerminalEnvironment { /** Logged current directory, read without filesystem validation. */ readonly cwd: string readonly maxInputBytes: number + /** Maximum complete encoded Client input bytes retained until acknowledgement. */ + readonly maxBufferedBytes: number readonly maxCols: number readonly maxRows: number readonly scrollback: number @@ -52,7 +52,6 @@ export interface WebTerminalInfo { readonly state: 'running' | 'exited' | 'failed' readonly exitCode: number | null readonly error?: string - readonly controllerId?: TerminalAttachmentId } /** Create is idempotent for an open identity; closed identities cannot be recreated. */ @@ -64,8 +63,17 @@ export interface TerminalCreateRequest { readonly rows: number } -/** Every attachment begins with a complete bounded screen, then ordered output. */ +/** Commands are numbered consecutively from one within each attachment; acknowledgements never authorize replay. */ +export type TerminalInput = + | { readonly type: 'write'; readonly sequence: number; readonly data: string } + | { readonly type: 'resize'; readonly sequence: number; readonly cols: number; readonly rows: number } + +/** + * Every attachment begins with a complete bounded screen, then ordered output. + * inputWindow is absent when the carrier has no encoded-frame inbox limit. + */ export type TerminalFrame = - | { readonly type: 'snapshot'; readonly sequence: number; readonly screen: string; readonly info: WebTerminalInfo } + | { readonly type: 'snapshot'; readonly sequence: number; readonly screen: string; readonly info: WebTerminalInfo; readonly writable: boolean; readonly inputWindow?: RemoteUplinkWindow } | { readonly type: 'output'; readonly sequence: number; readonly data: string } - | { readonly type: 'state'; readonly info: WebTerminalInfo } + | { readonly type: 'state'; readonly info: WebTerminalInfo; readonly writable: boolean } + | { readonly type: 'ack'; readonly sequence: number; readonly refusal?: 'read-only' | 'not-running' } diff --git a/packages/api/terminal-controller/tests/controller.spec.ts b/packages/api/terminal-controller/tests/controller.spec.ts index d9f7ee273f..aa907a95b8 100644 --- a/packages/api/terminal-controller/tests/controller.spec.ts +++ b/packages/api/terminal-controller/tests/controller.spec.ts @@ -11,8 +11,10 @@ import { SubprocessExecutableNotFoundError, type SubprocessRuntime, type Subproc import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import { afterEach, describe, expect, it, vi } from 'vitest' import { TerminalController, type Config } from '../src/index.ts' +import { inputChannel } from './input.ts' +import type { RemoteInvocation, PeerId } from '@deepseek-ai/dsh-typert-protocol' import { resolveShell } from '../src/shells.ts' -import type { TerminalAttachmentId, WebTerminalId } from '../src/types.ts' +import type { TerminalInput, WebTerminalId } from '../src/types.ts' const roots: Context[] = [] afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) }) @@ -70,7 +72,7 @@ describe('TerminalController', () => { it('creates the environment default shell and keeps an existing identity when that default changes', async () => { const { controller, agent, subprocess } = fixture({ shell: undefined }) subprocess.terminalEnvironment.mockResolvedValue({ platform: 'posix', defaultShell: '/usr/local/bin/zsh' }) - expect(controller.environment(agent, signal())).toEqual({ cwd: '/workspace', maxCols: 200, maxRows: 100, maxInputBytes: 1000, scrollback: 100 }) + expect(controller.environment(agent, signal())).toEqual({ cwd: '/workspace', maxCols: 200, maxRows: 100, maxInputBytes: 1000, maxBufferedBytes: 100_000, scrollback: 100 }) expect(subprocess.terminalEnvironment).not.toHaveBeenCalled() const created = await controller.create(agent, request, signal()) expect(created.shell.path).toBe('/usr/local/bin/zsh') @@ -87,7 +89,7 @@ describe('TerminalController', () => { subprocess.resolveExecutable.mockRejectedValue(new SubprocessExecutableNotFoundError('default shell was removed')) expect(controller.environment(agent, signal())).toMatchObject({ cwd: '/workspace', maxInputBytes: 1000 }) expect(controller.list(agent.id)).toMatchObject([{ id, state: 'running' }]) - const stream = controller.follow(agent, id, 'restored' as TerminalAttachmentId, signal())[Symbol.asyncIterator]() + const stream = attachController(agent, id, signal())[Symbol.asyncIterator]() try { expect(await stream.next()).toMatchObject({ done: false, value: { type: 'snapshot', info: { id, state: 'running' } } }) expect(subprocess.resolveExecutable).toHaveBeenCalledOnce() @@ -181,9 +183,9 @@ describe('TerminalController', () => { expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ terminalType: 'xterm-256color', cwd: '/workspace' })) expect(controller.list('other' as SessionId)).toEqual([]) - expect(() => controller.follow(owner(ctx, 'other'), id, 'writer' as TerminalAttachmentId, signal())).toThrow('no longer exists') + expect(() => attachController(owner(ctx, 'other'), id, signal())).toThrow('no longer exists') const abort = new AbortController() - const stream = controller.follow(agent, id, 'writer' as TerminalAttachmentId, abort.signal)[Symbol.asyncIterator]() + const stream = attachController(agent, id, abort.signal)[Symbol.asyncIterator]() const firstFrame = await stream.next() if (firstFrame.done === true) throw new Error('Terminal stream ended before its snapshot') expect(firstFrame.value.type).toBe('snapshot') @@ -266,13 +268,13 @@ describe('TerminalController', () => { expect(controller.list(agent.id)).toHaveLength(0) }) - it('rejects invalid dimensions, unavailable shells and oversized input before executing them', async () => { + it('rejects invalid dimensions and unavailable shells before allocating a process', async () => { const { controller, agent, subprocess } = fixture() await expect(controller.create(agent, { ...request, cols: 1 }, signal())).rejects.toThrow('dimensions') subprocess.resolveExecutable.mockRejectedValueOnce(new SubprocessExecutableNotFoundError('shell unavailable')) await expect(controller.create(agent, request, signal())).rejects.toThrow('unavailable') expect(subprocess.spawnTerminal).not.toHaveBeenCalled() - await expect(controller.write(agent, id, 'writer' as TerminalAttachmentId, 'x'.repeat(1001))).rejects.toThrow('input') + }) it('keeps the committed identity on repeated creation and counts pending reservations toward the Session limit', async () => { @@ -319,27 +321,32 @@ describe('TerminalController', () => { expect(controller.list(agent.id)).toEqual([]) }) - it('routes input, resize and trimmed names through the active attachment', async () => { + it('routes ordered input and resize through the current Remote attachment', async () => { const { controller, agent, handle } = fixture({ maxInputBytes: 6 }) await controller.create(agent, request, signal()) - const attachmentId = 'writer' as TerminalAttachmentId - const stream = controller.follow(agent, id, attachmentId, signal())[Symbol.asyncIterator]() + const input = inputChannel() + const stream = attachController(agent, id, signal(), input.source)[Symbol.asyncIterator]() await stream.next() try { - await controller.write(agent, id, attachmentId, '终端') + input.push({ type: 'write', sequence: 1, data: '终端' }) + expect((await stream.next()).value).toMatchObject({ type: 'ack', sequence: 1 }) expect(handle.write).toHaveBeenCalledWith('终端') - await expect(controller.write(agent, id, attachmentId, '终端!')).rejects.toThrow('input exceeds') - await controller.resize(agent, id, attachmentId, 200, 100) + input.push({ type: 'resize', sequence: 2, cols: 200, rows: 100 }) + expect((await stream.next()).value).toMatchObject({ type: 'state', info: { cols: 200, rows: 100 } }) + expect((await stream.next()).value).toMatchObject({ type: 'ack', sequence: 2 }) expect(handle.resize).toHaveBeenCalledWith(200, 100) controller.rename(agent, id, ' server logs ') expect(controller.list(agent.id)).toMatchObject([{ title: 'server logs', cols: 200, rows: 100 }]) + input.push({ type: 'write', sequence: 3, data: '终端!' }) + await stream.next() + await expect(stream.next()).rejects.toThrow('input exceeds') } finally { await stream.return?.() } }) - it.each([{ cols: 1.5, rows: 24 }, { cols: 201, rows: 24 }, { cols: 80, rows: 1.5 }, { cols: 80, rows: 0 }, { cols: 80, rows: 101 }])('rejects dimensions $cols × $rows before allocation or resize', async (dimensions) => { + it.each([{ cols: 1.5, rows: 24 }, { cols: 201, rows: 24 }, { cols: 80, rows: 1.5 }, { cols: 80, rows: 0 }, { cols: 80, rows: 101 }])('rejects dimensions $cols × $rows before allocation', async (dimensions) => { const { controller, agent, subprocess, handle } = fixture() await expect(controller.create(agent, { ...request, ...dimensions }, signal())).rejects.toThrow('dimensions') - await expect(controller.resize(agent, id, 'writer' as TerminalAttachmentId, dimensions.cols, dimensions.rows)).rejects.toThrow('dimensions') + expect(subprocess.spawnTerminal).not.toHaveBeenCalled() expect(handle.resize).not.toHaveBeenCalled() }) @@ -347,7 +354,7 @@ describe('TerminalController', () => { it('rejects invalid wire identities and names without changing retained terminals', async () => { const { controller, agent } = fixture() await expect(controller.create(agent, { ...request, id: '../terminal' as WebTerminalId }, signal())).rejects.toThrow('Invalid terminal identity') - expect(() => controller.follow(agent, id, '' as TerminalAttachmentId, signal())).toThrow('attachment identity') + expect(() => controller.attach(agent, id, signal())).toThrow('Remote invocation') expect(() => { controller.rename(agent, id, ' ') }).toThrow('1–120') expect(() => { controller.rename(agent, id, 'x'.repeat(121)) }).toThrow('1–120') await controller.close(agent, id) @@ -699,12 +706,40 @@ it.each(['allocation', 'committed'] as const)('retains failed %s cleanup, report it('classifies missing and closing terminal identities for localized recovery actions', async () => { const h = fixture() - expect(() => h.controller.follow(h.agent, id, 'view' as TerminalAttachmentId, signal())) + expect(() => attachController(h.agent, id, signal())) .toThrow(expect.objectContaining({ code: 'terminal/unavailable' })) await h.controller.create(h.agent, request, signal()) h.handle.terminate.mockRejectedValueOnce(new Error('cleanup pending')) await expect(h.controller.close(h.agent, id)).rejects.toThrow('cleanup pending') - expect(() => h.controller.follow(h.agent, id, 'view' as TerminalAttachmentId, signal())) + expect(() => attachController(h.agent, id, signal())) .toThrow(expect.objectContaining({ code: 'terminal/unavailable' })) await expect(h.controller.create(h.agent, request, signal())).rejects.toMatchObject({ code: 'terminal/unavailable' }) }) + +it('publishes the carrier input window in the attachment snapshot', async () => { + const { controller, agent } = fixture() + await controller.create(agent, request, signal()) + const abort = new AbortController() + const window = { maxBytes: 512, frameOverheadBytes: 76 } + const frames = attachController(agent, id, abort.signal, inputChannel().source, window)[Symbol.asyncIterator]() + try { + expect(await frames.next()).toMatchObject({ value: { type: 'snapshot', inputWindow: window } }) + } finally { + abort.abort() + await frames.return?.() + } +}) + +function attachController( + agent: Agent, terminalId: WebTerminalId, signal: AbortSignal, + input: AsyncIterable = inputChannel().source, + uplinkWindow?: RemoteInvocation['uplinkWindow'], +) { + const invocation: RemoteInvocation = { + request: { namespace: 'terminal', method: 'attach', args: {} }, service: 'terminalController', signal, + peer: { id: 'test-peer' as PeerId, ctx: agent.ctx, dispose: async () => {} }, + uplink: () => input as AsyncIterable, + uplinkWindow, + } + return agent.ctx.extend({ invocation }).terminalController.attach(agent, terminalId, signal) +} diff --git a/packages/api/terminal-controller/tests/input.ts b/packages/api/terminal-controller/tests/input.ts new file mode 100644 index 0000000000..ae7a75c559 --- /dev/null +++ b/packages/api/terminal-controller/tests/input.ts @@ -0,0 +1,16 @@ +/** A typed uplink whose return interrupts a pending read, matching Gateway invocation cleanup. */ +export function inputChannel() { + let controller!: ReadableStreamDefaultController + const stream = new ReadableStream({ start(value) { controller = value } }) + const reader = stream.getReader() + const source: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + async next() { + const result = await reader.read() + return result.done ? { done: true, value: undefined } : { done: false, value: result.value } + }, + async return() { await reader.cancel(); return { done: true, value: undefined } }, + }), + } + return { source, push: (value: T) => { controller.enqueue(value) }, end: () => { controller.close() } } +} diff --git a/packages/api/terminal-controller/tests/model.client.spec.ts b/packages/api/terminal-controller/tests/model.client.spec.ts index 2c34b9c54b..fbae792001 100644 --- a/packages/api/terminal-controller/tests/model.client.spec.ts +++ b/packages/api/terminal-controller/tests/model.client.spec.ts @@ -1,19 +1,18 @@ /** Browser view ownership across slow RPCs, remounts and transport generations. */ -import { setImmediate } from 'node:timers/promises' import { afterEach, expect, it, vi } from 'vitest' import { RemoteStream, RemoteStreamCarrierError, type ClientRemote, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' -import { streamMethod } from '@deepseek-ai/dsh-remote-mock' +import { RemoteError, type RemoteResult, type RemoteUplinkWindow } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteMock, streamMethod, type StreamHandle } from '@deepseek-ai/dsh-remote-mock' import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { TerminalView, type TerminalRemote } from '../src/client/model.ts' -import type { TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts' +import type { TerminalInput, TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts' const sessionId = 'session' as SessionId const info: WebTerminalInfo = { id: 'terminal' as WebTerminalId, shell: { name: 'bash', path: '/bin/bash', args: ['-i'] }, title: 'bash', cwd: '/workspace', rows: 24, cols: 80, state: 'running', exitCode: null } const environment: TerminalEnvironment = { - cwd: info.cwd, maxInputBytes: 1000, maxCols: 200, maxRows: 100, scrollback: 100, + cwd: info.cwd, maxInputBytes: 1000, maxBufferedBytes: 100_000, maxCols: 200, maxRows: 100, scrollback: 100, } const success = (value: T): RemoteResult => ({ ok: true, value }) const cleanups: (() => void | Promise)[] = [] @@ -30,7 +29,16 @@ function failure(message: string): RemoteResult { return { ok: false, error: new RemoteError('gateway/bad-request', message, {}) } } -function fixture(prepareStream?: (stream: RemoteStream) => void) { +function fixture(prepareStream?: (stream: RemoteStream) => void, inputWindow?: RemoteUplinkWindow) { + const attachments: StreamHandle[] = [] + const sent: TerminalInput[][] = [] + const mock = RemoteMock.create().stream('terminal/attach', async (args, stream) => { + attachments.push(stream) + const commands: TerminalInput[] = [] + sent.push(commands) + stream.push({ type: 'snapshot', sequence: 0, screen: 'ready', info: { ...info, id: args[1] }, writable: true, ...inputWindow === undefined ? {} : { inputWindow } }) + for await (const command of stream.uplink) commands.push(command as TerminalInput) + }) const remote: TerminalRemote = { retain: vi.fn(streamMethod(async function* (_session, _id, signal) { yield { type: 'retained' } @@ -43,11 +51,7 @@ function fixture(prepareStream?: (stream: RemoteStream) => void) { environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), create: vi.fn(async (_sessionId, request) => success({ ...info, id: request.id })), close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), - write: vi.fn(async () => success(undefined)), resize: vi.fn(async () => success(undefined)), - follow: vi.fn(streamMethod(async function* (_sessionId, id, attachmentId, signal) { - yield { type: 'snapshot', sequence: 0, screen: 'ready', info: { ...info, id, controllerId: attachmentId } } - await untilAborted(signal) - })), + attach: mock.remote.terminal.attach, } const options: RemoteStreamOptions[] = [] const streams: { dispose(): Promise; restart(): void }[] = [] @@ -64,7 +68,7 @@ function fixture(prepareStream?: (stream: RemoteStream) => void) { cleanups.push(async () => { await Promise.all(streams.map(stream => stream.dispose())) }) const model = new TerminalView(sessionId, remote, gateway, info.id) cleanups.push(() => model.dispose()) - return { model, remote, gateway, options, streams, generation } + return { model, remote, gateway, options, streams, generation, attachments, sent } } async function mount(model: TerminalView) { @@ -84,29 +88,11 @@ async function connected(model: TerminalView): Promise { acknowledge(model) } -it('does not let a late input failure downgrade a newer connection', async () => { - const { model, remote } = fixture() - await mount(model) - await expect.poll(() => model.state.getSnapshot().writable).toBe(true) - acknowledge(model) - const input = Promise.withResolvers>() - vi.mocked(remote.write).mockReturnValueOnce(input.promise) - model.write('x') - await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) - model.connect() - await expect.poll(() => vi.mocked(remote.follow).mock.calls.length).toBe(2) - await expect.poll(() => model.state.getSnapshot().writable).toBe(true) - model.write('current input') - input.reject(new Error('old attachment was replaced')) - await expect.poll(() => vi.mocked(remote.write).mock.calls.some(call => call[3] === 'current input')).toBe(true) - expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: true, error: undefined }) -}) - it('waits for render acknowledgement before publishing subsequent output', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_sessionId, id, controllerId, signal) { + vi.mocked(remote.attach).mockImplementation(streamMethod(async function* (_sessionId, id, signal) { const frames: TerminalFrame[] = [ - { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } }, + { type: 'snapshot', writable: true, sequence: 0, screen: '', info: { ...info, id } }, { type: 'output', sequence: 1, data: 'first' }, { type: 'output', sequence: 2, data: 'second' }, ] @@ -121,77 +107,6 @@ it('waits for render acknowledgement before publishing subsequent output', async await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'output', sequence: 2 }) }) -it('serializes input and resizes, clamps geometry, and suppresses unchanged sizes', async () => { - const { model, remote } = fixture() - await connected(model) - const input = Promise.withResolvers>() - vi.mocked(remote.write).mockReturnValueOnce(input.promise) - model.resize(80, 24) - model.write('first') - model.write('second') - model.resize(500, 300) - await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) - expect(remote.resize).not.toHaveBeenCalled() - input.resolve(success(undefined)) - await expect.poll(() => vi.mocked(remote.resize).mock.calls.length).toBe(1) - const attachmentId = vi.mocked(remote.follow).mock.calls[0]![2] - const id = model.state.getSnapshot().info!.id - expect(vi.mocked(remote.write).mock.calls.map(call => call[3])).toEqual(['first', 'second']) - expect(remote.resize).toHaveBeenCalledWith(sessionId, id, attachmentId, 200, 100) - model.resize(90, 24) - await expect.poll(() => vi.mocked(remote.resize).mock.calls.length).toBe(2) -}) - -it('bounds queued input by UTF-8 bytes and releases the byte budget after settlement', async () => { - const { model, remote } = fixture() - vi.mocked(remote.environment).mockResolvedValue(success({ ...environment, maxInputBytes: 6 })) - await connected(model) - const input = Promise.withResolvers>() - vi.mocked(remote.write).mockReturnValueOnce(input.promise) - model.write('界界') - await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) - model.write('a') - expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' }) - input.resolve(success(undefined)) - model.connect() - await expect.poll(() => model.state.getSnapshot().writable).toBe(true) - model.write('界界') - await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(2) - expect(vi.mocked(remote.write).mock.calls.map(call => call[3])).toEqual(['界界', '界界']) -}) - -it('drops queued commands from a detached attachment and ignores its late resize failure', async () => { - const { model, remote } = fixture() - await connected(model) - const input = Promise.withResolvers>() - vi.mocked(remote.resize).mockReturnValueOnce(input.promise) - model.resize(100, 30) - await expect.poll(() => vi.mocked(remote.resize).mock.calls.length).toBe(1) - model.write('stale') - model.resize(110, 35) - model.connect() - await expect.poll(() => model.state.getSnapshot().writable).toBe(true) - model.write('current') - input.resolve(failure('old attachment')) - await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) - expect(vi.mocked(remote.write).mock.calls[0]?.[3]).toBe('current') - expect(remote.resize).toHaveBeenCalledOnce() - expect(model.state.getSnapshot().phase).toBe('connected') -}) - -it('publishes current input and resize failures and can reconnect for another attempt', async () => { - const { model, remote } = fixture() - await connected(model) - vi.mocked(remote.write).mockResolvedValueOnce(failure('input refused')) - model.write('x') - await expect.poll(() => model.state.getSnapshot().error).toBe('input refused') - model.connect() - await expect.poll(() => model.state.getSnapshot().writable).toBe(true) - vi.mocked(remote.resize).mockResolvedValueOnce(failure('resize refused')) - model.resize(100, 30) - await expect.poll(() => model.state.getSnapshot().error).toBe('resize refused') -}) - it('treats carrier loss as disconnected only for the active attachment', async () => { const { model, options } = fixture() await connected(model) @@ -206,11 +121,11 @@ it('treats carrier loss as disconnected only for the active attachment', async ( it.each([ ['output before snapshot', [{ type: 'output', sequence: 1, data: 'orphan' }], 'missing its screen snapshot'], - ['sequence gap', [{ type: 'snapshot', sequence: 4, screen: '', info }, { type: 'output', sequence: 6, data: 'gap' }], 'sequence has a gap'], - ['repeated snapshot', [{ type: 'snapshot', sequence: 4, screen: '', info }, { type: 'snapshot', sequence: 4, screen: '', info }], 'Unexpected terminal screen snapshot'], + ['sequence gap', [{ type: 'snapshot', writable: true, sequence: 4, screen: '', info }, { type: 'output', sequence: 6, data: 'gap' }], 'sequence has a gap'], + ['repeated snapshot', [{ type: 'snapshot', writable: true, sequence: 4, screen: '', info }, { type: 'snapshot', writable: true, sequence: 4, screen: '', info }], 'Unexpected terminal screen snapshot'], ] satisfies readonly (readonly [string, readonly TerminalFrame[], string])[])('refuses a malformed output stream: %s', async (_name, frames, message) => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(streamMethod(async function* () { yield* frames })) + vi.mocked(remote.attach).mockImplementation(streamMethod(async function* () { yield* frames })) await mount(model) if (frames[0]?.type === 'snapshot') { await expect.poll(() => model.state.getSnapshot().render).toBeDefined() @@ -222,9 +137,9 @@ it.each([ it('retains an exited screen and closes controls when its stream ends', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_sessionId, id, controllerId) { - yield { type: 'snapshot', sequence: 0, screen: 'last screen', info: { ...info, id, controllerId } } - yield { type: 'state', info: { ...info, id, controllerId, state: 'exited', exitCode: 3 } } + vi.mocked(remote.attach).mockImplementation(streamMethod(async function* (_sessionId, id) { + yield { type: 'snapshot', writable: true, sequence: 0, screen: 'last screen', info: { ...info, id } } + yield { type: 'state', writable: true, info: { ...info, id, state: 'exited', exitCode: 3 } } })) await connected(model) await expect.poll(() => model.state.getSnapshot().phase).toBe('closed') @@ -233,8 +148,8 @@ it('retains an exited screen and closes controls when its stream ends', async () it('reports an attachment ending while the shell is still running and permits manual reconnect', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementationOnce(streamMethod(async function* (_sessionId, id, controllerId) { - yield { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } } + vi.mocked(remote.attach).mockImplementationOnce(streamMethod(async function* (_sessionId, id) { + yield { type: 'snapshot', writable: true, sequence: 0, screen: '', info: { ...info, id } } })) await connected(model) await expect.poll(() => model.state.getSnapshot().issue).toBe('attachmentEnded') @@ -244,14 +159,14 @@ it('reports an attachment ending while the shell is still running and permits ma it('publishes remote control transfer without replacing the retained screen', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_sessionId, id, controllerId, signal) { - yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } } - yield { type: 'state', info: { ...info, id } } + vi.mocked(remote.attach).mockImplementation(streamMethod(async function* (_sessionId, id, signal) { + yield { type: 'snapshot', writable: true, sequence: 0, screen: 'retained', info: { ...info, id } } + yield { type: 'state', writable: false, info: { ...info, id } } await untilAborted(signal) })) await connected(model) await expect.poll(() => model.state.getSnapshot().writable).toBe(false) - expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' }) + expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', writable: true, screen: 'retained' }) }) /** Pause delivery after the real Gateway iterator settles, before the model observes it. */ @@ -293,13 +208,13 @@ it('ignores an already delivered screen when its attachment is replaced before t barrier.release() await model.rename('settled') expect(model.state.getSnapshot().render).toBe(current.render) - expect(remote.follow).toHaveBeenCalledTimes(2) + expect(remote.attach).toHaveBeenCalledTimes(2) }) it('ignores an already settled stream error after a newer attachment has become writable', async () => { const barrier = deliveryBarrier() const { model, remote } = fixture(barrier.prepare) - vi.mocked(remote.follow).mockImplementationOnce(streamMethod(async function* () { + vi.mocked(remote.attach).mockImplementationOnce(streamMethod(async function* () { yield* [] throw new Error('retired attachment failed') })) @@ -319,7 +234,7 @@ it('releases a pending screen render on carrier generation cancellation and acce const revision = model.state.getSnapshot().render!.revision streams[0]!.restart() await expect.poll(() => model.state.getSnapshot().render?.revision).toBe(revision + 1) - expect(remote.follow).toHaveBeenCalledTimes(2) + expect(remote.attach).toHaveBeenCalledTimes(2) expect(model.state.getSnapshot().writable).toBe(true) }) @@ -331,96 +246,10 @@ it('does not wait for the DOM callback of a screen whose generation was already streams[0]!.restart() barrier.release() await expect.poll(() => model.state.getSnapshot().render?.revision).toBe(2) - expect(remote.follow).toHaveBeenCalledTimes(2) + expect(remote.attach).toHaveBeenCalledTimes(2) expect(model.state.getSnapshot().writable).toBe(true) }) -it.each(['write', 'resize'] as const)('keeps a fresh attachment writable when a pending %s fails after automatic transport recovery', async (operation) => { - const { model, remote, generation } = fixture() - generation.set({ id: 1, host: { home: '/home/fixture' } }) - const disconnected = Promise.withResolvers() - vi.mocked(remote.follow).mockImplementationOnce(streamMethod(async function* (_sessionId, id, controllerId, signal) { - yield { type: 'snapshot', sequence: 0, screen: 'before disconnect', info: { ...info, id, controllerId } } - await Promise.race([disconnected.promise, untilAborted(signal)]) - if (signal?.aborted) return - throw new RemoteStreamCarrierError('connection lost') - })) - await connected(model) - const pending = Promise.withResolvers>() - if (operation === 'write') { - vi.mocked(remote.write).mockReturnValueOnce(pending.promise) - model.write('old input') - } else { - vi.mocked(remote.resize).mockReturnValueOnce(pending.promise) - model.resize(100, 30) - } - await expect.poll(() => vi.mocked(remote[operation]).mock.calls.length).toBe(1) - const oldAttachmentId = vi.mocked(remote.follow).mock.calls[0]![2] - generation.set(undefined) - disconnected.resolve(undefined) - await expect.poll(() => model.state.getSnapshot().phase).toBe('disconnected') - expect(model.state.getSnapshot().writable).toBe(false) - expect(remote.follow).toHaveBeenCalledOnce() - - generation.set({ id: 2, host: { home: '/home/fixture' } }) - await expect.poll(() => model.state.getSnapshot().writable).toBe(true) - expect(remote.follow).toHaveBeenCalledTimes(2) - const attachmentId = vi.mocked(remote.follow).mock.calls[1]![2] - expect(attachmentId).not.toBe(oldAttachmentId) - expect(model.state.getSnapshot().info?.controllerId).toBe(attachmentId) - expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'ready' }) - - model.write('fresh input') - pending.reject(new Error('old attachment was replaced')) - await expect.poll(() => vi.mocked(remote.write).mock.calls.some(call => call[3] === 'fresh input')).toBe(true) - expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: true, error: undefined }) - expect(vi.mocked(remote.write).mock.calls.at(-1)?.[2]).toBe(attachmentId) - expect(remote.create).toHaveBeenCalledOnce() - expect(remote.close).not.toHaveBeenCalled() -}) - -it('ignores controls before discovery and reconnects an existing process after remount', async () => { - const { model, remote } = fixture() - model.connect() - model.write('early') - model.resize(100, 30) - model.acknowledge(100) - expect(remote.follow).not.toHaveBeenCalled() - expect(remote.write).not.toHaveBeenCalled() - expect(remote.resize).not.toHaveBeenCalled() - const unmount = await mount(model) - await expect.poll(() => model.state.getSnapshot().writable).toBe(true) - unmount() - model.connect() - model.write('detached') - model.resize(100, 30) - expect(remote.follow).toHaveBeenCalledOnce() - expect(remote.write).not.toHaveBeenCalled() - expect(remote.resize).not.toHaveBeenCalled() - model.mount() - await expect.poll(() => remote.follow).toHaveBeenCalledTimes(2) - await expect.poll(() => model.state.getSnapshot().writable).toBe(true) - expect(remote.create).toHaveBeenCalledOnce() -}) - -it('keeps controls inert when process metadata or the attachment is unavailable', async () => { - const { model, remote } = fixture() - await connected(model) - const state = model.state.getSnapshot() - model.state.set({ ...state, info: undefined }) - model.write('missing metadata') - model.resize(100, 30) - expect(remote.write).not.toHaveBeenCalled() - expect(remote.resize).not.toHaveBeenCalled() - model.state.set({ ...state, environment: undefined }) - model.resize(100, 30) - await expect.poll(() => remote.resize).toHaveBeenCalledOnce() - expect(vi.mocked(remote.resize).mock.calls[0]?.slice(-2)).toEqual([100, 30]) - model.write('unknown budget') - expect(model.state.getSnapshot().issue).toBe('inputFull') - expect(remote.write).not.toHaveBeenCalled() -}) - it('renames an existing terminal, skips unchanged names, and exposes rename failures', async () => { const { model, remote } = fixture() await connected(model) @@ -462,7 +291,7 @@ it('retains state when disposal overtakes successful allocation or a failed disc else discovery.reject(new Error('late discovery failure')) await loading expect(model.state.getSnapshot()).toBe(before) - expect(remote.follow).not.toHaveBeenCalled() + expect(remote.attach).not.toHaveBeenCalled() } }) @@ -487,7 +316,7 @@ it('allows retry after failed process cleanup and never reconnects while close i const first = model.close() expect(model.close()).toBe(first) model.connect() - expect(remote.follow).toHaveBeenCalledOnce() + expect(remote.attach).toHaveBeenCalledOnce() closing.resolve(failure('close refused')) await expect(first).rejects.toThrow('close refused') expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'close refused' }) @@ -509,55 +338,7 @@ it('does not publish a close result or reconnect after disposal', async () => { model.mount() model.connect() expect(model.state.getSnapshot()).toBe(before) - expect(remote.follow).toHaveBeenCalledOnce() -}) - -it.each(['write', 'resize'] as const)('keeps the output connection when %s loses control before its state frame arrives', async (operation) => { - const { model, remote } = fixture() - const transfer = Promise.withResolvers() - const response = Promise.withResolvers>() - cleanups.push(() => { transfer.resolve(undefined); response.resolve(success(undefined)) }) - vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_session, id, controllerId, signal) { - yield { type: 'snapshot', sequence: 0, screen: 'retained screen', info: { ...info, id, controllerId } } - await transfer.promise - yield { type: 'state', info: { ...info, id } } - await untilAborted(signal) - })) - await connected(model) - vi.mocked(remote[operation]).mockReturnValueOnce(response.promise) - if (operation === 'write') model.write('before transfer') - else model.resize(100, 30) - model.write('queued before transfer') - await expect.poll(() => vi.mocked(remote[operation]).mock.calls.length).toBe(1) - response.resolve({ ok: false, error: new RemoteError('terminal/control-unavailable', 'Another window owns input', { reason: 'read-only' }) }) - await expect.poll(() => model.state.getSnapshot().writable).toBe(false) - expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', error: undefined, issue: undefined, render: { frame: { screen: 'retained screen' } } }) - transfer.resolve(undefined) - await expect.poll(() => model.state.getSnapshot().info?.controllerId).toBeUndefined() - expect(remote.follow).toHaveBeenCalledOnce() - expect(vi.mocked(remote.write).mock.calls.some(call => call[3] === 'queued before transfer')).toBe(false) -}) - -it('keeps an exited screen when a pending input is refused after the exit state arrives', async () => { - const { model, remote } = fixture() - const exit = Promise.withResolvers() - const response = Promise.withResolvers>() - cleanups.push(() => { exit.resolve(undefined); response.resolve(success(undefined)) }) - vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_session, id, controllerId, signal) { - yield { type: 'snapshot', sequence: 0, screen: 'final screen', info: { ...info, id, controllerId } } - await exit.promise - yield { type: 'state', info: { ...info, id, state: 'exited', exitCode: 0 } } - await untilAborted(signal) - })) - await connected(model) - vi.mocked(remote.write).mockReturnValueOnce(response.promise) - model.write('exit race') - await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) - exit.resolve(undefined) - await expect.poll(() => model.state.getSnapshot().info?.state).toBe('exited') - response.resolve({ ok: false, error: new RemoteError('terminal/control-unavailable', 'Terminal is not running', { reason: 'not-running' }) }) - await setImmediate() - expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: false, error: undefined, issue: undefined, render: { frame: { screen: 'final screen' } } }) + expect(remote.attach).toHaveBeenCalledOnce() }) it('exposes a localized quota error and clears it after a successful retry', async () => { @@ -568,3 +349,208 @@ it('exposes a localized quota error and clears it after a successful retry', asy await connected(model) expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined, error: undefined }) }) + +it('sends input and clamped resize in order without waiting for acknowledgements', async () => { + const h = fixture() + await connected(h.model) + h.model.resize(80, 24) + h.model.write('first') + h.model.write('second') + h.model.resize(500, 300) + await expect.poll(() => h.sent[0]?.length).toBe(3) + expect(h.sent[0]).toEqual([ + { type: 'write', sequence: 1, data: 'first' }, + { type: 'write', sequence: 2, data: 'second' }, + { type: 'resize', sequence: 3, cols: 200, rows: 100 }, + ]) +}) + +it('releases the UTF-8 input budget only on acknowledgement', async () => { + const h = fixture() + vi.mocked(h.remote.environment).mockResolvedValue(success({ ...environment, maxInputBytes: 6 })) + await connected(h.model) + h.model.write('界界') + await expect.poll(() => h.sent[0]?.length).toBe(1) + h.attachments[0]!.push({ type: 'ack', sequence: 1 }) + h.attachments[0]!.push({ type: 'state', info: { ...info, cols: 90 }, writable: true }) + await expect.poll(() => h.model.state.getSnapshot().info?.cols).toBe(90) + h.model.write('界界') + await expect.poll(() => h.sent[0]?.length).toBe(2) + h.model.write('a') + expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' }) +}) + +it('keeps output attached after an input refusal and stops further input', async () => { + const h = fixture() + await connected(h.model) + h.model.write('before transfer') + h.attachments[0]!.push({ type: 'ack', sequence: 1, refusal: 'read-only' }) + await expect.poll(() => h.model.state.getSnapshot().writable).toBe(false) + h.model.write('refused') + h.model.resize(100, 30) + h.attachments[0]!.push({ type: 'output', sequence: 1, data: 'still following' }) + await expect.poll(() => h.model.state.getSnapshot().render?.frame).toMatchObject({ data: 'still following' }) + expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'connected', error: undefined }) + expect(h.remote.attach).toHaveBeenCalledOnce() + expect(h.sent[0]).toHaveLength(1) +}) + +it('discards the old generation budget and never replays unacknowledged input after reconnect', async () => { + const h = fixture() + h.generation.set({ id: 1, host: { home: '/home/fixture' } }) + await connected(h.model) + h.model.write('unconfirmed') + await expect.poll(() => h.sent[0]?.length).toBe(1) + h.generation.set(undefined) + h.attachments[0]!.fail(new RemoteStreamCarrierError('connection lost')) + await expect.poll(() => h.model.state.getSnapshot().phase).toBe('disconnected') + h.model.write('offline') + h.generation.set({ id: 2, host: { home: '/home/fixture' } }) + await expect.poll(() => h.attachments.length).toBe(2) + await expect.poll(() => h.model.state.getSnapshot().writable).toBe(true) + acknowledge(h.model) + h.model.write('fresh') + await expect.poll(() => h.sent[1]?.length).toBe(1) + expect(h.sent[1]).toEqual([{ type: 'write', sequence: 1, data: 'fresh' }]) + h.attachments[0]!.push({ type: 'ack', sequence: 1, refusal: 'read-only' }) + expect(h.model.state.getSnapshot().writable).toBe(true) + expect(h.remote.create).toHaveBeenCalledOnce() +}) + +it('ignores controls before mount and after detach, then reuses the process on remount', async () => { + const h = fixture() + h.model.write('early') + h.model.resize(100, 30) + const unmount = await mount(h.model) + await expect.poll(() => h.model.state.getSnapshot().writable).toBe(true) + unmount() + h.model.write('detached') + h.model.resize(100, 30) + expect(h.sent[0]).toEqual([]) + h.model.mount() + await expect.poll(() => h.remote.attach).toHaveBeenCalledTimes(2) + expect(h.remote.create).toHaveBeenCalledOnce() +}) + +it('rejects an out-of-order acknowledgement and reports synchronous send failures', async () => { + const h = fixture() + await connected(h.model) + h.model.write('input') + h.attachments[0]!.push({ type: 'ack', sequence: 2 }) + await expect.poll(() => h.model.state.getSnapshot().issue).toBe('invalidOutput') + h.model.connect() + await expect.poll(() => h.model.state.getSnapshot().writable).toBe(true) + const handle = vi.mocked(h.remote.attach).mock.results.at(-1)!.value as ReturnType + vi.spyOn(handle, 'send').mockImplementation(() => { throw new Error('send failed') }) + h.model.write('next') + expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'send failed' }) +}) + +it.each(['write', 'resize'] as const)('ignores empty input and stale render acknowledgements and refuses %s without limits', async (operation) => { + const h = fixture() + await connected(h.model) + h.model.acknowledge(-1) + h.model.write('') + const state = h.model.state.getSnapshot() + h.model.state.set({ ...state, info: undefined }) + h.model.write('missing') + h.model.resize(100, 30) + expect(h.sent[0]).toEqual([]) + h.model.state.set({ ...state, environment: undefined }) + if (operation === 'write') h.model.write('no budget') + else h.model.resize(100, 30) + expect(h.sent[0]).toEqual([]) + expect(h.model.state.getSnapshot().issue).toBe('inputFull') +}) + +function encodedFrame(command: TerminalInput): number { + return new TextEncoder().encode(JSON.stringify({ type: 'item', streamId: '00000000-0000-0000-0000-000000000000', value: command })).byteLength +} + +function inputWindow(maxBytes: number): RemoteUplinkWindow { + const command: TerminalInput = { type: 'write', sequence: 1, data: 'a' } + return { maxBytes, frameOverheadBytes: encodedFrame(command) - new TextEncoder().encode(JSON.stringify(command)).byteLength } +} + +it('waits for complete-frame capacity and sends queued write and resize commands in order', async () => { + const commands: TerminalInput[] = [ + { type: 'write', sequence: 1, data: '\u0001界' }, + { type: 'resize', sequence: 2, cols: 90, rows: 25 }, + { type: 'write', sequence: 3, data: 'last' }, + ] + const h = fixture(undefined, inputWindow(Math.max(...commands.map(encodedFrame)))) + await connected(h.model) + h.model.write('\u0001界') + h.model.resize(90, 25) + h.model.write('last') + await expect.poll(() => h.sent[0]).toEqual(commands.slice(0, 1)) + h.attachments[0]!.push({ type: 'ack', sequence: 1 }) + await expect.poll(() => h.sent[0]).toEqual(commands.slice(0, 2)) + h.attachments[0]!.push({ type: 'ack', sequence: 2 }) + await expect.poll(() => h.sent[0]).toEqual(commands) + expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined }) +}) + +it('keeps a burst of short commands within the advertised inbox and resumes on acknowledgements', async () => { + const window = inputWindow(262_144) + const h = fixture(undefined, window) + vi.mocked(h.remote.environment).mockResolvedValue(success({ ...environment, maxInputBytes: 65_536, maxBufferedBytes: 2_097_152 })) + await connected(h.model) + for (let index = 0; index < 3000; index++) h.model.write('a') + await expect.poll(() => h.sent[0]?.length).toBe(2250) + const first = h.sent[0]! + expect(first.reduce((bytes, command) => bytes + encodedFrame(command), 0)).toBeLessThanOrEqual(window.maxBytes) + for (let sequence = 1; sequence <= 1000; sequence++) h.attachments[0]!.push({ type: 'ack', sequence }) + await expect.poll(() => h.sent[0]?.length).toBe(3000) + expect(h.sent[0]!.slice(1000).reduce((bytes, command) => bytes + encodedFrame(command), 0)).toBeLessThanOrEqual(window.maxBytes) + expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined }) +}) + +it('counts queued resize frames against the Client buffer and cancels unsent commands on overflow', async () => { + const command: TerminalInput = { type: 'resize', sequence: 1, cols: 90, rows: 25 } + const bytes = encodedFrame(command) + const h = fixture(undefined, inputWindow(bytes)) + vi.mocked(h.remote.environment).mockResolvedValue(success({ ...environment, maxBufferedBytes: 2 * bytes })) + await connected(h.model) + h.model.resize(90, 25) + h.model.resize(91, 25) + await expect.poll(() => h.sent[0]?.length).toBe(1) + h.model.resize(92, 25) + expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' }) + expect(h.sent[0]).toEqual([command]) +}) + +it('refuses a single escaped input frame larger than the carrier window before sending it', async () => { + const h = fixture(undefined, inputWindow(150)) + await connected(h.model) + h.model.write('\u0001'.repeat(20)) + expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' }) + expect(h.sent[0]).toEqual([]) +}) + +it('rejects acknowledgements for commands still waiting in the Client queue', async () => { + const h = fixture(undefined, inputWindow(120)) + await connected(h.model) + h.model.write('a') + h.model.write('b') + await expect.poll(() => h.sent[0]?.length).toBe(1) + h.attachments[0]!.push({ type: 'ack', sequence: 2 }) + await expect.poll(() => h.model.state.getSnapshot().issue).toBe('invalidOutput') +}) + +it('drops waiting commands when input is refused and never replays them on a new attachment', async () => { + const h = fixture(undefined, inputWindow(120)) + await connected(h.model) + h.model.write('a') + h.model.write('b') + await expect.poll(() => h.sent[0]?.length).toBe(1) + h.attachments[0]!.push({ type: 'ack', sequence: 1, refusal: 'read-only' }) + await expect.poll(() => h.model.state.getSnapshot().writable).toBe(false) + expect(h.sent[0]).toHaveLength(1) + h.model.connect() + await expect.poll(() => h.attachments.length).toBe(2) + await expect.poll(() => h.model.state.getSnapshot().writable).toBe(true) + acknowledge(h.model) + h.model.write('c') + await expect.poll(() => h.sent[1]).toEqual([{ type: 'write', sequence: 1, data: 'c' }]) +}) diff --git a/packages/api/terminal-controller/tests/recovery.client.spec.ts b/packages/api/terminal-controller/tests/recovery.client.spec.ts index 690dd9bf0d..a3104bd55b 100644 --- a/packages/api/terminal-controller/tests/recovery.client.spec.ts +++ b/packages/api/terminal-controller/tests/recovery.client.spec.ts @@ -16,7 +16,9 @@ import * as TerminalClient from '../src/client/index.ts' const sessionId = 'session' as SessionId const info: WebTerminalInfo = { id: 'terminal' as WebTerminalId, shell: { name: 'zsh', path: '/bin/zsh', args: ['-i'] }, title: 'zsh', cwd: '/workspace', rows: 24, cols: 80, state: 'running', exitCode: null } -const environment: TerminalEnvironment = { cwd: info.cwd, maxInputBytes: 1000, maxCols: 200, maxRows: 100, scrollback: 100 } +const environment: TerminalEnvironment = { + cwd: info.cwd, maxInputBytes: 1000, maxBufferedBytes: 100_000, maxCols: 200, maxRows: 100, scrollback: 100, +} const success = (value: T): RemoteResult => ({ ok: true, value }) const failure = (message: string): RemoteResult => ({ ok: false, error: new RemoteError('gateway/bad-request', message, {}) }) const cleanups: (() => void | Promise)[] = [] @@ -41,9 +43,8 @@ function fixture() { environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), create: vi.fn(async (_session, request) => success({ ...info, id: request.id })), close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), - write: vi.fn(async () => success(undefined)), resize: vi.fn(async () => success(undefined)), - follow: vi.fn(streamMethod(async function* (_session, id, controllerId, signal) { - yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } } + attach: vi.fn(streamMethod(async function* (_session, id, signal) { + yield { type: 'snapshot', writable: true, sequence: 0, screen: 'retained', info: { ...info, id } } await new Promise((resolve) => { if (signal?.aborted) resolve() else signal?.addEventListener('abort', () => { resolve() }, { once: true }) @@ -93,8 +94,8 @@ it('recovers a listed process without creating another, including its title and model.mount() await model.refresh() expect(h.remote.create).not.toHaveBeenCalled() - await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' }) - expect(h.remote.follow).toHaveBeenCalledWith(sessionId, info.id, expect.any(String), expect.any(AbortSignal)) + await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', writable: true, screen: 'retained' }) + expect(h.remote.attach).toHaveBeenCalledWith(sessionId, info.id, expect.any(AbortSignal)) }) it('does not recreate a recovered terminal that disappeared after the recovery list was shown', async () => { @@ -140,7 +141,7 @@ it('waits for an in-flight creation while close detaches immediately and prevent await starting await closing expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id) - expect(h.remote.follow).not.toHaveBeenCalled() + expect(h.remote.attach).not.toHaveBeenCalled() }) it('does not allocate after close or disposal overtakes discovery', async () => { @@ -361,7 +362,7 @@ it('restores the same terminal in the same occurrence after reload without openi await restored.refresh() expect(restored.id).toBe(original.id) expect(h.remote.create).toHaveBeenCalledOnce() - await expect.poll(() => restored.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' }) + await expect.poll(() => restored.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', writable: true, screen: 'retained' }) }) it('offers retained processes when their saved occurrence is absent from the restored layout', async () => { @@ -490,10 +491,10 @@ it('waits for both active and detached stream finalizers during plugin disposal const finished = [Promise.withResolvers(), Promise.withResolvers()] cleanups.push(() => { for (const barrier of release) barrier.resolve(undefined) }) let index = 0 - vi.mocked(h.remote.follow).mockImplementation(streamMethod(async function* (_session, id, controllerId, signal) { + vi.mocked(h.remote.attach).mockImplementation(streamMethod(async function* (_session, id, signal) { const current = index++ try { - yield { type: 'snapshot', sequence: 0, screen: 'screen', info: { ...info, id, controllerId } } + yield { type: 'snapshot', writable: true, sequence: 0, screen: 'screen', info: { ...info, id } } await new Promise((resolve) => { if (signal?.aborted) resolve() else signal?.addEventListener('abort', () => { resolve() }, { once: true }) @@ -611,7 +612,7 @@ it('retains only open saved occurrences across inactive Sessions and deduplicate expect(h.remote.environment).not.toHaveBeenCalled() expect(h.remote.create).not.toHaveBeenCalled() expect(h.remote.list).not.toHaveBeenCalled() - expect(h.remote.follow).not.toHaveBeenCalled() + expect(h.remote.attach).not.toHaveBeenCalled() const calls = vi.mocked(h.remote.retain).mock.calls service.retainTabs([{ sessionId: otherSession, tabId: 'b', contentId: 'b' }]) await expect.poll(() => calls.find(call => call[0] === sessionId)?.[2]?.aborted).toBe(true) @@ -639,7 +640,7 @@ it('waits for the window hold acknowledgement before restoring an output attachm const model = service.view(sessionId, 'restored', 'restored') model.mount() await model.refresh() - expect(h.remote.follow).not.toHaveBeenCalled() + expect(h.remote.attach).not.toHaveBeenCalled() acknowledge.resolve(undefined) await expect.poll(() => model.state.getSnapshot().writable).toBe(true) expect(h.remote.retain).toHaveBeenCalledOnce() @@ -657,7 +658,7 @@ it('shows a missing terminal when retention loses the race to Host cleanup, with const model = service.view(sessionId, 'restored', 'restored') model.mount() await expect.poll(() => model.state.getSnapshot().issue).toBe('missingTerminal') - expect(h.remote.follow).not.toHaveBeenCalled() + expect(h.remote.attach).not.toHaveBeenCalled() expect(h.remote.create).not.toHaveBeenCalled() }) diff --git a/packages/api/terminal-controller/tests/stream.spec.ts b/packages/api/terminal-controller/tests/stream.spec.ts index a5fd9357ff..8a928378b7 100644 --- a/packages/api/terminal-controller/tests/stream.spec.ts +++ b/packages/api/terminal-controller/tests/stream.spec.ts @@ -78,3 +78,20 @@ describe('TerminalFollower', () => { expect(await follower.read(signal())[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined }) }) }) + +it('does not replace an ended attachment with a late input failure', async () => { + const follower = new TerminalFollower(1000) + follower.close() + follower.fail(new Error('late write failed')) + expect(await follower.read(signal())[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined }) +}) + +it('drains final frames when an input failure arrives after graceful finish', async () => { + const follower = new TerminalFollower(1000) + follower.push(frame) + follower.finish() + follower.fail(new Error('late write failed')) + const received: TerminalFrame[] = [] + for await (const item of follower.read(signal())) received.push(item) + expect(received).toEqual([frame]) +}) diff --git a/packages/api/terminal-controller/tests/terminal.spec.ts b/packages/api/terminal-controller/tests/terminal.spec.ts index 9735701b81..8881f18774 100644 --- a/packages/api/terminal-controller/tests/terminal.spec.ts +++ b/packages/api/terminal-controller/tests/terminal.spec.ts @@ -3,11 +3,11 @@ import { PassThrough } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SubprocessOutcome, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' import { BrowserTerminal } from '../src/terminal.ts' +import { inputChannel } from './input.ts' import { TerminalFollower } from '../src/stream.ts' -import type { TerminalAttachmentId, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts' +import type { TerminalInput, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts' const info: WebTerminalInfo = { id: 'terminal-test' as WebTerminalId, title: 'bash', shell: { path: '/bin/bash', name: 'bash', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running', exitCode: null } -const attachment = (id: string): TerminalAttachmentId => id as TerminalAttachmentId const cleanups: (() => Promise)[] = [] afterEach(async () => { await Promise.all(cleanups.splice(0).map(close => close())) }) @@ -26,12 +26,14 @@ function fixture() { return { terminal, output, outcome, handle } } -async function attach(terminal: BrowserTerminal, id = 'first') { +async function attach(terminal: BrowserTerminal) { const controller = new AbortController() - const iterator = terminal.follow(attachment(id), controller.signal)[Symbol.asyncIterator]() + const input = inputChannel() + const frames = terminal.attach(input.source, { maxInputBytes: 1000, maxCols: 200, maxRows: 100 }, controller.signal) + const iterator = frames[Symbol.asyncIterator]() const baseline = await readFrame(iterator) cleanups.push(async () => { controller.abort(); await iterator.return?.() }) - return { controller, iterator, baseline } + return { controller, iterator, baseline, input } } describe('BrowserTerminal', () => { @@ -46,7 +48,7 @@ describe('BrowserTerminal', () => { expect(handle.terminate).not.toHaveBeenCalled() output.write(Buffer.from('world')) await expect.poll(() => terminal.info.state).toBe('running') - const second = await attach(terminal, 'second') + const second = await attach(terminal) // The baseline and its output queue share the same ordered screen write queue. const frames = [second.baseline] if (second.baseline.type === 'snapshot' && !second.baseline.screen.includes('world')) frames.push(await readFrame(second.iterator)) @@ -55,26 +57,6 @@ describe('BrowserTerminal', () => { expect(handle.terminate).not.toHaveBeenCalled() }) - it('preserves split UTF-8, gives the newest attachment input, and resizes both PTY and recovery screen', async () => { - const { terminal, output, handle } = fixture() - const first = await attach(terminal) - const encoded = Buffer.from('终端') - output.write(encoded.subarray(0, 2)) - await expect.poll(() => output.readableLength).toBe(0) - output.write(encoded.subarray(2)) - expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '终端' }) - const second = await attach(terminal, 'second') - await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'read-only' } }) - await terminal.write(attachment('second'), '\t') - expect(handle.write).toHaveBeenCalledWith('\t') - await terminal.resize(attachment('second'), 100, 30) - expect(handle.resize).toHaveBeenCalledWith(100, 30) - expect(terminal.info).toMatchObject({ cols: 100, rows: 30 }) - second.controller.abort() - await second.iterator.return?.() - expect(terminal.info.controllerId).toBeUndefined() - }) - it('keeps exit facts and screen until explicit cleanup and never starts a replacement process', async () => { const { terminal, output, outcome, handle } = fixture() const first = await attach(terminal) @@ -83,7 +65,7 @@ describe('BrowserTerminal', () => { expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'done' }) expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited', exitCode: 7 } }) expect(handle.terminate).not.toHaveBeenCalled() - const second = await attach(terminal, 'second') + const second = await attach(terminal) expect(second.baseline).toMatchObject({ type: 'snapshot', info: { state: 'exited', exitCode: 7 } }) await terminal.close() expect(handle.terminate).toHaveBeenCalledOnce() @@ -102,72 +84,6 @@ describe('BrowserTerminal', () => { expect((await first.iterator.next()).done).toBe(true) }) - it('rejects input before attachment, during close and after process exit', async () => { - const { terminal, handle, output, outcome } = fixture() - await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'read-only' } }) - const first = await attach(terminal) - output.end() - outcome.resolve({ exitCode: 0, signal: null }) - expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } }) - await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } }) - await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } }) - const closing = terminal.close() - await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } }) - await closing - expect(handle.write).not.toHaveBeenCalled() - expect(handle.resize).not.toHaveBeenCalled() - }) - - it('preserves the input controller when an older follower detaches', async () => { - const { terminal, handle } = fixture() - const first = await attach(terminal) - const second = await attach(terminal, 'second') - first.controller.abort() - await first.iterator.return?.() - expect(terminal.info.controllerId).toBe(attachment('second')) - terminal.rename('build output') - expect(await readFrame(second.iterator)).toMatchObject({ type: 'state', info: { title: 'build output', controllerId: 'second' } }) - await terminal.write(attachment('second'), 'pwd\r') - expect(handle.write).toHaveBeenCalledWith('pwd\r') - }) - - it('does not grant input to attachments cancelled before their snapshot is ready', async () => { - const { terminal, handle } = fixture() - const aborted = new AbortController() - aborted.abort(new Error('already detached')) - await expect(terminal.follow(attachment('cancelled'), aborted.signal)[Symbol.asyncIterator]().next()).rejects.toThrow('already detached') - await attach(terminal) - const writing = Promise.withResolvers() - const written = Promise.withResolvers() - handle.write.mockImplementationOnce(async () => { writing.resolve(undefined); await written.promise }) - const pendingWrite = terminal.write(attachment('first'), 'pwd\r') - try { - await writing.promise - const abort = new AbortController() - const pendingAttachment = terminal.follow(attachment('late'), abort.signal)[Symbol.asyncIterator]().next() - const rejected = expect(pendingAttachment).rejects.toThrow('detached while waiting') - abort.abort(new Error('detached while waiting')) - written.resolve(undefined) - await pendingWrite - await rejected - expect(terminal.info.controllerId).toBe(attachment('first')) - } finally { written.resolve(undefined) } - }) - - it('continues accepting operations after a provider write or resize fails', async () => { - const { terminal, handle } = fixture() - await attach(terminal) - handle.write.mockRejectedValueOnce(new Error('input transport failed')) - handle.resize.mockRejectedValueOnce(new Error('resize transport failed')) - await expect(terminal.write(attachment('first'), 'failed')).rejects.toThrow('input transport failed') - await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toThrow('resize transport failed') - expect(terminal.info).toMatchObject({ cols: 80, rows: 24 }) - await terminal.write(attachment('first'), 'accepted') - await terminal.resize(attachment('first'), 90, 25) - expect(handle.write).toHaveBeenLastCalledWith('accepted') - expect(terminal.info).toMatchObject({ cols: 90, rows: 25 }) - }) - it.each([new Error('process wait failed'), 'remote process wait failed'])('publishes a failed process outcome and retains its recovery screen: %s', async (failure) => { const { terminal, output, outcome } = fixture() const first = await attach(terminal) @@ -175,7 +91,7 @@ describe('BrowserTerminal', () => { outcome.reject(failure) expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'last output' }) expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'failed', error: failure instanceof Error ? failure.message : failure } }) - const second = await attach(terminal, 'second') + const second = await attach(terminal) expect(second.baseline).toMatchObject({ type: 'snapshot', screen: 'last output', info: { state: 'failed' } }) }) @@ -205,12 +121,13 @@ describe('BrowserTerminal', () => { it('shares concurrent close attempts and permits retry after termination fails', async () => { const { terminal, handle } = fixture() - await attach(terminal) + const view = await attach(terminal) handle.terminate.mockRejectedValueOnce(new Error('process range remains alive')) const first = terminal.close() expect(terminal.close()).toBe(first) await expect(first).rejects.toThrow('remains alive') - await terminal.write(attachment('first'), 'retry cleanup next') + view.input.push({ type: 'write', sequence: 1, data: 'retry cleanup next' }) + expect(await readFrame(view.iterator)).toMatchObject({ type: 'ack', sequence: 1 }) await terminal.close() expect(handle.terminate).toHaveBeenCalledTimes(2) }) @@ -234,3 +151,181 @@ it('refuses retention before allocation commit and disposes an uncommitted scree await terminal.dispose() expect(handle.terminate).toHaveBeenCalledOnce() }) + +it('preserves split UTF-8 and gives input control only to the newest attachment', async () => { + const { terminal, output, handle } = fixture() + const first = await attach(terminal) + const encoded = Buffer.from('终端') + output.write(encoded.subarray(0, 2)) + await expect.poll(() => output.readableLength).toBe(0) + output.write(encoded.subarray(2)) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '终端' }) + const second = await attach(terminal) + expect(second.baseline).toMatchObject({ writable: true }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', writable: false }) + first.input.push({ type: 'write', sequence: 1, data: 'refused' }) + expect(await readFrame(first.iterator)).toEqual({ type: 'ack', sequence: 1, refusal: 'read-only' }) + second.input.push({ type: 'write', sequence: 1, data: '\t' }) + expect(await readFrame(second.iterator)).toEqual({ type: 'ack', sequence: 1 }) + expect(handle.write).toHaveBeenCalledExactlyOnceWith('\t') + first.controller.abort() + await first.iterator.return?.() + second.input.push({ type: 'resize', sequence: 2, cols: 100, rows: 30 }) + expect(await readFrame(second.iterator)).toMatchObject({ type: 'state', writable: true, info: { cols: 100, rows: 30 } }) + expect(await readFrame(second.iterator)).toEqual({ type: 'ack', sequence: 2 }) + expect(handle.resize).toHaveBeenCalledWith(100, 30) +}) + +it('acknowledges provider acceptance in order and joins in-flight input on detach', async () => { + const { terminal, handle } = fixture() + const view = await attach(terminal) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + handle.write.mockImplementationOnce(async () => { entered.resolve(undefined); await release.promise }) + cleanups.push(async () => { release.resolve(undefined) }) + view.input.push({ type: 'write', sequence: 1, data: 'accepted' }) + view.input.push({ type: 'write', sequence: 2, data: 'cancelled' }) + view.input.push({ type: 'resize', sequence: 3, cols: 100, rows: 30 }) + await entered.promise + expect(handle.write).toHaveBeenCalledOnce() + expect(handle.resize).not.toHaveBeenCalled() + view.controller.abort() + let detached = false + const closing = view.iterator.return?.().then(() => { detached = true }) + await Promise.resolve() + expect(detached).toBe(false) + release.resolve(undefined) + await closing + expect(handle.write).toHaveBeenCalledExactlyOnceWith('accepted') + expect(handle.resize).not.toHaveBeenCalled() + expect(handle.terminate).not.toHaveBeenCalled() +}) + +it('half-closes input while continuing to publish output and exit state', async () => { + const { terminal, output, outcome } = fixture() + const view = await attach(terminal) + view.input.push({ type: 'write', sequence: 1, data: 'exit\r' }) + view.input.end() + expect(await readFrame(view.iterator)).toEqual({ type: 'ack', sequence: 1 }) + output.end('last output') + outcome.resolve({ exitCode: 3, signal: null }) + expect(await readFrame(view.iterator)).toMatchObject({ type: 'output', data: 'last output' }) + expect(await readFrame(view.iterator)).toMatchObject({ type: 'state', writable: false, info: { exitCode: 3 } }) +}) + +it.each([ + { type: 'write', sequence: 2, data: 'gap' }, + { type: 'write', sequence: 1, data: '界'.repeat(334) }, + { type: 'resize', sequence: 1, cols: 201, rows: 24 }, + { type: 'resize', sequence: 1, cols: 80, rows: 0 }, + { type: 'resize', sequence: 1, cols: 1.5, rows: 24 }, +] satisfies TerminalInput[])('rejects invalid input before executing it: %j', async (command) => { + const { terminal, handle } = fixture() + const view = await attach(terminal) + view.input.push(command) + await expect(view.iterator.next()).rejects.toThrow() + expect(handle.write).not.toHaveBeenCalled() + expect(handle.resize).not.toHaveBeenCalled() + expect(handle.terminate).not.toHaveBeenCalled() +}) + +it.each(['write', 'resize'] as const)('fails the attachment on a provider %s error and permits fresh attachment recovery', async (operation) => { + const { terminal, handle } = fixture() + const first = await attach(terminal) + handle[operation].mockRejectedValueOnce(new Error('provider failed')) + first.input.push(operation === 'write' ? { type: 'write', sequence: 1, data: 'failed' } : { type: 'resize', sequence: 1, cols: 90, rows: 25 }) + await expect(first.iterator.next()).rejects.toThrow('provider failed') + expect(terminal.info).toMatchObject({ cols: 80, rows: 24 }) + const second = await attach(terminal) + second.input.push({ type: 'write', sequence: 1, data: 'fresh' }) + expect(await readFrame(second.iterator)).toEqual({ type: 'ack', sequence: 1 }) + expect(handle.write).toHaveBeenLastCalledWith('fresh') +}) + +it.each(['write', 'resize'] as const)('preserves final output when an in-flight %s is refused during process exit', async (operation) => { + const { terminal, handle, output, outcome } = fixture() + const view = await attach(terminal) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + cleanups.push(async () => { release.resolve(undefined) }) + handle[operation].mockImplementationOnce(async () => { entered.resolve(undefined); await release.promise }) + view.input.push(operation === 'write' ? { type: 'write', sequence: 1, data: 'late' } : { type: 'resize', sequence: 1, cols: 90, rows: 25 }) + await entered.promise + outcome.resolve({ exitCode: 7, signal: null }) + output.end('FINAL OUTPUT') + release.reject(new Error('terminal process has exited')) + const frames = [await readFrame(view.iterator), await readFrame(view.iterator), await readFrame(view.iterator)] + expect(frames).toEqual(expect.arrayContaining([ + { type: 'ack', sequence: 1, refusal: 'not-running' }, + expect.objectContaining({ type: 'output', data: 'FINAL OUTPUT' }), + ])) + expect(frames.find(frame => frame.type === 'state')).toMatchObject({ writable: false, info: { state: 'exited', exitCode: 7 } }) +}) + +it('refuses queued input once the process completes while output is still draining', async () => { + const { terminal, handle, output, outcome } = fixture() + const view = await attach(terminal) + outcome.resolve({ exitCode: 0, signal: null }) + await outcome.promise + expect(terminal.info.state).toBe('running') + view.input.push({ type: 'write', sequence: 1, data: 'late' }) + expect(await readFrame(view.iterator)).toEqual({ type: 'ack', sequence: 1, refusal: 'not-running' }) + expect(handle.write).not.toHaveBeenCalled() + output.end('FINAL OUTPUT') + expect(await readFrame(view.iterator)).toMatchObject({ type: 'output', data: 'FINAL OUTPUT' }) + expect(await readFrame(view.iterator)).toMatchObject({ type: 'state', writable: false, info: { state: 'exited' } }) +}) + +it('refuses input after exit without ending the screen attachment', async () => { + const { terminal, output, outcome, handle } = fixture() + const view = await attach(terminal) + output.end() + outcome.resolve({ exitCode: 0, signal: null }) + expect(await readFrame(view.iterator)).toMatchObject({ type: 'state', writable: false }) + view.input.push({ type: 'write', sequence: 1, data: 'late' }) + expect(await readFrame(view.iterator)).toEqual({ type: 'ack', sequence: 1, refusal: 'not-running' }) + terminal.rename('finished') + expect(await readFrame(view.iterator)).toMatchObject({ type: 'state', info: { title: 'finished' } }) + expect(handle.write).not.toHaveBeenCalled() +}) + +it('does not grant input to an attachment cancelled before its screen baseline', async () => { + const { terminal } = fixture() + const input = inputChannel() + const abort = new AbortController() + abort.abort(new Error('already detached')) + await expect(terminal.attach(input.source, { maxInputBytes: 1000, maxCols: 200, maxRows: 100 }, abort.signal)[Symbol.asyncIterator]().next()).rejects.toThrow('already detached') +}) + +it('reports a non-Error provider input rejection through the attachment', async () => { + const { terminal, handle } = fixture() + const view = await attach(terminal) + handle.write.mockRejectedValueOnce('remote input failed') + view.input.push({ type: 'write', sequence: 1, data: 'input' }) + await expect(view.iterator.next()).rejects.toThrow('remote input failed') +}) + +it('preserves the current controller when a queued attachment is cancelled during an input write', async () => { + const { terminal, handle } = fixture() + const first = await attach(terminal) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + cleanups.push(async () => { release.resolve(undefined) }) + handle.write.mockImplementationOnce(async () => { entered.resolve(undefined); await release.promise }) + first.input.push({ type: 'write', sequence: 1, data: 'first' }) + await entered.promise + const abort = new AbortController() + const input = inputChannel() + const iterator = terminal.attach(input.source, { maxInputBytes: 1000, maxCols: 200, maxRows: 100 }, abort.signal)[Symbol.asyncIterator]() + cleanups.push(async () => { abort.abort(); await iterator.return?.() }) + const failed = expect(iterator.next()).rejects.toThrow('cancelled while queued') + abort.abort(new Error('cancelled while queued')) + release.resolve(undefined) + await failed + expect(await readFrame(first.iterator)).toEqual({ type: 'ack', sequence: 1 }) + terminal.rename('retained control') + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', writable: true }) + first.input.push({ type: 'write', sequence: 2, data: 'still writable' }) + expect(await readFrame(first.iterator)).toEqual({ type: 'ack', sequence: 2 }) + expect(handle.write).toHaveBeenLastCalledWith('still writable') +}) diff --git a/packages/api/terminal-controller/tsconfig.client.json b/packages/api/terminal-controller/tsconfig.client.json index cdb554988a..055e471ad7 100644 --- a/packages/api/terminal-controller/tsconfig.client.json +++ b/packages/api/terminal-controller/tsconfig.client.json @@ -35,6 +35,9 @@ }, { "path": "../../util/crypto" + }, + { + "path": "../../util/deque" } ] } diff --git a/packages/api/terminal-controller/tsconfig.host.json b/packages/api/terminal-controller/tsconfig.host.json index 68bc1a559e..952e7df4b2 100644 --- a/packages/api/terminal-controller/tsconfig.host.json +++ b/packages/api/terminal-controller/tsconfig.host.json @@ -55,6 +55,9 @@ }, { "path": "../../util/lazy-require" + }, + { + "path": "../../util/values" } ] } diff --git a/packages/api/terminal-controller/tsdown.config.ts b/packages/api/terminal-controller/tsdown.config.ts index cb9d252eb2..0e42a1163e 100644 --- a/packages/api/terminal-controller/tsdown.config.ts +++ b/packages/api/terminal-controller/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-api-terminal-controller', + import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/api/workspace-controller/locale/en.json b/packages/api/workspace-controller/locale/en.json new file mode 100644 index 0000000000..233cc2ae0c --- /dev/null +++ b/packages/api/workspace-controller/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "api-workspace-controller" + } +} diff --git a/packages/api/workspace-controller/locale/zh.json b/packages/api/workspace-controller/locale/zh.json new file mode 100644 index 0000000000..43275acd31 --- /dev/null +++ b/packages/api/workspace-controller/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "工作区控制接口", + "description": "向客户端提供工作区操作和可在重连后恢复的状态。" + } +} diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 118a4772a9..ac9d45a3e1 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -39,7 +39,8 @@ "default": "./lib/typert.remote-client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -58,6 +59,7 @@ "watch": "tsdown --watch" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.js", @@ -72,7 +74,7 @@ "@deepseek-ai/dsh-deque": "workspace:*", "@deepseek-ai/dsh-native-command": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "^4.4.3" + "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/api/workspace-controller/tsdown.config.ts b/packages/api/workspace-controller/tsdown.config.ts index a79662aebc..0e42a1163e 100644 --- a/packages/api/workspace-controller/tsdown.config.ts +++ b/packages/api/workspace-controller/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-api-workspace-controller', + import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/api/workspace-files/locale/en.json b/packages/api/workspace-files/locale/en.json new file mode 100644 index 0000000000..63caa3de7d --- /dev/null +++ b/packages/api/workspace-files/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "api-workspace-files" + } +} diff --git a/packages/api/workspace-files/locale/zh.json b/packages/api/workspace-files/locale/zh.json new file mode 100644 index 0000000000..eebfab4170 --- /dev/null +++ b/packages/api/workspace-files/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "工作区文件接口", + "description": "提供工作区文件读取、目录列表和文件信息更新。" + } +} diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index 01a35b7da2..f77a965ad0 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -35,7 +35,8 @@ "default": "./lib/typert.remote-client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -55,7 +56,7 @@ "@deepseek-ai/dsh-deque": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "^4.4.3" + "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" @@ -74,6 +75,7 @@ "@deepseek-ai/dsh-util-workspace-path": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.js", diff --git a/packages/api/workspace-files/tsdown.config.ts b/packages/api/workspace-files/tsdown.config.ts index 6bf04a7409..0e42a1163e 100644 --- a/packages/api/workspace-files/tsdown.config.ts +++ b/packages/api/workspace-files/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-api-workspace-files', + import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/attachment/attachment-local/locale/en.json b/packages/attachment/attachment-local/locale/en.json new file mode 100644 index 0000000000..7d67c258a1 --- /dev/null +++ b/packages/attachment/attachment-local/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "attachment-local" + } +} diff --git a/packages/attachment/attachment-local/locale/zh.json b/packages/attachment/attachment-local/locale/zh.json new file mode 100644 index 0000000000..c0116cbbfe --- /dev/null +++ b/packages/attachment/attachment-local/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "本地附件存储", + "description": "保存会话中的图片等附件,并按内容标识读取。" + } +} diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 810513ab0e..f40c826a40 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -14,11 +14,19 @@ "main": "lib/index.js", "types": "lib/types/index.d.ts", "exports": { - ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, - "files": ["lib/index.js", "lib/types/**/*.d.ts"], + "files": [ + "locale/*.json", + "lib/index.js", + "lib/types/**/*.d.ts" + ], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-attachment": "workspace:*", @@ -28,7 +36,7 @@ "dependencies": { "@deepseek-ai/dsh-lazy-require": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "sharp": "^0.35.5" + "sharp": "0.35.5" }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:*", diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index e119eb4700..be1f97916f 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 20dc764eec..4468b0fd9a 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -36,11 +36,8 @@ en: a9afb263b03d112c zh: 31f5e797cd953c15 /deepseek-ai-dsh-app-boot/use-this-package/startup-and-reload-failures: - en: 09890e218ab49c84 - zh: e3d86597e7fb1adb -/deepseek-ai-dsh-app-boot/use-this-package/telling-the-agent-where-the-harness-lives: - en: 07c459f26f4f8517 - zh: 210f1e4894731b1a + en: 206c9093965bc5e1 + zh: d8525e950595c399 /deepseek-ai-dsh-app-boot/understand-the-implementation: en: f4a2695cb4c8a570 zh: 89837235d2f376cf @@ -48,20 +45,20 @@ en: 25a0d1b46fd66d68 zh: 5cd8b4ac382dccdc /deepseek-ai-dsh-app-boot/understand-the-implementation/helper-behavior: - en: f7bc3a8dd4b00bdd - zh: 30098428aa769ad0 + en: 6fa850d1bb000d0e + zh: aa83a40a9d3db295 /deepseek-ai-dsh-app-boot/understand-the-implementation/source-map: - en: 3da01827e3354655 - zh: 06ccfaed83f65a76 + en: 541700d5c39e0ab4 + zh: 7522dbd9cd68c930 /deepseek-ai-dsh-app-boot/further-exploration: en: 401269cc5ea8593e zh: cd337c356b320565 /deepseek-ai-dsh-app-boot/model-experience: - en: 769777f1cb1a9408 - zh: f02b2834179df50d + en: d2d0a21502d88c50 + zh: 82a60c7a490a6834 /deepseek-ai-dsh-app-boot/model-experience/kv-cache-effect: - en: c4d49630b68ae896 - zh: 6f450ec6ba39bb81 + en: 5a1d9344c8fdc790 + zh: 0b32579f658b2ca1 /deepseek-ai-dsh-app-boot/known-limitations-and-deferred-work: en: bf1a49013cad8e9c zh: 41b2b010f8650ec3 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 093c79252b..cba4c934e4 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -116,10 +116,6 @@ The [Web process matrix](../../../apps/cli/tests/profiles/web/tests/web-failure- If your app owns the terminal, it can hand the terminal back before the process exits, so your shell is never left in raw mode. The handoff is bounded: a stuck cleanup delays the fatal exit but never cancels it. -### Telling the agent where the harness lives - -When your app boots a model-backed agent, you can tell the agent where the DSH implementation checkout lives: it learns that path and that it must not infer the working directory from it — it should use `pwd`. The instruction appears once near the top of the system prompt. Apps without a system prompt service skip it; in development, reloading the system prompt drops it until the next boot. - ----- @@ -151,13 +147,13 @@ The startup error also retains inactive-entry metadata and raw startup warning/e ### Helper behavior -The exports each own one stage of the boot: config resolution and snapshot replay, layered environment loading, fail-loud reporting, activation auditing, patch parsing, root-include mounting, config dump rendering, profile composition, and the harness-source section. Per-export contracts live in the code, not this README — see [`src/index.ts`](src/index.ts) and [`src/profile.ts`](src/profile.ts). +The exports each own one stage of the boot: config resolution and snapshot replay, layered environment loading, fail-loud reporting, activation auditing, patch parsing, root-include mounting, config dump rendering, and profile composition. Per-export contracts live in the code, not this README — see [`src/index.ts`](src/index.ts) and [`src/profile.ts`](src/profile.ts). ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard, activation audit, patch parsing, config dump, harness-source section | +| [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard, activation audit, patch parsing, config dump | | [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution, runtime resolution construction | | [`src/profile-plugins.ts`](src/profile-plugins.ts) | Installed dependencies, bundle activation policy, and manifest updates | | [`src/profile-sanitize.ts`](src/profile-sanitize.ts) | Profile patch backup and recovery bundle activation | @@ -187,11 +183,11 @@ Read these pages when the package-level contract is not enough. They move from t ## Model Experience -Indirectly, through the loaded plugin tree, which alone contributes model context; the one export that adds model-visible text, `addHarnessSourceSection`, does so only when a consumer calls it after boot. +Indirectly, through the loaded plugin tree, which alone contributes model context; no export adds model-visible text. #### KV Cache effect -Boot itself changes no request prefix. `addHarnessSourceSection` places its source path after first-party reusable instructions, so different checkouts leave those preceding bytes unchanged when tools and configuration match. Provider cache reuse is not guaranteed. +Boot itself changes no request prefix. ## Known Limitations and Deferred Work diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 379d602176..cb383af462 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -116,10 +116,6 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如 如果你的应用持有终端,它可以在进程退出前把终端交还,你的 shell 绝不会残留在 raw 模式。交还过程有界:卡住的清理只会延迟致命退出,而不会取消它。 -### 告诉 agent(智能体)harness 所在位置 - -当你的应用启动模型驱动的 agent 时,你可以告诉 agent DSH 实现代码 checkout 的位置:它得知该路径,也知道不得据此推断工作目录——它应使用 `pwd`。这条指示在系统提示词靠前位置出现一次。没有系统提示词服务的应用会跳过;开发环境中,重新加载系统提示词后它会消失,直至下次启动。 - ----- @@ -151,13 +147,13 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如 ### Helper 行为 -每个导出各负责启动的一个阶段:配置解析与快照回放、分层环境加载、明确报错的保护机制、激活审计、patch 解析、根 include 挂载、配置 dump 渲染、profile 组合,以及 harness 源码段落。各导出的约定在代码中,不在本 README——见 [`src/index.ts`](src/index.ts) 与 [`src/profile.ts`](src/profile.ts)。 +每个导出各负责启动的一个阶段:配置解析与快照回放、分层环境加载、明确报错的保护机制、激活审计、patch 解析、根 include 挂载、配置 dump 渲染与 profile 组合。各导出的约定在代码中,不在本 README——见 [`src/index.ts`](src/index.ts) 与 [`src/profile.ts`](src/profile.ts)。 ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、会明确报错的保护机制、激活审计、patch 解析、配置 dump、harness 源码段落 | +| [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、会明确报错的保护机制、激活审计、patch 解析、配置 dump | | [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、组合包解析、runtime resolution 构造 | | [`src/profile-plugins.ts`](src/profile-plugins.ts) | 已安装依赖、bundle 启用策略与 manifest 更新 | | [`src/profile-sanitize.ts`](src/profile-sanitize.ts) | profile patch 备份与恢复 bundle 启用状态 | @@ -187,11 +183,11 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如 ## 模型体验 -模型通过此包加载的插件树间接受影响——只有该树贡献模型上下文;唯一贡献模型可见文本的导出 `addHarnessSourceSection`,也只有在消费方启动后调用它时才会产生影响。 +模型通过此包加载的插件树间接受影响——只有该树贡献模型上下文;没有导出会添加模型可见文本。 #### KV Cache 影响 -启动本身不改变请求前缀。`addHarnessSourceSection` 将源码路径放在第一方可复用指令之后,因此工具与配置一致时,不同 checkout 不会改变前置字节。不保证提供方复用缓存。 +启动本身不改变请求前缀。 ## 已知限制与延期工作 diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 2050837c3b..5717ca71cc 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -47,7 +47,6 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:~", "@deepseek-ai/dsh-launch-environment": "workspace:*", "@deepseek-ai/dsh-home-paths": "workspace:*", - "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index d02c5d554f..f5b08a4596 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -31,7 +31,6 @@ export { ON_DEMAND_BUNDLES, OFFICIAL_ON_DEMAND_CATALOG, type OfficialBundleCatal export { generateConfigSchema, type ConfigSchemaDump, type NativeConfigSchema } from './config-schema/index.ts' export { createConfigProjector, LOADER_EXPRESSION_SCHEMA, type ConfigProjection } from './config-schema/projector.ts' export { isNativeConfigSchema } from './config-schema/native.ts' -import type {} from '@deepseek-ai/dsh-system-prompt' export { readProfilePlugins, reconcileProfilePlugins, writeProfileBundles, @@ -1039,31 +1038,3 @@ export async function boot( await diagnostics.fiber.dispose() } } - -/** Prompt-section name for the harness-source location line an app bin adds after boot. */ -export const HARNESS_SOURCE_SECTION = 'harness:source' - -/** - * Add a global prompt section naming the on-disk harness source checkout while - * explicitly distinguishing it from the task workspace and current working - * directory. The self-referential `dsh-tool-cordis` toolset reads and edits this - * checkout. Call once on the settled boot context ({@link boot}); the section - * uses the shared first-party placement after reusable instructions - * and before the Web surface and persona suffix. A booted tree with no - * `systemPrompt` service has no prompt to augment, so this is then a no-op - * that returns `undefined`. The section is - * registered against the `systemPrompt` service's fiber, so a dev HMR reload of - * that plugin drops it until the next boot. - * @param ctx - the settled boot context whose global system prompt to augment. - * @param sourceRoot - the absolute path to the harness checkout root. - * @returns the section disposer, or `undefined` when no `systemPrompt` service is mounted. - */ -export function addHarnessSourceSection(ctx: Context, sourceRoot: string): (() => void) | undefined { - const systemPrompt = ctx.get('systemPrompt') - if (systemPrompt === undefined) return undefined - return systemPrompt.section({ - name: HARNESS_SOURCE_SECTION, - order: systemPrompt.getSectionOrder('HARNESS_SOURCE'), - text: `The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`, - }) -} diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index c1601313b6..826e4fa368 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -5,10 +5,10 @@ import { pathToFileURL } from 'node:url' import { inspect } from 'node:util' import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-system-prompt' import { - addHarnessSourceSection, auditStartupEntries, boot, StartupError, - FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION, + auditStartupEntries, boot, StartupError, + FAIL_LOUD_RELEASE_TIMEOUT_MS, installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudEvent, type FailLoudProcess, } from '../src/index.ts' @@ -1266,61 +1266,3 @@ describe('boot', () => { }) }) - -describe('addHarnessSourceSection', () => { - const SOURCE_ROOT = `${sep}opt${sep}harness-src` - const EXPECTED = `The DeepSeek Harness implementation checkout is at ${SOURCE_ROOT}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.` - - it('distinguishes the source path from the current workdir after reusable instructions', async () => { - const ctx = new Context() - try { - await ctx.plugin(SystemPrompt, { personaPrefix: 'You are a coding agent.' }) - ctx.systemPrompt.section({ - name: 'tools:sdk', order: ctx.systemPrompt.getSectionOrder('TOOLS_SDK'), text: 'Reusable tool SDK.', - }) - const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT) - expect(dispose).toBeTypeOf('function') - const systemPrompt = ctx.get('systemPrompt')! - const rendered = renderPrompt(await systemPrompt.assemble()) - expect(rendered).toContain(EXPECTED) - // The >= 0 guards keep a drifted opener/persona string from a false pass - // through `-1 < n`. - const identityAt = rendered.indexOf('You are an AI agent powered by DeepSeek Harness.') - const sourceAt = rendered.indexOf(EXPECTED) - const personaAt = rendered.indexOf('You are a coding agent.') - expect(identityAt).toBeGreaterThanOrEqual(0) - expect(personaAt).toBeGreaterThanOrEqual(0) - const sdkAt = rendered.indexOf('Reusable tool SDK.') - expect(personaAt).toBeGreaterThan(identityAt) - expect(sdkAt).toBeGreaterThan(personaAt) - expect(sdkAt).toBeLessThan(sourceAt) - } finally { - await ctx.fiber.dispose() - } - }) - - it('is a no-op returning undefined when no systemPrompt service is mounted', async () => { - const ctx = new Context() - try { - expect(addHarnessSourceSection(ctx, SOURCE_ROOT)).toBeUndefined() - } finally { - await ctx.fiber.dispose() - } - }) - - it('disposes the section it added, so a systemPrompt reload leaves no residue', async () => { - const ctx = new Context() - try { - await ctx.plugin(SystemPrompt, {}) - const systemPrompt = ctx.get('systemPrompt')! - const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)! - const present = await systemPrompt.assemble() - expect(present.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(true) - dispose() - const gone = await systemPrompt.assemble() - expect(gone.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(false) - } finally { - await ctx.fiber.dispose() - } - }) -}) diff --git a/packages/boot/app-boot/tsconfig.json b/packages/boot/app-boot/tsconfig.json index e59c38f2ba..9c3e57c809 100644 --- a/packages/boot/app-boot/tsconfig.json +++ b/packages/boot/app-boot/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../../../vendor/group" }, - { - "path": "../../core/system-prompt" - }, { "path": "../../util/launch-environment" }, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index c6ea6cb37a..7d8f6d2b37 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/config-editor/locale/en.json b/packages/boot/config-editor/locale/en.json new file mode 100644 index 0000000000..ac0e222f08 --- /dev/null +++ b/packages/boot/config-editor/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "config-editor" + } +} diff --git a/packages/boot/config-editor/locale/zh.json b/packages/boot/config-editor/locale/zh.json new file mode 100644 index 0000000000..11a55507d9 --- /dev/null +++ b/packages/boot/config-editor/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "插件配置编辑", + "description": "保存插件配置,并将变更应用到当前运行配置。" + } +} diff --git a/packages/boot/config-editor/package.json b/packages/boot/config-editor/package.json index 346d9d4442..9d5e4dd44e 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -19,9 +19,11 @@ "default": "./lib/index.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "files": [ + "locale/*.json", "lib/index.js", "lib/types/**/*.d.ts" ], diff --git a/packages/boot/hmr/README.i18n.yaml b/packages/boot/hmr/README.i18n.yaml index 4f1eef45bf..0c7fcc7a1f 100644 --- a/packages/boot/hmr/README.i18n.yaml +++ b/packages/boot/hmr/README.i18n.yaml @@ -21,8 +21,8 @@ en: 45ee194ad781d603 zh: b3d7fcd8ed73717c /deepseek-ai-dsh-hmr/understand-the-implementation: - en: 227c3c99c3c4b597 - zh: 853b656340cb8bce + en: 8c19121bdb5176c1 + zh: 8a01a60ecca6175c /deepseek-ai-dsh-hmr/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 diff --git a/packages/boot/hmr/README.md b/packages/boot/hmr/README.md index 513abc07b2..97ee67c8e9 100644 --- a/packages/boot/hmr/README.md +++ b/packages/boot/hmr/README.md @@ -54,7 +54,7 @@ Chokidar options, including polling, retain their existing meaning. Exact config
Implementation internals — click to expand -`watchConfig()` registers an awaited configuration handler. `runExclusive()` serializes configuration changes and Loader updates with automatic reloads and rejects nested transactions. Package installation and removal run outside this queue. HMR does not acquire the package writer lock; manifest notifications reload only when the ordered `dsh.profile.bundles` list changes. Profile and home patch changes also trigger recomposition. File events received during a configuration transaction are processed afterward. Include refreshes and profile reconciliation reach plugins through ordinary Loader entry updates; Loader commits volatile-only changes in place. +`watchConfig()` registers an awaited configuration handler and watches only its target path and required parent directories; sibling files, including temporary writer locks, are excluded before native watcher creation. `runExclusive()` serializes configuration changes and Loader updates with automatic reloads and rejects nested transactions. Package installation and removal run outside this queue. HMR does not acquire the package writer lock; manifest notifications reload only when the ordered `dsh.profile.bundles` list changes. Profile and home patch changes also trigger recomposition. File events received during a configuration transaction are processed afterward. Include refreshes and profile reconciliation reach plugins through ordinary Loader entry updates; Loader commits volatile-only changes in place. App-boot owns profile parsing and patch precedence. HMR reads the launcher’s data-only `profileContext`, registers the profile manifest and both user patch watches during initialization, and waits for application readiness before processing changes. Its disposal closes the watchers and cancels reloads waiting for startup. Configuration watchers start outside the active transaction context so later notifications can enter the queue. diff --git a/packages/boot/hmr/README.zh.md b/packages/boot/hmr/README.zh.md index ba99733ccf..a7a9f84b1b 100644 --- a/packages/boot/hmr/README.zh.md +++ b/packages/boot/hmr/README.zh.md @@ -54,7 +54,7 @@ Chokidar 选项(包括轮询)保持原有含义。精确配置监听同时
实现细节——点击展开 -`watchConfig()` 注册会被等待的配置处理器。`runExclusive()` 将配置变更、Loader 更新与自动重载串行化,并拒绝嵌套事务。包安装和删除在该队列之外执行。HMR 不获取包操作写锁;manifest 通知仅在有序的 `dsh.profile.bundles` 列表变化时触发重载。profile 与 home patch 变化也会触发重新组合。配置事务期间收到的文件事件在事务结束后处理。 Include 刷新和 profile 重载都通过普通的 Loader 条目更新到达插件;仅 volatile 变化由 Loader 就地提交。 +`watchConfig()` 注册会被等待的配置处理器,仅监听目标路径及必要的父目录;同级文件(包括临时写锁)在创建原生 watcher 前即被排除。`runExclusive()` 将配置变更、Loader 更新与自动重载串行化,并拒绝嵌套事务。包安装和删除在该队列之外执行。HMR 不获取包操作写锁;manifest 通知仅在有序的 `dsh.profile.bundles` 列表变化时触发重载。profile 与 home patch 变化也会触发重新组合。配置事务期间收到的文件事件在事务结束后处理。 Include 刷新和 profile 重载都通过普通的 Loader 条目更新到达插件;仅 volatile 变化由 Loader 就地提交。 App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提供的纯数据 `profileContext`,在初始化时注册 profile manifest 和两份用户 patch 的监听,并等待应用就绪后处理更改。销毁 HMR 时会关闭监听器并取消等待启动的重载。配置监听器在当前事务上下文之外启动,使后续通知可以进入队列。 diff --git a/packages/boot/hmr/locale/en.json b/packages/boot/hmr/locale/en.json new file mode 100644 index 0000000000..47f242be8c --- /dev/null +++ b/packages/boot/hmr/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "hmr" + } +} diff --git a/packages/boot/hmr/locale/zh.json b/packages/boot/hmr/locale/zh.json new file mode 100644 index 0000000000..f216cf3442 --- /dev/null +++ b/packages/boot/hmr/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "插件热重载", + "description": "在应用运行期间重新加载插件代码和配置。" + } +} diff --git a/packages/boot/hmr/package.json b/packages/boot/hmr/package.json index 9460462734..5c86376b65 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -19,9 +19,11 @@ "default": "./lib/index.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "files": [ + "locale/*.json", "lib/index.js", "lib/types/**/*.d.ts" ], @@ -37,7 +39,7 @@ }, "dependencies": { "@babel/code-frame": "^7.29.0", - "chokidar": "^4.0.3", + "chokidar": "5.0.0", "node-addon-require-builtin": "^0.1.9", "picomatch": "^4.0.7", "@deepseek-ai/schemastery": "workspace:~" diff --git a/packages/boot/hmr/src/watch-config.ts b/packages/boot/hmr/src/watch-config.ts index 6c62610dec..41815b5d8d 100644 --- a/packages/boot/hmr/src/watch-config.ts +++ b/packages/boot/hmr/src/watch-config.ts @@ -1,5 +1,5 @@ /** Exact-path watching for live profile patch files outside Cordis module roots. */ -import { dirname, relative, resolve } from 'node:path' +import { dirname, relative, resolve, sep } from 'node:path' import { realpath, stat } from 'node:fs/promises' import { watch, type ChokidarOptions } from 'chokidar' import type { Context } from '@deepseek-ai/cordis' @@ -25,7 +25,8 @@ async function findWatchRoot(filename: string): Promise<{ filename: string; root } /** - * Watch one patch path, including missing parents, and serialize refresh callbacks. + * Watch one patch path and its parent chain without subscribing to sibling files or directories. + * Missing parents are observed as they appear; refresh callbacks run serially. * @param ctx Context that owns watcher disposal and receives refresh failures. * @param filename Absolute patch-file path. * @param options Deployment watcher options; configuration watches enable write stabilization by default. @@ -46,6 +47,10 @@ export async function watchConfig( const watcher = watch(target.root, { // Stabilized events bypass Chokidar's lossy 50 ms change-event throttle. awaitWriteFinish: true, ...watchOptions, depth: target.depth, ignoreInitial: false, + ignored: (path) => { + const remainder = relative(resolve(path), target.filename) + return remainder === '..' || remainder.startsWith(`..${sep}`) + }, }) paths.add(target.filename) const state = { dirty: false } diff --git a/packages/boot/hmr/tests/watch-config.spec.ts b/packages/boot/hmr/tests/watch-config.spec.ts index 7d0caf035b..e187d20a07 100644 --- a/packages/boot/hmr/tests/watch-config.spec.ts +++ b/packages/boot/hmr/tests/watch-config.spec.ts @@ -10,10 +10,13 @@ import { watchConfig } from '../src/watch-config.ts' import Hmr from '../src/index.ts' import Loader from '@deepseek-ai/cordis-plugin-loader' import Timer from '@deepseek-ai/cordis-plugin-timer' +import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { FSWatcher, type ChokidarOptions } from 'chokidar' -const configWatch = vi.hoisted(() => ({ create: undefined as ((options?: ChokidarOptions) => FSWatcher) | undefined })) +const configWatch = vi.hoisted(() => ({ + create: undefined as ((paths: string | string[], options?: ChokidarOptions) => FSWatcher) | undefined, +})) vi.mock('node:fs', async (importOriginal) => { const native = await importOriginal() return { ...native, realpathSync: vi.fn(native.realpathSync) } @@ -25,7 +28,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { vi.mock('chokidar', async (importOriginal) => { const native = await importOriginal() return { ...native, watch: (paths: string | string[], options?: ChokidarOptions) => - configWatch.create === undefined ? native.watch(paths, options) : configWatch.create(options) } + configWatch.create === undefined ? native.watch(paths, options) : configWatch.create(paths, options) } }) /** Every per-test tree root, removed once the booted watcher has been disposed. */ @@ -80,6 +83,42 @@ describe('HMR exact config paths', () => { for (const root of hmrRoots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + it.for([false, true])('excludes siblings while observing config replacement through an alias (missing parents: %s)', async (missingParents, test) => { + const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-scope-')) + onTestFinished(() => { rmSync(root, { recursive: true, force: true }) }) + const target = join(root, 'target') + const dir = join(root, 'alias') + mkdirSync(target) + symlinkSync(target, dir, process.platform === 'win32' ? 'junction' : 'dir') + const filename = join(dir, ...(missingParents ? ['later', 'nested'] : []), 'package.json') + writeFileSync(join(dir, 'package.json.lock'), 'held') + mkdirSync(join(dir, 'unrelated')) + writeFileSync(join(dir, 'unrelated', 'config.yml'), 'ignored') + if (!missingParents) writeFileSync(filename, 'initial') + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + let watcher!: FSWatcher + const previousFactory = configWatch.create + onTestFinished(() => { configWatch.create = previousFactory }) + configWatch.create = (paths, options) => { + watcher = new FSWatcher(options) + return watcher.add(paths) + } + const observed: string[] = [] + await watchConfig(ctx, filename, {}, () => { observed.push(readFileSync(filename, 'utf8')) }) + const watchedEntries = () => Object.values(watcher.getWatched()).flat() + expect(watchedEntries()).not.toContain('package.json.lock') + expect(watchedEntries()).not.toContain('unrelated') + ensureNativeWatchLive(dir) + await writeFileAtomic(filename, 'created', { mode: 0o600 }) + await eventually(() => observed.includes('created'), 'created config was not observed', test.task.timeout) + await writeFileAtomic(filename, 'replaced', { mode: 0o600 }) + await eventually(() => observed.includes('replaced'), 'replaced config was not observed', test.task.timeout) + expect(watchedEntries()).toContain('package.json') + expect(watchedEntries()).not.toContain('package.json.lock') + expect(watchedEntries()).not.toContain('unrelated') + }) + it.each(['relative', 'absolute'])('observes %s module roots through a filesystem alias', { timeout: 30_000 }, async (rootKind) => { const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-')) const alias = `${target}-alias` @@ -252,7 +291,7 @@ describe('HMR exact config paths', () => { let watcher!: FSWatcher const previousFactory = configWatch.create onTestFinished(() => { configWatch.create = previousFactory }) - configWatch.create = (options) => { + configWatch.create = (_paths, options) => { watcher = new FSWatcher(options) // Use Chokidar's real normalization with deterministic event delivery. Reflect.set(watcher, '_readyEmitted', true) diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 033a14fc65..8c7be9fe6f 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -24,8 +24,8 @@ en: d5643a06f300c30a zh: 9348b6a7676a2849 /deepseek-ai-dsh-plugin-manager/understand-the-implementation: - en: 7b54d30e231bb325 - zh: 08df9441fed9927f + en: 21ccb07cfee8974c + zh: 5790cd89d9c14e9e /deepseek-ai-dsh-plugin-manager/further-exploration: en: c6538243d8702a0d zh: 04a82d923353c75f diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index bc1550d9aa..1a2d7a023f 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -99,7 +99,7 @@ The service and `dsh plugin` share the package operations in [operations.ts](src Installing a new package, enabling a bundle, disabling it with HMR after its plugins stop, and a successful removal publish the profile's runtime resolution. Shared dependencies may pass to another bundle's declaration when their normalized directory, version, and scope stay unchanged. Without HMR, a deselected startup bundle still runs: while any such bundle is absent from the saved selection, subsequent package operations retain the entire current table and report `restart-required`. Other new installations and removals of non-running bundles can still publish without HMR; activation waits for restart. Overwriting an installed package publishes nothing and reports `restart-required` ([decision](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md)). -A service run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` alongside its exit status, is classified `timeout` whatever status the signal left behind, and is not retried on the next registry, which bounds how long one operation can hold the profile lock; the CLI inherits the terminal, captures no output, and stays unbounded for its operator to interrupt. A run completes when its process exits, and the pipes then drain under a bounded grace period, so a descendant that inherited them cannot hold the operation open. A terminated run stops its whole process tree and waits for it, because a lifecycle script outlives the pnpm process that started it (issue #4981). Each operation records the pnpm run it starts in `.plugin-manager/run.json` and removes the record when the run ends. A lock whose holder process exited is taken over by the next writer, but that process's pnpm tree can still be running, so an operation that finds a record waits up to five seconds for the recorded run to stop and otherwise fails with a diagnostic that names the process and the record, without running pnpm. Dependency-only changes do not trigger configuration reloads. +A service run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` alongside its exit status, is classified `timeout` whatever status the signal left behind, and is not retried on the next registry, which bounds how long one operation can hold the profile lock; the CLI inherits the terminal, captures no output, and stays unbounded for its operator to interrupt. A run completes when its process exits, and the pipes then drain under a bounded grace period, so a descendant that inherited them cannot hold the operation open. The diagnostic file is closed before the call settles, including preparation or spawn failures. A terminated run stops its whole process tree and waits for it, because a lifecycle script outlives the pnpm process that started it (issue #4981). Each operation records the pnpm run it starts in `.plugin-manager/run.json` and removes the record when the run ends. A lock whose holder process exited is taken over by the next writer, but that process's pnpm tree can still be running, so an operation that finds a record waits up to five seconds for the recorded run to stop and otherwise fails with a diagnostic that names the process and the record, without running pnpm. Dependency-only changes do not trigger configuration reloads. 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. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 8d5d3aabe7..aa7bdb7df2 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -99,7 +99,7 @@ CLI 提供 `dsh plugin --profile version-exemptions`、`allow-version 安装新包、启用组合包、有 HMR 时停用组合包并等插件退出后、以及删除成功后,都会发布 profile 的 runtime resolution。共享依赖的规范化目录、版本和作用域不变时,可以改由另一个组合包声明。没有 HMR 时,已取消选入的启动组合包仍在运行:只要保存的选择列表中缺少任何这样的组合包,后续包操作就保留整张当前表,并报告 `restart-required`。其他新安装和未运行组合包的删除仍可在没有 HMR 时发布;激活等待重启。覆盖已安装的包不发布,并报告 `restart-required`([决策](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md))。 -service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。每个操作把它启动的 pnpm 运行记录在 `.plugin-manager/run.json` 中,并在运行结束时删除该记录。持有者进程已退出的锁会被下一个写入方接管,但该进程的 pnpm 进程树可能仍在运行,因此发现记录的操作最多等待五秒让记录中的运行停止,否则不运行 pnpm,并以指明该进程与记录文件的诊断失败。仅依赖字段变化不会触发配置重载。 +service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。诊断文件会在调用结束前关闭,包括准备或创建进程失败的情况。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。每个操作把它启动的 pnpm 运行记录在 `.plugin-manager/run.json` 中,并在运行结束时删除该记录。持有者进程已退出的锁会被下一个写入方接管,但该进程的 pnpm 进程树可能仍在运行,因此发现记录的操作最多等待五秒让记录中的运行停止,否则不运行 pnpm,并以指明该进程与记录文件的诊断失败。仅依赖字段变化不会触发配置重载。 结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。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 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。 diff --git a/packages/boot/plugin-manager/locale/en.json b/packages/boot/plugin-manager/locale/en.json new file mode 100644 index 0000000000..094967d50f --- /dev/null +++ b/packages/boot/plugin-manager/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "plugin-manager" + } +} diff --git a/packages/boot/plugin-manager/locale/tools/en.json b/packages/boot/plugin-manager/locale/tools/en.json new file mode 100644 index 0000000000..2fdfce031d --- /dev/null +++ b/packages/boot/plugin-manager/locale/tools/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "plugin-manager/tools" + } +} diff --git a/packages/boot/plugin-manager/locale/tools/zh.json b/packages/boot/plugin-manager/locale/tools/zh.json new file mode 100644 index 0000000000..ab001f0ccb --- /dev/null +++ b/packages/boot/plugin-manager/locale/tools/zh.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "插件管理工具" + } +} diff --git a/packages/boot/plugin-manager/locale/zh.json b/packages/boot/plugin-manager/locale/zh.json new file mode 100644 index 0000000000..e5ffb5869b --- /dev/null +++ b/packages/boot/plugin-manager/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "插件管理服务", + "description": "管理当前运行配置中的插件与插件包,支持安装、移除和启停。" + } +} diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json index c6fc6af78a..756d5adb93 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -43,9 +43,13 @@ "./tools": { "types": "./lib/types/tools.d.ts", "default": "./lib/types/tools.js" - } + }, + "./tools/locale/*.json": "./locale/tools/*.json", + "./locale/*.json": "./locale/*.json" }, "files": [ + "locale/*.json", + "locale/tools/*.json", "lib/index.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", @@ -61,7 +65,7 @@ "@deepseek-ai/schemastery": "workspace:~", "execa": "^10.0.1", "yaml": "^2.9.0", - "zod": "^4.4.3", + "zod": "4.4.3", "@deepseek-ai/dsh-util-values": "workspace:*" }, "peerDependencies": { diff --git a/packages/boot/plugin-manager/src/operations.ts b/packages/boot/plugin-manager/src/operations.ts index 2df35b7efc..a6fe676544 100644 --- a/packages/boot/plugin-manager/src/operations.ts +++ b/packages/boot/plugin-manager/src/operations.ts @@ -280,6 +280,7 @@ 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. + * The diagnostic file is closed on return or rejection, including preparation and spawn failures. */ export async function runProfilePnpm( context: PackageOperationContext, args: readonly string[], options: PackageOperationOptions, @@ -291,7 +292,7 @@ export async function runProfilePnpm( await mkdir(logRoot, { recursive: true, mode: 0o700 }) const logDir = await mkdtemp(join(logRoot, 'operation-')) const logPath = join(logDir, 'pnpm.log') - const log = await open(logPath, 'wx', 0o600) + await using log = await open(logPath, 'wx', 0o600) if (active !== undefined) { await log.write(active) await log.close() @@ -348,7 +349,7 @@ export async function runProfilePnpm( } } catch (error) { void error; continue } } - if (preflight.length > 0) return rejected(preflight, 'nothing was installed') + if (preflight.length > 0) return await rejected(preflight, 'nothing was installed') const cancellation = new AbortController() // A service run captures output, so execa terminates the tree it leads when the // run is killed: a lifecycle script outlives the pnpm process that started it. diff --git a/packages/boot/plugin-manager/tests/operation-logs.spec.ts b/packages/boot/plugin-manager/tests/operation-logs.spec.ts new file mode 100644 index 0000000000..a7a19367ab --- /dev/null +++ b/packages/boot/plugin-manager/tests/operation-logs.spec.ts @@ -0,0 +1,48 @@ +/** Pnpm diagnostics release their real file descriptor when preparation rejects. */ +import { fstatSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import type { FileHandle } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, onTestFinished, vi } from 'vitest' +import { initProfile } from '@deepseek-ai/dsh-app-boot' +import { runProfilePnpm } from '../src/operations.ts' + +const observed = vi.hoisted(() => { + const logs: Array<{ handle: FileHandle; fd: number }> = [] + return { logs } +}) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + open: async (...args: Parameters) => { + const handle = await actual.open(...args) + if (typeof args[0] === 'string' && args[0].endsWith('pnpm.log')) { + observed.logs.push({ handle, fd: handle.fd }) + } + return handle + }, + } +}) + +it('closes the pnpm log when the profile manifest cannot be parsed', async () => { + const home = mkdtempSync(join(tmpdir(), 'manager-log-lifetime-')) + onTestFinished(async () => { + for (const { handle } of observed.logs.splice(0)) await handle.close() + rmSync(home, { recursive: true, force: true }) + }) + const dir = join(home, 'profiles', 'test') + const installAnchor = join(home, 'package.json') + writeFileSync(installAnchor, '{}\n') + initProfile(dir, []) + writeFileSync(join(dir, 'package.json'), '{') + + await expect(runProfilePnpm({ home, profile: 'test', installAnchor, cwd: home }, ['install'], { + execution: 'service', outputBytes: 100, activateNewBundles: false, + })).rejects.toThrow(SyntaxError) + + expect(observed.logs).toHaveLength(1) + const { fd } = observed.logs[0]! + expect(() => fstatSync(fd)).toThrow(expect.objectContaining({ code: 'EBADF' })) +}) diff --git a/packages/boot/plugin-manager/tests/tools.spec.ts b/packages/boot/plugin-manager/tests/tools.spec.ts index bc7da82dfe..dab620bff1 100644 --- a/packages/boot/plugin-manager/tests/tools.spec.ts +++ b/packages/boot/plugin-manager/tests/tools.spec.ts @@ -1,10 +1,11 @@ +import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' /** Agent controls use the same manager methods as the Web and bound inventory reads. */ import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import { ToolCallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import SandboxPolicy, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy' import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import SessionProjections from '@deepseek-ai/dsh-session-projection' import ApprovalService, { type ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' diff --git a/packages/browser-use/browser-use/package.json b/packages/browser-use/browser-use/package.json index 8178f5d01d..88f9365e81 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index d1d12860c2..525414a75b 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 9e3f03eefc..21e24f3865 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -105,8 +105,8 @@ name: '@deepseek-ai/dsh-settings' disabled: !!js "!ctx.get('profileContext')" - # Credential sources: inherited environment over the managed - # `$DSH_HOME/.credentials.yaml`, with project and user `.env` fallbacks. + # Credential sources: managed `$DSH_HOME/.credentials.yaml`, then inherited + # environment, project `.env`, and user `.env` fallbacks. # Adapters resolve references per request; the Models page writes only the # managed document, which is never materialized into the process environment. - id: authorization diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index be152900d3..fa80fcfb55 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index c3715b490f..6597e4b433 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -18,8 +18,11 @@ en: 4447012f4c84100d zh: faf579ae9f04c096 /deepseek-ai-dsh-headless/use-this-package/running-a-one-shot-task: - en: 76a22e98d2b3cc2f - zh: c9eb624609b8cc32 + en: 89a48af61a38afc3 + zh: fe202a1bb2c11781 +/deepseek-ai-dsh-headless/use-this-package/credentials: + en: ca8217837ee9c698 + zh: fa20daf27d4e09ef /deepseek-ai-dsh-headless/use-this-package/choosing-the-session-identity: en: dbaa2fac63910a4c zh: 4fd5c17815ca444b @@ -57,8 +60,8 @@ en: 0b0f74b412c7c6d8 zh: 6e8f918d0c4bf342 /deepseek-ai-dsh-headless/known-limitations-and-deferred-work: - en: b2df65836d53ba03 - zh: 82a56dc1255a0c3b + en: 3a6dc9666d852219 + zh: b764f203815e179e /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 e7776f0591..bc2a7ff532 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -49,6 +49,26 @@ The task and run options are supplied through three settings: The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-headless) is the exhaustive source for every accepted field and its JSDoc. + +### Credentials + +The shipped headless profile disables the managed credential store and its authorization and DeepSeek account consumers. API-key routes use the launch environment, then `/.env`, then `$DSH_HOME/.env`. Keys and sign-ins saved by the GUI are ignored without changing their file. Providers that require a saved sign-in fail unless stored authentication is restored; external provider credential chains, such as AWS profiles, remain available. + +Unset an empty launch variable to allow its `.env` fallback; an exported empty value shadows that fallback. + +To restore stored authentication, add these rows to `$DSH_HOME/profiles/headless/cordis.patch.yml` or a `--patch` overlay: + +```yaml +- id: credentials + disabled: false +- id: authorization + disabled: false +- id: deepseek-account + disabled: false +``` + +The restored store uses [saved credentials before environment fallbacks](../../credentials/credentials-local/README.md). Web, SDK and ACP profiles keep that default ordering. Headless sessions, settings and tool behavior are unchanged; this profile choice is not a filesystem access boundary. + ### 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. @@ -133,6 +153,7 @@ The runner adds nothing to the request prefix; it only drives one user message t These limits tell you when headless does not fit and what it needs from the `dsh` launcher. They are current package constraints, not a general CLI comparison or a task backlog. - **One task per run** — after the task is answered the process exits; there is no interactive follow-up, so split multi-step work into separate runs. +- **Final background-job settlement may be absent from persisted history** — process teardown can end Session routing or close its writer before an owned job settles, including normal completion and signal shutdown; the [job lifecycle contract](../../../docs/subsystems/jobs.md#session-lifecycle-records) defines how consumers retain incomplete intervals. - **Runs through the `dsh` launcher** — starting the headless profile another way fails at startup, because only the launcher can request the process exit. - **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. diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index a7d35fdbe9..fb79a898e2 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -49,6 +49,26 @@ agent 会完成该任务,把提供方的每个非空推理(reasoning)增 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-headless)是所有受支持字段及其 JSDoc 的完整真源。 + +### 凭据 + +随附的 headless profile 禁用托管凭据存储,以及依赖它的 authorization 和 DeepSeek account 消费方。API 密钥路由依次使用启动环境、`<运行命令的目录>/.env` 和 `$DSH_HOME/.env`。GUI 保存的密钥和登录凭据不会被使用,存储文件保持原样。需要已保存登录凭据的提供方会失败,除非恢复存储认证;AWS profile 等提供方外部凭据链仍然可用。 + +启动变量为空时,请取消导出该变量,让 `.env` 后备生效;已导出的空值会遮蔽后备值。 + +要恢复存储认证,请把以下行加入 `$DSH_HOME/profiles/headless/cordis.patch.yml` 或 `--patch` 覆盖文件: + +```yaml +- id: credentials + disabled: false +- id: authorization + disabled: false +- id: deepseek-account + disabled: false +``` + +恢复后,存储仍按[已保存凭据优先于环境后备](../../credentials/credentials-local/README.zh.md)解析。Web、SDK 和 ACP profile 保留这一默认顺序。headless 的会话、settings 和工具行为不变;这种 profile 配置选择不构成文件系统访问边界。 + ### 选择 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 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 @@ -133,6 +153,7 @@ runner 不向请求前缀添加任何内容;它只是驱动组合出的配置 这些限制告诉你 headless 何时不适用、它需要 `dsh` 启动器提供什么。它们是当前包约束,不是通用的 CLI(命令行界面)对比或任务积压。 - **每次运行一个任务**——任务得到回答后进程即退出;没有交互式后续,因此多步工作请拆成多次运行。 +- **持久化历史可能缺少后台 job 的最后一条结算**——进程销毁可能在有主 job 结算前停止 Session 路由或关闭写句柄,正常结束和信号关停也可能如此;[job 生命周期约定](../../../docs/subsystems/jobs.zh.md#session-lifecycle-records)说明消费方如何保留不完整区间。 - **通过 `dsh` 启动器运行**——以其他方式启动 headless profile 会在启动时失败,因为只有启动器能请求进程退出。 - **首个 token 前没有心跳**——默认模式下,提供方发出第一个非空推理增量前,stderr 保持静默;延迟首个 token 的提供方不会更早给出进度信号。 - **推理进入 stderr 日志**——默认模式下,重定向与监督进程可能保留显著更多且可能敏感的模型输出;需要时应把 stderr 路由到受控位置。 diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index 20f13b54e6..cf224539eb 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -31,3 +31,13 @@ - id: hmr disabled: true + +# One-shot jobs use launch-time credentials instead of the interactive store. +- id: credentials + disabled: true + +- id: authorization + disabled: true + +- id: deepseek-account + disabled: true diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index cf2dc9f38f..1eea38a9f0 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index cc73507d5f..468c1e67a9 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -260,7 +260,7 @@ describe('headless runner', () => { await test.ctx.plugin(SubagentRuntime) test.ctx.subagents.registerProvider({ name: 'external', inheritsParentContext: false, - capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, agentPreset: false, outputSchema: false, depthLimit: false }, start: async () => ({ id: brandString('settlement-child'), result: Promise.resolve({ output: [{ type: 'text' as const, text: 'child answer' }], stopReason: 'completed' as const }), diff --git a/packages/bundle/sdk-app/README.i18n.yaml b/packages/bundle/sdk-app/README.i18n.yaml index ca0a12fc89..fb39b99573 100644 --- a/packages/bundle/sdk-app/README.i18n.yaml +++ b/packages/bundle/sdk-app/README.i18n.yaml @@ -15,8 +15,8 @@ en: 4e53164159b5ce41 zh: 89582dc0294d0776 /deepseek-ai-dsh-sdk-app/use-this-package: - en: 465be8e510a86913 - zh: 07e2e1efba1d108b + en: 63573087d1db9aff + zh: 638612ac3f0da2c0 /deepseek-ai-dsh-sdk-app/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 diff --git a/packages/bundle/sdk-app/README.md b/packages/bundle/sdk-app/README.md index e2615fc435..a149ec1254 100644 --- a/packages/bundle/sdk-app/README.md +++ b/packages/bundle/sdk-app/README.md @@ -33,7 +33,7 @@ The startup provider binds stdin EOF to the launcher's bounded successful shutdo The SDK uses the base `read`, `write`, and `edit` defaults. To add `str_replace_editor`, use the explicit insertion patch in the [base configuration guide](../base/README.md#use-this-package). The standalone `sdk-minimal` profile owns its separate tool selection. -The packaged Python runtime enables the [Office runtime query and skills](../../skill/tool-workspace-dependencies/README.md#use-this-package) by default. `DSH_PRIMARY_RUNTIME` overrides its bundled payload; an empty value disables both rows. Source launches without a carrier default remain opt-in. The tool reads the payload in place. Profile patches can disable `skill-office` or replace its `assetRoot` independently of `workspace-dependencies`; filesystem skills with the same name take precedence over bundled skills. See the [runtime package](../../../python/sdk-runtime/README.md) for packaging and configuration. +The Python runtime exposes explicitly downloaded authoring resources and Office sidecars to this profile. `DSH_PRIMARY_RUNTIME` overrides the authoring payload; an empty value disables its query. Office skills can use either resource independently. Profile patches can disable `skill-office` or replace its `assetRoot` independently of `workspace-dependencies`; filesystem skills with the same name take precedence. See the [runtime package](../../../python/sdk-runtime/README.md) for downloads and configuration. ----- diff --git a/packages/bundle/sdk-app/README.zh.md b/packages/bundle/sdk-app/README.zh.md index 610f4bef5c..f244072935 100644 --- a/packages/bundle/sdk-app/README.zh.md +++ b/packages/bundle/sdk-app/README.zh.md @@ -33,7 +33,7 @@ kind: "package-bundle" SDK 使用 base 默认提供的 `read`、`write` 和 `edit`。要添加 `str_replace_editor`,请使用 [base 配置指南](../base/README.zh.md#use-this-package)中的显式插入 patch。独立的 `sdk-minimal` profile 自行决定其工具选择。 -打包的 Python runtime 默认启用 [Office 运行时查询与 skills](../../skill/tool-workspace-dependencies/README.zh.md#use-this-package)。`DSH_PRIMARY_RUNTIME` 覆盖随包 payload;空值禁用两行。没有载体默认路径的源码启动仍需显式启用。工具原位读取 payload。profile patch 可独立于 `workspace-dependencies` 禁用 `skill-office` 或替换其 `assetRoot`;同名文件系统 skills 优先于随包 skills。打包与配置见 [runtime 包](../../../python/sdk-runtime/README.zh.md)。 +Python 运行时向本 profile 提供显式下载的创作资源和 Office sidecar。`DSH_PRIMARY_RUNTIME` 覆盖创作载荷;空值禁用其查询。Office skills 可独立使用任一种资源。Profile patch 可独立于 `workspace-dependencies` 禁用 `skill-office` 或替换其 `assetRoot`;同名文件系统 skills 优先。下载与配置见[运行时包](../../../python/sdk-runtime/README.zh.md)。 ----- diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml index 1c0beaae95..004a81ce9b 100644 --- a/packages/bundle/sdk-app/cordis.patch.yml +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -29,7 +29,8 @@ - id: skill-office name: '@deepseek-ai/dsh-skill-office' - disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME)" + disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME) && !process.env.DSH_BUNDLED_OFFICE_CLI" config: - assetRoot: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', '..', 'office-skills')" - node: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')" + assetRoot: !!js "process.env.DSH_BUNDLED_OFFICE_SKILLS ?? process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', '..', 'office-skills')" + cli: !!js "process.env.DSH_BUNDLED_OFFICE_CLI === '' ? false : process.env.DSH_BUNDLED_OFFICE_CLI" + node: !!js "process.env.DSH_BUNDLED_OFFICE_NODE ?? process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')" diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index fdfc1d2ad3..389582f50c 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/README.i18n.yaml b/packages/bundle/sdk-minimal/README.i18n.yaml index 1a22a57b32..b93af7197d 100644 --- a/packages/bundle/sdk-minimal/README.i18n.yaml +++ b/packages/bundle/sdk-minimal/README.i18n.yaml @@ -3,23 +3,23 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/sdk-minimal/README.md /: - en: 7cbc0e332890851f - zh: 07c71026d8e1bb15 + en: b2fce08b0fa865da + zh: 97e214d87ded4b54 /deepseek-ai-dsh-sdk-minimal: en: 807c43eedba7d53e zh: 9f1efe5d6ac393f7 /deepseek-ai-dsh-sdk-minimal/summary: - en: c0a98b4bcbb3e719 - zh: 92895dd5ce381cd1 + en: 31b83af058b212f9 + zh: 289678ac3738ee5e /deepseek-ai-dsh-sdk-minimal/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-sdk-minimal/use-this-package: - en: f920307c0cf12118 - zh: edf9051846fecd5c + en: 39483eef4a61f212 + zh: 62d82f46a04e07fe /deepseek-ai-dsh-sdk-minimal/understand-the-implementation: - en: 91f4a78ab7a688d3 - zh: 7bb8bf33c0d8b429 + en: 5db5d8f041867958 + zh: bff5ef3a2e140712 /deepseek-ai-dsh-sdk-minimal/understand-the-implementation/source-map: en: 3784c554e96ab004 zh: 0a05d4fe1e26b3b1 @@ -33,17 +33,17 @@ en: bd87e16b0c6e0506 zh: c8ee5e3b4578754e /deepseek-ai-dsh-sdk-minimal/model-experience/minimal-coding-agent-composition/what-the-model-sees: - en: 724e1f4075df9293 - zh: 35955569ded10b56 + en: df40d9529b566cfa + zh: 4678769b4f85120b /deepseek-ai-dsh-sdk-minimal/model-experience/minimal-coding-agent-composition/token-effect: - en: 5df298b2c9f95d27 - zh: def93cd59b5ca843 + en: 9b7a23c7a447eb22 + zh: e01bfd06d19b7d18 /deepseek-ai-dsh-sdk-minimal/model-experience/minimal-coding-agent-composition/kv-cache-effect: - en: 904ae09b5fdb87c1 - zh: de6fcaaa12bd9733 + en: dd887b6519f40ab3 + zh: a98e56411fdc79df /deepseek-ai-dsh-sdk-minimal/known-limitations-and-deferred-work: - en: 8f74b1b4f39eda83 - zh: af96ec4cb42810c5 + en: 00c4192d6af9f621 + zh: 460451ab6dc07c3a /deepseek-ai-dsh-sdk-minimal/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: e88aeb617ffb74ea diff --git a/packages/bundle/sdk-minimal/README.md b/packages/bundle/sdk-minimal/README.md index 7a4a0ff127..9accd1fec2 100644 --- a/packages/bundle/sdk-minimal/README.md +++ b/packages/bundle/sdk-minimal/README.md @@ -1,5 +1,5 @@ --- -description: "Standalone SDK profile with a persistent shell and Session working-directory tool for a minimal cross-platform coding agent without the shared base bundle." +description: "Standalone SDK profile with a persistent shell, image reading, automatic context compaction, and a Session working-directory tool for a minimal cross-platform coding agent without the shared base bundle." kind: "package-bundle" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh --profile sdk-minimal` when an SDK client needs a small, explicit coding-agent runtime. By default, the profile advertises a platform-selected persistent shell and `working_directory`, persists sessions as uncompressed JSONL, and selects the model from the SDK initialization request. It supplies a complete Cordis tree and excludes `dsh-base`, Web, settings, managed credentials, telemetry, compaction, filesystem tools, workspace instructions, skills, jobs, and subagents. Its danger-full-access policy lets the shell modify any path available to the process, so use it only with an isolated workspace. +Use `dsh --profile sdk-minimal` when an SDK client needs a small, explicit coding-agent runtime. By default, the profile advertises a platform-selected persistent shell, `read_image`, and `working_directory`, persists sessions as uncompressed JSONL, and selects the model from the SDK initialization request. It supplies a complete Cordis tree and excludes `dsh-base`, Web, settings, managed credentials, telemetry, text file tools, workspace instructions, skills, jobs, and subagents. Its danger-full-access policy lets the shell modify any path available to the process, so use it only with an isolated workspace. ## Table of Contents @@ -38,7 +38,7 @@ Use `dsh plugin --profile sdk-minimal` to manage persistent external dependencie The profile mounts exactly one persistent shell stack: Bash on Linux and macOS, or PowerShell on Windows. Both stacks use a 300-second timeout and one owner-scoped terminal; the other platform's rows remain disabled. -Like every shipped profile, it mounts [MCP resources](../../mcp/mcp-resources/README.md) once. Configure only [MCP client entries](../../mcp/mcp-client/README.md) to add servers. Clients mounted by another provider also count as configured. With no server configured in the caller's scope, MCP contributes no prompt text or tools and the default remains the shell and `working_directory` tools. +Like every shipped profile, it mounts [MCP resources](../../mcp/mcp-resources/README.md) once. Configure only [MCP client entries](../../mcp/mcp-client/README.md) to add servers. Clients mounted by another provider also count as configured. With no server configured in the caller's scope, MCP contributes no prompt text or tools and the default remains the shell, `read_image`, and `working_directory` tools. ----- @@ -48,7 +48,7 @@ Like every shipped profile, it mounts [MCP resources](../../mcp/mcp-resources/RE
Implementation internals — click to expand -The bundle's single insert is the complete application tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the explicit agent core, conditional MCP resource tools, local filesystem and subprocess services, Session working-directory state and its tool, a platform-selected persistent shell PTY, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not inherit another bundle, so every extra row is an explicit profile change. +The bundle's single insert is the complete application tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the explicit agent core, conditional MCP resource tools, local filesystem, image attachments, automatic context compaction, and subprocess services, Session working-directory state and its tool, a platform-selected persistent shell PTY, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not inherit another bundle, so every extra row is an explicit profile change. ### Source map @@ -78,21 +78,23 @@ The bundle's single insert is the complete application tree: SDK stdio startup a #### What the model sees -The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. With no MCP server configured, the advertised tools are `working_directory` and owner-scoped persistent `bash` on Linux/macOS or `pwsh` on Windows. Required user context supplies the current directory. Optional runtime context, filesystem tools, workspace instructions, skills, jobs controls, compaction, and Harness identity are absent. +The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. With no MCP server configured, the advertised tools are `read_image`, `working_directory`, and owner-scoped persistent `bash` on Linux/macOS or `pwsh` on Windows. Required user context supplies the current directory. Optional runtime context, text file tools, workspace instructions, skills, jobs controls, and Harness identity are absent. #### Token effect -By default, one stable persona, two tool schemas, and user context for the initial directory and subsequent changes. Configured MCP servers add their tools, shared resource tools, and available server instructions. Tool results and ordinary conversation history grow with the session. +By default, one stable persona, three tool schemas, and user context for the initial directory and subsequent changes. Configured MCP servers add their tools, shared resource tools, and available server instructions. Images consume model input tokens. When images exceed the route budget, [image offload](../../compaction/compaction-image-offload/README.md) permanently replaces older image occurrences with attachment/path placeholders and retries the request or compaction summary. Automatic compaction sends an additional summarization request when context pressure reaches the configured threshold and replaces older model-visible history with its summary; the durable JSONL log retains those events. #### KV Cache effect -Stable for a fixed persona, platform, provider, model, and bundle patch stack. Profile changes take effect on the next process. +Stable for a fixed persona, platform, provider, model, and bundle patch stack. Compaction replaces part of the request history and can reduce cache reuse. Image offload also ends cache reuse at the first changed image. Profile changes take effect on the next process. ## Known Limitations and Deferred Work - **The composition intentionally omits shared product services** — select `dsh --profile sdk` when settings, managed credentials, policy presets, telemetry, Web tools, or the full default tool roster are required. +- **Image reading requires an image-capable model** — select a route whose adapter declares image input; text-only routes return a tool error. Images are stored under `$DSH_HOME/attachments/v1`. +- **Compaction is automatic** — configure [compaction-basic](../../compaction/compaction-basic/README.md) through profile patches. SDK prompt text does not execute `/compact` or other slash commands. - **User patches can expand the tree and corrupt stdout** — profile customization is trusted application composition; a plugin that writes ordinary text to stdout can break JSON-RPC framing. diff --git a/packages/bundle/sdk-minimal/README.zh.md b/packages/bundle/sdk-minimal/README.zh.md index 96de535e19..d35655df20 100644 --- a/packages/bundle/sdk-minimal/README.zh.md +++ b/packages/bundle/sdk-minimal/README.zh.md @@ -1,5 +1,5 @@ --- -description: "独立 SDK profile,提供持久 shell 和 Session 工作目录工具,用于不含共享 base bundle 的极简跨平台 coding agent(编程智能体)。" +description: "独立 SDK profile,提供持久 shell、图片读取、自动上下文压缩和 Session 工作目录工具,用于不含共享 base bundle 的极简跨平台 coding agent(编程智能体)。" kind: "package-bundle" --- @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -当 SDK 客户端需要小型、显式的 coding agent 运行时时,请使用 `dsh --profile sdk-minimal`。该 profile 默认公布按平台选择的持久 shell 和 `working_directory`,把会话持久化为未压缩 JSONL,并从 SDK 初始化请求选择模型。它提供完整 Cordis 配置树,并排除 `dsh-base`、Web、settings、托管凭据、遥测、压缩(compaction)、文件系统工具、workspace 指令、skill(技能)、jobs 与 subagent。其 danger-full-access 策略允许 shell 修改进程可访问的任何路径,因此只能配合隔离 workspace 使用。 +当 SDK 客户端需要小型、显式的 coding agent 运行时时,请使用 `dsh --profile sdk-minimal`。该 profile 默认公布按平台选择的持久 shell、`read_image` 和 `working_directory`,把会话持久化为未压缩 JSONL,并从 SDK 初始化请求选择模型。它提供完整 Cordis 配置树,并排除 `dsh-base`、Web、settings、托管凭据、遥测、文本文件工具、workspace 指令、skill(技能)、jobs 与 subagent。其 danger-full-access 策略允许 shell 修改进程可访问的任何路径,因此只能配合隔离 workspace 使用。 ## 目录 @@ -38,7 +38,7 @@ dsh --profile sdk-minimal 该 profile 只挂载一套持久 shell:Linux 和 macOS 使用 Bash,Windows 使用 PowerShell。两套配置都使用 300 秒超时与一个 agent 自有终端;另一平台的配置项保持禁用。 -与其他随附 profile 一样,它统一挂载 [MCP 资源](../../mcp/mcp-resources/README.zh.md)一次。只需配置 [MCP 客户端条目](../../mcp/mcp-client/README.zh.md)即可添加服务器。其他提供方挂载的客户端也属于已配置状态。调用方作用域中未配置服务器时,MCP 不贡献提示词文本或工具,默认仍为 shell 与 `working_directory` 两个工具。 +与其他随附 profile 一样,它统一挂载 [MCP 资源](../../mcp/mcp-resources/README.zh.md)一次。只需配置 [MCP 客户端条目](../../mcp/mcp-client/README.zh.md)即可添加服务器。其他提供方挂载的客户端也属于已配置状态。调用方作用域中未配置服务器时,MCP 不贡献提示词文本或工具,默认仍为 shell、`read_image` 与 `working_directory` 三个工具。 ----- @@ -48,7 +48,7 @@ dsh --profile sdk-minimal
实现细节——点击展开 -该 bundle 的单个 insert 就是完整应用配置树:SDK stdio 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、显式 agent 核心、按配置启用的 MCP 资源工具、本地文件系统与子进程服务、Session 工作目录状态及其工具、按平台选择的持久 shell PTY,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不继承其他 bundle,因此每个额外配置项都是显式 profile 变更。 +该 bundle 的单个 insert 就是完整应用配置树:SDK stdio 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、显式 agent 核心、按配置启用的 MCP 资源工具、本地文件系统、图片附件、自动上下文压缩与子进程服务、Session 工作目录状态及其工具、按平台选择的持久 shell PTY,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不继承其他 bundle,因此每个额外配置项都是显式 profile 变更。 ### 源码地图 @@ -78,21 +78,23 @@ dsh --profile sdk-minimal #### 模型看到的内容 -系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。未配置 MCP 服务器时,对外公布的工具为 `working_directory`,以及 Linux/macOS 上 agent 所有的持久 `bash` 或 Windows 上的 `pwsh`。必需的用户上下文提供当前目录。可选运行时上下文、文件系统工具、workspace 指令、skill、jobs 控制、压缩与 Harness 身份均不存在。 +系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。未配置 MCP 服务器时,对外公布的工具为 `read_image`、`working_directory`,以及 Linux/macOS 上 agent 所有的持久 `bash` 或 Windows 上的 `pwsh`。必需的用户上下文提供当前目录。可选运行时上下文、文本文件工具、workspace 指令、skill、jobs 控制与 Harness 身份均不存在。 #### Token 影响 -默认是一个稳定 persona、两个工具 schema,以及初始目录与后续变更的用户上下文。已配置 MCP 服务器会添加自己的工具、共享资源工具及可用的服务器指令。工具结果与普通对话历史随会话增长。 +默认是一个稳定 persona、三个工具 schema,以及初始目录与后续变更的用户上下文。已配置 MCP 服务器会添加自己的工具、共享资源工具及可用的服务器指令。图片消耗模型输入 token。图片超过路由预算时,[图片省略](../../compaction/compaction-image-offload/README.zh.md)会永久将较早的图片位置替换为包含附件与路径的占位文本,并重试请求或压缩摘要。上下文压力达到配置阈值时,自动压缩会额外发起摘要请求,并用摘要替换较早的模型可见历史;持久 JSONL 日志保留这些事件。 #### KV Cache 影响 -当 persona、平台、提供方、模型与 bundle patch 栈固定时保持稳定。Profile 变更在下一个进程生效。 +当 persona、平台、提供方、模型与 bundle patch 栈固定时保持稳定。压缩会替换部分请求历史,可能降低缓存复用率。图片省略还会使缓存复用止于第一张被修改的图片。Profile 变更在下一个进程生效。 ## 已知限制与延期工作 - **该组合刻意省略共享产品服务** — 需要 settings、托管凭据、策略预设、遥测、Web 工具或完整默认工具清单时,请选择 `dsh --profile sdk`。 +- **图片读取需要支持图片输入的模型** — 选择适配器声明支持图片输入的路由;纯文本路由会返回工具错误。图片存储在 `$DSH_HOME/attachments/v1` 下。 +- **压缩由系统自动执行** — 通过 profile patch 配置 [compaction-basic](../../compaction/compaction-basic/README.zh.md)。SDK 提示词文本不会执行 `/compact` 或其他斜杠命令。 - **用户 patch 可以扩展配置树并破坏 stdout** — profile 自定义属于受信任的应用组合;向 stdout 写入普通文本的插件会破坏 JSON-RPC 分帧。 diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml index 6c8dbce78a..79922e7113 100644 --- a/packages/bundle/sdk-minimal/cordis.patch.yml +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -38,6 +38,17 @@ - id: filesystem name: '@deepseek-ai/dsh-fs-local' + - id: attachment-local + name: '@deepseek-ai/dsh-attachment-local' + + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + config: + read: false + readImage: true + write: false + edit: false + - id: working-directory name: '@deepseek-ai/dsh-working-directory' @@ -47,6 +58,15 @@ - id: session-projection name: '@deepseek-ai/dsh-session-projection' + - id: token-meter + name: '@deepseek-ai/dsh-token-meter' + + - id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + + - id: image-offload + name: '@deepseek-ai/dsh-compaction-image-offload' + - id: sandbox-policy name: '@deepseek-ai/dsh-sandbox-policy' config: diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index df59eaae4d..04095beabb 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -37,6 +37,9 @@ "@deepseek-ai/cordis-plugin-timer": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-attachment-local": "workspace:*", + "@deepseek-ai/dsh-compaction-basic": "workspace:*", + "@deepseek-ai/dsh-compaction-image-offload": "workspace:*", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:*", "@deepseek-ai/dsh-jobs-local": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", @@ -57,7 +60,9 @@ "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-terminal": "workspace:*", "@deepseek-ai/dsh-terminal-bash": "workspace:*", + "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:*", + "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*", diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts index 4194bdf4b0..f4e24e5bc5 100644 --- a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts +++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts @@ -34,9 +34,14 @@ describe('dsh-sdk-minimal bundle', () => { ['llm-deepseek', '@deepseek-ai/dsh-llm-deepseek-api-key'], ['sandbox', '@deepseek-ai/dsh-sandbox-local'], ['filesystem', '@deepseek-ai/dsh-fs-local'], + ['attachment-local', '@deepseek-ai/dsh-attachment-local'], + ['tool-fs', '@deepseek-ai/dsh-tool-fs'], ['working-directory', '@deepseek-ai/dsh-working-directory'], ['tool-working-directory', '@deepseek-ai/dsh-tool-working-directory'], ['session-projection', '@deepseek-ai/dsh-session-projection'], + ['token-meter', '@deepseek-ai/dsh-token-meter'], + ['compaction-basic', '@deepseek-ai/dsh-compaction-basic'], + ['image-offload', '@deepseek-ai/dsh-compaction-image-offload'], ['sandbox-policy', '@deepseek-ai/dsh-sandbox-policy'], ['subprocess', '@deepseek-ai/dsh-subprocess-local'], ['pty', '@deepseek-ai/dsh-terminal'], @@ -73,6 +78,8 @@ describe('dsh-sdk-minimal bundle', () => { personaPrefix: { __jsExpr: "process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.'" }, }) expect(rows.find(row => row.id === 'agent-loop')?.config).toEqual({ agents: [] }) + expect(rows.find(row => row.id === 'tool-fs')?.config).toEqual({ read: false, readImage: true, write: false, edit: false }) + expect(rows.find(row => row.id === 'compaction-basic')?.config).toBeUndefined() expect(rows.find(row => row.id === 'terminal-bash')).toMatchObject({ disabled: { __jsExpr: "process.platform === 'win32'" }, }) diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 1c6e763915..1d8e07a01e 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -21,11 +21,11 @@ en: e3cac911f080d8ef zh: 60749e9f5c39c1bc /deepseek-ai-dsh-web-app/use-this-package/configuration: - en: 2b09528c8fc8d370 - zh: 7b374ebda8841172 + en: b1787804544f2587 + zh: ce09d2c248ffd999 /deepseek-ai-dsh-web-app/use-this-package/listening-trust-and-public-deployments: - en: 177400de33509826 - zh: dd88bc096accffc8 + en: e6ddea1d2fc40501 + zh: ff08b6fa3fc24136 /deepseek-ai-dsh-web-app/use-this-package/running-over-ssh: en: df205a26b6d49168 zh: e46ed7cf9e06248a @@ -33,38 +33,38 @@ en: ccd6fc264b51e44b zh: 3265c68477a89ec9 /deepseek-ai-dsh-web-app/understand-the-implementation: - en: 2ea88398028ebff8 - zh: 0e589c297aeb9d43 + en: 323742a00cfe67b2 + zh: eff5245a82fc16e7 /deepseek-ai-dsh-web-app/understand-the-implementation/patch-semantics: en: 8a2554b3d17daa1f zh: 66f18946791cabb6 /deepseek-ai-dsh-web-app/understand-the-implementation/advertised-application-url: - en: ca977d2b2bcf72c5 - zh: 2b81b377c133c1a8 + en: 0b22e03e28d41420 + zh: fd45a97dd4336c25 /deepseek-ai-dsh-web-app/understand-the-implementation/readiness: en: 973128fd346869df zh: 54e6792f9f82a192 /deepseek-ai-dsh-web-app/understand-the-implementation/source-map: - en: d4c7f39a9fd90683 - zh: ac2dbf75b2ceb255 + en: 58800b6f2142c34f + zh: 10bf512b16db1cb8 /deepseek-ai-dsh-web-app/further-exploration: en: f81694f7424e2988 zh: 6643f9227039e228 /deepseek-ai-dsh-web-app/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 -/deepseek-ai-dsh-web-app/model-experience/harness-source-and-web-surface-context: - en: e6febc53efe0f51d - zh: 9177816a8678d65d -/deepseek-ai-dsh-web-app/model-experience/harness-source-and-web-surface-context/what-the-model-sees: - en: 583592075e04687d - zh: 9defddfd77bab0f7 -/deepseek-ai-dsh-web-app/model-experience/harness-source-and-web-surface-context/token-effect: - en: 908fc84d6849a830 - zh: 56fe5e418bab60ba -/deepseek-ai-dsh-web-app/model-experience/harness-source-and-web-surface-context/kv-cache-effect: - en: 1d7d87f54b20b563 - zh: d150a51615d278e8 +/deepseek-ai-dsh-web-app/model-experience/web-runtime-variable: + en: 623bf8a0bf05599d + zh: 398ded2e7b20c1ea +/deepseek-ai-dsh-web-app/model-experience/web-runtime-variable/what-the-model-sees: + en: ebaddc153ad7a835 + zh: 21aa60f73d14f26c +/deepseek-ai-dsh-web-app/model-experience/web-runtime-variable/token-effect: + en: 449fbfa3986f98f0 + zh: a7d66333594ab2dd +/deepseek-ai-dsh-web-app/model-experience/web-runtime-variable/kv-cache-effect: + en: 37542a8b81a744f5 + zh: c0048aaadecae296 /deepseek-ai-dsh-web-app/known-limitations-and-deferred-work: en: 635c1336e2f4e821 zh: caabdecb02e2df14 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index b1bce71984..2305d402cd 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -46,13 +46,15 @@ Saved model selections override the composition default. The settings card accep ### Configuration +The `minimal` agent preset uses a persistent shell, the `read_image` tool, and a fixed persona, with automatic history compaction and the idle-session `/compact` command. It shares the Host token meter and owns an isolated compaction backend. [Compaction configuration and retention](../../compaction/compaction-basic/README.md#use-this-package) follow the existing backend. Image reads reuse the Host filesystem and attachment store and require an image-capable model; text read, write, and edit tools remain excluded. + `--host` and `--port` configure the listener; `--tls-cert` and `--tls-key` make it serve HTTPS; `--public-url` names the advertised public HTTP(S) root the GUI is reached at behind a prefix-stripping proxy, and `--trusted-host` adds further accepted authorities. All are described under [Listening, trust, and public deployments](#public-deployments), and the `tls` field those flags set is documented with the [carrier's config](../../../docs/subsystems/web-server.md#config): | Field | Default | Meaning | |---|---|---| | `openBrowser` | `true` | Open the default browser after startup; SSH launches suppress it | | `printUrl` | `true` | Print the `dsh web:` URL line at startup | -| `surfaceContext` | `true` | Give the agent GUI-orientation context and expose `DSH_WEB_URL` to its shell commands | +| `surfaceContext` | `true` | Expose `DSH_WEB_URL` to the agent's shell commands | | `publicUrl` | Unset | Advertised HTTP(S) application root; otherwise announce the bind-address URL | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-app) lists this runtime plugin's accepted fields and their JSDoc. The shipped composition inserts the `schedule` service row and the `ui-schedule` task page row, while the clock reading and the four reminder tools belong to the `standard`, `cordis`, and `ptc` presets. The `tool-subagent` and `tool-subagent-fork` rows in those presets deny the four tools, so a delegated child's scope never lists them. @@ -74,7 +76,7 @@ dsh --profile web --no-open --host "$(hostname -i | awk '{print $1}')" --public- Without `--public-url`, mapped IPv6 loopback binds such as `::ffff:127.0.0.1` advertise their IPv4 form, `127.0.0.1`, so the browser recognizes a local, trustworthy origin. The listener keeps the configured bind address. A dotted-quad IPv6 tail names the address's own low 32 bits, exactly as `listen` reads it, so `::0.0.0.1` is IPv6 loopback and advertises `[::1]`, while `::127.0.0.1` is a non-loopback address, not IPv4 loopback. -`--public-url` advertises the HTTP(S) root browsers use — the printed and opened startup URL, `DSH_WEB_URL`, and the web-surface orientation. Advertisement grants no trust: the browser-visible authority must also be named with `--trusted-host`. The flag configures no listener, routing, or cookie scope, because the proxy owns the external leg: [Publish the Web UI behind a reverse proxy](../../../docs/user/guide/public-deployments.md) lists what such a deployment must provide. +`--public-url` advertises the HTTP(S) root browsers use — the printed and opened startup URL, `DSH_WEB_URL`, and the Web GUI URL in the agent's runtime context. Advertisement grants no trust: the browser-visible authority must also be named with `--trusted-host`. The flag configures no listener, routing, or cookie scope, because the proxy owns the external leg: [Publish the Web UI behind a reverse proxy](../../../docs/user/guide/public-deployments.md) lists what such a deployment must provide. Neither the advertised URL nor the browser-trust fence protects the port itself, so restrict the port to the trusted proxy or network. @@ -96,7 +98,7 @@ Each browser session selects a shipped preset (`standard` by default). The Agent
Implementation internals — click to expand -The bundle is one patch layer of five files plus one runtime glue plugin: `cordis.patch.yml` carries the host rows and the preset registry, and each `presets/.patch.yml` inserts one shipped preset declaration, applied in the order `dsh.bundle.patch` lists them. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, the advertised application URL, the plain-HTTP exposure warning, prompt sections, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache. +The bundle is one patch layer of five files plus one runtime glue plugin: `cordis.patch.yml` carries the host rows and the preset registry, and each `presets/.patch.yml` inserts one shipped preset declaration, applied in the order `dsh.bundle.patch` lists them. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, the advertised application URL, the plain-HTTP exposure warning, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache. ### Patch semantics @@ -104,7 +106,7 @@ A patch replaces the targeted row's whole `config`, so each web row restates eve ### Advertised application URL -Startup display and browser handoff receive the advertised root with its launch token; the web-surface prompt and `DSH_WEB_URL` receive it clean. The root itself is defined under [Listening, trust, and public deployments](#public-deployments). +Startup display and browser handoff receive the advertised root with its launch token; the agent's runtime context and `DSH_WEB_URL` receive it clean. The root itself is defined under [Listening, trust, and public deployments](#public-deployments). ### Readiness @@ -114,12 +116,12 @@ The URL line and browser handoff are readiness signals: supervisors RPC as soon | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | The `web-app` glue plugin: dist resolution, advertised application URL, prompt sections, bash variable, URL line, browser handoff | +| [`src/index.ts`](src/index.ts) | The `web-app` glue plugin: dist resolution, advertised application URL, bash variable, URL line, browser handoff | | [`src/public-url.ts`](src/public-url.ts) | Advertised-root validation and trailing-slash normalization; a leaf module for local imports, not package API | | [`src/startup.ts`](src/startup.ts) | The `web-startup` provider: `--host`, `--port`, `--tls-cert`, `--tls-key`, `--public-url`, `--trusted-host`, `--no-open`, `--help` | | [`cordis.patch.yml`](cordis.patch.yml) | The web patch: restated base values, web host rows, browser roster, preset registry | | [`presets/`](presets) | One `@deepseek-ai/dsh-agent-preset` declaration per shipped preset (`standard`, `ptc`, `minimal`, `cordis`), each its own patch file | -| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | Dist index anchoring, advertised-URL publication and warnings, prompt sections, readiness publication | +| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | Dist index anchoring, advertised-URL publication and warnings, bash variable, readiness publication | | [`tests/startup.spec.ts`](tests/startup.spec.ts) | Command-line parsing over a real Loader tree | | [`tests/public-url.spec.ts`](tests/public-url.spec.ts) | Advertised-root parsing and normalization | | [`tests/browser-open.spec.ts`](tests/browser-open.spec.ts) | Default-browser handoff after the page is reachable | @@ -144,19 +146,19 @@ Read these pages when you want to go deeper into the shared core, the browser re ## Model Experience -### Harness-source and Web-surface context +### Web runtime variable #### What the model sees -When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (first-party order 10100, after reusable instructions) orients the model to the GUI: the advertised application URL (defined under Listening, trust, and public deployments above), the "this page" referent, the update contract (the reload receiver is always on; no-refresh reloads additionally need the `pnpm run dev:web` watcher), and the instruction not to start replacement servers. `DSH_WEB_URL` additionally appears in the managed bash environment with its description, resolved per invocation from the live server. When it is false, neither section nor the variable is registered. +When `surfaceContext` is true, `DSH_WEB_URL` appears in the managed bash environment with its description, resolved per invocation as the advertised application URL defined under Listening, trust, and public deployments above. When it is false, the variable is not registered. The GUI orientation, the implementation checkout, and the current GUI URL come from the [`dsh-harness-dev-context`](../../context/harness-dev-context/README.md) row this bundle mounts. #### Token effect -One source line and one prompt paragraph per session plus two managed-environment variable lines; constant per process. +Two managed-environment variable lines; constant per process. #### KV Cache effect -Source and Web sections follow first-party reusable instructions. Different checkout paths or application URLs leave that preceding prefix unchanged when tools and configuration match; provider cache reuse is not guaranteed. +None; the plugin contributes no system-prompt text. ## Known Limitations and Deferred Work diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index c74fd1ccc0..8d08ede8af 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -46,13 +46,15 @@ dsh --profile web --no-open --port 8080 ### 配置 +`minimal` agent preset 使用持久 Shell、`read_image` 工具和固定 persona,支持自动历史压缩以及会话空闲时的 `/compact` 命令。它复用 Host token meter,并拥有隔离的压缩后端。[压缩配置与保留策略](../../compaction/compaction-basic/README.zh.md#use-this-package) 沿用现有后端。图片读取复用 Host 文件系统和附件存储,要求模型支持图片输入;不加载文本读取、写入和编辑工具。 + `--host` 与 `--port` 配置监听器;`--tls-cert` 与 `--tls-key` 让它直接提供 HTTPS;`--public-url` 指定 GUI 在剥离前缀的代理之后对外公告的公开 HTTP(S) 根,`--trusted-host` 则添加更多被接受的 authority。这些都在[监听、信任与公开部署](#public-deployments)中说明,而这些 flag 设置的 `tls` 字段记录在[载体的配置](../../../docs/subsystems/web-server.zh.md#config)中: | 字段 | 默认值 | 含义 | |---|---|---| | `openBrowser` | `true` | 启动后用默认浏览器打开;SSH 启动会抑制它 | | `printUrl` | `true` | 启动时打印 `dsh web:` URL 行 | -| `surfaceContext` | `true` | 给 agent 提供 GUI 定位上下文,并把 `DSH_WEB_URL` 暴露给其 shell 命令 | +| `surfaceContext` | `true` | 把 `DSH_WEB_URL` 暴露给 agent 的 shell 命令 | | `publicUrl` | 未设置 | 对外公告的 HTTP(S) 应用根;否则公告绑定地址 URL | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-app)列出了本运行时插件接受的字段及其 JSDoc。随发行版交付的组合插入 `schedule` 服务行与 `ui-schedule` 任务页面行,时钟读数与四个提醒工具则属于 `standard`、`cordis` 与 `ptc` 三个 preset。这三个 preset 的 `tool-subagent` 与 `tool-subagent-fork` 两行都 deny 这四个工具,因此被委派子 agent 的作用域不会列出它们。 @@ -74,7 +76,7 @@ dsh --profile web --no-open --host "$(hostname -i | awk '{print $1}')" --public- 未设置 `--public-url` 时,`::ffff:127.0.0.1` 这样的 mapped IPv6 回环绑定会公告其 IPv4 形式 `127.0.0.1`,让浏览器将其识别为本地可信来源。监听器仍使用配置的绑定地址。点分四段式的 IPv6 尾部表示该地址自身的低 32 位,与 `listen` 的读法完全一致:`::0.0.0.1` 属于 IPv6 回环并公告 `[::1]`,而 `::127.0.0.1` 是非回环地址,并非 IPv4 回环。 -`--public-url` 公告浏览器使用的 HTTP(S) 根——打印与打开的启动 URL、`DSH_WEB_URL` 与 web 表层定位。公告不授予任何信任:浏览器可见的 authority 还必须用 `--trusted-host` 点名。该 flag 不配置监听器、路由或 cookie 作用域,因为外部链路归代理所有:[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了这样的部署必须提供什么。 +`--public-url` 公告浏览器使用的 HTTP(S) 根——打印与打开的启动 URL、`DSH_WEB_URL`,以及 agent 运行时上下文中的 Web GUI URL。公告不授予任何信任:浏览器可见的 authority 还必须用 `--trusted-host` 点名。该 flag 不配置监听器、路由或 cookie 作用域,因为外部链路归代理所有:[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了这样的部署必须提供什么。 无论是公告 URL 还是浏览器信任栅栏都不保护端口本身,因此请把端口限制在可信代理或网络内。 @@ -96,7 +98,7 @@ dsh --profile web --no-open --host "$(hostname -i | awk '{print $1}')" --public-
实现细节——点击展开 -此 bundle 由一层五个文件的补丁和一个运行时胶水插件组成:`cordis.patch.yml` 承载宿主行和 preset 注册表,每个 `presets/.patch.yml` 插入一条随发行版交付的 preset 声明,按 `dsh.bundle.patch` 列出的顺序应用。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、公告应用 URL、明文 HTTP 暴露警告、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。 +此 bundle 由一层五个文件的补丁和一个运行时胶水插件组成:`cordis.patch.yml` 承载宿主行和 preset 注册表,每个 `presets/.patch.yml` 插入一条随发行版交付的 preset 声明,按 `dsh.bundle.patch` 列出的顺序应用。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、公告应用 URL、明文 HTTP 暴露警告、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。 ### patch 语义 @@ -104,7 +106,7 @@ patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己 ### 公告应用 URL -启动显示与浏览器交接接收附带启动 token 的公告根;web 表层提示词与 `DSH_WEB_URL` 接收不含凭据的形式。根本身由[监听、信任与公开部署](#public-deployments)定义。 +启动显示与浏览器交接接收附带启动 token 的公告根;agent 的运行时上下文与 `DSH_WEB_URL` 接收不含凭据的形式。根本身由[监听、信任与公开部署](#public-deployments)定义。 ### 就绪宣告 @@ -114,12 +116,12 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | `web-app` 粘合插件:dist 解析、公告应用 URL、提示词段落、bash 变量、URL 行、浏览器交接 | +| [`src/index.ts`](src/index.ts) | `web-app` 粘合插件:dist 解析、公告应用 URL、bash 变量、URL 行、浏览器交接 | | [`src/public-url.ts`](src/public-url.ts) | 公告根的校验与尾斜杠归一化;供本地导入的叶子模块,不属于包 API | | [`src/startup.ts`](src/startup.ts) | `web-startup` 提供方:`--host`、`--port`、`--tls-cert`、`--tls-key`、`--public-url`、`--trusted-host`、`--no-open`、`--help` | | [`cordis.patch.yml`](cordis.patch.yml) | Web patch:重述的基础值、Web 宿主行、浏览器名录、preset 注册表 | | [`presets/`](presets) | 每个随发行版交付的 preset(`standard`、`ptc`、`minimal`、`cordis`)各一条 `@deepseek-ai/dsh-agent-preset` 声明,各自一个补丁文件 | -| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | dist index 锚定、公告 URL 发布与警告、提示词段落、就绪宣告发布 | +| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | dist index 锚定、公告 URL 发布与警告、bash 变量、就绪宣告发布 | | [`tests/startup.spec.ts`](tests/startup.spec.ts) | 在真实 Loader 树上的命令行解析 | | [`tests/public-url.spec.ts`](tests/public-url.spec.ts) | 公告根的解析与归一化 | | [`tests/browser-open.spec.ts`](tests/browser-open.spec.ts) | 页面可达后的默认浏览器交接 | @@ -144,19 +146,19 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发 ## 模型体验 -### Harness 源码与 Web 表层上下文 +### Web 运行时变量 #### 模型看到什么 -当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(first-party 顺序 10100,位于可复用指令之后)则向模型说明 GUI:公告应用 URL(定义见上文「监听、信任与公开部署」)、「this page」指代什么、更新约定(重载接收端始终开启;无刷新重载还需要 `pnpm run dev:web` watcher),以及不要启动替代服务器的指令。`DSH_WEB_URL` 还会连同描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和该变量都不会注册。 +当 `surfaceContext` 为 true 时,`DSH_WEB_URL` 连同描述出现在受管 bash 环境中,每次调用时解析为上文「监听、信任与公开部署」定义的公告应用 URL。为 false 时不注册该变量。GUI 定位、实现 checkout 与当前 GUI URL 由本 bundle 挂载的 [`dsh-harness-dev-context`](../../context/harness-dev-context/README.zh.md) 行提供。 #### Token 影响 -每个会话一行源码说明和一段提示词,外加两行受管环境变量;每个进程内保持恒定。 +两行受管环境变量;每个进程内保持恒定。 #### KV Cache 影响 -源码与 Web 段落位于第一方可复用指令之后。工具与配置一致时,不同 checkout 路径或应用 URL 不会改变前置前缀;不保证提供方复用缓存。 +无;该插件不贡献系统提示词文本。 ## 已知限制与延期工作 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index a479ce8623..6e1c019323 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -197,14 +197,11 @@ # Web glue owned by this bundle: resolves the built frontend dist (an # assembly fact of dsh-web-app, never user config), mounts the - # frontend-static fallback owner, registers the web-surface prompt - # section and the bash runtime variable, prints the URL line, and opens the - # canonical URL after the full Loader tree settles and the required- - # entry audit passes. Invocation-only values come from the webStartup - # provider; the advertised application URL is this row's `publicUrl`, - # otherwise the bind-address URL. A complete agent-preset persona - # suppresses the prompt section for that agent while retaining the - # host-owned shell variable. + # frontend-static fallback owner, registers the bash runtime variable, + # prints the URL line, and opens the canonical URL after the full Loader + # tree settles and the required-entry audit passes. Invocation-only values + # come from the webStartup provider; the advertised application URL is + # this row's `publicUrl`, otherwise the bind-address URL. - id: web-runtime name: '@deepseek-ai/dsh-web-app' inject: [webStartup] @@ -214,6 +211,14 @@ publicUrl: !!js ctx.webStartup.publicUrl surfaceContext: true + # Model context for an agent that inspects or extends the harness serving + # this GUI: value-free prompt sections plus the current checkout and GUI + # URL as optional runtime context. A complete agent-preset persona replaces + # the sections, and a persona that suppresses optional runtime context + # also drops the two value lines. + - id: harness-dev-context + name: '@deepseek-ai/dsh-harness-dev-context' + # The client-plugin reload chain, always mounted: it is idle until a # rebuild watcher (pnpm run dev:web) actually rewrites client bundles. It # is a row rather than a child of web-runtime because its node half is a @@ -386,6 +391,10 @@ - id: ui-reference name: '@deepseek-ai/dsh-client-ui-reference' + # Display masking for recognized credentials in sent user text; remove + # this entry to draw credentials as written. + - id: ui-secret-mask + name: '@deepseek-ai/dsh-client-ui-secret-mask' # Background jobs: the session-header roster with on-demand streaming # record panels over the session job-output service. diff --git a/packages/bundle/web-app/locale/en.json b/packages/bundle/web-app/locale/en.json new file mode 100644 index 0000000000..38435d75ce --- /dev/null +++ b/packages/bundle/web-app/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "web-app" + } +} diff --git a/packages/bundle/web-app/locale/startup/en.json b/packages/bundle/web-app/locale/startup/en.json new file mode 100644 index 0000000000..517b230f59 --- /dev/null +++ b/packages/bundle/web-app/locale/startup/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "web-app/startup" + } +} diff --git a/packages/bundle/web-app/locale/startup/zh.json b/packages/bundle/web-app/locale/startup/zh.json new file mode 100644 index 0000000000..71c9dfc066 --- /dev/null +++ b/packages/bundle/web-app/locale/startup/zh.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "Web 启动配置" + } +} diff --git a/packages/bundle/web-app/locale/zh.json b/packages/bundle/web-app/locale/zh.json new file mode 100644 index 0000000000..c224b7e344 --- /dev/null +++ b/packages/bundle/web-app/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Web 应用运行支持", + "description": "提供前端页面资源,并连接 Web 应用所需的运行配置。" + } +} diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 3ff28ba9dc..70421b36c6 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", - "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.2.1-alpha.1", + "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-alpha.2", "publishConfig": { "access": "public" }, @@ -25,9 +25,13 @@ "./cordis.patch.yml": "./cordis.patch.yml", "./presets/*.patch.yml": "./presets/*.patch.yml", "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./startup/locale/*.json": "./locale/startup/*.json", + "./locale/*.json": "./locale/*.json" }, "files": [ + "locale/*.json", + "locale/startup/*.json", "lib/index.js", "lib/startup.js", "cordis.patch.yml", @@ -92,6 +96,7 @@ "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*", "@deepseek-ai/dsh-client-ui-reference": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-secret-mask": "workspace:*", "@deepseek-ai/dsh-client-ui-session": "workspace:*", "@deepseek-ai/dsh-client-ui-schedule": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", @@ -181,13 +186,13 @@ "@deepseek-ai/dsh-client-ui-shortcuts": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", "@deepseek-ai/dsh-host-product-telemetry-otel": "workspace:*", - "@deepseek-ai/dsh-client-ui-settings-session-log": "workspace:*" + "@deepseek-ai/dsh-client-ui-settings-session-log": "workspace:*", + "@deepseek-ai/dsh-harness-dev-context": "workspace:*" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/cordis-plugin-loader": "workspace:~", - "@deepseek-ai/dsh-shell-env": "workspace:*", - "@deepseek-ai/dsh-system-prompt": "workspace:*" + "@deepseek-ai/dsh-shell-env": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", @@ -199,7 +204,6 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-shell-env": "workspace:*", - "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-typert-registry": "workspace:*", "@deepseek-ai/libreoffice-kit": "^0.1.5", "@deepseek-ai/dsh-otel": "workspace:*" diff --git a/packages/bundle/web-app/presets/cordis.patch.yml b/packages/bundle/web-app/presets/cordis.patch.yml index 8c8d567ae3..75b94016d2 100644 --- a/packages/bundle/web-app/presets/cordis.patch.yml +++ b/packages/bundle/web-app/presets/cordis.patch.yml @@ -95,23 +95,11 @@ provider: spawn toolName: subagent modelSelectionSettings: true - toolFilter: - deny: - - schedule_create - - schedule_delete - - schedule_list - - schedule_update - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: provider: fork toolName: subagent_fork - toolFilter: - deny: - - schedule_create - - schedule_delete - - schedule_list - - schedule_update - id: workflow-ptc name: '@deepseek-ai/dsh-workflow-ptc' config: diff --git a/packages/bundle/web-app/presets/minimal.patch.yml b/packages/bundle/web-app/presets/minimal.patch.yml index ee36823e27..043e34cb01 100644 --- a/packages/bundle/web-app/presets/minimal.patch.yml +++ b/packages/bundle/web-app/presets/minimal.patch.yml @@ -14,6 +14,23 @@ prefix: You are a helpful software engineer assistant. complete: true includeRuntimeContext: false + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + config: + read: false + readImage: true + write: false + edit: false + - id: compaction + name: cordis:group + group: true + isolate: + compaction: true + config: + - id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' - id: persistent-shell name: cordis:group group: true diff --git a/packages/bundle/web-app/presets/ptc.patch.yml b/packages/bundle/web-app/presets/ptc.patch.yml index 8e1ec1fb70..19771435ba 100644 --- a/packages/bundle/web-app/presets/ptc.patch.yml +++ b/packages/bundle/web-app/presets/ptc.patch.yml @@ -96,23 +96,11 @@ provider: spawn toolName: subagent modelSelectionSettings: true - toolFilter: - deny: - - schedule_create - - schedule_delete - - schedule_list - - schedule_update - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: provider: fork toolName: subagent_fork - toolFilter: - deny: - - schedule_create - - schedule_delete - - schedule_list - - schedule_update - id: workflow-ptc name: '@deepseek-ai/dsh-workflow-ptc' disabled: true diff --git a/packages/bundle/web-app/presets/standard.patch.yml b/packages/bundle/web-app/presets/standard.patch.yml index 6a42d0a351..77c048feb8 100644 --- a/packages/bundle/web-app/presets/standard.patch.yml +++ b/packages/bundle/web-app/presets/standard.patch.yml @@ -96,23 +96,11 @@ provider: spawn toolName: subagent modelSelectionSettings: true - toolFilter: - deny: - - schedule_create - - schedule_delete - - schedule_list - - schedule_update - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: provider: fork toolName: subagent_fork - toolFilter: - deny: - - schedule_create - - schedule_delete - - schedule_list - - schedule_update - id: workflow-ptc name: '@deepseek-ai/dsh-workflow-ptc' config: diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 28e078bd31..b93b559620 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -4,9 +4,9 @@ * manifest field). The plugin owns the browser-surface glue: it resolves * the built frontend dist (workspace knowledge of this bundle, never user * config), mounts the `frontend-static` fallback owner over it, registers the - * harness-source and web-surface prompt sections, the bash-visible web runtime - * variable, the process-token URL line, and the default-browser handoff. An - * advertised `publicUrl` replaces the published bind-address URL. Non-loopback + * bash-visible web runtime variable, the process-token URL line, and the + * default-browser handoff, and provides the `webApp` service. An advertised + * `publicUrl` replaces the published bind-address URL. Non-loopback * HTTP binds trigger an exposure warning. App command-line values arrive * through the `webStartup` service expressions in the bundle patch. * @module @deepseek-ai/dsh-web-app @@ -14,26 +14,38 @@ import { spawn, type ChildProcess } from 'node:child_process' import { createRequire } from 'node:module' -import { isIP } from 'node:net' import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { addHarnessSourceSection, auditStartupEntries } from '@deepseek-ai/dsh-app-boot' +import { auditStartupEntries } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-client-connection' import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static' -import { isLoopbackHost, normalizeBindAddress } from '@deepseek-ai/dsh-host-webserver' +import { bindUrlHost, isLoopbackHost } from '@deepseek-ai/dsh-host-webserver' import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-shell-env' import { parsePublicUrl } from './public-url.ts' +/** The Web GUI this process serves, provided by the `web-runtime` row. */ +export interface WebApp { + /** + * The URL this GUI advertises, also used for `DSH_WEB_URL` and the startup URL line. + * @returns the configured `publicUrl`, otherwise the listener origin. + */ + getAdvertisedUrl(): string +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** The Web GUI this process serves; absent when no Web runtime is mounted. */ + webApp: WebApp + } +} + /** Stable Cordis plugin name. */ export const name = 'web-app' -/** This dsh installation's root, from either this package's source or built entry. */ -const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)) const ANNOUNCED_ROOTS = new WeakSet() /** Services required before the web runtime can mount. */ @@ -46,16 +58,14 @@ export interface Config { /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** - * Register the model-visible surface context (the `app:web-surface` prompt - * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive - * layer can turn it off when its user is not in the GUI, so the - * orientation text would be false. + * Register the `DSH_WEB_URL` bash variable naming this GUI. A one-shot + * non-interactive layer can turn it off when its user is not in the GUI. */ surfaceContext: boolean /** * Canonical HTTP(S) root to advertise in the printed and opened URL, - * `DSH_WEB_URL`, and the web-surface orientation, e.g. - * `https://app.example/ui/`, normalized to end in `/`. Configures no routing + * `DSH_WEB_URL`, and `webApp.getAdvertisedUrl()`, which supplies the Web GUI + * URL in agent runtime context, e.g. `https://app.example/ui/`, normalized to end in `/`. Configures no routing * or authentication; see [public deployments](../README.md#public-deployments). * Absent or YAML `null` advertises the bind-address URL. */ @@ -103,65 +113,6 @@ try { } ` -/** - * Local root URL of the active Web server, using the browser-compatible form - * of its bind address and brackets for IPv6 literals. Non-loopback binds such - * as a container's Pod address advertise that address. - * @throws when the runtime resolves without a bound webServer, or when the bind - * address has no URL form (see {@link advertisedBindHost}). - */ -function localWebUrl(ctx: Context): string { - const webServer = ctx.get('webServer') - const port = webServer?.port - if (webServer === undefined || port === undefined) { - throw new Error('web-app: webServer service missing while resolving Web runtime') - } - const host = advertisedBindHost(webServer.host) - return `${webServer.protocol}//${isIP(host) === 6 ? `[${host}]` : host}:${String(port)}` -} - -/** - * Bind address in a URL. Loopback addresses use their canonical address text, - * so browsers treat them as local, trustworthy origins: a genuinely mapped - * literal such as `::ffff:127.0.0.1` reads as `127.0.0.1`, and a dotted-quad - * tail reads as the address it names, so `::0.0.0.1` reads as `::1` rather - * than as `0.0.0.1`. Interface zone IDs cannot appear in a URL; only a - * redundant loopback zone can be dropped. - * @param host - webserver bind address. - * @returns the zone-free URL host. - * @throws when a non-loopback zone requires an explicit public URL. - */ -function advertisedBindHost(host: string): string { - const zoneAt = host.indexOf('%') - const bare = zoneAt === -1 ? host : host.slice(0, zoneAt) - if (isLoopbackHost(bare)) return normalizeBindAddress(bare) - if (zoneAt === -1) return host - throw new Error( - `web-app: bind address ${JSON.stringify(host)} carries an interface zone id, which no URL can express;` - + ' pass --public-url with the root browsers actually reach it through', - ) -} - -/** Resolve the advertised root: this plugin's canonical `publicUrl`, or the bind-address URL (loopback when bound to loopback). */ -function appRootUrl(ctx: Context, publicUrl: string | undefined): string { - if (publicUrl !== undefined) return publicUrl - return localWebUrl(ctx) -} - -/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */ -function webSurfacePrompt(webUrl: string): string { - const updateContract = 'The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while ' - + '`pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. ' - + 'Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. ' - return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. ` - + 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. ' - + 'The browser provides no implicit DOM, route, or screenshot context. ' - + updateContract - + 'Starting another server does not update this GUI. ' - + 'The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. ' - + 'Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL.' -} - /** * Dist location is workspace knowledge of this bundle: anchored on the * frontend package manifest, not configured. Existence is a request-time @@ -227,8 +178,8 @@ export const internals: { } = { resolveDistIndex, openBrowser } /** - * Mount the Web runtime: dist serving, surface prompt, the bash runtime - * variable, the URL line, and the default-browser handoff. + * Mount the Web runtime: dist serving, the bash runtime variable, the URL + * line, and the default-browser handoff. * @param ctx - plugin context carrying the webServer service. * @param config - validated {@link Config}. */ @@ -237,30 +188,30 @@ export function apply(ctx: Context, config: Config): void { // the string transform and reaches here, meaning unset. const publicUrl = config.publicUrl ?? undefined const boundHost = ctx.webServer.host - if (publicUrl === undefined) advertisedBindHost(boundHost) + if (publicUrl === undefined && bindUrlHost(boundHost) === undefined) { + throw new Error( + `web-app: bind address ${JSON.stringify(boundHost)} carries an interface zone id, which no URL can express;` + + ' pass --public-url with the root browsers actually reach it through', + ) + } if (ctx.webServer.protocol === 'http:' && !isLoopbackHost(boundHost)) { console.warn(`dsh web: listening on ${boundHost} over plain HTTP; restrict this port to a trusted proxy or network`) } // The bind URL belongs to this host. Under SSH, the operator reaches it // through a local forwarding address that this process cannot derive. const handoffBrowser = config.openBrowser && !launchedThroughSsh(launchEnvironmentOf(ctx)) + /** The advertised root: the canonical `publicUrl`, or the listener's own origin. */ + const appRootUrl = (): string => publicUrl ?? ctx.webServer.getOrigin() + ctx.provide('webApp', { getAdvertisedUrl: appRootUrl } satisfies WebApp) ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) if (config.surfaceContext) { - ctx.inject(['systemPrompt'], (promptCtx) => { - addHarnessSourceSection(promptCtx, SOURCE_ROOT) - promptCtx.systemPrompt.section({ - name: 'app:web-surface', - order: promptCtx.systemPrompt.getSectionOrder('WEB_SURFACE'), - text: () => webSurfacePrompt(appRootUrl(promptCtx, publicUrl)), - }) - }) ctx.inject(['shellEnv'], (runtimeCtx) => { runtimeCtx.shellEnv.register({ name: 'web-runtime', variables: { [DSH_WEB_URL]: { description: 'Advertised URL of the DeepSeek Harness Web GUI serving this session.' }, }, - resolve: () => ({ [DSH_WEB_URL]: appRootUrl(runtimeCtx, publicUrl) }), + resolve: () => ({ [DSH_WEB_URL]: appRootUrl() }), }) }) } @@ -273,7 +224,7 @@ export function apply(ctx: Context, config: Config): void { // hand-built tree without a Loader is already the complete tree. const announceReady = (): void => { if (ANNOUNCED_ROOTS.has(connectionCtx.root)) return - const webUrl = appRootUrl(connectionCtx, publicUrl) + const webUrl = appRootUrl() const authenticatedUrl = connectionCtx.connection.authenticatedUrl(webUrl) ANNOUNCED_ROOTS.add(connectionCtx.root) if (config.printUrl) { diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index f24e4ea386..3152e085fb 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -64,7 +64,7 @@ function webCommand(): Command { .option('--host ', 'bind address: one concrete IPv4 or IPv6 literal of a local interface; wildcard addresses are rejected') .option('--no-open', 'do not open the Web UI in the default browser') .option('--port ', 'listen port; pass 0 to let the OS pick a free one') - .option('--public-url ', 'advertise this HTTP(S) root in the printed, opened, web-surface, and DSH_WEB_URL forms; grants no trust') + .option('--public-url ', 'advertise this HTTP(S) root in the printed, opened, runtime-context, and DSH_WEB_URL forms; grants no trust') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .option('--tls-cert ', 'PEM server certificate chain; requires --tls-key') .option('--tls-key ', 'unencrypted PEM private key; requires --tls-cert') diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index e1c21573ac..67a350355d 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -11,8 +11,7 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import * as AppBoot from '@deepseek-ai/dsh-app-boot' import { createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import type { WebServer } from '@deepseek-ai/dsh-host-webserver' +import { bindUrlHost, type WebServer } from '@deepseek-ai/dsh-host-webserver' import { apply, Config, internals } from '../src/index.ts' vi.mock('node:child_process', async importOriginal => ({ @@ -59,10 +58,11 @@ function stageDist(): string { /** Web carrier without a network listener. */ function fakeHttpServer(host = '127.0.0.1'): { server: WebServer } { - const server: Pick = { + const server: Pick = { host, port: 4567, protocol: 'http:', + getOrigin() { return `http://${bindUrlHost(host) ?? ''}:${String(this.port)}` }, registerFallback: () => () => {}, renderIndex: (html: string) => html, } @@ -126,7 +126,6 @@ describe('web-app runtime glue', () => { const openBrowser = vi.fn(async (url: string) => { lifecycle.push(`open:${url}`) }) internals.openBrowser = openBrowser apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: true })) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await vi.waitFor(() => { expect(openBrowser).toHaveBeenCalled() }) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token') @@ -137,15 +136,9 @@ describe('web-app runtime glue', () => { 'dsh web: opening the default browser; pass --no-open to disable', 'open:http://127.0.0.1:4567/?token=test-token', ]) - const assembly = await ctx.systemPrompt.assemble() - expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout') - const section = assembly.sections.find(entry => entry.name === 'app:web-surface') - expect(section?.text).toContain('http://127.0.0.1:4567') - // The single update contract: the receiver is always on; no-refresh - // reloads additionally need the rebuild watcher. - expect(section?.text).toContain('pnpm run dev:web') const webRuntime = contributions.find(contribution => contribution.name === 'web-runtime') expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567' }) + expect(ctx.webApp.getAdvertisedUrl()).toBe('http://127.0.0.1:4567') }) it('advertises and opens the configured public root behind a prefix-stripping proxy', async () => { @@ -173,7 +166,6 @@ describe('web-app runtime glue', () => { surfaceContext: true, publicUrl: 'https://proxy.example:8443/web', })) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await vi.waitFor(() => { expect(openBrowser).toHaveBeenCalled() }) expect(lifecycle).toEqual([ @@ -181,12 +173,10 @@ describe('web-app runtime glue', () => { 'dsh web: opening the default browser; pass --no-open to disable', 'open:https://proxy.example:8443/web/?token=test-token', ]) - const assembly = await ctx.systemPrompt.assemble() - const section = assembly.sections.find(entry => entry.name === 'app:web-surface') - expect(section?.text).toContain('https://proxy.example:8443/web/') - expect(section?.text).not.toContain('127.0.0.1') const urlContribution = contributions.find(contribution => contribution.name === 'web-runtime') expect(urlContribution?.resolve()).toEqual({ DSH_WEB_URL: 'https://proxy.example:8443/web/' }) + // The service advertises the row's effective publicUrl, whether it came from --public-url or a profile override. + expect(ctx.webApp.getAdvertisedUrl()).toBe('https://proxy.example:8443/web/') }) it('publishes no readiness side effect when printing and browser opening are disabled', async () => { @@ -198,17 +188,13 @@ describe('web-app runtime glue', () => { const openBrowser = vi.fn(async () => {}) internals.openBrowser = openBrowser apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true })) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() expect(openBrowser).not.toHaveBeenCalled() - const assembly = await ctx.systemPrompt.assemble() - expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text) - .toContain('rebuilding the affected Web artifacts') await ctx.fiber.dispose() }) - it('skips the surface context when disabled (the one-shot layer): no prompt section, no bash variables', async () => { + it('skips the surface context when disabled (the one-shot layer): no bash variables', async () => { stageDist() const ctx = new Context() ctx.provide('webServer', fakeHttpServer().server) @@ -221,11 +207,7 @@ describe('web-app runtime glue', () => { }, } as never) apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: false })) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await new Promise(resolve => setTimeout(resolve, 0)) - const assembly = await ctx.systemPrompt.assemble() - expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false) - expect(assembly.sections.some(entry => entry.name === 'harness:source')).toBe(false) expect(contributions).toEqual([]) await ctx.fiber.dispose() }) @@ -465,22 +447,6 @@ describe('web-app runtime glue', () => { } }) - it('fails loud when the prompt section resolves against a portless webserver', async () => { - stageDist() - const ctx = new Context() - // A webserver whose bound port is gone (torn down mid-request): the - // section must throw, never render a URL with an undefined port. - const { server } = fakeHttpServer() - Object.defineProperty(server, 'port', { get: () => undefined }) - ctx.provide('webServer', server) - provideConnection(ctx) - apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true })) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) - await new Promise(resolve => setTimeout(resolve, 0)) - await expect(ctx.systemPrompt.assemble()).rejects.toThrow('webServer service missing') - await ctx.fiber.dispose() - }) - it('anchors the dist index on the frontend package manifest without requiring a built dist', () => { // The production resolver (not the test hook): the anchor resolves on any // checkout, built or not — dist existence is the fallback owner's diff --git a/packages/bundle/web-app/tsconfig.json b/packages/bundle/web-app/tsconfig.json index d4a74c09ba..2f7f148e60 100644 --- a/packages/bundle/web-app/tsconfig.json +++ b/packages/bundle/web-app/tsconfig.json @@ -35,9 +35,6 @@ { "path": "../../util/launch-environment" }, - { - "path": "../../core/system-prompt" - }, { "path": "../../shell/shell-env" }, diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 2d39d6b3ca..16956ca71d 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -8,7 +8,7 @@ Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-..` (for example, `'tool.call.toolview'`). 3. **Component props are the five shares, all derived**: `PropsRuntime` (owner data plus scope/global seats), `PropsRenderSlots` (children), `PropsRenderFactories` (`renderFactorySlot`), `PropsStore` (store), and the inject face. Never hand-write or locally re-type a derived member. Factory props follow the corresponding `Factory*PropsOf` aliases. 4. **Hooks are framework-made only**: standing seats include `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot`, and `renderFactorySlot`; Factory definitions additionally receive `useFactorySlot`. The renderer also binds `use` hooks from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.) @@ -16,6 +16,7 @@ The [Slots reference](../../docs/subsystems/slots.md) owns the current design; t 6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation API. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to registrations inside `apply`. A Factory may instead receive the factory itself to create one handle per committed render position. Production code never calls the factory or `.create()` outside registration; tests do (that is the sanctioned zero-machinery path). 7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact uses the reserved `hooks` compartment (bare observables the renderer binds to `use`; components never see the sources). The plugin may use only the dependencies named by its `inject` declaration; there is no wider ctx to reach for. 8. **Factory local Components keep stable identities**: pass module-level function Components through `renderFactorySlot(..., { slots })`. Creating a Component function during render intentionally remounts that local subtree and resets its local state; the caller owns that behavior. +9. **A `key` states identity**: pass one when it names which entity or instance the element represents. A key that only silences a warning, or that encodes render order, misstates identity and makes a later remount or reuse decision wrong. ## Reactive read and contract-currency discipline @@ -27,6 +28,8 @@ How live data reaches render code, and what UI domains may share: 4. **UI domains share JSON-compatible data and callbacks.** Owner props, injected values, store state, and provide contributions use these values. Tool `args` may carry the [lazy argument reader](../../.agents/notes/implemented/architecture/2026-09-24-preparing-tool-arguments.md); its Definition republishes the block when an observed answer changes. Transient file-read and document-preview content may also carry `Uint8Array`: published buffers are borrowed read-only, copied before Worker transfer, and never persisted in layout or Session JSON. The injected `hooks` compartment is the only place for bare observables, and components never receive those sources directly. Route ReactNode content through a slot; do not add ReactNode-valued owner props or injected members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` fields remain until they move to slots). 5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves). 6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering. +7. **Resource consumers go through `useResource`.** An element showing a resource reads it through the injected `useResource` hook, and a domain wrapper composes its own hook over it rather than reimplementing the fetch, cache, or polling below. Wrapping is free at the business layer as long as the bottom layer is not rebuilt ([client resources](../../docs/subsystems/client-resources.md)). +8. **Effects do not synchronize state.** `useLayoutEffect` is not a default and `useEffect` is not the fix: a layout measurement answers why it happens here, why CSS cannot express it, and why it must be pixel-based, then takes an injected measurement hook. An effect never pushes render-derivable data back to a parent through a props setter; decide it during render, give the host a render-time channel, or move it to the inject face. ## Export discipline (client plugin packages) @@ -66,6 +69,7 @@ Npm sections describe installation and development relationships; each build fac 5. **Only Host runtime libraries require installation dependencies.** Browser-only third-party implementations belong in `devDependencies`, whether inlined into `lib/client.js` or retained as bare imports in a static browser library. Static libraries are Web-shell build inputs; the final Vite build merges their imports and styles. Distributed browser code remains subject to runtime license disclosure ([reference](../../.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md)). 6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents. 7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view. +8. **Only the root manifest declares React type packages.** `@types/react` and `@types/react-dom` belong only in the root `package.json` `devDependencies`, and `verify-package-dependencies` rejects them in every manifest under `apps/*`, `packages/*/*`, and `vendor/*`; packages still declare the `react` and `react-dom` they import. Each package-local pnpm link is one more location TypeScript scans when it computes module specifiers for every JSX file ([rationale](../../.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md#1-only-the-root-manifest-declares-react-type-packages)). ## Build-time browser environment @@ -73,11 +77,11 @@ Client business code may statically read `process.env.DSH_CLIENT_*`; every refer ## Shared modules and the module graph -A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in [`web/src/platform.ts`](web/src/platform.ts): `PLATFORM_MODULES` names shell-seeded React, Cordis, and static Client libraries; `PRELOADED_CLIENT_EXTERNALS` is reserved for dynamic rows whose factories must arrive before shell boot and is empty when no such row exists. +A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in [`web/src/platform.ts`](web/src/platform.ts): `PLATFORM_MODULES` names shell-seeded React, Cordis, selected third-party libraries, and static Client libraries; `PRELOADED_CLIENT_EXTERNALS` is reserved for dynamic rows whose factories must arrive before shell boot and is empty when no such row exists. -1. **Baseline externals are implicit for every dynamic bundle.** Do not repeat React, Cordis, `client/store`, `ui-primitives`, `ui-slots`, or `ui-dockkit` in package manifests. +1. **Baseline externals are implicit for every dynamic bundle.** Do not repeat entries from `PLATFORM_MODULES` in `dsh.client.external`; npm dependency declarations still follow the dependency rules above. 2. **`dsh.client.external` is not a feature-plugin dependency mechanism.** Only infrastructure, transport, or generated assembly may add a package-specific non-baseline value request whose dynamic row must be materialized through the module table. Declare the exact import specifier; only a trailing `/client` aliases the package row. -3. **Silence means a private copy.** Ordinary third-party implementation libraries may be bundled independently. A value reached only through `import type` is erased and creates no request. +3. **Silence means a private copy.** Third-party implementation libraries outside the baseline may be bundled independently. A value reached only through `import type` is erased and creates no request. 4. **A request has two possible suppliers.** A dynamic package supplies its own row; `PLATFORM_MODULES` supplies an exact static-table key. There is no `dsh.client.provide` alias protocol. 5. **Validate both sides.** The dynamic build preset externalizes the baseline and rejects undeclared workspace value imports; [`verify-client-packages`](../../scripts/verify-client-packages.ts) rejects malformed or redundant requests, missing suppliers, and synchronous request cycles. 6. **Package-local dynamic chunks are self-contained.** Source uses `import()` and the shared preset emits `require.async("./client..js")`; an entry or chunk must not retain a synchronous relative `require("./client*.js")`. Keep every static relative dependency inside its owning output chunk rather than relying on a sibling-chunk graph the runtime does not support. @@ -139,7 +143,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore Bringing up a new `packages/client/` plugin package (ui-workspace is a complete example; ui-sidebar/ui-user-questions are minimal skeletons): -1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js'])`), `src/index.ts` (empty node-half apply), `src/css-modules.d.ts` when using CSS Modules, and `README.md` with the Model Experience section. +1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency), `tsdown.config.ts` (`clientBundle(import.meta.dirname, ['lib/types/index.js'])`), `src/index.ts` (empty node-half apply), `src/css-modules.d.ts` when using CSS Modules, and `README.md` with the Model Experience section. 2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the computed runtime resolution, which is installed as an interception on Node's resolvers — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. 3. **dsh.client manifest semantics**: `platform: 'web'` always, and the declaration requires a `./client` export (the scan throws without one); `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is Cordis fiber inject waiting on *services*, nothing else. A non-baseline `external` request sequences its dynamic supplier ahead of the consumer — see [shared modules](#shared-modules-and-the-module-graph). 4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads. @@ -150,7 +154,7 @@ Bringing up a new `packages/client/` plugin package (ui-workspace is a com 1. **Check the [ui-primitives catalog](ui-primitives/README.md#component-catalog) before writing a control.** A plugin cannot import another plugin's component, so `ui-primitives` is the only place a control can be shared; the catalog states when to reuse, when to promote, and when your own package is the right home. -2. Compose through `register`: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component. Use a Component Factory only for a reusable assembly rendered independently under unrelated parents; see the [Slots reference](../../docs/subsystems/slots.md). +2. Compose through `register`: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component. A component or assembly designed for reuse may instead use a Component Factory; one-off components need not become Factories. See the [Slots reference](../../docs/subsystems/slots.md). 3. Type ordinary props from the five shares (`PropsRuntime` & `PropsRenderSlots` & `PropsRenderFactories` & `PropsStore` & inject face), and use the derived `Factory*PropsOf` aliases for Factory Components. Shared or remount-surviving state goes in a declared store; component-private state stays local. 4. Component tests feed props directly (`createXXXStore().create()` for the store data; plain stubs for framework hooks) and assert behavior without render machinery. 5. Tokens only in CSS; product copy follows the localization rule above; English comments. diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 766b7d16d0..f4e03603ff 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /client-web-gui-browser-half/packages: - en: 1aa93b8088fb1962 - zh: 10feaed78bc6408c + en: e0bdfa58440be810 + zh: a006bb2e73f3f264 /client-web-gui-browser-half/related-documentation: en: d1d506e68fab7333 zh: 1a90fe6f3f8f24a9 diff --git a/packages/client/README.md b/packages/client/README.md index 04eb19d8f7..19ed68cdbc 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -63,6 +63,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-input-trigger/`](ui-input-trigger/README.md) | Coordinates inline command and reference suggestions | — | | [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions | — | | [`ui-reference/`](ui-reference/README.md) | Unified Web `@file` / `@session` reference source | — | +| [`ui-secret-mask/`](ui-secret-mask/README.md) | Masks recognized credentials in sent user text | — | | [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references | — | | [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs with on-demand streaming record panels | — | | [`ui-schedule/`](ui-schedule/README.md) | Lists the current Session's active reminders in a read-only header catalog | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 2ebc91a3e1..1ef85c42b5 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -63,6 +63,7 @@ kind: "package-group" | [`ui-input-trigger/`](ui-input-trigger/README.zh.md) | 协调内联命令与引用建议 | — | | [`ui-skill/`](ui-skill/README.zh.md) | 向内联建议添加 skill(技能)引用 | — | | [`ui-reference/`](ui-reference/README.zh.md) | 统一的 Web `@file` / `@session` 引用 source | — | +| [`ui-secret-mask/`](ui-secret-mask/README.zh.md) | 遮挡已发送用户文本中可识别的凭据 | — | | [`ui-subagent/`](ui-subagent/README.zh.md) | 提供 subagent 导航、子级 transcript(文本记录)状态与内联引用 | — | | [`ui-jobs/`](ui-jobs/README.zh.md) | 列出本会话的后台任务,并提供按需展开的流式输出面板 | — | | [`ui-schedule/`](ui-schedule/README.zh.md) | 在只读标题栏目录中列出当前会话中生效的提醒 | — | diff --git a/packages/client/connection/locale/en.json b/packages/client/connection/locale/en.json new file mode 100644 index 0000000000..85615ed94d --- /dev/null +++ b/packages/client/connection/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "connection" + } +} diff --git a/packages/client/connection/locale/zh.json b/packages/client/connection/locale/zh.json new file mode 100644 index 0000000000..642e58d1cd --- /dev/null +++ b/packages/client/connection/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "客户端连接", + "description": "建立已认证的客户端通信,并管理连接生命周期。" + } +} diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index b233722f10..21e395fb6f 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -36,9 +37,10 @@ "dependencies": { "@deepseek-ai/dsh-credentials": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "^4.4.3" + "zod": "4.4.3" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/connection/tsdown.config.ts b/packages/client/connection/tsdown.config.ts index 69cd7ce36d..7aa03cebc1 100644 --- a/packages/client/connection/tsdown.config.ts +++ b/packages/client/connection/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-connection', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/file-upload/locale/en.json b/packages/client/file-upload/locale/en.json new file mode 100644 index 0000000000..1a2719ee6c --- /dev/null +++ b/packages/client/file-upload/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "file-upload" + } +} diff --git a/packages/client/file-upload/locale/zh.json b/packages/client/file-upload/locale/zh.json new file mode 100644 index 0000000000..5a330e6b60 --- /dev/null +++ b/packages/client/file-upload/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "文件上传", + "description": "将浏览器中的文件上传到当前会话,供消息和工具使用。" + } +} diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json index eecaa28d28..7c5f43b873 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -35,7 +35,8 @@ "default": "./lib/typert.remote-client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -52,6 +53,7 @@ }, "license": "MIT", "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.js", @@ -62,7 +64,7 @@ "lib/typert.remote-client.d.ts" ], "dependencies": { - "zod": "^4.4.3", + "zod": "4.4.3", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*" }, diff --git a/packages/client/file-upload/tsdown.config.ts b/packages/client/file-upload/tsdown.config.ts index 97292cd3cc..7aa03cebc1 100644 --- a/packages/client/file-upload/tsdown.config.ts +++ b/packages/client/file-upload/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-file-upload', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/hmr/locale/en.json b/packages/client/hmr/locale/en.json new file mode 100644 index 0000000000..47f242be8c --- /dev/null +++ b/packages/client/hmr/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "hmr" + } +} diff --git a/packages/client/hmr/locale/zh.json b/packages/client/hmr/locale/zh.json new file mode 100644 index 0000000000..28262e2f7c --- /dev/null +++ b/packages/client/hmr/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Web 客户端热更新", + "description": "同步浏览器中的插件,并在代码重建后更新相关界面。" + } +} diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 373c176123..3fd0ca2c4d 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -48,6 +49,7 @@ "@deepseek-ai/cordis": "workspace:~" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/hmr/tsdown.config.ts b/packages/client/hmr/tsdown.config.ts index 69b96b4229..7aa03cebc1 100644 --- a/packages/client/hmr/tsdown.config.ts +++ b/packages/client/hmr/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-hmr', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 4b363e6971..2e2736659e 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/README.i18n.yaml @@ -9,8 +9,8 @@ en: 9365090d78467dea zh: 74b6fbb9769d795f /deepseek-ai-dsh-client-locale/summary: - en: a548ff3095e8d7d8 - zh: fec6ad47291018f8 + en: d8cc5ae25160ca20 + zh: e8fc2b9fb663cdee /deepseek-ai-dsh-client-locale/table-of-contents: en: d152484eb41ac6b4 zh: bda879a892f240f0 @@ -30,8 +30,8 @@ en: aa4e9c70523339cc zh: 3359eddc4e9ef15e /deepseek-ai-dsh-client-locale/use-this-package/what-the-host-half-does: - en: 8fa91333f7fc3387 - zh: 789a9cbb3eace3b0 + en: 784035ca4b4838b8 + zh: 4a258795f408528f /deepseek-ai-dsh-client-locale/understand-the-implementation: en: 288d8db39f9acfc5 zh: d65cf2ffae8ad35c @@ -39,8 +39,8 @@ en: c2e0ab8bb6bcb26a zh: 508dec4db2c0fde6 /deepseek-ai-dsh-client-locale/understand-the-implementation/preference-resolution: - en: 4bc9dffa4629f537 - zh: f51633c658b8d25b + en: d602d957654641c6 + zh: 2db822b746a6471d /deepseek-ai-dsh-client-locale/understand-the-implementation/dictionary-lookup: en: f8dcc4035830e738 zh: 8452ac63ee148c61 diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index 930c715a47..8c52703748 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-client-locale` to switch the web GUI between the shipped English and Chinese locales or languages added by client plugins. User selections take effect immediately; loopback pages persist them in `$DSH_HOME/cordis.patch.yml`, while non-loopback pages keep them only for the current process. New browsers use the first supported language requested by the browser until an allowed stored preference arrives. Plugin authors add typed namespace dictionaries and translate through the public locale API; slot-rendered copy updates without a reload. +Use `dsh-client-locale` to switch the web GUI between the shipped English and Chinese locales or languages added by client plugins. User selections take effect immediately; every authenticated browser persists them as `locale.preference` in `$DSH_HOME/cordis.patch.yml` through the Host settings service. New browsers use the first supported language requested by the browser until the Host read delivers a stored preference. Plugin authors add typed namespace dictionaries and translate through the public locale API; slot-rendered copy updates without a reload. ## Table of Contents @@ -67,7 +67,7 @@ An external id is a non-empty ASCII BCP 47-style tag. Its fallback must already ### What the Host half does -The Host persists the preference through the settings service on loopback pages. The Client deliberately withholds that settings scope on non-loopback pages, so their locale selection remains process-local even though Connection authenticates every API method. +Every authenticated browser persists the preference through the Host settings service under the shared [settings scope policy](../ui-settings/README.md). ----- @@ -85,7 +85,7 @@ One `LocaleRuntime` owns the preference and the dictionary registry, and is itse ### Preference resolution -The provisional locale comes from the browser (`navigator.languages` matched by full tag and then primary subtag, English as the fallback), standing in until the allowed Host-backed settings scope delivers its stored preference. The Host read runs after plugin activation so an unavailable or withheld settings scope cannot block the page, and the result replaces the provisional value live. A stored external locale waits for its definition to register. `setLocale` is the only write entry; it persists even when the id already matches the active locale, because the active value may be provisional and must survive a different browser sharing the same home. +The provisional locale comes from the browser (`navigator.languages` matched by full tag and then primary subtag, English as the fallback), standing in until the Host settings scope delivers its stored preference. The Host read runs after plugin activation so an unavailable settings scope cannot block the page, and the result replaces the provisional value live. A stored external locale waits for its definition to register. `setLocale` is the only write entry; it persists even when the id already matches the active locale, because the active value may be provisional and must survive a different browser sharing the same home. ### Dictionary lookup diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index 6298fac88f..0527984591 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用 `dsh-client-locale` 可在 web GUI 中切换内置的英文和中文 locale,或 client 插件添加的语言。用户选择会立即生效;loopback 页面把选择持久化到 `$DSH_HOME/cordis.patch.yml`,非 loopback 页面则只为当前进程保留选择。全新浏览器会使用浏览器请求的第一个受支持语言,直到允许读取的已存储偏好到达。插件作者可添加类型化命名空间字典,并通过公开 locale API 翻译;经 slot 渲染的文案无需重新加载即可随语言切换更新。 +使用 `dsh-client-locale` 可在 web GUI 中切换内置的英文和中文 locale,或 client 插件添加的语言。用户选择会立即生效;每个已认证浏览器都通过 Host settings 服务把选择以 `locale.preference` 持久化到 `$DSH_HOME/cordis.patch.yml`。全新浏览器会使用浏览器请求的第一个受支持语言,直到 Host 读取送达已存储偏好。插件作者可添加类型化命名空间字典,并通过公开 locale API 翻译;经 slot 渲染的文案无需重新加载即可随语言切换更新。 ## 目录 @@ -67,7 +67,7 @@ export function apply(ctx) { ### Host 半侧做什么 -Host 通过 settings 服务为 loopback 页面持久化偏好。Client 会刻意拒绝非 loopback 页面使用该 settings scope,因此即使 Connection 认证所有 API 方法,它们的 locale 选择仍只存在于进程内。 +每个已认证浏览器都遵循共享的 [settings scope 策略](../ui-settings/README.zh.md),通过 Host settings 服务持久化偏好。 ----- @@ -85,7 +85,7 @@ Host 通过 settings 服务为 loopback 页面持久化偏好。Client 会刻意 ### 偏好解析 -临时 locale 来自浏览器(`navigator.languages` 先按完整标签、再按主语言子标签匹配,以英文作为回退),在允许使用的 Host-backed settings scope 送达其存储偏好之前生效。Host 读取在插件激活后运行,因此 settings scope 不可用或被拒绝都不会阻塞页面,结果会实时替换临时值。已存储的外部 locale 会等待其定义注册。`setLocale` 是唯一写入入口;即使 id 已与生效中的 locale 匹配也会持久化,因为生效中的值可能是临时的,必须能供共享同一 home 的其他浏览器继续使用。 +临时 locale 来自浏览器(`navigator.languages` 先按完整标签、再按主语言子标签匹配,以英文作为回退),在 Host settings scope 送达其存储偏好之前生效。Host 读取在插件激活后运行,因此 settings scope 不可用不会阻塞页面,结果会实时替换临时值。已存储的外部 locale 会等待其定义注册。`setLocale` 是唯一写入入口;即使 id 已与生效中的 locale 匹配也会持久化,因为生效中的值可能是临时的,必须能在共享同一 home 的其他浏览器上存活。 ### 字典查找 diff --git a/packages/client/locale/locale/en.json b/packages/client/locale/locale/en.json new file mode 100644 index 0000000000..9f4e0d1c02 --- /dev/null +++ b/packages/client/locale/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "locale" + } +} diff --git a/packages/client/locale/locale/zh.json b/packages/client/locale/locale/zh.json new file mode 100644 index 0000000000..8fa3c8e462 --- /dev/null +++ b/packages/client/locale/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "界面语言", + "description": "管理界面语言偏好和各插件的翻译文案。" + } +} diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index a1577330a2..6cc7ea6e2a 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -51,7 +52,6 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-package-manifest": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*" @@ -60,6 +60,7 @@ "@deepseek-ai/schemastery": "workspace:~" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/locale/tsdown.config.ts b/packages/client/locale/tsdown.config.ts index 8babf9f1eb..7aa03cebc1 100644 --- a/packages/client/locale/tsdown.config.ts +++ b/packages/client/locale/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-locale', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index 4ab13cc6e9..ebb8ecbb3a 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/README.i18n.yaml @@ -27,8 +27,8 @@ en: 45740a56e931d094 zh: b3362af77c804459 /deepseek-ai-dsh-client-modules/use-this-package/sharing-modules: - en: 0aaf0693db8bb203 - zh: 9f4738cdc6b55c26 + en: 1778b5a63ecde5e8 + zh: 537b4f8c755af8ad /deepseek-ai-dsh-client-modules/use-this-package/build-requirements: en: b6a4d3b901c89efe zh: 615b5f3eec41f281 diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index 3388eb2ae4..8224ab0ac7 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -43,7 +43,7 @@ An open Web page follows the Host's complete module graph through the HMR transp ### Sharing modules -The shell seeds a frozen module table (`PLATFORM_MODULES`: React, Cordis, and static UI libraries); every dynamic bundle resolves its externals against exactly that baseline. `dsh.client.external` adds only exact non-baseline requests, each answered by the dynamic package row it names or an exact static-table key. Type-only imports are erased and create no request. Composition rejects malformed requests, missing suppliers, self-requests, and synchronous request cycles. +The shell seeds a frozen module table ([`PLATFORM_MODULES`](../web/README.md#the-shared-module-table)); every dynamic bundle resolves its externals against exactly that baseline. `dsh.client.external` adds only exact non-baseline requests, each answered by the dynamic package row it names or an exact static-table key. Type-only imports are erased and create no request. Composition rejects malformed requests, missing suppliers, self-requests, and synchronous request cycles. ### Build requirements diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index 3cbc600ee5..c972fe38d4 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -43,7 +43,7 @@ application combo 脚本只携带每个插件的 `client.js` 入口,并在启 ### 共享模块 -外壳初始化一张冻结的模块表(`PLATFORM_MODULES`:React、Cordis 与静态 UI 库);每个动态 bundle 都精确针对该基座解析其 external。`dsh.client.external` 只添加基座之外的精确请求;系统会将每个请求解析到其指定的动态包 row 或完全匹配的静态表键。纯类型 import 会被擦除,不产生请求。组合阶段会拒绝畸形请求、缺失提供方、自请求与同步请求环。 +外壳初始化一张冻结的模块表([`PLATFORM_MODULES`](../web/README.zh.md#the-shared-module-table));每个动态 bundle 都精确针对该基座解析其 external。`dsh.client.external` 只添加基座之外的精确请求;系统会将每个请求解析到其指定的动态包 row 或完全匹配的静态表键。纯类型 import 会被擦除,不产生请求。组合阶段会拒绝畸形请求、缺失提供方、自请求与同步请求环。 ### 构建要求 diff --git a/packages/client/modules/locale/en.json b/packages/client/modules/locale/en.json new file mode 100644 index 0000000000..cd77943573 --- /dev/null +++ b/packages/client/modules/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "modules" + } +} diff --git a/packages/client/modules/locale/zh.json b/packages/client/modules/locale/zh.json new file mode 100644 index 0000000000..d24fc0c1fd --- /dev/null +++ b/packages/client/modules/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "客户端模块加载", + "description": "管理浏览器插件模块及其加载依赖。" + } +} diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 497adf5366..c9d0440c04 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -45,6 +46,7 @@ "@deepseek-ai/dsh-client-store": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/modules/tsdown.config.ts b/packages/client/modules/tsdown.config.ts index 06db107f32..3a0694bdf9 100644 --- a/packages/client/modules/tsdown.config.ts +++ b/packages/client/modules/tsdown.config.ts @@ -1,6 +1,6 @@ import { clientBundle } from '../tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-client-modules', + import.meta.dirname, ['lib/types/index.js'], ) diff --git a/packages/client/product-analytics/locale/en.json b/packages/client/product-analytics/locale/en.json new file mode 100644 index 0000000000..0351b24e9f --- /dev/null +++ b/packages/client/product-analytics/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "product-analytics" + } +} diff --git a/packages/client/product-analytics/locale/zh.json b/packages/client/product-analytics/locale/zh.json new file mode 100644 index 0000000000..6740088981 --- /dev/null +++ b/packages/client/product-analytics/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "桌面端使用统计", + "description": "采集桌面端指定交互事件,并提交给产品统计服务。" + } +} diff --git a/packages/client/product-analytics/package.json b/packages/client/product-analytics/package.json index a7a8b4cddc..a8240ccc6b 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -34,7 +34,8 @@ }, "./types": { "types": "./lib/types/events.d.ts" - } + }, + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -66,6 +67,7 @@ "@deepseek-ai/dsh-otel": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts", @@ -77,6 +79,6 @@ "dependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "zod": "^4.4.3" + "zod": "4.4.3" } } diff --git a/packages/client/product-analytics/tsdown.config.ts b/packages/client/product-analytics/tsdown.config.ts index 4479f52fe2..620261533c 100644 --- a/packages/client/product-analytics/tsdown.config.ts +++ b/packages/client/product-analytics/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-product-analytics', ['lib/types/index.js'], { hostPhase: true }) +export default clientBundle(import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }) diff --git a/packages/client/resources/locale/en.json b/packages/client/resources/locale/en.json new file mode 100644 index 0000000000..8350a2a390 --- /dev/null +++ b/packages/client/resources/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "resources" + } +} diff --git a/packages/client/resources/locale/zh.json b/packages/client/resources/locale/zh.json new file mode 100644 index 0000000000..4a9685490b --- /dev/null +++ b/packages/client/resources/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "客户端资源服务", + "description": "将文件等资源地址解析为可供界面读取的内容。" + } +} diff --git a/packages/client/resources/package.json b/packages/client/resources/package.json index 531d64def9..1790c6ac68 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -50,6 +51,7 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/resources/tsdown.config.ts b/packages/client/resources/tsdown.config.ts index a6e81e749c..7aa03cebc1 100644 --- a/packages/client/resources/tsdown.config.ts +++ b/packages/client/resources/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-resources', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/shortcuts/locale/en.json b/packages/client/shortcuts/locale/en.json new file mode 100644 index 0000000000..b79351a738 --- /dev/null +++ b/packages/client/shortcuts/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "shortcuts" + } +} diff --git a/packages/client/shortcuts/locale/zh.json b/packages/client/shortcuts/locale/zh.json new file mode 100644 index 0000000000..9bcdbae433 --- /dev/null +++ b/packages/client/shortcuts/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "快捷键服务", + "description": "注册应用快捷键,并将按键操作分发到对应功能。" + } +} diff --git a/packages/client/shortcuts/package.json b/packages/client/shortcuts/package.json index 76f34297ec..8bc13120c6 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -27,7 +27,8 @@ "./protocol": { "types": "./lib/types/protocol.d.ts", "default": "./lib/protocol.js" - } + }, + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -55,6 +56,7 @@ "@deepseek-ai/dsh-host-webserver": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/protocol.js", diff --git a/packages/client/shortcuts/tsdown.config.ts b/packages/client/shortcuts/tsdown.config.ts index 4f32cd61c0..bae160bb21 100644 --- a/packages/client/shortcuts/tsdown.config.ts +++ b/packages/client/shortcuts/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-client-shortcuts', + import.meta.dirname, ['lib/types/index.js', 'lib/types/protocol.js'], { hostPhase: true }, ) diff --git a/packages/client/store/package.json b/packages/client/store/package.json index e6284b5e8c..a0b08df37f 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/store/tsdown.config.ts b/packages/client/store/tsdown.config.ts index b505d0388d..a6264e0b21 100644 --- a/packages/client/store/tsdown.config.ts +++ b/packages/client/store/tsdown.config.ts @@ -1,6 +1,6 @@ import { staticLinked } from '../tsdown.client.ts' export default staticLinked( - '@deepseek-ai/dsh-client-store', + import.meta.dirname, ['lib/types/index.js'], ) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 2e8c06b18e..888519a890 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -11,7 +11,7 @@ * inputs, including origins recorded by chained source maps. */ import { readFile, stat, utimes } from 'node:fs/promises' -import { existsSync, globSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { createRequire, isBuiltin } from 'node:module' import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path' import { fileURLToPath } from 'node:url' @@ -100,22 +100,25 @@ function browserSourcePath(source: string, sourcemapPath: string): string { * the package without lib/index.js and the host Loader cannot import its node * half. The Client build consumes `lib/types` and chains those tsc maps, with * original source content, into the standalone plugin map. - * @param id - plugin id (package name), stamped into the __ModuleLoader__.load - * handoff and onto the injected style tags. + * @param packageDir - the package's own directory, normally the config's + * `import.meta.dirname`. Its `package.json` name is the plugin id stamped into + * the __ModuleLoader__.load handoff and onto the injected style tags. * @param libEntry - node-half entries. * @param options - phase placement, lib overrides, companion Node configs, and optional per-file Client banner. * @returns ENV-selected tsdown config for the current build face. + * @throws {Error} when `/package.json` is unreadable or declares no name. */ export function clientBundle( - id: string, + packageDir: string, libEntry: readonly string[], options: ClientBundleOptions = {}, ): BuildFaceConfig { - const lib = clientLibraryConfig(id, libEntry, options.lib) + const manifest = readPackageManifest(packageDir) + const lib = clientLibraryConfig(manifest, libEntry, options.lib) return ({ env }) => { const face = buildFace(env?.DSH_BUILD_FACE) const clientEntry = face === undefined ? 'src/client/index.ts' : 'lib/types/client/index.js' - const client = clientConfig(id, clientEntry, options.clientBanner) + const client = clientConfig(manifest, clientEntry, options.clientBanner) const node = [lib, ...(options.companions ?? [])] if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD] if (face === 'client') { @@ -147,13 +150,16 @@ export function clientBundle( * 4. stylesheets ship with the package: a relative `.css` import survives as a * relative external and the sheet is emitted under `lib/` at its * `src`-relative path, so vite stays the only owner of class hashing. - * @param id - package name, used in tsdown diagnostics. + * @param packageDir - the package's own directory, normally the config's + * `import.meta.dirname`; its `package.json` name is used in tsdown diagnostics. * @param libEntry - emitted JavaScript entries consumed from `lib/types`, one * bundle each: a multi-entry build would emit a hash-named shared chunk that * the exact `files` list cannot publish. * @returns ENV-selected tsdown config for the Client build face. + * @throws {Error} when `/package.json` is unreadable or declares no name. */ -export function staticLinked(id: string, libEntry: readonly string[]): BuildFaceConfig { +export function staticLinked(packageDir: string, libEntry: readonly string[]): BuildFaceConfig { + const id = readPackageManifest(packageDir).name // Each entry names its own output file, so two entries with the same basename // would overwrite one artifact instead of emitting two. const names = new Set(libEntry.map(entry => basename(entry, '.js'))) @@ -177,12 +183,14 @@ export function isStaticLinkedConfig(configs: readonly UserConfig[]): boolean { /** * Build a Client-only Node library during the Client pass. - * @param id - Package name used in tsdown diagnostics. + * @param packageDir - The package's own directory, normally the config's + * `import.meta.dirname`; its `package.json` name is used in tsdown diagnostics. * @param libEntry - Emitted JavaScript entries consumed from `lib/types`. * @returns ENV-selected tsdown config for the Client build face. + * @throws {Error} when `/package.json` is unreadable or declares no name. */ -export function clientLibrary(id: string, libEntry: readonly string[]): BuildFaceConfig { - const lib = clientLibraryConfig(id, libEntry) +export function clientLibrary(packageDir: string, libEntry: readonly string[]): BuildFaceConfig { + const lib = clientLibraryConfig(readPackageManifest(packageDir), libEntry) return clientOnly([lib]) } @@ -218,14 +226,15 @@ function buildFace(value: unknown): BuildFace { } function clientLibraryConfig( - id: string, + manifest: PackageManifest, libEntry: readonly string[], overrides: UserConfig = {}, ): UserConfig { + let externals: readonly RegExp[] | undefined const isProductionDependency = (specifier: string): boolean => - matchesSpecifier(productionExternals(id), specifier) + matchesSpecifier(externals ??= productionExternals(manifest), specifier) return { - name: id, + name: manifest.name, entry: [...libEntry], outDir: 'lib', format: ['esm'], @@ -330,9 +339,10 @@ function stylesheetAsset(source: string, importer: string): { readonly file: str return { file, fileName: file.slice(boundary + SOURCE_MARKER.length).split(sep).join('/') } } -/** The manifest fields the build faces read to state their own module edges. */ -interface WorkspaceManifest { - readonly name?: string +/** The manifest fields the build faces read to name the package and state its own module edges. */ +interface PackageManifest { + /** Package name: the tsdown config name, the Client loader id, and the diagnostic subject. */ + readonly name: string /** Sections a real install materializes on disk next to the built package. */ readonly dependencies?: Record readonly peerDependencies?: Record @@ -340,52 +350,44 @@ interface WorkspaceManifest { readonly dsh?: { readonly client?: { readonly external?: unknown } } } -const manifestCache = new Map() -const productionExternalCache = new Map() -const clientExternalCache = new Map>() - /** - * Read one workspace package's manifest. Located by package name rather than by - * cwd, because tsdown evaluates every package config with the repository root as - * `process.cwd()` during a workspace build. Callers read it on the first - * resolveId of a build, not while a config is built, so selecting a build face - * never touches a manifest. - * @param id - package name, as spelled at the preset call site. + * Read the manifest of the package whose config calls a preset. The caller + * passes its directory because tsdown evaluates every package config with the + * repository root as `process.cwd()` during a workspace build. + * @param packageDir - the package's own directory. * @returns the parsed manifest. - * @throws {Error} when no workspace package declares that name. + * @throws {Error} when `package.json` is unreadable, is not JSON, or declares no string name. */ -function workspaceManifest(id: string): WorkspaceManifest { - const cached = manifestCache.get(id) - if (cached !== undefined) return cached - for (const manifestPath of globSync('packages/*/*/package.json', { cwd: REPOSITORY_ROOT })) { - const manifest = JSON.parse( - readFileSync(resolvePath(REPOSITORY_ROOT, manifestPath), 'utf8'), - ) as WorkspaceManifest - if (manifest.name !== id) continue - manifestCache.set(id, manifest) - return manifest +function readPackageManifest(packageDir: string): PackageManifest { + const manifestPath = resolvePath(packageDir, 'package.json') + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(manifestPath, 'utf8')) + } catch (error) { + throw new Error( + `tsdown: cannot read ${manifestPath}; pass the package directory (the import.meta.dirname of its tsdown.config.ts)`, + { cause: error }, + ) } - throw new Error(`tsdown: no packages/*/*/package.json declares the name ${id}`) + if (typeof parsed !== 'object' || parsed === null || !('name' in parsed) || typeof parsed.name !== 'string') { + throw new Error(`tsdown: ${manifestPath} declares no string package name`) + } + return parsed as PackageManifest } /** * External patterns for one package's Node half: its own production sections, * subpaths included. - * @param id - package name, as spelled at the preset call site. + * @param manifest - the package's own manifest. * @returns one `^name(/|$)` pattern per production dependency, name-sorted. */ -function productionExternals(id: string): readonly RegExp[] { - const cached = productionExternalCache.get(id) - if (cached !== undefined) return cached - const manifest = workspaceManifest(id) +function productionExternals(manifest: PackageManifest): readonly RegExp[] { const names = new Set([ ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {}), ...Object.keys(manifest.optionalDependencies ?? {}), ]) - const patterns = [...names].sort().map(name => new RegExp(`^${escapeSpecifier(name)}(/|$)`)) - productionExternalCache.set(id, patterns) - return patterns + return [...names].sort().map(name => new RegExp(`^${escapeSpecifier(name)}(/|$)`)) } /** @@ -408,19 +410,15 @@ export function requestedExternals( * Module-table specifiers one package requests. The shell baseline is implicit * for every dynamic bundle; `dsh.client.external` only adds package-specific * dynamic rows or subpaths. - * @param id - package name, as spelled at the preset call site. + * @param manifest - the package's own manifest, whose `dsh.client` declaration adds requests. * @returns the baseline plus the package's explicit requests. */ -function clientExternals(id: string): ReadonlySet { - const cached = clientExternalCache.get(id) - if (cached !== undefined) return cached - const externals = new Set([ +function clientExternals(manifest: PackageManifest): ReadonlySet { + return new Set([ ...PLATFORM_MODULES, ...PRELOADED_CLIENT_EXTERNALS, - ...requestedExternals(id, workspaceManifest(id).dsh?.client ?? {}), + ...requestedExternals(manifest.name, manifest.dsh?.client ?? {}), ]) - clientExternalCache.set(id, externals) - return externals } /** Escape a package name for literal use inside a RegExp source. */ @@ -469,8 +467,10 @@ function asyncChunkRequirePlugin(): TsdownPlugin { } } -function clientConfig(id: string, entry: string, clientBanner?: (fileName: string) => string | undefined): UserConfig { - const isRequested = (specifier: string): boolean => clientExternals(id).has(specifier) +function clientConfig(manifest: PackageManifest, entry: string, clientBanner?: (fileName: string) => string | undefined): UserConfig { + const id = manifest.name + let requested: ReadonlySet | undefined + const isRequested = (specifier: string): boolean => (requested ??= clientExternals(manifest)).has(specifier) const isolation = clientInputIsolation(id) return { name: `${id}/client`, @@ -490,7 +490,7 @@ function clientConfig(id: string, entry: string, clientBanner?: (fileName: strin deps: { neverBundle: isRequested, // Anything NOT requested from the loader module table must inline - // (wire/type layers, zod, clsx — every non-shared dep). A require() the + // (wire/type layers, clsx — every non-shared dep). A require() the // table cannot answer is a guaranteed runtime throw, so the rule is the // package's own request list: requested specifiers stay imports, // everything else is bundled. diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 7181942309..6027efa51f 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: b3ad7867f23dad1c /deepseek-ai-dsh-client-ui-agent-preset/use-this-package: - en: d616984e840f036e - zh: 3e7c97c7d10669eb + en: e015215c32d78b52 + zh: e8a8029903de7515 /deepseek-ai-dsh-client-ui-agent-preset/understand-the-implementation: - en: b3221ef8bc6d8096 - zh: 68cb05566249da10 + en: a90bf7539dbce636 + zh: d77c1fecac2c6a4b /deepseek-ai-dsh-client-ui-agent-preset/further-exploration: en: bbae8d79844bc481 zh: 4302107fd1f460e6 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 09df3e46f3..fdade6a4af 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -29,9 +29,9 @@ Settings shows the built-in and custom card groups with default highlighting and With Coding Tools off in General Settings, the new-session picker and Settings roster offer Standard, Creator, and custom presets; turning them on also offers PTC and Minimal. Default selection and the Creator entry remain available in both states. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new task using the `cordis` preset. -Turning Coding Tools off clears all unapplied choices through the existing Developer tools subscription. Once the off value is accepted, a host-backed connection resets a saved built-in PTC or Minimal default to Standard. Saved Standard, Creator, and custom defaults (including named PTC or Minimal overrides) stay unchanged. The user can select an available preset again after turning Coding Tools off. Existing sessions keep their selected mode; New Session uses the current default. An existing mode's label remains visible even when that mode is absent from the menu. Turning Coding Tools back on does not restore the old default or cleared choice. A remote connection using in-memory settings does not rewrite the Host's default. If Standard is missing or broken, the default remains unchanged and an error is shown. +Turning Coding Tools off clears all unapplied choices through the existing Developer tools subscription. Once the off value is accepted, every authenticated browser resets a saved built-in PTC or Minimal default to Standard. Saved Standard, Creator, and custom defaults (including named PTC or Minimal overrides) stay unchanged. The user can select an available preset again after turning Coding Tools off. Existing sessions keep their selected mode; New Session uses the current default. An existing mode's label remains visible even when that mode is absent from the menu. Turning Coding Tools back on does not restore the old default or cleared choice. If Standard is missing or broken, the default remains unchanged and an error is shown. -Known shipped presets offer mode details and usage examples in a read-only dialog. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's group badge, and the preset id appears beside the title. Guide copy and examples belong to this package. +Known shipped presets offer mode details and usage examples in a read-only dialog. Minimal's guide explains image reading, automatic history compaction, and the idle-session `/compact` command. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's group badge, and the preset id appears beside the title. Guide copy and examples belong to this package. **Let the agent create a plugin** in the Plugins page's **Add plugin** arrow menu enters the same Creator flow as Settings. It submits no message and preserves unsent drafts. While the roster loads or Creator is unavailable, the menu item is disabled with an explanation. Both entries show a Toast with the reason if the Host refuses the preset switch. @@ -45,7 +45,7 @@ Entering Creator selects `cordis` for the receiving blank Session without changi `agentPresets/list` supplies the roster and marks the current default, and `agentPresets/read` one declaration's YAML for the viewer; default changes write the `agent-preset-registry` settings namespace. The picker, blank-session synchronization and read-only session label use recorded preset identities. Connection resets and settings updates refresh the roster. -`CreatePluginMenuItem` contributes to `plugins.add.actions` using the shared roster store. Its selection calls the owner's `onDismiss()` before the same `startCreatorDraft` callback exposed to Settings as `creatorDraft`. That callback stages `cordis` through `AgentPresetSeatController` and opens the task through `uiWorkspace.startSession` with explicit draft preservation; it can reuse a blank Session and applies the staged choice when a Session binding becomes available. The choice is consumed once applied. Both Coding Tools states use the same picker and selection store, and `AgentPresetSeat` renders refusals through the shared `Toast` component. +`CreatePluginMenuItem` contributes to `plugins.add.actions` using the shared roster store. Its selection calls the owner's `onDismiss()` before the same `startCreatorDraft` callback exposed to Settings as `creatorDraft`. That callback stages `cordis` through `AgentPresetSeatController` and opens the task through `uiWorkspace.startSession` with explicit draft preservation and `agentPreset: 'cordis'`, so the Host composes the reused blank or new Session under Creator; without a target Workspace, the staged choice applies when a Session binding becomes available. The choice is consumed once applied. Both Coding Tools states use the same picker and selection store, and `AgentPresetSeat` renders refusals through the shared `Toast` component.
diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 7514fc9953..eacce41712 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -29,9 +29,9 @@ kind: "package-reference" 关闭通用设置中的代码工作工具后,新会话选择器和设置页列表提供标准、创造和自定义预设;开启后还提供 PTC 和极简模式。两种状态下均保留默认值选择和创造入口。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口开启一个使用 `cordis` preset 的新任务。 -关闭代码工作工具时,沿用现有 Developer tools 订阅清空所有尚未应用的选择。关闭状态被接受后,使用 Host 持久化设置的连接会将已保存的内置 PTC 或极简默认值回退为标准模式。已保存的标准、创造和自定义默认值(包括有名称的 PTC 或极简覆盖项)保持不变。关闭后用户仍可重新选择可用预设。已有会话保留所选模式;新会话使用当前默认值。即使已有会话的模式不在菜单中,其标签仍会显示。重新开启代码工作工具不会恢复原默认值或已清除的选择。使用内存设置的远程连接不会改写 Host 的默认值。标准模式缺失或损坏时,默认值保持不变,并显示错误。 +关闭代码工作工具时,沿用现有 Developer tools 订阅清空所有尚未应用的选择。关闭状态被接受后,每个已认证浏览器都会将已保存的内置 PTC 或极简默认值回退为标准模式。已保存的标准、创造和自定义默认值(包括有名称的 PTC 或极简覆盖项)保持不变。关闭后用户仍可重新选择可用预设。已有会话保留所选模式;新会话使用当前默认值。即使已有会话的模式不在菜单中,其标签仍会显示。重新开启代码工作工具不会恢复原默认值或已清除的选择。标准模式缺失或损坏时,默认值保持不变,并显示错误。 -已知的内置预设提供只读的模式说明与使用示例对话框。各页签保留各自的滚动位置;关闭后焦点回到打开它的操作。帮助不会改变新任务默认值。默认徽标取代卡片的分组徽标,预设 id 显示在标题旁。指南文案与示例归本包所有。 +已知的内置预设提供只读的模式说明与使用示例对话框。极简模式指南介绍图片读取、自动历史压缩以及会话空闲时的 `/compact` 命令。各页签保留各自的滚动位置;关闭后焦点回到打开它的操作。帮助不会改变新任务默认值。默认徽标取代卡片的分组徽标,预设 id 显示在标题旁。指南文案与示例归本包所有。 插件页**添加插件**箭头菜单中的**让 Agent 创建插件**进入与设置页相同的创造流程,不发送消息,并保留未发送的草稿。读取预设列表或创造模式不可用时,菜单项禁用并说明原因。Host 拒绝切换预设时,两处入口均通过 Toast 显示原因。 @@ -45,7 +45,7 @@ kind: "package-reference" `agentPresets/list` 提供列表并标记当前默认值,`agentPresets/read` 为查看器提供一条声明的 YAML;默认值的修改写入 `agent-preset-registry` settings 命名空间。选择器、空白会话同步和只读会话标签使用记录的 preset 标识。连接重置和设置更新会刷新列表。 -`CreatePluginMenuItem` 使用共享的预设列表 store,向 `plugins.add.actions` 贡献菜单项。选择时先调用页面传入的 `onDismiss()`,再调用与设置页 `creatorDraft` 相同的 `startCreatorDraft` 回调。该回调通过 `AgentPresetSeatController` 暂存 `cordis`,再通过显式保留草稿的 `uiWorkspace.startSession` 调用打开任务;它可以复用空白 Session,并在 Session 绑定可用时应用暂存选择。暂存选择应用后即清除。代码工作工具开启或关闭时均使用同一个选择器和选择 store,`AgentPresetSeat` 通过共享的 `Toast` 组件显示拒绝原因。 +`CreatePluginMenuItem` 使用共享的预设列表 store,向 `plugins.add.actions` 贡献菜单项。选择时先调用页面传入的 `onDismiss()`,再调用与设置页 `creatorDraft` 相同的 `startCreatorDraft` 回调。该回调通过 `AgentPresetSeatController` 暂存 `cordis`,再通过显式保留草稿并传入 `agentPreset: 'cordis'` 的 `uiWorkspace.startSession` 调用打开任务,由 Host 以创造模式组合被复用的空白 Session 或新 Session;没有目标工作区时,暂存选择在 Session 绑定可用时应用。暂存选择应用后即清除。代码工作工具开启或关闭时均使用同一个选择器和选择 store,`AgentPresetSeat` 通过共享的 `Toast` 组件显示拒绝原因。
diff --git a/packages/client/ui-agent-preset/locale/en.json b/packages/client/ui-agent-preset/locale/en.json new file mode 100644 index 0000000000..6cb010026a --- /dev/null +++ b/packages/client/ui-agent-preset/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-agent-preset" + } +} diff --git a/packages/client/ui-agent-preset/locale/zh.json b/packages/client/ui-agent-preset/locale/zh.json new file mode 100644 index 0000000000..4e8dd6b04d --- /dev/null +++ b/packages/client/ui-agent-preset/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Agent 预设界面", + "description": "选择当前及新会话的 Agent 预设,并编辑预设使用的插件。" + } +} diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index d65a284667..93a5319006 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -67,10 +68,10 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-agent-preset/src/client/guide-locales.ts b/packages/client/ui-agent-preset/src/client/guide-locales.ts index c41b55aef7..2d8320f298 100644 --- a/packages/client/ui-agent-preset/src/client/guide-locales.ts +++ b/packages/client/ui-agent-preset/src/client/guide-locales.ts @@ -42,7 +42,7 @@ export const guideEn = { guideMinimalIntro: 'Choose Minimal mode for a new task. For a comparison, hold the model, permissions, input, and starting workspace state constant across runs.', guideMinimalExplanation: [ '### What is included', - 'One persistent shell tool and a fixed system prompt. The built-in preset does not load Skills, planning, context compaction, or the standard runtime context.', + 'One persistent shell tool, read_image, and a fixed system prompt. Image reading requires an image-capable model. Older history is compacted automatically under context pressure; use /compact while the agent is idle to compact it manually. The built-in preset does not load Skills, planning, or the standard runtime context.', '### When to choose it', 'Use it as a baseline for experiments and comparisons. It can still read files and execute scripts through shell commands, but offers fewer built-in ways to manage a long task. Fewer tools does not necessarily make it easier for a beginner.', ].join('\n\n'), @@ -120,7 +120,7 @@ export const guideZh: Record = { guideMinimalIntro: '新建任务时选择「极简模式」。做对照测试时,保持模型、权限、任务输入和工作区起始状态一致。', guideMinimalExplanation: [ '### 保留哪些能力', - '仅提供一个持久 Shell 工具,并使用固定系统提示词。内置预设不加载 Skills、计划、上下文压缩,也不注入标准运行时上下文。', + '提供持久 Shell 和 read_image 工具,并使用固定系统提示词。读取图片需要支持图片输入的模型。上下文压力达到阈值时自动压缩较早历史;Agent 空闲时可输入 /compact 手动压缩。内置预设不加载 Skills、计划,也不注入标准运行时上下文。', '### 什么时候选', '适合作为实验和对照测试的基线。Agent 仍能通过终端命令读写文件、运行脚本,但缺少管理长任务的内置辅助能力。工具少,不代表对新手更容易。', ].join('\n\n'), diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 184b7fd791..6fa0e3e4ad 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -5,8 +5,8 @@ * the roster (selection, the new-task default, a read-only view of each * declared composition, and the way into Creator mode). * - * A running session keeps the composition it began with (the host refuses to - * adopt an existing session under a different preset). That is what splits + * A running session keeps the composition it began with (the host switches a + * session's preset only before its first turn). That is what splits * the choice from the display: the hero chip is before-the-fact, while the * header only reports what a session already runs. The default preset is * edited where the roster is visible — the settings section's "make default" @@ -74,8 +74,7 @@ export function apply(ctx: ClientContext): void { const presetSettings = ctx.configForms.get(AGENT_PRESET_SETTINGS_NS) let active = true // Host preferences start at false before their first accepted document. - const codingToolsDisabled = (): boolean => active && toolsSettings.getSnapshot().mode === 'host' - && toolsSettings.getSnapshot().value?.enabled === false + const codingToolsDisabled = (): boolean => active && toolsSettings.getSnapshot().value?.enabled === false const controller = new AgentPresetSettingsController(ctx) const staged: AgentPresetStage = { id: undefined, introduce: false } const seats = new WeakMapWithValues() @@ -199,10 +198,10 @@ export function apply(ctx: ClientContext): void { }) const startCreatorDraft = () => { + // The Host composes the reused draft or new Session; the stage only drives the chip's display and introduction. const seat = mainBlankSeat() ?? unboundSeat seat.stage('cordis', true) - scope.uiWorkspace.startSession(undefined, { clearPreviousDraft: false }) - void seat.apply() + scope.uiWorkspace.startSession(undefined, { clearPreviousDraft: false, agentPreset: 'cordis' }) } scope.effect(() => { diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 1863c2e108..49178ca524 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -55,7 +55,7 @@ export const en: Record = { 'Includes all Standard mode capabilities. Better suited to tasks that call tools in batches and then filter, organize, deduplicate, count, or summarize the results.', presetMinimalName: 'Minimal mode', presetMinimalDescription: - 'The agent works using only a terminal tool. Useful for testing and comparing its basic performance.', + 'The agent uses a terminal and image reading, with context compaction. Useful for testing and comparing its basic performance.', presetCordisName: 'Creator mode', presetCordisDescription: 'Customize DSH through conversation. Let the agent write plugins that add features or UI, or combine tools and prompts to create your own mode.', @@ -97,7 +97,7 @@ export const zh: Record = { presetPtcName: 'PTC 模式', presetPtcDescription: '包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。', presetMinimalName: '极简模式', - presetMinimalDescription: 'Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。', + presetMinimalDescription: 'Agent 使用终端和图片读取工具,并支持上下文压缩,适合测试和对比其基础表现。', presetCordisName: '创造模式', presetCordisDescription: '用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。', diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index dfa2c69945..0a1860ba86 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -3,8 +3,10 @@ * * The new-session screen has no session, so a pick is staged rather than * applied. It reaches a session when one becomes current and is still blank — - * whether the workspace connect created it or reused an existing blank one, - * which is why staging cannot simply ride along on `sessions.create`. + * whether the workspace connect created it or reused an existing blank one. A + * flow that knows its preset before it starts (the settings section's creator + * entry) passes it to session creation instead, and its stage only drives the + * chip's display. * * The stage is forgotten once applied. The next new session starts from the * Host-effective default again. diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index 3ab2bdd2aa..317543daf8 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -86,7 +86,7 @@ export class AgentPresetSectionController { while (this.pendingSave !== undefined) await this.pendingSave if (!shouldReset()) return const settings = this.ctx.configForms.get(AGENT_PRESET_SETTINGS_NS).getSnapshot() - if (settings.mode !== 'host' || settings.status !== 'ready' || !settings.writable || settings.revision === undefined) return + if (settings.status !== 'ready' || !settings.writable || settings.revision === undefined) return await this.load() if (!shouldReset() || this.store.getSnapshot().status !== 'ready') return const rows = this.store.getSnapshot().rows diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index f23bde9a6e..c891d1d6d4 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -72,7 +72,6 @@ async function bench(options: { describeGate?: Promise presets?: AgentPresetRow[] developerToolsEnabled?: boolean - memory?: boolean } = {}) { const ctx = new Context() // The host's answer, mutable so a spec can move the default the way the @@ -138,7 +137,6 @@ async function bench(options: { } const remote = new TestRemote(ctx, { settings }) - if (options.memory === true) remote.$host = { home: undefined, isLoopback: false } // The roster and the switch are the AgentPresets Remote namespace; the // shared double carries no generated namespaces, so this spec stages its // own. Registered twice on purpose: the nested key satisfies the plugin's @@ -174,9 +172,9 @@ async function bench(options: { await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() if (options.describeGate === undefined) { await vi.waitFor(() => { - expect(ctx.configForms.developerTools.enabled.getSnapshot()).toBe(options.memory === true ? true : developerToolsEnabled) + expect(ctx.configForms.developerTools.enabled.getSnapshot()).toBe(developerToolsEnabled) }) - if (options.memory !== true) await vi.waitFor(() => { expect(ctx.configForms.get('ui-settings').getSnapshot().status).toBe('ready') }) + await vi.waitFor(() => { expect(ctx.configForms.get('ui-settings').getSnapshot().status).toBe('ready') }) } /** Publish a new Developer tools choice the way the settings mirror does. */ const setDeveloperTools = async (enabled: boolean): Promise => { @@ -214,7 +212,7 @@ function declareConversation(slots: SlotRegistry): () => void { /** A Workspace UI double recording new-session starts. */ function uiWorkspaceDouble() { - const starts: unknown[] = [] + const starts: { workspaceId: unknown; options: unknown }[] = [] return { starts, startSession: (workspaceId?: unknown, options?: unknown) => { starts.push({ workspaceId, options }) }, @@ -933,7 +931,6 @@ describe('ui-agent-preset apply', () => { await b.ctx.plugin({ inject: [...inject], apply }).await() expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual([]) await b.setDeveloperTools(false) - expect(b.ctx.configForms.get('agent-preset-registry').getSnapshot()).toMatchObject({ status: 'ready', mode: 'host', writable: true, revision: 0 }) await vi.waitFor(() => { expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}']) }) await b.ctx.fiber.dispose() }) @@ -1030,36 +1027,6 @@ describe('ui-agent-preset apply', () => { await b.ctx.fiber.dispose() }) - it.each(['ptc', 'minimal'])('clears a browser-local staged %s choice without rewriting the Host default', async (preset) => { - const b = await bench({ memory: true, presets: [ - ...ROSTER_MOVED.value.presets, { id: 'ptc', isDefault: false }, - ] }) - declareRoot(b.slots) - declareConversation(b.slots) - b.ctx.provide('conversation', {} as never) - const state: { current?: string; byId: Record } = { - byId: { s1: { id: 's1', blank: true } }, - } - const sessions = sessionsDouble(b.ctx, state) - b.ctx.provide('sessions', sessions as never) - b.ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) - await b.ctx.plugin({ inject: [...inject], apply }).await() - const injectSeat = b.slots.entries('conversation.hero.agentPreset')[0]! - .inject as (sessionId?: SessionId) => AgentPresetSeatInjected & Record - const chip = injectSeat() - await chip.load() - await chip.select(preset) - await b.ctx.configForms.developerTools.setEnabled(false) - const section = (b.slots.entries('settings.section')[0]!.inject as () => AgentPresetSectionInjected & Record)() - await section.load() - expect(section.hooks.agentPresetSection.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('minimal') - expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual([]) - state.current = 's1' - await injectSeat(SessionId('s1')).load() - sessions.notify() - expect(b.calls.filter(call => call.startsWith('select:'))).toEqual([]) - await b.ctx.fiber.dispose() - }) it('loads the header label from the shared roster store', async () => { const { ctx, slots } = await bench() @@ -1099,7 +1066,7 @@ describe('ui-agent-preset apply', () => { // new-session flow began. expect(section.startCreatorDraft).toBeDefined() expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') - expect(uiWorkspace.starts).toHaveLength(1) + expect(uiWorkspace.starts).toEqual([{ workspaceId: undefined, options: { clearPreviousDraft: false, agentPreset: 'cordis' } }]) // A cross-screen stage carries the introduce cue; the chip acknowledges // it once, and a repeat acknowledgement leaves the snapshot untouched. @@ -1112,15 +1079,16 @@ describe('ui-agent-preset apply', () => { conversation() }) - it('applies the creator preset to an existing blank main Session', async () => { + it('lets the new-session flow compose the creator preset on a blank main Session', async () => { const { ctx, slots, calls } = await bench() declareRoot(slots) const conversation = declareConversation(slots) ctx.provide('conversation', {} as never) - const sessions = sessionsDouble(ctx, { + const state = { current: 's1', byId: { s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } } }, - }) + } + const sessions = sessionsDouble(ctx, state) ctx.provide('sessions', sessions as never) const uiWorkspace = uiWorkspaceDouble() ctx.provide('uiWorkspace', uiWorkspace as never) @@ -1133,10 +1101,13 @@ describe('ui-agent-preset apply', () => { await seat.load() await section.load() section.startCreatorDraft?.() + expect(uiWorkspace.starts).toEqual([{ workspaceId: undefined, options: { clearPreviousDraft: false, agentPreset: 'cordis' } }]) - await vi.waitFor(() => { expect(calls).toContain('select:cordis') }) - expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') - expect(uiWorkspace.starts).toEqual([{ workspaceId: undefined, options: { clearPreviousDraft: false } }]) + // The Host composes the reused Session; the chip only follows its projection. + state.byId.s1 = { id: 's1', blank: true, projectionValues: { agentPreset: 'cordis' } } + sessions.notify() + await vi.waitFor(() => { expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') }) + expect(calls).not.toContain('select:cordis') conversation() }) diff --git a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts index ba2d1841b9..f1a88d720a 100644 --- a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts @@ -7,7 +7,7 @@ import { en } from '../src/client/locales.ts' function fixture() { const form = createSnapshotStore>({ - status: 'ready', mode: 'host', writable: true, revision: 7, value: { selectedDefault: 'minimal' }, base: {}, user: {}, + status: 'ready', writable: true, revision: 7, value: { selectedDefault: 'minimal' }, base: {}, user: {}, }) const remote = { agentPresets: { list: vi.fn(async (): Promise<{ ok: true; value: { presets: AgentPresetRow[] } }> => ({ @@ -149,7 +149,7 @@ describe('Coding Tools default reconciliation', () => { it.each([ { status: 'loading' as const }, { status: 'unavailable' as const }, - { mode: 'memory' as const }, { writable: false }, { revision: undefined }, + { writable: false }, { revision: undefined }, ])('does not write without a writable accepted Host form: %j', async (patch) => { const f = setup() f.form.set({ ...f.form.getSnapshot(), ...patch }) diff --git a/packages/client/ui-agent-preset/tsdown.config.ts b/packages/client/ui-agent-preset/tsdown.config.ts index 3f5cc0a823..7aa03cebc1 100644 --- a/packages/client/ui-agent-preset/tsdown.config.ts +++ b/packages/client/ui-agent-preset/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-agent-preset', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-approval/locale/en.json b/packages/client/ui-approval/locale/en.json new file mode 100644 index 0000000000..48c1a5785f --- /dev/null +++ b/packages/client/ui-approval/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-approval" + } +} diff --git a/packages/client/ui-approval/locale/zh.json b/packages/client/ui-approval/locale/zh.json new file mode 100644 index 0000000000..e93a40b026 --- /dev/null +++ b/packages/client/ui-approval/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "操作审批界面", + "description": "展示待审批的操作,并将用户选择返回给运行中的任务。" + } +} diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index 4d8ef679e1..8c86cc1348 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -59,11 +60,11 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0", "@deepseek-ai/dsh-client-shortcuts": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-approval/tsdown.config.ts b/packages/client/ui-approval/tsdown.config.ts index 4ddd1fdffe..7aa03cebc1 100644 --- a/packages/client/ui-approval/tsdown.config.ts +++ b/packages/client/ui-approval/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-approval', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index fa658714af..123b686b4e 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -27,8 +27,8 @@ en: 836a62c8f73366ac zh: 35da7836eaf68a67 /deepseek-ai-dsh-client-ui-attachment/understand-the-implementation: - en: 415bd2b09bb84b6b - zh: 2e49a39fb3957b4f + en: 07204b7d538b7434 + zh: 705cc6b0c9387cce /deepseek-ai-dsh-client-ui-attachment/further-exploration: en: 733ac3e4df52d6e9 zh: 98a6a8cac7d66645 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 4c344a07d7..4fee29e123 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -49,7 +49,7 @@ While a file drag is over the page, the full-viewport overlay announces the drop
Implementation internals — click to expand -The plugin waits for `conversation.input.attachments`, `conversation.message.images`, `conversation.trajectory.images`, and `tool.call.images` through `ctx.slots.inject`. It then registers the composer rail, document drop target, shared history gallery for Chat, Trajectory, and Tool results, and original-image lightbox. The presentation components are driven entirely by props: the slot owner supplies attachment data, image loading, callbacks, and the locale translator; the package entry exports no components. +The plugin provides the root-scoped `conversation.images.content` Factory. Its official `MessageImages` entries fill `conversation.chat.user.images`, `conversation.chat.message.images`, `conversation.trajectory.images`, and `tool.call.images` at priority 100 through `ctx.slots.inject`. A lower-priority entry replaces only its own position; removing it restores the official entry. All defaults reuse one gallery with explicit image references, authorized loader, layout hints, and optional complete labels. The Factory owns gallery presentation and default copy, not Session state or read authorization. Each domain owns its Slot declaration. The composer attachment rail remains a separate entry, and the package entry exports no components. | File | Role | |---|---| diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index c9cc5e60f3..706a6d97c7 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -49,7 +49,7 @@ Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪
实现细节——点击展开 -插件通过 `ctx.slots.inject` 等待 `conversation.input.attachments`、`conversation.message.images`、`conversation.trajectory.images` 与 `tool.call.images`。随后它注册 composer rail、文档拖放目标、供 Chat、Trajectory 与工具结果共用的历史图片 gallery,以及原图灯箱。呈现组件仅依赖 props:slot 持有方提供附件数据、图片加载、回调与语言包翻译器;包入口不导出任何组件。 +插件提供 root 作用域的 `conversation.images.content` Factory。官方 `MessageImages` 条目通过 `ctx.slots.inject`,以优先级 100 填入 `conversation.chat.user.images`、`conversation.chat.message.images`、`conversation.trajectory.images` 和 `tool.call.images`。较低优先级的条目只替换自身所在位置,移除后恢复官方条目。各默认条目复用同一画廊,显式传入图片引用、授权 loader、布局参数和可选的完整 labels。Factory 负责画廊展示与默认文案,不拥有 Session 状态或读取授权。各领域拥有自己的 Slot 声明。Composer 附件栏仍为独立条目,包入口不导出任何组件。 | 文件 | 职责 | |---|---| diff --git a/packages/client/ui-attachment/locale/en.json b/packages/client/ui-attachment/locale/en.json new file mode 100644 index 0000000000..5d1be6fe24 --- /dev/null +++ b/packages/client/ui-attachment/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-attachment" + } +} diff --git a/packages/client/ui-attachment/locale/zh.json b/packages/client/ui-attachment/locale/zh.json new file mode 100644 index 0000000000..13fe1edf53 --- /dev/null +++ b/packages/client/ui-attachment/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "附件展示", + "description": "在消息输入、对话和运行记录中展示附件与图片。" + } +} diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index e37afaf50d..3b2483a7e6 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,11 +23,13 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-chat", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-renderer", @@ -44,8 +46,7 @@ "license": "MIT", "devDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", + "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-ui-chat": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:*", @@ -59,6 +60,7 @@ "clsx": "^2.0.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index 7f350bbc94..a067ffd446 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -2,9 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { IconLoadingOutlineRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ImageLightboxLabels } from '@deepseek-ai/dsh-client-ui-primitives' +import type { MessageImageLabels } from '@deepseek-ai/dsh-client-ui-conversation/client' 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. */ export type ImageLoader = ((attachment: ImageAttachmentRef) => Promise) & { peek?: (attachment: ImageAttachmentRef) => string | undefined @@ -26,22 +28,6 @@ export type MessageImageSpec = } } -/** Message-image strings the owner resolves from its own locale namespace. */ -export interface MessageImageLabels { - /** Fallback display name for an unnamed image. */ - image: string - /** Thumbnail tooltip inviting the original-image preview. */ - open: string - /** Accessible thumbnail label; receives the image's display name. */ - openNamed: (label: string) => string - /** Loading placeholder shown until bytes resolve. */ - loading: string - /** Retry-control label shown when the load fails. */ - loadFailed: string - /** Lightbox strings forwarded to the opened preview. */ - lightbox: ImageLightboxLabels -} - /** Display box for a lone image (DeepSeek Chat rule): long edge 240px with * the rendered aspect ratio clamped to [0.25, 4] — the overflow is cropped by * `object-fit: cover` — and never upscaled past the image's natural size. The diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx index 192147f877..76a0718c1f 100644 --- a/packages/client/ui-attachment/src/client/MessageImages.tsx +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -1,17 +1,33 @@ -import type { MessageImagesProps } from '@deepseek-ai/dsh-client-ui-chat/client' +/** Official image gallery Factory and default image Slot adapter. */ +import { useMemo } from 'react' +import type { FactoryComponentPropsOf, PropsRenderFactories } from '@deepseek-ai/dsh-client-ui-slots' +import type { MessageImagesOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { ImageGallery } from '../MessageImage.tsx' import { messageImageLabels } from './labels.ts' -/** Historical message-image slot entry. */ -export function MessageImages({ images, loadImage, align, compact = false, thumbnail = false, t }: MessageImagesProps) { - return ( - - ) +type MessageImagesProps = MessageImagesOwnerProps & PropsRenderFactories + +/** + * Render a gallery with an authorized loader and caller-owned or default labels. + * @param props - image sources, authorized loader, layout hints, optional labels, and Factory locale seat. + * @returns the official gallery; no Session binding is needed by this component. + */ +export function MessageImagesContent({ images, loadImage, align, compact = false, thumbnail = false, labels, t }: +FactoryComponentPropsOf<'conversation.images.content'>) { + const resolved = useMemo(() => labels ?? messageImageLabels(t), [labels, t]) + return +} + +/** + * Fill a message, trajectory, or tool image position through the shared Content Factory. + * @param props - Slot owner image data, authorized loader, and framework Factory renderer. + * @returns the official gallery occurrence. + */ +export function MessageImages({ images, loadImage, align, compact, thumbnail, renderFactorySlot }: MessageImagesProps) { + return renderFactorySlot('conversation.images.content', { + images, loadImage, align, + ...compact === undefined ? {} : { compact }, + ...thumbnail === undefined ? {} : { thumbnail }, + }) } diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts index 62deeebfc3..75059f84b3 100644 --- a/packages/client/ui-attachment/src/client/index.ts +++ b/packages/client/ui-attachment/src/client/index.ts @@ -1,4 +1,4 @@ -/** Browser attachment plugin: fills conversation's composer and image slots. */ +/** Browser attachment plugin: composer entries and independently replaceable image galleries. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -6,29 +6,33 @@ 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-client-ui-trajectory/client' import { ComposerAttachments } from './ComposerAttachments.tsx' -import { MessageImages } from './MessageImages.tsx' +import { MessageImages, MessageImagesContent } from './MessageImages.tsx' -/** Slot registry required by this presentation plugin. */ +/** Slot registry required by the attachment presentation plugin. */ export const inject = ['slots'] -/** Register attachment presentation without exporting React components as package values. */ +/** + * Register composer attachments and the shared gallery with its domain adapters. + * @param ctx - client plugin context with the Slot registry. + */ export function apply(ctx: ClientContext): void { + ctx.slots.registerFactory({ + name: 'conversation.images.content', scope: 'root', locale: 'conversation', + }, MessageImagesContent) ctx.slots.inject('conversation.input.attachments', () => ctx.slots.register({ name: 'conversation.input.attachments', locale: 'conversation', }, ComposerAttachments)) - ctx.slots.inject('conversation.message.images', () => ctx.slots.register({ - name: 'conversation.message.images', - locale: 'conversation', + ctx.slots.inject('conversation.chat.message.images', () => ctx.slots.register({ + name: 'conversation.chat.message.images', priority: 100, + }, MessageImages)) + ctx.slots.inject('conversation.chat.user.images', () => ctx.slots.register({ + name: 'conversation.chat.user.images', priority: 100, }, MessageImages)) ctx.slots.inject('conversation.trajectory.images', () => ctx.slots.register({ - name: 'conversation.trajectory.images', - locale: 'conversation', + name: 'conversation.trajectory.images', priority: 100, }, MessageImages)) - // The tool image gallery reuses the message gallery renderer: its owner - // carries the same images/loadImage/align share the message arm does. ctx.slots.inject('tool.call.images', () => ctx.slots.register({ - name: 'tool.call.images', - locale: 'conversation', + name: 'tool.call.images', priority: 100, }, MessageImages)) } diff --git a/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx b/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx new file mode 100644 index 0000000000..04f83f3ba8 --- /dev/null +++ b/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +/** Independent domain image positions with one shared official gallery Factory. */ +import { act, cleanup, within } from '@testing-library/react' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/assembly.ts' +import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import type { MessageImagesOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import * as attachmentPlugin from '../src/client/index.ts' + +afterEach(cleanup) + +const SLOTS = [ + 'conversation.chat.user.images', 'conversation.chat.message.images', + 'conversation.trajectory.images', 'tool.call.images', +] as const + +async function bench() { + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + const locale = new LocaleRuntime(runtime.ctx) + locale.register('conversation', { en, zh }) + locale.setLocale('en') + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) + new UiConversation(runtime.ctx, runtime.sessions) + await runtime.mount(attachmentPlugin) + await runtime.sessions.add({ id: 'image-domains' }) + const session = runtime.sessions.retainFor(runtime.ctx, SessionId('image-domains')) + const input: MessageImagesOwnerProps = { + images: [{ attachment: { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), mediaType: 'image/png', + name: 'shared.png', width: 1, height: 1, bytes: 68, + } }], + loadImage: vi.fn(async () => 'blob:shared'), align: 'start', + } + await runtime.root.declare({ + 'conversation.chat.user.images': { kind: 'single', scope: 'session' }, + 'conversation.chat.message.images': { kind: 'single', scope: 'session' }, + 'conversation.trajectory.images': { kind: 'single', scope: 'session' }, + 'tool.call.images': { kind: 'single', scope: 'session' }, + }, ({ renderSlot, SessionProvider }) => + {SLOTS.map(name =>
{renderSlot(name, input)}
)} +
) + return { runtime, view: runtime.renderRoot() } +} + +describe('domain image Slots', () => { + it.each(SLOTS)('replaces only %s and restores the shared gallery on disposal', async (name) => { + const { runtime, view } = await bench() + expect(runtime.factoryOf('conversation.images.content').scope).toBe('root') + const images = new Map() + for (const slot of SLOTS) { + expect(runtime.slots.entries(slot)[0]?.options.priority).toBe(100) + images.set(slot, await within(view.getByTestId(slot)).findByAltText('shared.png')) + } + const override = await runtime.mount({ inject: ['slots'], apply(ctx) { + ctx.slots.register({ name, priority: 0 }, () => Domain override) + } }) + const target = within(view.getByTestId(name)) + expect(target.getByText('Domain override')).toBeTruthy() + expect(target.queryByAltText('shared.png')).toBeNull() + for (const other of SLOTS.filter(slot => slot !== name)) { + expect(within(view.getByTestId(other)).getByAltText('shared.png')).toBe(images.get(other)) + } + await act(async () => { await override.dispose() }) + expect(target.queryByText('Domain override')).toBeNull() + expect(await target.findByAltText('shared.png')).toBeTruthy() + for (const other of SLOTS.filter(slot => slot !== name)) { + expect(within(view.getByTestId(other)).getByAltText('shared.png')).toBe(images.get(other)) + } + }) +}) 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 7fa2c0a980..d447df5fd9 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -1,18 +1,18 @@ // @vitest-environment jsdom -import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { useState } from 'react' +import type { PropsRenderFactories, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import { EMPTY_CHAT_SNAPSHOT, type MessageImagesProps } from '@deepseek-ai/dsh-client-ui-chat/client' -import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/assembly.ts' import { ImageGallery, MessageImage } from '../src/MessageImage.tsx' import type { MessageImageLabels } from '../src/MessageImage.tsx' -import { MessageImages } from '../src/client/MessageImages.tsx' - -// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] -const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) +import * as attachmentPlugin from '../src/client/index.ts' afterEach(cleanup) @@ -34,23 +34,6 @@ const attachment = { name: 'history.png', } -type AttentionSnapshot = Parameters[0]>[0] -type TrajectorySnapshot = Parameters[0]>[0] - -const noAttention: AttentionSnapshot = new Map() -const emptyTrajectory: TrajectorySnapshot = { - eventNodes: [], - eventLocations: new Map(), - requests: [], - callSchemas: new Map(), - partial: null, - runningCalls: [], -} -const useSessionStatus: MessageImagesProps['useSessionStatus'] = selector => selector(noAttention) -const useConversation: MessageImagesProps['useConversation'] = selector => selector(EMPTY_CONVERSATION_SNAPSHOT) -const useChat: MessageImagesProps['useChat'] = selector => selector(EMPTY_CHAT_SNAPSHOT) -const useTrajectory: MessageImagesProps['useTrajectory'] = selector => selector(emptyTrajectory) - describe('MessageImage', () => { it('renders a cached URL on the first frame while refreshing it', () => { const load = Object.assign(vi.fn(() => new Promise(() => {})), { @@ -285,65 +268,77 @@ describe('ImageGallery', () => { expect(several.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(3) }) + it('uses complete Factory labels unchanged and restores defaults without remounting the image', async () => { + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + const locale = new LocaleRuntime(runtime.ctx) + locale.register('conversation', { en, zh }) + locale.setLocale('en') + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) + await runtime.mount(attachmentPlugin) + const images = [{ preview: { url: 'blob:labels', name: 'preview.png' } }] + const loadImage = vi.fn(async () => 'blob:unused') + await runtime.root.declare({}, ({ renderFactorySlot }: PropsRuntime<'root'> & PropsRenderFactories) => { + const [custom, setCustom] = useState(true) + return <> + + {renderFactorySlot('conversation.images.content', { + images, loadImage, align: 'start', ...(custom ? { labels } : {}), + })} + + }) + const view = runtime.renderRoot() + const image = view.getByAltText('preview.png') + const frame = view.getByRole('button', { name: 'preview.png,点击查看原图' }) + expect(frame.getAttribute('title')).toBe(labels.open) + fireEvent.click(frame) + expect(view.getByRole('dialog', { name: labels.lightbox.dialog })).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: labels.lightbox.close })) + fireEvent.click(view.getByRole('button', { name: 'Default labels' })) + const defaultFrame = view.getByRole('button', { name: 'preview.png, click to view original' }) + expect(defaultFrame).toBe(frame) + expect(view.getByAltText('preview.png')).toBe(image) + expect(defaultFrame.getAttribute('title')).toBe('View original') + fireEvent.click(defaultFrame) + expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: 'Close original image preview' })) + expect(loadImage).not.toHaveBeenCalled() + }) + it('renders the conversation slot entry with translated labels', async () => { - const t = ((key: string, params?: Readonly>) => { - const translated: Record = { - 'image.label': '图片', - 'image.openOriginal': '查看原图', - 'image.loading': '图片加载中…', - 'image.loadFailed': '图片加载失败,点击重试', - 'image.preview': '原图预览', - 'image.closePreview': '关闭原图预览', - } - if (key === 'image.openOriginalLabel') { - const label = params?.label - return `${typeof label === 'string' ? label : ''},点击查看原图` - } - return translated[key] ?? key - }) as MessageImagesProps['t'] + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + const locale = new LocaleRuntime(runtime.ctx) + locale.register('conversation', { en, zh }) + locale.setLocale('zh') + runtime.ctx.provide('locale', locale) + new UiConversation(runtime.ctx, runtime.sessions) + runtime.slots.installLocale(locale) + await runtime.declare({ 'conversation.chat.message.images': { kind: 'single', scope: 'session' } }) + await runtime.mount(attachmentPlugin) + await runtime.sessions.add({ id: 'message-images-test' }) + const session = runtime.sessions.retainFor(runtime.ctx, SessionId('message-images-test')) const loadImage = vi.fn().mockResolvedValue('blob:slot-image') - const useSession: MessageImagesProps['useSession'] = () => { - throw new Error('MessageImages does not read the session snapshot') - } - const useInput: MessageImagesProps['useInput'] = () => { - throw new Error('MessageImages does not read the input snapshot') - } - const useSessions: MessageImagesProps['useSessions'] = () => { - throw new Error('MessageImages does not read the session list snapshot') - } - const useWorkspaces: MessageImagesProps['useWorkspaces'] = () => { - throw new Error('MessageImages does not read the workspace list snapshot') - } - const props: MessageImagesProps = { - sessionId: 'message-images-test' as MessageImagesProps['sessionId'], - useSession, - useSessions, - usePanelInfo, useSessionRetainInfo: () => undefined, useResource, - useSessionStatus, - useWorkspaces, - useProjection: () => undefined, - useConversation, - useChat, - useTrajectory, - useInput, - inputActions: { - captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }), - insertText: () => false, - setDraft: vi.fn(), - persistDraft: vi.fn(), - addAttachments: vi.fn(() => true), - removeAttachment: vi.fn(), - pruneAttachments: vi.fn(), - submit: vi.fn(), - }, - images: [{ attachment }], - loadImage, - align: 'end', - t, - } - const view = render() + const slot = runtime.renderSlot('conversation.chat.message.images', { + images: [{ attachment }], loadImage, align: 'end', + }, { session }) + const { view, container } = slot await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) expect(view.getByRole('button', { name: 'history.png,点击查看原图' })).toBeTruthy() - expect(view.container.querySelector('[data-align="end"]')).not.toBeNull() + expect(container.querySelector('[data-align="end"]')).not.toBeNull() + expect(loadImage).toHaveBeenCalledWith(attachment) + expect(view.getByRole('button', { name: 'history.png,点击查看原图' }).getAttribute('data-variant')).toBe('single') + for (const [compact, thumbnail, variant] of [ + [true, false, 'tile'], + [false, true, 'thumbnail'], + [false, false, 'single'], + ] as const) { + slot.update({ images: [{ attachment }], loadImage, align: 'end', compact, thumbnail }) + expect(view.getByRole('button', { name: 'history.png,点击查看原图' }).getAttribute('data-variant')).toBe(variant) + expect(view.getByAltText('history.png').getAttribute('src')).toBe('blob:slot-image') + expect(container.querySelector('[data-align="end"]')).not.toBeNull() + } + expect(loadImage).toHaveBeenCalledOnce() }) }) diff --git a/packages/client/ui-attachment/tests/plugin.client.spec.ts b/packages/client/ui-attachment/tests/plugin.client.spec.ts index a83b33e8e0..6181d8db35 100644 --- a/packages/client/ui-attachment/tests/plugin.client.spec.ts +++ b/packages/client/ui-attachment/tests/plugin.client.spec.ts @@ -1,26 +1,42 @@ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { TestSessions } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyHost } from '../src/index.ts' import { apply, inject } from '../src/client/index.ts' import { ComposerAttachments } from '../src/client/ComposerAttachments.tsx' -import { MessageImages } from '../src/client/MessageImages.tsx' +import { MessageImages, MessageImagesContent } from '../src/client/MessageImages.tsx' + +const IMAGE_SLOTS = [ + 'conversation.chat.message.images', 'conversation.chat.user.images', + 'conversation.trajectory.images', 'tool.call.images', +] as const async function bench() { const ctx = new Context() + const sessions = new TestSessions(async (action) => { await action() }, ctx) + onTestFinished(async () => { + await sessions.disposeScopes() + await ctx.fiber.dispose() + }) + ctx.provide('sessions', sessions) + ctx.provide('locale', new LocaleRuntime(ctx)) await ctx.plugin(SlotRegistry).await() ctx.slots.register({ name: 'root', children: { 'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' }, - 'conversation.message.images': { kind: 'single', scope: 'session' }, + 'conversation.chat.message.images': { kind: 'single', scope: 'session' }, + 'conversation.chat.user.images': { kind: 'single', scope: 'session' }, 'conversation.trajectory.images': { kind: 'single', scope: 'session' }, 'tool.call.images': { kind: 'single', scope: 'session' }, }, } as never, () => null) + const registerFactory = vi.spyOn(ctx.slots, 'registerFactory') const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - return { ctx, fiber } + return { ctx, fiber, registerFactory } } describe('attachment plugin', () => { @@ -29,30 +45,21 @@ describe('attachment plugin', () => { }) it('registers all entries and removes them with the plugin fiber', async () => { - const { ctx, fiber } = await bench() + const { ctx, fiber, registerFactory } = await bench() expect(inject).toEqual(['slots']) + expect(registerFactory).toHaveBeenCalledWith({ + name: 'conversation.images.content', scope: 'root', locale: 'conversation', + }, MessageImagesContent) + expect(ctx.slots.snapshot('factory:conversation.images.content')).toHaveLength(1) expect(ctx.slots.entries('conversation.input.attachments')).toMatchObject([{ - locale: 'conversation', - component: ComposerAttachments, + locale: 'conversation', component: ComposerAttachments, }]) - expect(ctx.slots.entries('conversation.message.images')).toMatchObject([{ - locale: 'conversation', - component: MessageImages, - }]) - expect(ctx.slots.entries('conversation.trajectory.images')).toMatchObject([{ - locale: 'conversation', - component: MessageImages, - }]) - expect(ctx.slots.entries('tool.call.images')).toMatchObject([{ - locale: 'conversation', - component: MessageImages, - }]) - + for (const name of IMAGE_SLOTS) { + expect(ctx.slots.entries(name)).toMatchObject([{ component: MessageImages, options: { priority: 100 } }]) + } await fiber.dispose() - expect(ctx.slots.entries('conversation.input.attachments')).toHaveLength(0) - expect(ctx.slots.entries('conversation.message.images')).toHaveLength(0) - expect(ctx.slots.entries('conversation.trajectory.images')).toHaveLength(0) - expect(ctx.slots.entries('tool.call.images')).toHaveLength(0) + for (const name of IMAGE_SLOTS) expect(ctx.slots.entries(name)).toHaveLength(0) + expect(ctx.slots.snapshot('factory:conversation.images.content')).toHaveLength(0) }) }) diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index 1f5b771ac1..136e1ebb26 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../ui-primitives" + }, + { + "path": "../locale/tsconfig.client.json" } ] } diff --git a/packages/client/ui-attachment/tsdown.config.ts b/packages/client/ui-attachment/tsdown.config.ts index 2c903ce9af..3a0694bdf9 100644 --- a/packages/client/ui-attachment/tsdown.config.ts +++ b/packages/client/ui-attachment/tsdown.config.ts @@ -1,6 +1,6 @@ import { clientBundle } from '../tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-client-ui-attachment', + import.meta.dirname, ['lib/types/index.js'], ) diff --git a/packages/client/ui-brand-official/locale/en.json b/packages/client/ui-brand-official/locale/en.json new file mode 100644 index 0000000000..09781290d4 --- /dev/null +++ b/packages/client/ui-brand-official/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-brand-official" + } +} diff --git a/packages/client/ui-brand-official/locale/zh.json b/packages/client/ui-brand-official/locale/zh.json new file mode 100644 index 0000000000..479797b84e --- /dev/null +++ b/packages/client/ui-brand-official/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "DeepSeek 品牌展示", + "description": "在应用侧栏等位置展示官方品牌元素。" + } +} diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index b4587a84ac..e21a4838eb 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -48,11 +49,11 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", "react": "^18.2.0", "react-dom": "^18.2.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-brand-official/tsdown.config.ts b/packages/client/ui-brand-official/tsdown.config.ts index 9edc90cbc9..7aa03cebc1 100644 --- a/packages/client/ui-brand-official/tsdown.config.ts +++ b/packages/client/ui-brand-official/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-brand-official', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 1be8b3526e..dd9033ae18 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -15,8 +15,8 @@ en: 41cd21266011d571 zh: 3172d1a2acbb27b7 /deepseek-ai-dsh-client-ui-chat/reference-previews: - en: 0a1948349b1fbfd9 - zh: 56256e4d695e5192 + en: aa6b1abec265bf1b + zh: 7260ce78afba2584 /deepseek-ai-dsh-client-ui-chat/hidden-chat-rows: en: cbf1f0a2205046a5 zh: 2e59d0815351349f @@ -24,20 +24,20 @@ en: 2157fcd6b0d100b5 zh: 9e865ac3953ebe30 /deepseek-ai-dsh-client-ui-chat/turn-token-usage: - en: 14343dd14e493f4b - zh: 7cc7a25d05ea798f + en: bb20da372b144302 + zh: 75a8c5ab7ec8d665 /deepseek-ai-dsh-client-ui-chat/completed-turn-footer: en: b4cd36e7ab91281e zh: 8ccf412b9dde61d5 /deepseek-ai-dsh-client-ui-chat/turn-process-folding: - en: 0c63ffa8f245050e - zh: f421d1a569b16c3d + en: 4cd7c81d70d2f365 + zh: 18eca6d16e44b6c2 /deepseek-ai-dsh-client-ui-chat/grouped-rendering: - en: 8b2586e6b9f188a7 - zh: 6687f9e67432d04f + en: 81d7ca7595c34d39 + zh: 29eb8248c75e9ea6 /deepseek-ai-dsh-client-ui-chat/scroll-ownership: - en: 838659819e09413d - zh: 43c754f34c193fff + en: 3f4e6c161b4583bb + zh: e4265bb2bce8eb1c /deepseek-ai-dsh-client-ui-chat/model-experience: en: c17b4dd43a61c569 zh: f7414309810c3a08 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index a0e84873c3..f04957566b 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -33,11 +33,19 @@ File-mention providers receive the viewed Session ID with the closing-turn owner ## Reference previews -Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` around its node list. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state. +Sent generic-file attachments retain the draft's [file-card radius](../../../docs/ui-radius.md#radius-scale). + +`ChatView` supplies file and HTTP(S) navigation through the existing `MarkdownDelegateProvider` around its node list, passing `requestOpenFile`, `openExternalLink`, and `fileImages` directly. Its file opener retains Chat's failure feedback and retry handling. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state. + +Native message images use separate user and output positions. The `conversation.chat.user.content` Factory declares `conversation.chat.user.images`, which `UserContent` renders; the Assistant node registration declares `conversation.chat.message.images`, which `AssistantMarkdown` renders. Both receive image sources, the authorized `loadImage`, and layout options through explicit props. Their official `MessageImages` entries call the shared root-scoped `conversation.images.content` Factory. Each position can be replaced independently without duplicating the gallery implementation. + +The session-scoped `conversation.chat.user.content` Factory renders only the user content stack: text, extra blocks, image/file attachments in input order, and reference summaries. Its [`UserContentInput`](src/client/contract/slots.ts) carries prepared `content`, transform `facts`, display props, and explicit `loadImage`; node-owned injections provide reference actions. It calls the shared `conversation.user.text` Factory for text and its own image Slot for images. Direct callers need a Session binding and supply their own row wrapper. + +`UserStyleBubble` and the [source-specific wrappers]() retain the user row, keys, pending/echo markers, timestamps, copy controls, and other message actions. The content Factory reads neither source-specific models nor private Context, and is not a global user-content override entry point. Standalone Markdown images show contained previews and open the shared image lightbox; local paths resolve against the viewed workspace after settlement. Image file links keep their sidebar activation and show a thumbnail after hover dwell or keyboard focus. Escape dismisses the thumbnail. Failed images retain a localized status and their description; no duplicate-image filtering is applied. -Settings → General → Open chat links in selects the destination for ordinary clicks on Chat HTTP(S) links: In-App Sidebar (default) opens a new right-Sidebar Browser tab, while Default Browser opens an external tab. The setting follows Keyboard shortcuts and is shown only while the Sidebar Browser is available. If the Sidebar Browser is not registered, both choices use the external browser; modified clicks retain native behavior. The `ui-chat.linkOpening` preference persists on loopback browsers and stays process-local when settings cannot persist writes. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. +Settings → General → Open chat links in selects the destination for ordinary clicks on Chat HTTP(S) links: In-App Sidebar (default) opens a new right-Sidebar Browser tab, while Default Browser opens an external tab. The setting follows Keyboard shortcuts and is shown only while the Sidebar Browser is available. If the Sidebar Browser is not registered, both choices use the external browser; modified clicks retain native behavior. The `ui-chat.linkOpening` preference persists on the Host for authenticated browsers and stays process-local when settings cannot persist writes. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. ## Hidden Chat rows @@ -60,9 +68,9 @@ A completed Turn shows an expandable usage row only when the loaded window inclu Settings → General → Performance & usage appears after Send behavior while busy and before Session Log upload, and stores `ui-chat.performanceUsage` as `detailed` (default) or `compact`. Compact shows only available output speed and cache-hit percentage beneath the composer, without interactive statistic dialogs or per-Turn usage. Detailed exposes session statistics and per-Turn token usage. Neither mode shows elapsed time in the completed-turn footer. The preference changes presentation only; accounting and Session events remain intact. -The composer statistics are two `conversation.composer.dock` list entries: `activity` (order 0, turn and step counts and output speed, with LLM time, tool time, and TTFT) and `usage` (order 1, token total and cache hit). Another plugin replaces one pill by registering the same id and the same `order`; the dock orders rows by `order`, while a lower `priority` picks the replacement: a dynamic plugin receives one automatically, and a statically composed plugin passes it explicitly. A new id adds a pill. Each pill carries its id in `data-composer-stat`. An open pill dialog closes on Escape or on a pointerdown or click outside it, so activating another pill by pointer or keyboard swaps dialogs instead of stacking them. +The composer statistics are two `conversation.composer.dock` list entries: `activity` (order 0, turn and step counts and output speed, with LLM time, tool time, and TTFT) and `usage` (order 1, token total and cache hit). The controls follow the shared [statistics-control radius](../../../docs/ui-radius.md#radius-scale). Another plugin replaces one control by registering the same id and the same `order`; the dock orders rows by `order`, while a lower `priority` picks the replacement: a dynamic plugin receives one automatically, and a statically composed plugin passes it explicitly. A new id adds a control. Each control carries its id in `data-composer-stat`. An open statistics dialog closes on Escape or on a pointerdown or click outside it, so activating another control by pointer or keyboard swaps dialogs instead of stacking them. -On non-loopback browsers, the preference remains process-local because the settings scope cannot persist writes. Explicit selections update every consumer immediately; accepted Host settings reconcile the live value on loopback browsers. +Explicit selections update every consumer immediately; accepted Host settings reconcile the live value, so a refused or pending write keeps the selected mode until the Host answers. Preference menus restore focus to their trigger without scrolling before publishing a new selection. @@ -84,7 +92,7 @@ When Chat ends with an open Turn control and that Turn has no visible input, the Work-details modes control process-group display and reasoning previews. Compact, Standard, and Detailed fold eligible completed Turns without hiding the final answer; Verbose retains the duration/status header without a collapse action and shows historical process rows directly. The [business-rule reference](src/client/conversation-nodes/README.md#display-modes) contains the mode table, title behavior, whole-Turn eligibility, clocks, and disclosure resets. -The Settings row below Work details offers an [independent collapse-timing preference](src/client/conversation-nodes/README.md#collapse-timing). +The Settings row below Work details offers an [independent collapse-timing preference](src/client/conversation-nodes/README.md#collapse-timing), saved through the same Host profile configuration. ----- @@ -95,7 +103,7 @@ Chat registers its process Group Definition through `uiConversation.groups`. Rea `groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. A Tool node owns its preparing, dispatched, and result stages under one callId. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector. -The Chat Builder publishes `bottomSource` per Node: true for visible Nodes belonging to the last Turn in the loaded timeline, not just the final row. Advancing the bottom Turn notifies only Nodes in the old and new bottom Turns. Groups read their first member's position. Pending-input deduplication and collapse decisions remain in the Chat view, outside the Builder. +The Chat Builder publishes `bottomSource` per Node: true for visible Nodes belonging to the latest loaded exchange, not just the final row. Nodes declare their group-tree position through `groupTree`; a Turn holding an `exchange` Node opens an exchange, and the exchange lasts until the next opening Turn. The positions, from outermost inward `exchange`, `input`, `turn`, and `process`, are defined in [group-tree.ts](src/client/contract/group-tree.ts); process grouping, whole-Turn folding, and Turn-input detection read them, so an external Definition places its Chat rows by declaring one. Moving the exchange start notifies only Nodes in Turns that entered or left it. Groups read their first member's position. Pending-input deduplication and collapse decisions remain in the Chat view, outside the Builder. Live tool deltas share reasoning's frame-batched publication; durable calls and results publish immediately. Native and nested results retain event presentation metadata, so historical file and command labels use the operation’s location. Repeated deltas retain the Tool node when observed argument answers, the anchor, location, and visibility are unchanged. The Definition supplies the same lazy argument reader to tool rows and group detail, without tool-specific registration. @@ -109,7 +117,7 @@ Scroll-edge fades initialize when `ResizeObserver` reports the open group's layo Each group owns local `useDisclosure` state that survives mode changes while its component stays mounted. -The session-scoped `conversation.chat.reasoning.body` single Slot offers the original reasoning text and streaming state only when its body is expanded. Its official entry registers at priority 100 and calls the `conversation.chat.reasoning.content` Factory. A replacement uses a lower priority number and may call that same Factory with original or derived text. The root-scoped Factory requires no Session and fixes compact typography. Its optional `labels` accepts complete `MarkdownLabels` unchanged; omission uses Chat's localized defaults. The host supplies no inline fallback or plugin action state. Display extensions must preserve access to the original text and own their controls inside their Body wrapper. Extensions do not replace the reasoning preview or its disclosure policy. +The session-scoped `conversation.chat.reasoning.body` single Slot offers the original reasoning text and streaming state only when its body is expanded. Its official entry registers at priority 100 and calls the `conversation.chat.reasoning.content` Factory. A replacement uses a lower priority number and may call that same Factory with original or derived text. The root-scoped Factory requires no Session and fixes compact typography. Its optional `labels` accepts complete `MarkdownLabels` unchanged; omission uses Chat's localized defaults. The host supplies no inline fallback or plugin action state. Display extensions must preserve access to the original text and own their controls inside their Body wrapper. Extensions do not replace the reasoning preview or its disclosure policy. The [reasoning display rules](src/client/conversation-nodes/README.md#display-modes) define which blocks produce rows. The Chat-node slot injects a reset-bound `useDisclosure` Hook for reasoning and tools. Intermediate renderers forward it without subscribing; each invocation owns independent open state. Source callbacks retain their receiver and stable identity. When an enclosing Turn actually hides a process member, its seat resets those disclosures without replacing component keys or changing the Hook reference. Display-mode changes preserve their open state. @@ -120,6 +128,8 @@ Process rows share one text and icon color in every Work details mode: tertiary ## Scroll ownership +Chat waits for initial history before automatic content, scroll, or size updates initialize reading state. Running Sessions restore their initial visibility and position before fold animations become eligible. + Chat restores semantic anchors across history prepend and renderer remounts, with browser scroll anchoring disabled on its scrollport only while following the tail. Pinned scroll deliveries without reader movement, and reader input that reaches the floor within subpixel rounding, update follow ownership immediately, before subsequent layout changes can invalidate their floor. Other reader movement remains pending until the sampling interval or `scrollend`, even inside the follow threshold, so layout growth cannot erase small scroll gestures. Under completion-time folding, submitting transcript input or steering immediately restores tail following and clears an older pending reader sample. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer. The turn rail and back-to-bottom button sit outside the clipped transcript. They use the shared conversation scrollport for sticky positioning, or the Chat frame for absolute positioning when Chat owns its scrollport. The rail hides when the transcript's available width, excluding its horizontal padding, is at most 900px; the browser viewport width is not the criterion. @@ -130,7 +140,9 @@ Outer transcript following and each open capped group's following are independen Next-message folding combines row closure and tail following in one cancellable task, with browser scroll anchoring disabled during the fold. If the removed height exceeds the new content's height, the difference remains as bottom space rather than reducing the scroll offset to chase the unpadded content floor. This applies even when the conversation already exceeds the viewport. New content fills the retained space before following advances again; a smaller fold leaves only the remaining forward distance to scroll. Reader gestures cancel the task and active native following; resize cannot revive a cancelled task in the same fold, but new submitted input can. Off-bottom readers retain their position, and acknowledged scrolling releases only space that can be removed without clamping them. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes. -Fold coordination is viewport-local: reservations and idle callbacks never broadcast to other Chat views. ChatView supplies its motion capability once to `conversation.chat.flow` through the render's `hookContext`. Flow binds the group and header behavior hooks, owns node and image rendering, and shares the existing Chat store and Session node sources. Layout nodes and inline reasoning receive the bound hooks without receiving the motion controller or changing content parents. Generic searchable hiding applies before paint and owns focus protection and beforematch handling; pending-input reconciliation remains in ChatView. +Changing Work details clears reserved bottom space after active folds finish and reconciles the scroll range with the selected mode. Readers already following the tail stay at the new bottom; other readers are not forced to follow. + +Fold coordination is viewport-local: reservations and idle callbacks never broadcast to other Chat views. ChatView supplies its motion capability once to `conversation.chat.flow` through the render's `hookContext`. Flow binds the group and header behavior hooks, owns node dispatch, supplies Factory rendering for local echoes, and shares the existing Chat store and Session node sources. The image entry Factory owns the replaceable image Slot. Layout nodes and inline reasoning receive the bound hooks without receiving the motion controller or changing content parents. Generic searchable hiding applies before paint and owns focus protection and beforematch handling; pending-input reconciliation remains in ChatView. | Outer follows | Open group follows | Back-to-bottom button | New content | |---|---|---|---| diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index b1ea0eb081..064607a1a3 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -33,11 +33,19 @@ kind: "package-reference" ## 引用预览 -Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。 +已发送的普通文件附件保留草稿中的[文件卡片圆角](../../../docs/ui-radius.zh.md#radius-scale)。 + +`ChatView` 通过节点列表外围既有的 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航,直接传入 `requestOpenFile`、`openExternalLink` 和 `fileImages`。文件打开动作保留 Chat 的失败反馈与重试处理。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。 + +原生消息图片使用独立的用户与输出位置。`conversation.chat.user.content` Factory 声明 `conversation.chat.user.images`,由 `UserContent` 渲染;Assistant 节点注册声明 `conversation.chat.message.images`,由 `AssistantMarkdown` 渲染。两者通过显式 props 接收图片来源、授权 `loadImage` 和布局参数。官方 `MessageImages` 条目调用共享的 root 作用域 `conversation.images.content` Factory。每个位置均可独立替换,无需复制画廊实现。 + +Session 作用域的 `conversation.chat.user.content` Factory 只渲染用户内容区域:文本、额外内容块、按输入顺序排列的图片/文件附件及引用摘要。[`UserContentInput`](src/client/contract/slots.ts) 携带预处理的 `content`、变换 `facts`、显示 props 和显式 `loadImage`;引用动作由节点自己的 Injection 提供。正文调用共享 `conversation.user.text` Factory,图片调用自身拥有的图片 Slot。直接调用需要 Session 绑定,行包装层由调用方提供。 + +`UserStyleBubble` 和[各来源包装层]() 保留用户行、key、pending/echo 标记、时间、复制控件及其他消息操作。内容 Factory 不读取来源专属模型或私有 Context,也不是全局用户内容替换入口。 独立的 Markdown 图片以内嵌预览显示,点击打开共享图片浮层;本地路径在消息落定后基于当前查看的工作区解析。图片文件链接保持点击打开侧栏,鼠标停留或键盘聚焦时显示缩略图,Esc 关闭缩略图。图片加载失败时保留本地化状态与图片说明;不执行重复图片过滤。 -设置 → 通用设置 → 网页链接默认打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「应用内侧边栏」(默认)打开新的右侧 Sidebar Browser tab,「默认浏览器」打开外部标签页。该设置项位于快捷键之后,仅在 Sidebar Browser 可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。`ui-chat.linkOpening` 偏好在回环地址浏览器中持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 +设置 → 通用设置 → 网页链接默认打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「应用内侧边栏」(默认)打开新的右侧 Sidebar Browser tab,「默认浏览器」打开外部标签页。该设置项位于快捷键之后,仅在 Sidebar Browser 可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。已认证浏览器的 `ui-chat.linkOpening` 偏好在 Host 上持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 ## Chat 隐藏的行 @@ -60,9 +68,9 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node, 设置 → 通用设置 → 性能与用量位于繁忙时的发送行为之后、Session Log 上传之前,将 `ui-chat.performanceUsage` 保存为 `detailed`(默认)或 `compact`。简洁模式仅在输入框下方显示可用的输出速度和缓存命中率,不显示统计交互卡片或每轮用量。详细模式提供会话统计和每轮 token 用量。两种模式的已完成轮次页脚均不显示耗时。该偏好仅影响展示,记账和 Session 事件保持完整。 -输入框统计信息由两个 `conversation.composer.dock` 列表条目组成:`activity`(order 0,轮次数、步骤数与输出速度,以及 LLM 耗时、工具耗时与 TTFT)和 `usage`(order 1,token 总量与缓存命中)。其他插件以相同的 id 和相同的 `order` 注册即可替换某个胶囊;dock 按 `order` 排列各行,更低的 `priority` 决定由替换者渲染:动态插件会自动获得,静态组合的插件需显式传入。以新 id 注册则新增胶囊。每个胶囊在 `data-composer-stat` 中携带其 id。按下 Escape,或在已打开的对话框之外发生 pointerdown 或点击时,该对话框会关闭,因此用指针或键盘激活另一个胶囊会切换对话框,而不会叠加两个。 +输入框统计信息由两个 `conversation.composer.dock` 列表条目组成:`activity`(order 0,轮次数、步骤数与输出速度,以及 LLM 耗时、工具耗时与 TTFT)和 `usage`(order 1,token 总量与缓存命中)。这些控件遵循公共的[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。其他插件以相同的 id 和相同的 `order` 注册即可替换某个控件;dock 按 `order` 排列各行,更低的 `priority` 决定由替换者渲染:动态插件会自动获得,静态组合的插件需显式传入。以新 id 注册则新增控件。每个控件在 `data-composer-stat` 中携带其 id。按下 Escape,或在已打开的对话框之外发生 pointerdown 或点击时,该对话框会关闭,因此用指针或键盘激活另一个控件会切换对话框,而不会叠加两个。 -在非回环地址浏览器中,设置作用域无法持久化写入,因此该偏好仅在当前进程内生效。明确选择会立即更新所有使用方;回环地址浏览器收到 Host 已接受的设置后会同步当前值。 +明确选择会立即更新所有使用方;Host 接受的设置会校正实时值,因此写入被拒绝或尚未应答时保持所选模式,直到 Host 应答。 偏好菜单在发布新选择之前,先将焦点还给触发按钮,且不引起滚动。 @@ -84,7 +92,7 @@ Chat 末尾为进行中的 Turn 控制行,且该轮尚无可见输入时,第 工作步骤展示模式控制过程组显示与推理预览。简洁、标准、详细模式收起符合条件的已完成轮次,不隐藏最终答案;完全展开模式保留时长或状态抬头,但不支持收起,历史过程行直接显示。[业务规则明细](src/client/conversation-nodes/README.zh.md#display-modes) 统一说明模式表、组头行为、整轮折叠资格、时钟与开合重置。 -工作步骤展示下方的设置行提供[独立的收起时机设置](src/client/conversation-nodes/README.zh.md#collapse-timing)。 +工作步骤展示下方的设置行提供[独立的收起时机设置](src/client/conversation-nodes/README.zh.md#collapse-timing),通过相同的 Host profile 配置保存。 ----- @@ -95,7 +103,7 @@ Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过 `groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。同一个 callId 的准备、派发与结果阶段由 Tool 节点自己拥有。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。 -Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载时间线的最后一轮时为 true,而不只是列表最后一行。底部轮次推进时,只有旧、新底部轮次的节点收到位置变化通知。Group 读取其首个成员的位置。待发送消息去重与收起判断仍由 Chat 视图处理,不进入 Builder。 +Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的最新交互段时为 true,而不只是列表最后一行。节点通过 `groupTree` 声明自己在分组树中的位置;含 `exchange` 节点的轮次开启一个交互段,交互段持续到下一个开启轮次。这些位置由外到内依次为 `exchange`、`input`、`turn`、`process`,定义在 [group-tree.ts](src/client/contract/group-tree.ts);过程分组、整轮折叠和轮次输入判断都读取它们,外部 Definition 声明位置即可放置自己的 Chat 行。交互段起点移动时,只有进入或离开该交互段的轮次中的节点收到通知。Group 读取其首个成员的位置。待发送消息去重与收起判断仍由 Chat 视图处理,不进入 Builder。 实时工具 delta 与推理共用按帧合并的发布节奏;持久调用和结果立即发布。原生与嵌套结果保留事件中的展示元数据,使历史文件与命令标签使用执行时的位置。当已读取参数的答案、锚点、位置及可见性均未变化时,重复 delta 保持 Tool 节点不变。Definition 向工具行和分组详情提供同一个懒计算参数读器,无需按工具注册。 @@ -109,7 +117,7 @@ Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载时 每个组拥有本地 `useDisclosure` 状态,组件保持挂载时,模式切换保留该状态。 -Session 作用域的 `conversation.chat.reasoning.body` single Slot 仅在思考正文展开时提供原始思考文本和流式状态。官方条目以优先级 100 注册,并调用 `conversation.chat.reasoning.content` Factory。替换项使用更小的优先级数值,可向同一个 Factory 传入原文或派生显示文本。root 作用域的 Factory 不要求 Session,并固定使用紧凑排版。其可选 `labels` 原样接受完整的 `MarkdownLabels`,省略时使用 Chat 的默认本地化文案。宿主不提供内联 fallback 或插件操作状态。显示扩展必须保留查看原文的能力,并在自己的 Body 包装内维护控件。扩展不替换思考预览或展开策略。 +Session 作用域的 `conversation.chat.reasoning.body` single Slot 仅在思考正文展开时提供原始思考文本和流式状态。官方条目以优先级 100 注册,并调用 `conversation.chat.reasoning.content` Factory。替换项使用更小的优先级数值,可向同一个 Factory 传入原文或派生显示文本。root 作用域的 Factory 不要求 Session,并固定使用紧凑排版。其可选 `labels` 原样接受完整的 `MarkdownLabels`,省略时使用 Chat 的默认本地化文案。宿主不提供内联 fallback 或插件操作状态。显示扩展必须保留查看原文的能力,并在自己的 Body 包装内维护控件。扩展不替换思考预览或展开策略。哪些块生成思考行由[思考展示规则](src/client/conversation-nodes/README.zh.md#display-modes)定义。 Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure` 钩子。中间 renderer 只透传、不订阅,每次调用拥有独立展开状态。来源回调保留接收对象及稳定引用。外层轮次实际隐藏过程成员时,所在节点重置这些开合状态,不替换组件 key,也不改变钩子引用。展示模式切换保留展开状态。 @@ -120,6 +128,8 @@ Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure` ## 滚动归属 +Chat 等待初始历史就绪后,才允许自动内容、滚动或尺寸更新初始化阅读状态。进行中的 Session 先恢复初始显隐和位置,再允许折叠动画。 + Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且只在跟随底部时禁用自己滚动区域的浏览器自动锚定。没有读者移动的贴底滚动事件,以及到达底部(容许小于 1px 的取整误差)的读者输入,会立即更新跟随归属,避免后续布局变化使其底部位置失效。其他读者移动即使位于跟随阈值内,也保持待处理直到采样周期或 `scrollend`,防止布局增长抵消小幅滚动操作。采用完成时收起时,提交正文输入或 steering 会立即恢复跟随底部,并清除先前待处理的读者滚动采样。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载轮次;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃轮次。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。 轮次轨道与回到底部按钮位于正文裁剪层外。它们相对共享会话滚动容器做 sticky 定位;Chat 自己持有滚动容器时,则相对 Chat 外框做 absolute 定位。正文扣除水平内边距后的可用宽度不超过 900px 时,隐藏轮次轨道;判断依据不是浏览器视口宽度。 @@ -130,7 +140,9 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且 下次消息触发的折叠与跟尾属于同一个可取消任务,折叠期间禁用浏览器滚动锚定。如果移除的高度大于新增内容的高度,差值保留为底部空间,不通过减小滚动偏移追到未含预留空间的内容底部;对话总高度已经超过视口时也遵循此规则。后续新增内容先填充保留区域,填满后才继续跟尾;折叠高度较小时,仅滚动剩余的向前距离。读者手势取消该任务及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复已取消的任务,但新提交的输入可以。离底读者保留位置,已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。 -折叠协调状态由各自的 viewport 持有,空间预留和空闲回调不会广播到其他 Chat 视图。ChatView 通过本次渲染的 `hookContext`,向 `conversation.chat.flow` 交接一次动画能力。Flow 绑定组内容与组头的行为 hook,负责节点和图片渲染,并共享既有 Chat store 与 Session 节点来源。布局节点和行内思考接收绑定后的 hook,不接收动画控制器,也不改变内容的父级。通用的可搜索隐藏在绘制前生效,并负责焦点保护及 beforematch;pending 输入对账仍留在 ChatView。 +切换工作步骤展示档位时,当前折叠结束后清空底部预留空间,并按所选档位调整滚动范围。原本跟随底部的读者保持在新的底部;其他读者不会被强制拉回底部。 + +折叠协调状态由各自的 viewport 持有,空间预留和空闲回调不会广播到其他 Chat 视图。ChatView 通过本次渲染的 `hookContext`,向 `conversation.chat.flow` 交接一次动画能力。Flow 绑定组内容与组头的行为 hook,负责节点分发,为本地回显提供 Factory renderer,并共享既有 Chat store 与 Session 节点来源。图片入口 Factory 拥有可替换的图片 Slot。布局节点和行内思考接收绑定后的 hook,不接收动画控制器,也不改变内容的父级。通用的可搜索隐藏在绘制前生效,并负责焦点保护及 beforematch;pending 输入对账仍留在 ChatView。 | 外层跟随 | 已展开小组跟随 | 回到底部按钮 | 新增内容 | |---|---|---|---| diff --git a/packages/client/ui-chat/locale/en.json b/packages/client/ui-chat/locale/en.json new file mode 100644 index 0000000000..a07944e34f --- /dev/null +++ b/packages/client/ui-chat/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-chat" + } +} diff --git a/packages/client/ui-chat/locale/zh.json b/packages/client/ui-chat/locale/zh.json new file mode 100644 index 0000000000..bece9e8048 --- /dev/null +++ b/packages/client/ui-chat/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "聊天内容展示", + "description": "渲染聊天消息、工具内容和相关详情。" + } +} diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 432346bf9d..1eb2d42930 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -87,9 +88,7 @@ "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-util-workspace-path": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", - "@tanstack/react-virtual": "^3.14.10", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", + "@tanstack/react-virtual": "3.14.10", "react": "^18.2.0", "react-dom": "^18.2.0", "@deepseek-ai/dsh-settings": "workspace:*", @@ -99,6 +98,7 @@ "@deepseek-ai/schemastery": "workspace:~" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-chat/src/chat-settings.ts b/packages/client/ui-chat/src/chat-settings.ts index c4c6ce3b95..8e76bb29ed 100644 --- a/packages/client/ui-chat/src/chat-settings.ts +++ b/packages/client/ui-chat/src/chat-settings.ts @@ -8,6 +8,18 @@ export const CHAT_SETTINGS_NAMESPACE = 'ui-chat' /** Field carrying the work-details presentation mode. */ export const TRANSCRIPT_VIEW_FIELD = 'transcriptView' +/** Field carrying the independent work-details collapse timing. */ +export const COLLAPSE_TIMING_FIELD = 'collapseTiming' + +/** Available points when a completed Turn returns to its historical presentation. */ +export const COLLAPSE_TIMINGS = ['completion', 'next-input'] as const + +/** When a completed Turn returns to its historical presentation. */ +export type CollapseTiming = typeof COLLAPSE_TIMINGS[number] + +/** Preserve completion-time folding when no timing preference is saved. */ +export const DEFAULT_COLLAPSE_TIMING: CollapseTiming = 'completion' + /** Work-details presentation modes a user can choose. */ export const TRANSCRIPT_VIEW_MODES = ['compact', 'standard', 'detailed', 'verbose'] as const @@ -50,6 +62,8 @@ export const DEFAULT_LINK_OPENING: LinkOpening = 'sidebar' export interface ChatSettings { /** Work-details preference; absence uses the client default, and legacy saved values remain accepted. */ transcriptView?: TranscriptViewMode | typeof LEGACY_TRANSCRIPT_VIEW_MODE | typeof LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE | null + /** Independent collapse timing; absence preserves completion-time folding. */ + collapseTiming?: CollapseTiming /** Detail level for composer statistics and completed-Turn usage. */ performanceUsage: PerformanceUsageMode /** Default destination for Chat HTTP(S) links. */ @@ -58,6 +72,7 @@ export interface ChatSettings { /** Durable Chat schema; also the wire envelope the browser scope validates against. */ export const ChatSettingsFields = { + [COLLAPSE_TIMING_FIELD]: z.union([...COLLAPSE_TIMINGS]).default(DEFAULT_COLLAPSE_TIMING), linkOpening: z.union(['sidebar', 'new-tab']).default(DEFAULT_LINK_OPENING), performanceUsage: z.union([...PERFORMANCE_USAGE_MODES]).default(DEFAULT_PERFORMANCE_USAGE), // Missing and unrecognized modes defer to the client's default. diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index eabd566256..8aa63aa1f7 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -31,6 +31,7 @@ import { ApprovalCommand } from './chat/ApprovalCommand.tsx' import { ChatView } from './chat/ChatView.tsx' import { ChatFlow } from './chat/ChatFlow.tsx' import { registerChatNodeRenderers } from './chat/register-node-renderers.ts' +import { UserContent } from './chat/UserContent.tsx' import { ActivityPill, UsagePill } from './chat/StatsPills.tsx' import { registerConversationNodes } from './conversation-nodes/register.ts' import { QuotaNoticeHost } from './chat/QuotaNoticeHost.tsx' @@ -169,6 +170,10 @@ export function apply(ctx: Context): void { ctx.effect(() => () => { transcriptView.dispose(); performancePolicy.dispose() }) const performanceUsage = performancePolicy.mode registerChatNodeRenderers(ctx, performanceUsage, presentation) + ctx.slots.registerFactory({ + name: 'conversation.chat.user.content', scope: 'session', locale: NS, + children: { 'conversation.chat.user.images': { kind: 'single', scope: 'session' } }, + }, UserContent) ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', @@ -211,7 +216,6 @@ export function apply(ctx: Context): void { name: 'conversation.chat.flow', locale: NS, store: chatStore, children: { 'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT }, - 'conversation.message.images': { kind: 'single', scope: 'session' }, }, inject: (sessionId: SessionId): ChatFlowDataInjected => { const binding = ctx.sessions.binding(sessionId) @@ -258,11 +262,6 @@ export function apply(ctx: Context): void { else ctx.sidebarRight.openResource(url, { params: { line: options.line } }) await Promise.resolve() }, - openSkill: (name) => { - const scope = ctx.sessions.scope(sessionId) - if (scope === undefined) return - ctx.get('inputTriggers')?.sessionOf(scope).openReference('skill', { ref: `/${name}` }) - }, openExternalLink: (url) => { if (linkOpening.getSnapshot() === 'sidebar' && ctx.get('sidebarRightTabs')?.get('browser') !== undefined) { ctx.sidebarRight.openTab('browser', { params: { url } }) diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index dcc1141b47..22ae5bac70 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -27,7 +27,9 @@ export function localPathMediaUrl(base: string, value: string): string | undefin return fileMediaUrl(base, path) } -export interface AssistantMarkdownProps extends Pick, 'renderSlot'> { +export interface AssistantMarkdownProps extends + Pick, 'renderSlot'>, + Pick { /** Render only the requested business portion, preserving original block indexes. */ groupPart?: string | undefined /** Stable Hook forwarded to each independently expandable reasoning block. */ @@ -36,8 +38,6 @@ export interface AssistantMarkdownProps extends Pick - {renderMessageImages({ - images: group.map(({ attachment }) => ({ attachment })), - align: 'start', + {renderSlot('conversation.chat.message.images', { + images: group.map(({ attachment }) => ({ attachment })), loadImage, align: 'start', })} , ) diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx index b77dffb7f1..5f61fe7007 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx @@ -4,11 +4,11 @@ import type { ChatNodeViewProps, PresentationInjected, TurnTailOwnerProps } from import { AssistantMarkdown } from './AssistantMarkdown.tsx' type AssistantNodeViewProps = ChatNodeViewProps<'assistant-step'> & InjectFace - & PropsRenderSlots<'conversation.chat.reasoning.body'> + & PropsRenderSlots<'conversation.chat.reasoning.body' | 'conversation.chat.message.images'> /** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */ export const AssistantNodeView = memo(function AssistantNodeView({ - node, groupPart, useDisclosure, useGroupAction, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, + node, groupPart, useDisclosure, useGroupAction, useTurnData, turnProcess, openFile, loadImage, fileMentions, usePresentation, renderSlot, t, }: AssistantNodeViewProps) { const data = node.data @@ -38,7 +38,7 @@ export const AssistantNodeView = memo(function AssistantNodeView({ useDisclosure={useDisclosure} streaming={data.status === 'running'} interrupted={data.status === 'interrupted'} - renderMessageImages={renderMessageImages} + loadImage={loadImage} reasoningHidden={reasoningHidden} useGroupAction={useGroupAction} usePresentation={usePresentation} diff --git a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx index 1b8306ee5f..c54e8a108f 100644 --- a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx @@ -1,6 +1,5 @@ /** Ordered Chat rows, local echoes, and running status share the flow slot's parent. */ -import { memo, useCallback } from 'react' -import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { memo } from 'react' import { assertNever } from '@deepseek-ai/dsh-util-values' import type { ChatFlowSlotProps } from '../contract/slots.ts' import { ChatNodeSeat } from './ChatNodeSeat.tsx' @@ -13,8 +12,8 @@ import { RunningStatus } from './RunningStatus.tsx' export const ChatFlow = memo(function ChatFlow({ entries, pendingInputs, lastInputTurn, deferCollapse, useSession, useChat, useChatNode, useChatNodeBottom, useChatNodeProcess, useChatGroup, usePresentation, - useStore, actions, renderSlot, t, useGroupAction, useGroupHeaderAction, - cwd, openFile, openSkill, inspectCall, forkAt, loadImage, fileMentions, + useStore, actions, renderSlot, renderFactorySlot, t, useGroupAction, useGroupHeaderAction, + cwd, openFile, inspectCall, forkAt, loadImage, fileMentions, }: ChatFlowSlotProps) { const nodeStore = useChat(snapshot => snapshot.nodes) const running = useSession(snapshot => snapshot.running) @@ -25,14 +24,10 @@ export const ChatFlow = memo(function ChatFlow({ ? location.turn.status === 'open' ? location.turn.start?.time : undefined : undefined }) - const renderMessageImages = useCallback( - owner => renderSlot('conversation.message.images', { ...owner, loadImage }), - [loadImage, renderSlot], - ) const seatProps = { nodeStore, useChatNode, useChatNodeBottom, useChatNodeProcess, usePresentation, useStore, actions, renderSlot, t, useGroupAction, deferCollapse, - cwd, openFile, openSkill, inspectCall, forkAt, loadImage, renderMessageImages, fileMentions, + cwd, openFile, inspectCall, forkAt, loadImage, fileMentions, } const rows = entries.map((entry) => { switch (entry.kind) { @@ -48,10 +43,10 @@ export const ChatFlow = memo(function ChatFlow({ }) const pendingRows = pendingInputs.map(item => 'requestId' in item ? ( + loadImage={loadImage} renderFactorySlot={renderFactorySlot} t={t} /> ) : ( + loadImage={loadImage} renderFactorySlot={renderFactorySlot} t={t} /> )) const tail = entries.at(-1) const node = tail?.kind === 'node' ? nodeStore.get(tail.key) : undefined diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 4bc1947dca..9721b4a0a8 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -5,7 +5,8 @@ import type { ConversationLocationDataStore, ConversationTurnDataMap } from '@de import type { ChatNodeHookContext, ChatNodeOwnerProps, ChatFlowSlotProps, UsePresentation } from '../contract/slots.ts' import type { ChatNode } from '../contract/chat-nodes.ts' import type { ChatNodeStore } from '../contract/snapshot.ts' -import { TURN_PROCESS_INDEPENDENT_KINDS, turnProcessAlwaysOpen } from '../contract/turn-process.ts' +import { isAtOrOutside } from '../contract/group-tree.ts' +import { turnProcessAlwaysOpen } from '../contract/turn-process.ts' import { turnProcessOpen } from '../stores.ts' import { hiddenOrCollapsing } from './flow-motion.ts' import css from './ChatView.module.css' @@ -47,8 +48,8 @@ function turnOf(node: ChatNode | undefined): number | undefined { */ export const ChatNodeSeat = memo(function ChatNodeSeat({ nodeKey, groupPart, useChatNode, useChatNodeProcess, usePresentation, - cwd, openFile, openSkill, inspectCall, forkAt, - loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, t, useChatNodeBottom, deferCollapse, useGroupAction, + cwd, openFile, inspectCall, forkAt, + loadImage, fileMentions, useStore, actions, renderSlot, t, useChatNodeBottom, deferCollapse, useGroupAction, }: ChatNodeSeatProps) { const bottom = useChatNodeBottom(nodeKey) const node = useChatNode(nodeKey) @@ -76,7 +77,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ && (processPresentation.turnStarted || processPresentation.turnClosed) const processMember = routedNode !== undefined && processWindowReady - && !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind) + && !isAtOrOutside(routedNode, 'turn') && routedNode.anchorSeq >= processSpec.processStartSeq && (liveProcess || processSpec.answerAnchorSeq === null || routedNode.anchorSeq < processSpec.answerAnchorSeq || (groupPart === 'reasoning' && routedNode.kind === 'assistant-step' && routedNode.data.step === processSpec.answerStep)) @@ -127,16 +128,14 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ ...groupPart === undefined ? {} : { groupPart }, cwd, openFile, - openSkill, inspectCall, forkAt, loadImage, - renderMessageImages, fileMentions, turnProcess, }, [ - node, groupPart, cwd, openFile, openSkill, inspectCall, forkAt, - loadImage, renderMessageImages, fileMentions, turnProcess, + node, groupPart, cwd, openFile, inspectCall, forkAt, + loadImage, fileMentions, turnProcess, ]) if (routedNode === undefined || owner === null) return null // Runtime dispatch owns the correlation: every Node's discriminant is the 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 5ff669c1fb..6696330f0a 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -101,6 +101,11 @@ transition: margin-top 160ms cubic-bezier(0.4, 0, 0.2, 1); } +.column[data-input-fold] > :not([data-chat-motion]), +.column[data-input-fold] > :global([data-slot="conversation.chat.flow"]) > :not([data-chat-motion]) { + transition: none; +} + @media (prefers-reduced-motion: reduce) { .column[data-chat-motion] > :not([data-chat-motion]), .column[data-chat-motion] > :global([data-slot="conversation.chat.flow"]) > :not([data-chat-motion]) { diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 4d3d3fda52..1e0741f31f 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -10,6 +10,7 @@ import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/ import { Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' +import { isAtOrOutside } from '../contract/group-tree.ts' import type { ChatFlowHookContext, ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' import { TurnNavigator } from './TurnNavigator.tsx' @@ -37,13 +38,12 @@ function observedInputs( let lastInputTurn: number | undefined for (const key of order) { const node = nodes.get(key) - if (node === undefined || (node.kind !== 'user' && node.kind !== 'steering' && node.kind !== 'turn-trigger')) continue + if (node === undefined || !isAtOrOutside(node, 'input')) continue if (node.location.kind === 'turn' || node.location.kind === 'step') lastInputTurn = node.location.turn.turn - if (node.kind === 'turn-trigger') continue - const source = (node.data as { readonly source?: unknown }).source as - | { readonly kind?: unknown; readonly rpcId?: unknown } - | undefined - if (source?.kind === 'user' && typeof source.rpcId === 'string') observed.add(source.rpcId) + const data: unknown = node.data + const source = typeof data === 'object' && data !== null && 'source' in data ? data.source : undefined + if (typeof source === 'object' && source !== null && 'kind' in source && source.kind === 'user' + && 'rpcId' in source && typeof source.rpcId === 'string') observed.add(source.rpcId) } return { rpcIds: observed, lastInputTurn } } @@ -59,7 +59,7 @@ const MOTION_TAIL_MS = 800 */ export function ChatView({ useSession, useChat, useConversation, useSessions, renderSlot, - sessionId, openFile, openSkill, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions, + sessionId, openFile, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions, usePresentation, useProjection, t, }: ChatViewSlotProps) { const order = useChat(s => s.order) @@ -68,6 +68,7 @@ export function ChatView({ ?? order.map(key => ({ kind: 'node', key: key as NodeKey })), [groupedEntries, order]) const nodeStore = useChat(s => s.nodes) const deferCompletedTurns = usePresentation(policy => policy.collapseTiming === 'next-input') + const transcriptView = usePresentation(policy => policy.mode) // The rail's items are accumulated in the Chat snapshot, so this selector is // both the data and its change signal: the array identity moves only when a // Turn enters, leaves, or changes its preview. @@ -189,6 +190,7 @@ export function ChatView({ steeringId, submissionId: visibleSubmissions.at(-1)?.requestId ?? null, loadedTurns: turnNavigationItems, + transcriptView, }) const flowContext = useMemo(() => ({ motion: scroll.motion }), [scroll.motion]) @@ -221,17 +223,17 @@ export function ChatView({
)} - + {renderSlot('conversation.chat.flow', { entries, pendingInputs, lastInputTurn, deferCollapse, - cwd, openFile: requestOpenFile, openSkill, inspectCall, forkAt, loadImage, fileMentions, + cwd, openFile: requestOpenFile, inspectCall, forkAt, loadImage, fileMentions, }, { hookContext: flowContext })} {/* No pending placeholders: questions (ui-user-questions) and approvals (ApprovalPanel) both take over the composer, so a flow card would double-render the same wait. */}
- {/* Room added when rows above the reader close, given back only as content grows (use-chat-viewport). */} + {/* Fold room is reclaimed by viewport scroll, content-growth, and display-mode policies. */}
diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.module.css b/packages/client/ui-chat/src/client/chat/MessageItem.module.css index 5d19483335..3bacafab47 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -326,7 +326,7 @@ min-height: 64px; padding: 8px 12px; border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12)); - border-radius: var(--dsw-radius-xl); + border-radius: var(--dsw-radius-lg); background: var(--dsw-specific-input-major, transparent); box-sizing: border-box; } diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 9005150af0..a2d394c106 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -1,28 +1,29 @@ -import { Fragment, memo, useEffect, useMemo, useState } from 'react' +import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' -import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { fileExtension, FileTypeIcon, fileSizeText, JsonBlock, projectUserText, StateDot, TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' +import type { PropsRenderFactories } from '@deepseek-ai/dsh-client-ui-slots' +import type { UserTextFacts } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { JsonBlock, StateDot, TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives' +import type { + ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, UserContentAttachment, UserContentData, UserContentInput, +} from '../contract/slots.ts' import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' import { CompactionItem } from './CompactionItem.tsx' import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' +const EMPTY_TEXT_FACTS: Readonly> = {} + type UserImage = Extract type UserFile = Extract -type PresentedAttachment = - | { readonly type: 'image'; readonly image: MessageImageSource } - | { readonly type: 'file'; readonly file: UserFile['attachment'] } - function contentParts(content: readonly unknown[]): { text: string - attachments: PresentedAttachment[] + attachments: UserContentAttachment[] rest: unknown[] } { const texts: string[] = [] - const attachments: PresentedAttachment[] = [] + const attachments: UserContentAttachment[] = [] const rest: unknown[] = [] for (const block of content) { const b = block as { type?: string; text?: string; attachment?: unknown } @@ -160,11 +161,11 @@ function TurnMaxTokensItem({ t }: { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], skillNames = [], - previewAttachments, references, t, + content, loadImage, renderFactorySlot, actions, pending = false, echo = false, referenceLabels, skillNames, + previewAttachments, facts: inputFacts = EMPTY_TEXT_FACTS, interactive = false, }: { content: readonly unknown[] - renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] + renderFactorySlot: PropsRenderFactories['renderFactorySlot'] /** Optional IconActions (or similar) below the bubble; receives the joined text. */ actions?: (text: string) => ReactNode /** Whether this is the Host-authoritative pre-admission steering projection. */ @@ -176,59 +177,35 @@ function UserStyleBubble({ /** Skill names the step's `skill-invocation` injections loaded for this message. */ skillNames?: readonly string[] /** Local submission-echo attachments replacing the content-derived attachment sequence. */ - previewAttachments?: readonly PresentedAttachment[] - references?: Pick - t: ChatViewSlotProps['t'] -}): ReactNode { - const { text, attachments: contentAttachments, rest } = contentParts(content) - const attachments = previewAttachments ?? contentAttachments - const compactImages = attachments.length > 1 - const truncated = (total: number): string => t('json.truncated', { total }) - const showBubble = text !== '' || rest.length > 0 + previewAttachments?: readonly UserContentAttachment[] + /** Inputs used by text transforms, separate from row and rendering props. */ + facts?: Readonly> +} & Pick & Pick): ReactNode { + const parts = useMemo(() => contentParts(content), [content]) + const prepared = useMemo(() => ({ + text: parts.text, + attachments: previewAttachments ?? parts.attachments, + extraBlocks: parts.rest, + ...(referenceLabels === undefined ? {} : { referenceLabels }), + }), [parts, previewAttachments, referenceLabels]) + const facts = useMemo>>(() => ({ + ...inputFacts, + ...(referenceLabels === undefined ? {} : { sessionLabels: referenceLabels }), + ...(skillNames === undefined ? {} : { skillNames }), + }), [inputFacts, referenceLabels, skillNames]) return (
-
- {attachments.length > 0 && ( -
- {attachments.map((attachment, index) => attachment.type === 'image' - ? ( - - {renderMessageImages({ - images: [attachment.image], - align: 'end', - compact: compactImages, - })} - - ) - : ( - - - - {attachment.file.name} - - {[fileExtension(attachment.file.name).toUpperCase().slice(0, 8), fileSizeText(attachment.file.bytes)] - .filter(Boolean).join(' ')} - - - - ))} -
- )} - {showBubble &&
- {projectUserText(text, referenceLabels, skillNames, 'skill', references)} - {rest.map((block, i) => )} -
} - {referenceLabels.length > 0 && ( -
- {t('message.referenceSummary', { labels: referenceLabels.join(t('message.referenceSeparator')) })} -
- )} -
- {actions?.(text)} + {renderFactorySlot('conversation.chat.user.content', { + content: prepared, + loadImage, + facts, + interactive, + })} + {actions?.(prepared.text)}
) } @@ -236,20 +213,20 @@ function UserStyleBubble({ /** * Render one Host-authoritative pending steering item with the same visual * language as its eventual durable transcript node. - * @param props - Pending message content and conversation translator. + * @param props - pending content, authorized image loader, and Factory/locale seats. * @returns the pending steering bubble. */ -export function PendingSteeringBubble({ content, renderMessageImages, t }: { +export function PendingSteeringBubble({ content, loadImage, renderFactorySlot, t }: { content: readonly unknown[] - renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] + renderFactorySlot: PropsRenderFactories['renderFactorySlot'] t: ChatViewSlotProps['t'] -}): ReactNode { +} & Pick): ReactNode { return ( ( ): ReactNode { const content = useMemo( () => (submission.text === '' ? [] : [{ type: 'text', text: submission.text }]), [submission.text], ) - const previewAttachments = useMemo( + const previewAttachments = useMemo( () => submission.attachments.map(attachment => attachment.type === 'image' ? { type: 'image', @@ -299,10 +276,10 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }: ( ) { const data = node.data return ( ( ) { + const { text, attachments, extraBlocks, referenceLabels = EMPTY_LABELS } = content + const compactImages = attachments.length > 1 + const truncated = (total: number): string => t('json.truncated', { total }) + const showBubble = text !== '' || extraBlocks.length > 0 + return ( +
+ {attachments.length > 0 && ( +
+ {attachments.map((attachment, index) => attachment.type === 'image' + ? ( + + {renderSlot('conversation.chat.user.images', { + images: [attachment.image], loadImage, align: 'end', compact: compactImages, + })} + + ) + : ( + + + + {attachment.file.name} + + {[fileExtension(attachment.file.name).toUpperCase().slice(0, 8), fileSizeText(attachment.file.bytes)] + .filter(Boolean).join(' ')} + + + + ))} +
+ )} + {showBubble &&
+ {renderFactorySlot('conversation.user.text', { text, facts, interactive, ellipsis })} + {extraBlocks.map((block, index) => ( + + ))} +
} + {referenceLabels.length > 0 && ( +
+ {t('message.referenceSummary', { labels: referenceLabels.join(t('message.referenceSeparator')) })} +
+ )} +
+ ) +} diff --git a/packages/client/ui-chat/src/client/chat/flow-motion.ts b/packages/client/ui-chat/src/client/chat/flow-motion.ts index 2804a93b77..9705f1f22a 100644 --- a/packages/client/ui-chat/src/client/chat/flow-motion.ts +++ b/packages/client/ui-chat/src/client/chat/flow-motion.ts @@ -1,7 +1,7 @@ /** - * Fade-then-close transitions owned by one Chat viewport. Rows fade before their boxes close; - * the owner reserves removed height and holds scrolling until that viewport's folds finish. - * Motion is opt-in under `[data-chat-motion]`, respects reduced motion, and clears temporary styles. + * Fold transitions owned by one Chat viewport. Input folds coordinate box sizes and scrolling on + * one frame clock; other folds use CSS transitions. Motion is opt-in under `[data-chat-motion]`, + * respects reduced motion, and clears temporary styles. */ import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react' import { useSearchableHidden, type HiddenTransition } from './searchable-hidden.ts' @@ -24,12 +24,53 @@ export interface FlowMotionRows { cancelMotion(element: HTMLElement): void } +/** Geometry captured before one input-owned fold changes its boxes. */ +export interface InputFoldBox { + readonly element: HTMLElement + readonly height: number + readonly marginTop: number + readonly closing: boolean +} + +/** The viewport follows the same progress that changes the folded boxes. */ +export interface InputFoldDriver { + /** + * Capture geometry before the frame clock changes the boxes. + * @param boxes - non-nested boxes whose layout changes on this clock. + */ + begin(boxes: readonly InputFoldBox[]): void + /** + * Reserve scroll range before box writes; omit when the driver needs no pre-write compensation. + * @param progress - shared eased progress from zero to one. + * @param cancelled - automatic following has stopped; reader-position compensation still applies. + */ + prepare?(progress: number, cancelled: boolean): void + /** + * Apply viewport geometry after box writes, even when automatic following is cancelled. + * @param progress - shared eased progress from zero to one. + * @param cancelled - preserve the reader's position rather than applying the automatic target. + * @returns actual scrollTop displacement from the batch start, for closing-content visual compensation. + */ + frame(progress: number, cancelled: boolean): number + /** + * Release temporary geometry after the last frame or cancellation. + * @param cancelled - clean up without reacquiring follow ownership or committing an obsolete landing. + */ + finish(cancelled: boolean): void +} + /** Animation lifetime and fold coordination for one viewport. */ export interface FlowMotion extends FlowMotionRows { /** @returns whether a row owned by this viewport is closing. */ foldActive(): boolean /** @param listener - callback after the last fold. @returns cancellation for a pending callback. */ onFoldIdle(listener: () => void): () => void + /** @param driver - one input-owned viewport motion. @returns whether an active fold was adopted. */ + followInput(driver: InputFoldDriver): boolean + /** Stop input-owned scrolling while letting its closing rows finish. */ + cancelInput(): void + /** @returns whether a coordinated fold currently owns layout and scrolling. */ + inputActive(): boolean /** Cancel owned animations and pending work when the viewport detaches. */ clear(): void } @@ -66,6 +107,101 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: const pendingReserve = new Set() let pendingGrowth = 0 let reserveScheduled = false + const boxes = new Map void; finish: () => void }>() + let inputDriver: InputFoldDriver | null = null + let inputFrame: number | null = null + let inputBatch = false + let clearEpoch = 0 + let inputCleanup: (() => void) | null = null + let activeDriver: InputFoldDriver | null = null + let cancelEmptyBatch: (() => void) | null = null + + function cancelInput(): void { inputDriver = null } + + function followInput(driver: InputFoldDriver): boolean { + if (folding === 0) return false + if (inputBatch) { + inputDriver = activeDriver + return true + } + inputDriver = driver + scheduleReserve() + return true + } + + function runInputBatch(): void { + const driver = inputDriver + if (driver === null || inputBatch) return + const owned = [...boxes.values()] + const batch = owned.filter(box => box.element.parentElement?.closest('[data-chat-motion="collapse"]') === null) + .sort((left, right) => left.element.compareDocumentPosition(right.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1) + if (!batch.some(box => box.closing)) return + inputBatch = true + activeDriver = driver + driver.begin(batch) + inputCleanup = () => { driver.finish(true) } + for (const box of owned) box.take() + for (const box of batch) { + box.element.style.transition = 'none' + box.element.style.height = `${box.closing ? box.height : 0}px` + box.element.style.marginTop = `${box.closing ? box.marginTop : 0}px` + box.element.style.opacity = box.closing ? '1' : '0' + } + const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches + let started: number | undefined + let displacement = 0 + cancelEmptyBatch = () => { + if (batch.some(box => box.closing && boxes.get(box.element) === box)) return + if (inputFrame !== null) cancelAnimationFrame(inputFrame) + inputFrame = null + inputBatch = false + activeDriver = null + inputDriver = null + inputCleanup = null + cancelEmptyBatch = null + for (const box of owned) if (boxes.get(box.element) === box) box.finish() + driver.finish(true) + } + const tick = (now: number): void => { + inputFrame = null + started ??= now + const elapsed = reduced ? CLOSE_DELAY_MS + CLOSE_MS : now - started + const linear = Math.min(1, Math.max(0, (elapsed - CLOSE_DELAY_MS) / CLOSE_MS)) + const progress = linear * linear * (3 - 2 * linear) + driver.prepare?.(progress, inputDriver !== driver) + for (const box of batch) { + if (boxes.get(box.element) !== box) continue + const remaining = box.closing ? 1 - progress : progress + box.element.style.height = `${box.height * remaining}px` + box.element.style.marginTop = `${box.marginTop * remaining}px` + box.element.style.opacity = box.closing ? String(Math.max(0, 1 - elapsed / FADE_OUT_MS)) : String(progress) + } + displacement = driver.frame(progress, inputDriver !== driver) + let removedBefore = 0 + for (const box of batch) { + if (boxes.get(box.element) !== box) continue + if (box.closing) { + // Transforming the closing box changes its pixels, not its flow contribution. + box.element.style.translate = `0 ${displacement + removedBefore}px` + removedBefore += (box.height + box.marginTop) * progress + } else removedBefore -= (box.height + box.marginTop) * progress + } + if (linear < 1) { + inputFrame = requestAnimationFrame(tick) + return + } + for (const box of owned) box.finish() + inputBatch = false + activeDriver = null + inputCleanup = null + cancelEmptyBatch = null + const cancelled = inputDriver !== driver + if (!cancelled) inputDriver = null + driver.finish(cancelled) + } + if (reduced) tick(0) + else inputFrame = requestAnimationFrame(tick) + } function foldActive(): boolean { return folding > 0 } @@ -77,6 +213,18 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: function flushReserve(): void { reserveScheduled = false + if (inputDriver !== null && !inputBatch) { + pendingReserve.clear() + pendingGrowth = 0 + runInputBatch() + return + } + if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) { + pendingReserve.clear() + pendingGrowth = 0 + for (const box of [...boxes.values()]) box.finish() + return + } let total = -pendingGrowth pendingGrowth = 0 for (const element of pendingReserve) { @@ -92,7 +240,8 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: if (reserveScheduled) return reserveScheduled = true // After the whole commit's collapses and reveals have started, before any box changes size. - queueMicrotask(flushReserve) + const epoch = clearEpoch + queueMicrotask(() => { if (epoch === clearEpoch) flushReserve() }) } function foldStarted(): void { @@ -113,18 +262,21 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: element.removeEventListener('transitionend', onEnd) clearTimeout(timer) running.delete(element) + boxes.delete(element) const { style } = element style.height = '' style.marginTop = '' style.opacity = '' style.overflow = '' style.transition = '' + style.translate = '' delete element.dataset.chatMotion } function cancelMotion(element: HTMLElement): void { pendingReserve.delete(element) running.get(element)?.() + cancelEmptyBatch?.() } function collapse(element: HTMLElement, done: () => void): void { @@ -168,6 +320,8 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: } element.addEventListener('transitionend', onEnd) running.set(element, cancel) + boxes.set(element, { element, height, marginTop, closing: true, finish, + take: () => { element.removeEventListener('transitionend', onEnd); clearTimeout(timer) } }) } function reveal(element: HTMLElement): void { @@ -200,16 +354,28 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: const timer = setTimeout(finish, CLOSE_DELAY_MS + CLOSE_MS + SETTLE_SLACK_MS) element.addEventListener('transitionend', onEnd) running.set(element, finish) + boxes.set(element, { element, height, marginTop, closing: false, finish, + take: () => { element.removeEventListener('transitionend', onEnd); clearTimeout(timer) } }) } function clear(): void { + clearEpoch++ + reserveScheduled = false + inputDriver = null + activeDriver = null + inputBatch = false + if (inputFrame !== null) cancelAnimationFrame(inputFrame) + inputFrame = null + inputCleanup?.() + inputCleanup = null + cancelEmptyBatch = null foldIdleListeners.clear() pendingReserve.clear() pendingGrowth = 0 for (const cancel of [...running.values()]) cancel() } - return { collapse, reveal, cancelMotion, foldActive, onFoldIdle, clear } + return { collapse, reveal, cancelMotion, foldActive, onFoldIdle, followInput, cancelInput, inputActive: () => inputBatch, clear } } function useFlowTransition(motion: FlowMotionRows | undefined, grow: boolean): HiddenTransition | undefined { @@ -219,11 +385,12 @@ function useFlowTransition(motion: FlowMotionRows | undefined, grow: boolean): H current.current = null }, [motion]) const transition = useCallback((element, hidden, commit) => { - const animate = current.current === element && motionEnabled(element) + const mounted = current.current === element + const animate = mounted && motionEnabled(element) current.current = element if (hidden) { if (hiddenOrCollapsing(element)) return - if (animate && motion !== undefined) motion.collapse(element, commit) + if (mounted && element.closest(MOTION_SCOPE) !== null && motion !== undefined) motion.collapse(element, commit) else commit() return } diff --git a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts index a5d4f9bc7a..7e58223287 100644 --- a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts @@ -51,7 +51,10 @@ export function registerChatNodeRenderers( name: 'conversation.chat.node', key: 'assistant-step', locale: NS, - children: { 'conversation.chat.reasoning.body': { kind: 'single', scope: 'session' } }, + children: { + 'conversation.chat.reasoning.body': { kind: 'single', scope: 'session' }, + 'conversation.chat.message.images': { kind: 'single', scope: 'session' }, + }, inject: () => ({ hooks: { presentation } }), }, AssistantNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ diff --git a/packages/client/ui-chat/src/client/chat/use-chat-reading.ts b/packages/client/ui-chat/src/client/chat/use-chat-reading.ts index 85561c8fb8..3d3c6ae048 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-reading.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-reading.ts @@ -87,6 +87,17 @@ export class ChatReading { this.commit(landing, true, this.viewport.latestTurn) } + /** + * Follow submitted input on the active fold's animation clock. + * @returns whether this input joined a closing batch instead of starting another scroll animation. + */ + followInputFold(): boolean { + this.cancelPending() + return this.viewport.followInputFold(this.follow, (landing) => { + this.commit(landing, true, this.viewport.latestTurn) + }) + } + /** Stop a native follow animation where it is; the next sampled position decides follow intent. */ interruptFollow(): void { this.viewport.interruptFollow(this.follow) diff --git a/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts b/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts index b695e7d0fe..8f867de148 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts @@ -1,5 +1,6 @@ /** Composes viewport operations, reading policy, and history navigation for Chat. */ import { useCallback, useLayoutEffect, useMemo, useRef, type RefObject } from 'react' +import type { TranscriptViewMode } from '../../chat-settings.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' import type { ChatViewSlotProps } from '../contract/slots.ts' import { useChatNavigation, type ChatNavigation, type ChatNavigationInput } from './use-chat-navigation.ts' @@ -19,6 +20,8 @@ export interface ChatScrollInput extends ChatNavigationInput { readonly running: boolean /** Enable the browser-local delayed-fold experiment and its input reveal animation. */ readonly deferCompletedTurns: boolean + /** A changed work-details preference resets fold reservations after the layout settles. */ + readonly transcriptView: TranscriptViewMode readonly loadedTurns: ReturnType } @@ -33,7 +36,8 @@ interface ChatScrollState extends ChatReadingState { } /** - * Coordinate scroll policy after Chat content commits. + * Coordinate scroll policy after Chat content commits. Initial history must be ready + * before automatic scroll or resize handling can initialize reading state and fold motion. * New submitted input supersedes pending reader sampling. * @param input - current Chat content, scroll memory, and history operations. * @returns element refs, visible reading state, and navigation callbacks. @@ -41,7 +45,7 @@ interface ChatScrollState extends ChatReadingState { export function useChatScroll(input: ChatScrollInput): ChatScrollState { const { ready, order, firstSeq, lastKey, lastIsUser, steeringId, submissionId, running, deferCompletedTurns, - loadedTurns, chatScroll, hasMore, loadingOlder, loadOlder, loadThrough, + loadedTurns, chatScroll, hasMore, loadingOlder, loadOlder, loadThrough, transcriptView, } = input const { viewport, listRef, columnRef } = useChatViewport() const { reading, state } = useChatReading(viewport, chatScroll, loadedTurns.at(-1)?.turn ?? null) @@ -60,6 +64,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { cancelFollow.current = null }, []) const cancelPendingFollow = useCallback(() => { + viewport.motion.cancelInput() clearPendingFollow() // Retain a cancelled wait until this fold ends, so resize cannot reacquire follow ownership. if (viewport.motion.foldActive()) { @@ -67,7 +72,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { } }, [clearPendingFollow, viewport]) const followAfterFold = useCallback(() => { - if (cancelFollow.current !== null) return + if (cancelFollow.current !== null || viewport.motion.inputActive()) return reading.interruptFollow() cancelFollow.current = viewport.motion.onFoldIdle(() => { cancelFollow.current = null @@ -78,6 +83,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { const processContent = useCallback(() => { const current = content.current.input + if (!current.ready && !content.current.opened) return const previous = content.current.applied const ownInput = (current.lastIsUser && current.lastKey !== previous?.lastKey) || (current.steeringId !== null && current.steeringId !== previous?.steeringId @@ -95,8 +101,8 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { if (ownInput) { clearPendingFollow() navigation.cancel() - if (current.deferCompletedTurns && viewport.motion.foldActive()) followAfterFold() - else reading.followTail(current.deferCompletedTurns ? 'smooth' : 'instant') + if (current.deferCompletedTurns && reading.followInputFold()) return + reading.followTail(current.deferCompletedTurns ? 'smooth' : 'instant') return } if (navigation.contentCommitted()) { @@ -117,6 +123,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { useLayoutEffect(() => { const disconnectViewport = viewport.connect({ scroll: (event) => { + if (!content.current.opened) return if (!event.movedByReader && viewport.motion.foldActive()) return reading.onScroll(event) }, @@ -127,6 +134,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { interact: () => { navigation.cancel() }, intent: () => { cancelPendingFollow(); reading.interruptFollow() }, resize: () => { + if (!content.current.opened) return if (navigation.contentCommitted()) { navigation.reconcile() return @@ -154,7 +162,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { const previous = content.current.input content.current.input = { ready, order, lastKey, lastIsUser, steeringId, submissionId, running, loadedTurns, chatScroll, - deferCompletedTurns, ...navigationInput, + deferCompletedTurns, transcriptView, ...navigationInput, } if (!deferCompletedTurns) { cancelPendingFollow() @@ -165,12 +173,24 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { if (layoutChanged) viewport.invalidate() viewport.reclaimBelow() processContent() - if (layoutChanged) reading.refreshActiveTurn() + if (layoutChanged && content.current.opened) reading.refreshActiveTurn() }, [ viewport, reading, processContent, navigationInput, ready, order, lastKey, lastIsUser, - steeringId, submissionId, running, loadedTurns, chatScroll, deferCompletedTurns, cancelPendingFollow, + steeringId, submissionId, running, loadedTurns, chatScroll, deferCompletedTurns, transcriptView, cancelPendingFollow, ]) + const previousView = useRef(transcriptView) + useLayoutEffect(() => { + if (previousView.current === transcriptView) return + previousView.current = transcriptView + cancelPendingFollow() + reading.interruptFollow() + return viewport.motion.onFoldIdle(() => { + viewport.resetBelow() + if (content.current.opened) reading.onResize() + }) + }, [transcriptView, viewport, reading, cancelPendingFollow]) + const returnToBottom = useCallback(() => { cancelPendingFollow() navigation.cancel() diff --git a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts index 6a895fd527..ad6de91901 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts @@ -160,6 +160,113 @@ export class ChatViewport { this.setSpacer(this.spacerHeight() + px) } + /** + * Join an input-caused fold and its tail movement on the fold's frame clock. + * @param follow - the existing reader's follow controller. + * @param settled - commits follow ownership after the final geometry is installed. + * @returns whether a closing batch owns this input. + */ + followInputFold(follow: ScrollFollow, settled: (landing: ViewportLanding) => void): boolean { + const elements = this.elements + if (elements === null) return false + this.interruptFollow(follow) + let start = 0 + let target = 0 + let removed = 0 + let initialHeight = 0 + let originalRoom = 0 + let finalRoom = 0 + let growth = 0 + let base = 0 + let previousColumnHeight = 0 + let previousProgress = 0 + let segmentProgress = 0 + let segmentTop = 0 + return this.motion.followInput({ + begin: (boxes) => { + const viewport = elements.scroller.getBoundingClientRect() + const metrics = scrollMetrics(elements.scroller) + start = metrics.top + originalRoom = this.spacerHeight() + initialHeight = Math.max(elements.column.offsetHeight, elements.scroller.scrollHeight - originalRoom) + let above = 0 + for (const box of boxes) { + const amount = box.height + box.marginTop + if (box.closing) { + removed += amount + const top = box.element.getBoundingClientRect().top - box.marginTop + above += Math.max(0, Math.min(amount, viewport.top - top)) + } else growth += amount + } + const finalHeight = initialHeight - removed + growth + base = Math.max(0, start - above) + segmentTop = start + previousColumnHeight = elements.column.offsetHeight + target = Math.max(base, Math.max(0, finalHeight - metrics.height)) + finalRoom = target === 0 ? 0 : Math.max(0, target + metrics.height - finalHeight) + elements.column.dataset.inputFold = '' + // This reserve only prevents an intermediate clamp; the final frame installs the exact deficit. + this.setSpacer(Math.max(originalRoom, finalRoom, removed)) + follow.setFollowing(true) + }, + prepare: (progress, cancelled) => { + if (this.elements !== elements) return + const added = elements.column.offsetHeight - previousColumnHeight + if (added !== 0) { + initialHeight += added + const finalHeight = initialHeight - removed + growth + target = Math.max(base, finalHeight - elements.scroller.clientHeight, 0) + finalRoom = target === 0 ? 0 : Math.max(0, target + elements.scroller.clientHeight - finalHeight) + segmentTop = elements.scroller.scrollTop + segmentProgress = previousProgress + } + const remaining = segmentProgress === 1 ? 1 : (progress - segmentProgress) / (1 - segmentProgress) + const top = cancelled ? elements.scroller.scrollTop : segmentTop + (target - segmentTop) * remaining + const currentHeight = initialHeight + (growth - removed) * progress + const room = Math.max(cancelled ? 0 : finalRoom, top + elements.scroller.clientHeight - currentHeight) + // Growing the room before shrinking boxes avoids a native clamp between writes. + this.setSpacer(Math.max(this.spacerHeight(), room)) + }, + frame: (progress, cancelled) => { + if (this.elements !== elements) return 0 + const remaining = segmentProgress === 1 ? 1 : (progress - segmentProgress) / (1 - segmentProgress) + const top = cancelled ? elements.scroller.scrollTop : segmentTop + (target - segmentTop) * remaining + const currentHeight = initialHeight + (growth - removed) * progress + const room = Math.max(cancelled ? 0 : finalRoom, top + elements.scroller.clientHeight - currentHeight) + elements.scroller.scrollTop = top + this.setSpacer(room) + if (!cancelled) this.observation = { top: elements.scroller.scrollTop, landing: null } + previousColumnHeight = elements.column.offsetHeight + previousProgress = progress + return elements.scroller.scrollTop - start + }, + finish: (cancelled) => { + if (this.elements !== elements) return + // Commit the final sibling margins while their input-fold transition override still applies. + void elements.column.offsetHeight + delete elements.column.dataset.inputFold + if (cancelled) { + const metrics = scrollMetrics(elements.scroller) + const height = elements.scroller.scrollHeight - this.spacerHeight() + this.setSpacer(metrics.top === 0 ? 0 : Math.max(0, metrics.top + metrics.height - height)) + this.invalidate() + return + } + this.setSpacer(finalRoom) + const metrics = scrollMetrics(elements.scroller) + const landing = { metrics, position: null, turn: this.latestTurn } + this.observation = { top: metrics.top, landing } + settled(landing) + }, + }) + } + + /** Clear fold reservations after a display-mode change, allowing the scroll range to shrink. */ + resetBelow(): void { + this.setSpacer(0) + this.invalidate() + } + /** * Give reserved room back after acknowledged scrolling or content growth without clamping the reader. * Holds still during folds and unsampled movement; following includes the room that remains. diff --git a/packages/client/ui-chat/src/client/contract/chat-nodes.ts b/packages/client/ui-chat/src/client/contract/chat-nodes.ts index cade646b1c..ea65a14834 100644 --- a/packages/client/ui-chat/src/client/contract/chat-nodes.ts +++ b/packages/client/ui-chat/src/client/contract/chat-nodes.ts @@ -3,6 +3,7 @@ import type { ConversationLocation, ConversationViewNode, ModelRetryNode, RunningToolCall, ToolCallBlock, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatGroupTree } from './group-tree.ts' /** Final Chat render unit produced by a Chat business Definition. */ export interface ChatConversationViewNode extends ConversationViewNode { @@ -10,6 +11,13 @@ export interface ChatConversationViewNode extends ConversationViewNode { readonly anchorSeq: number readonly location: ConversationLocation readonly visibility: 'visible' | 'hidden' + /** + * Group-tree position, visible or hidden; absence joins the surrounding step + * run. An `exchange` Node's Turn and every later Turn before the next opening + * Turn form one exchange; the latest loaded exchange starts at the last loaded + * opening Turn, or at the first loaded Turn when none is loaded. + */ + readonly groupTree?: ChatGroupTree | undefined } /** Merge-extensible payload registry keyed by final Chat renderer kind. */ diff --git a/packages/client/ui-chat/src/client/contract/group-tree.ts b/packages/client/ui-chat/src/client/contract/group-tree.ts new file mode 100644 index 0000000000..961a0ebb8f --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/group-tree.ts @@ -0,0 +1,37 @@ +import type { ChatConversationViewNode } from './chat-nodes.ts' + +/** + * Chat group-tree positions from the outermost inward; a Node at a position + * also has the effect of every inner position. + * - `exchange`: the Node's Turn opens an exchange, the run of Turns that lasts + * until the next opening Turn. + * - `input`: the Node is an input of its Turn. An input before the Turn's + * process is the Turn's opening input; a later one is interleaved and keeps + * the whole Turn open. + * - `turn`: the Node stays in its Turn outside the Turn's process disclosure. + * - `process`: the Node stays in the process disclosure outside step runs. + * - absent: the Node joins the step run around it. + * + * The Definition that builds a Node chooses its position, and step runs, the + * process disclosure, and Turn inputs are selected by position. An + * `assistant-step` Node places its rows without one: its reasoning row joins + * the step run and its reply row stands outside it. + * + * Whole-Turn folding also exempts the finalized answer's reply. That exemption + * comes from the Turn's process specification (`answerAnchorSeq` and + * `answerStep`), not from a position, because which step answers changes as + * later steps arrive. + */ +export type ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process' + +const DEPTH: Readonly> = { exchange: 0, input: 1, turn: 2, process: 3 } + +/** + * Test whether a Node stands at a group-tree position or outside it. + * @param node - Chat Node. + * @param position - group-tree position to compare against. + * @returns whether the Node's position is `position` or an outer one. + */ +export function isAtOrOutside(node: Pick, position: ChatGroupTree): boolean { + return node.groupTree !== undefined && DEPTH[node.groupTree] <= DEPTH[position] +} diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 52d7786e20..9bce65619b 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -3,15 +3,16 @@ import type { RefObject } from 'react' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' +import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { InboxState } from '@deepseek-ai/dsh-agent/types' import type { CommandNode, CompactionSummaryNode, ConversationLocationDataStore, ConversationTurnDataMap, ConversationGroupData, GroupSnapshot, - MessageImageLoader, MessageImagesOwnerProps, RenderEntry, RenderMessageImages, TurnLocation, + MessageImageLoader, MessageImageSource, MessageImagesOwnerProps, RenderEntry, TurnLocation, UserTextFacts, UserTextInput, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { HostObservable, InjectFace, KeyedSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, - PropsStore, SlotHookFactory, SnapshotSelectorHook, + PropsRenderFactories, PropsStore, SlotHookFactory, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { MarkdownFileMentions, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' @@ -209,24 +210,39 @@ export interface ChatNodeOwnerProps { /** Renderer-owned Node portion selected by the grouping Definition. */ groupPart?: string cwd?: string | undefined - /** Open the current source file of a skill referenced by a sent message. */ - openSkill: (name: string) => void openFile: (path: string, options?: OpenFileOptions) => void inspectCall: ((callId: ToolCallId) => void) | undefined forkAt: (seq: number) => void - /** - * Session-authorized image loader, down-threaded from the Chat view so a - * chat-node renderer can render the attachment presentation slot directly - * with only the durable references plus this loader, instead of receiving a - * rendering closure. - */ + /** Session-authorized image loader supplied by the Chat view. */ loadImage: MessageImageLoader - renderMessageImages: RenderMessageImages fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined /** Turn-process state when this Node belongs to a projected Turn. */ turnProcess?: TurnProcessOwnerProps | undefined } +/** One attachment in the original user-message display order. */ +export type UserContentAttachment = + | { readonly type: 'image'; readonly image: MessageImageSource } + | { readonly type: 'file'; readonly file: FileAttachmentRef } + +/** Prepared user contents without row identity, admission state, time, or actions. */ +export interface UserContentData { + readonly text: string + readonly attachments: readonly UserContentAttachment[] + /** Blocks not represented by text or attachment presentation. */ + readonly extraBlocks: readonly unknown[] + /** Labels displayed in the caller-provided reference summary. */ + readonly referenceLabels?: readonly string[] +} + +/** Public content rendering input; reference renderers own their injected actions. */ +export interface UserContentInput extends Pick, + Pick { + readonly content: UserContentData + /** Transform inputs passed unchanged to UserText, separate from summary and rendering props. */ + readonly facts: Readonly> +} + /** Shared presentation state for one Turn-process answer generation. */ export interface TurnProcessOwnerProps { /** Process content eligible to share one Turn-level disclosure. */ @@ -247,7 +263,7 @@ export interface PresentationInjected { /** Full props of one keyed Chat renderer. */ export type ChatNodeViewProps = - PropsRuntime<'conversation.chat.node', Kind> & PropsLocale<'chat'> + PropsRuntime<'conversation.chat.node', Kind> & PropsLocale<'chat'> & PropsRenderFactories /** Command-row owner share. */ export interface CommandRowOwnerProps { @@ -292,8 +308,6 @@ export interface ChatViewInjected { /** Resolve one optional group without subscribing the root View to its data. */ chatGroup: (key: string) => ObservableSnapshot> | undefined> | undefined } - /** Open the current source file of a skill referenced by a sent message. */ - openSkill: (name: string) => void /** Open one HTTP(S) message link at the selected destination, using an external tab if Sidebar Browser is unavailable. */ openExternalLink: (url: string) => void openFile: (path: string, options?: OpenFileOptions) => Promise @@ -314,7 +328,7 @@ export type ChatFlowDataInjected = Pick { + 'cwd' | 'openFile' | 'inspectCall' | 'forkAt' | 'loadImage' | 'fileMentions'> { readonly entries: readonly RenderEntry[] readonly pendingInputs: readonly (PendingSubmission | InboxState['next-step'][number])[] readonly lastInputTurn: number | undefined @@ -324,7 +338,8 @@ export interface ChatFlowOwnerProps extends Pick - & PropsRenderSlots<'conversation.chat.node' | 'conversation.message.images'> + & PropsRenderSlots<'conversation.chat.node'> + & PropsRenderFactories & PropsStore & InjectFace & PropsLocale<'chat'> @@ -337,8 +352,9 @@ export type ChatViewSlotProps = & InjectFace & PropsLocale<'chat'> -/** Full props of the durable-message image renderer. */ -export type MessageImagesProps = PropsRuntime<'conversation.message.images'> & PropsLocale<'conversation'> +/** Full props shared by the user and output image entries. */ +export type MessageImagesProps = + PropsRuntime<'conversation.chat.user.images' | 'conversation.chat.message.images'> & PropsRenderFactories declare module '@deepseek-ai/dsh-client-ui-slots' { interface SessionStandardProps { @@ -352,6 +368,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } interface SlotFactoryMap { + /** Prepared user text, attachments, and references inside a caller-owned message row. */ + 'conversation.chat.user.content': { + scope: 'session' + props: UserContentInput + locale: 'chat' + children: { 'conversation.chat.user.images': { kind: 'single'; scope: 'session' } } + } /** Session-independent reasoning Markdown with Chat labels and compact typography. */ 'conversation.chat.reasoning.content': { scope: 'root' @@ -387,7 +410,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * supplies image references, an authorized loader, and alignment. A * registration replaces the shipped gallery; without one, images are omitted. */ - 'conversation.message.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps } + 'conversation.chat.message.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps } + /** User-message images, including pending and local-echo previews, independently replaceable from output images. */ + 'conversation.chat.user.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps } /** * Command row keyed by the command name. The component receives the folded * command lifecycle and linked compaction when present. Reusing a key diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts index b337c929bf..7e5f5038d3 100644 --- a/packages/client/ui-chat/src/client/contract/snapshot.ts +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -35,7 +35,10 @@ export interface ChatNodeStore { get(key: string): ChatConversationViewNode | undefined /** @param key - stable Conversation Context key. @returns its identity-stable observable source. */ source(key: string): ChatNodeSource - /** @param key - Chat Node identity. @returns whether the visible Node belongs to the last Turn in the loaded timeline. */ + /** + * @param key - Chat Node identity. + * @returns whether the visible Node belongs to the latest loaded exchange (see `ChatConversationViewNode.groupTree`). + */ bottomSource(key: string): ObservableSnapshot /** * Observe one Turn's data for a single Node kind, including hidden Nodes, in anchor order. diff --git a/packages/client/ui-chat/src/client/contract/turn-process.ts b/packages/client/ui-chat/src/client/contract/turn-process.ts index 8b7d89f64e..63c0e56af6 100644 --- a/packages/client/ui-chat/src/client/contract/turn-process.ts +++ b/packages/client/ui-chat/src/client/contract/turn-process.ts @@ -17,22 +17,6 @@ export interface TurnProcessSpec { readonly subagentCount: number } -const TURN_PROCESS_INDEPENDENT_KIND_LIST = [ - 'system-prompt', - 'user', - 'steering', - 'turn-trigger', - 'turn-process', - 'turn-error', - 'turn-max-tokens', - 'turn-tail', -] as const satisfies readonly ChatNode['kind'][] - -/** Chat Node kinds that remain independent of a Turn's process disclosure. */ -export const TURN_PROCESS_INDEPENDENT_KINDS: ReadonlySet = new Set( - TURN_PROCESS_INDEPENDENT_KIND_LIST, -) - /** * Compare immutable Turn-process specifications by their published fields. * @param left - previous specification. 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 a26c18c63a..ce5ab755d1 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 @@ -24,23 +24,23 @@ en: 91b35cbebe8c2a1d zh: a4fee6d7102bc874 /chat-conversation-node-rules/whole-turn-folding: - en: 6feeb1527191633f - zh: 401656a7b9a4a4ed + en: 42c6f6b9e2433263 + zh: 651ceb5c22314259 /chat-conversation-node-rules/display-modes: en: ada5a60d2c46f6a8 zh: f3eaeb5cb4d9b3de /chat-conversation-node-rules/display-modes/defaults-and-saving-preferences: - en: 71249bd1ef74a307 - zh: 9c8273ffd8823afa + en: b25bf00ce3a6a119 + zh: 0ab47a4014ea5e1e /chat-conversation-node-rules/display-modes/collapse-timing: - en: c31be5bb8cb77dc7 - zh: 9550ab598119076f + en: 03d744dd1c92bdd3 + zh: 19804eaf3234632a /chat-conversation-node-rules/display-modes/group-title-rules: - en: 5b40d65e660549b7 - zh: 09b6894aa00154e7 + en: 2392290662bc69e8 + zh: 8872d651c07a8f4c /chat-conversation-node-rules/process-grouping: - en: c2650489ded57503 - zh: f2554d74e75ae0f9 + en: 3d28b3bb8757bf55 + zh: 3b0f451b48db6048 /chat-conversation-node-rules/process-grouping/examples: en: 2a550f7e04bce4b0 zh: b8f95b687448cd77 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 f823ed9b65..bda19aba52 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -70,7 +70,7 @@ If steering, a User message, or a trigger notice follows process output, the Tur Whole-Turn folding controls the loaded process range, independently of secondary groups, in Compact, Standard, and Detailed. Verbose keeps that range visible and retains the duration/status header without a collapse action. A recorded Turn end makes that range eligible even when paging has not loaded the Turn start. -The rules below use the default completion-time folding. The [collapse-timing preference](#collapse-timing) can defer the latest Turn's historical presentation without changing whole-Turn eligibility. +The rules below use the default completion-time folding. The [collapse-timing preference](#collapse-timing) can defer the latest exchange's historical presentation without changing whole-Turn eligibility. The Turn control follows all its opening inputs, including human steering and non-human trigger notices, while waiting for the first Assistant output and after that output arrives. Consecutive inputs before the first process evidence are opening inputs, anchored by the last one. Later inputs retain their positions: even when paging has not loaded their inbox insertions and they temporarily appear as ordinary User messages, preceding process content must not move after them. @@ -94,7 +94,7 @@ Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs` | Neither start nor end is loaded | Keep process rows visible and withhold the whole-Turn control. | | A Turn control exists, but there is no process content | Retain its title, without a collapse action. | -The final answer is the latest Step's settled Assistant reply, provided it has visible reply content and no tool-call block. Its response remains outside whole-Turn folding; its reasoning remains process content. User and steering inputs, trigger notices, terminal errors, max-token notices, and the completed-turn footer remain independent. Secondary grouping treats model retries as separators, but whole-Turn folding still includes retry rows. +The final answer is the latest Step's settled Assistant reply, provided it has visible reply content and no tool-call block. Its response remains outside whole-Turn folding; its reasoning remains process content. A Node whose [group-tree position](../contract/group-tree.ts) is `turn` or outer remains independent of whole-Turn folding. Turn inputs stand at `input` or `exchange`: user and steering messages and trigger notices; an input after the process start keeps the whole Turn open. Terminal errors, max-token notices, the completed-turn footer, and the Turn control stand at `turn`. A `process` Node, such as a model retry, separates secondary groups but whole-Turn folding still includes it. Loading an older page preserves the reader's group-opening choices. Newly loaded process content follows the same Turn state while the final answer is unchanged and whole-Turn folding remains eligible. If the page reveals an intervening input, individual group disclosures replace whole-Turn hiding. When the real start arrives, the duration becomes available; loading all history is not an additional folding condition. When new content only extends an existing group at its beginning, that group and its old message rows retain their identities and opening choices. Replies, steering, and other real boundaries in the new page still separate groups; not every new row joins the old group. @@ -131,14 +131,16 @@ Work details uses the Host setting `ui-chat.transcriptView`. The client reads it - Legacy `normal` and `expanded` values display as `detailed`, without automatically rewriting the saved values on disk. - When the setting is missing, `null`, or invalid, Desktop uses `standard` and non-Desktop Web uses `detailed`. Each client also uses its default before Host settings arrive; invalid values in other settings still fail validation. -Reading a default does not automatically save configuration. When the user changes modes, the selected value is saved through Host settings; memory mode does not write to disk. Desktop onboarding writes preferences according to its rules when completed or skipped. +Reading a default does not automatically save configuration. When the user changes modes, the selected value is saved through Host settings and stays process-local when settings cannot persist writes. Desktop onboarding writes preferences according to its rules when completed or skipped. ### Collapse timing -Settings → General → Collapse work details appears directly below Work details and is independent of the display mode. On completion is the default and retains immediate folding and tail positioning. On next message enables delayed folding and its coordinated transitions. The choice lasts for the current Client instance: closing Settings preserves it; reloading or reopening the app resets it to On completion. It never writes Host settings, browser storage, or Session data. +Settings → General → Collapse work details appears directly below Work details and is independent of the display mode. On completion is the default and retains immediate folding and tail positioning. On next message enables delayed folding and its coordinated transitions. The choice is saved as `ui-chat.collapseTiming` through the same Host profile settings as Work details and is restored after reload or reopening the app. Profiles without a saved value use On completion without writing that default. It does not write browser storage or Session data. -With On next message, the latest Turn keeps its running presentation after completion: Compact and Standard retain their process headers, and Detailed retains its flat process rows. A pending ordinary transcript submission or a newer Turn ends this exemption without clearing the reader's manual whole-Turn choice. Inner disclosures reset only when their outer process actually hides. Queued input and steering alone do not end it. The latest Turn of a reopened Session also receives the exemption; manual whole-Turn choices still take precedence. Switching back to On completion ends the exemption immediately. +With On next message, the latest exchange keeps its running presentation after completion: Compact and Standard retain their process headers, and Detailed retains its flat process rows. An exchange starts with the Turn whose first Step admits human input and includes every later Turn until the next such Turn, so a Turn woken by a notice (a settled subagent, goal round, schedule, or job) leaves the preceding Turns of its exchange expanded. A pending ordinary transcript submission or a Turn that opens a newer exchange ends this exemption without clearing the reader's manual whole-Turn choice; a bare Turn start does not, because the host claims the waking input before it logs Step 1. Inner disclosures reset only when their outer process actually hides. Queued input and steering alone do not end it. The latest exchange of a reopened Session also receives the exemption, starting at the first loaded Turn when the loaded window holds no opening Turn; manual whole-Turn choices still take precedence. Switching back to On completion ends the exemption immediately. + +When a new message triggers a fold, process details close and the transcript follows the new message in one upward movement, without a second scroll after the fold. Off-screen steps do not add bottom space; only the space needed to preserve the visible movement remains when the retained answer and new message cannot fill it. Content added during the fold updates its remaining movement. Reopening every closing row cancels that movement and releases obsolete bottom space. A reading gesture interrupts automatic following; finishing the fold does not resume it. Verbose retains its flat process rows under either timing. Failed, stopped, and interleaved-input Turns retain their existing whole-Turn eligibility rules; delayed folding can still postpone Detailed's conversion from flat rows to historical groups. @@ -183,7 +185,7 @@ Group headers show a category icon, replace it with a down arrow on hover or key 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. -Individual reasoning starts collapsed, including while streaming. All modes preview the latest paragraph whose first line ends with a newline; an unfinished single line has no preview. Later text in that paragraph does not change the preview. After settlement, the mode table applies. Expanded reasoning uses compact Markdown typography. +Only reasoning blocks with non-whitespace text produce individual rows; each retains its original text and expands independently. A blank streaming tail produces no row and does not transfer its running state to an earlier block. Individual reasoning starts collapsed, including while streaming. All modes preview the latest paragraph whose first line ends with a newline; an unfinished single line has no preview. Later text in that paragraph does not change the preview. After settlement, the mode table applies. Expanded reasoning uses compact Markdown typography. Switching modes retains manually opened groups and inner disclosures. It changes visibility and sizing without recreating the message rows. Detailed and Verbose do not open every individual disclosure. Only Verbose keeps the whole Turn open. @@ -196,15 +198,14 @@ Switching modes retains manually opened groups and inner disclosures. It changes A group collects adjacent process content within one Turn. Step-number changes alone do not split it. All four modes use the same grouping result. A group's `closed` flag means its content segment has ended, not that its UI disclosure is collapsed. -When `question-reply` and `turn-trigger` project the same message id, grouping references only the question reply. Both Nodes stay in the Node Store; the duplicate trigger neither renders nor splits a group. The Turn control precedes the group containing an opening reply. This aggregation also applies before the owning Turn is loaded. +Grouping reads each Node's [group-tree position](../contract/group-tree.ts), which its Definition sets, rather than its kind. | Input | Membership and segmentation | |---|---| | Non-blank Assistant reasoning | Append one `reasoning` reference for that Assistant to the current group, creating a group if necessary. | | Assistant reply | End the preceding group and emit an independent `response` reference. If the same Node has reasoning, append that reasoning before ending the group. | -| `user`, `steering`, `turn-trigger`, `model-retry`, `turn-error`, `turn-max-tokens`, `turn-tail` | End the preceding group and retain the Node as an independent root. | -| `turn-process` | Retain the control as an independent root without ending the current group. | -| Other visible Nodes owned by a Turn, including tools | Append the whole Node to the current group, creating a group if necessary. | +| Node at `process` or an outer position: `model-retry`, Turn inputs, `turn-error`, `turn-max-tokens`, `turn-tail`, and the `turn-process` control | End the preceding group and retain the Node as an independent root. | +| Other visible Nodes owned by a Turn without a position, including tools | Append the whole Node to the current group, creating a group if necessary. | | Node from another Turn or without a Turn | Break the same-Turn sequence. A Node without a Turn remains independent, including an unsplit Assistant. | | Hidden Nodes, system prompts, ordinary Context injection, and `permission` commands | Excluded from the grouping input; they neither join nor split a group. | 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 886427a49a..3eedbc8c9a 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 @@ -70,7 +70,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 简洁、标准、详细模式的整轮折叠控制已加载的过程范围,独立于二级组。完全展开模式保持该范围可见,保留时长或状态抬头,但不支持收起。已经记录轮次结束时,即使向前分页尚未加载到轮次起点,也可以对该范围折叠。 -以下规则采用默认的完成时收起。[收起时机设置](#collapse-timing) 可以推迟最新轮次恢复历史呈现的时间,不改变整轮折叠资格。 +以下规则采用默认的完成时收起。[收起时机设置](#collapse-timing) 可以推迟最新交互段恢复历史呈现的时间,不改变整轮折叠资格。 整轮控件位于该轮所有起始输入之后,包括人工 steering 和非人工触发通知;等待首个 Assistant 输出及输出到达后均保持这一顺序。首个过程证据之前的连续输入都属于开场,以最后一条为准;过程证据之后的输入留在原位,即使分页尚未加载其入队记录、消息暂时归为普通 User,也不能把前面的过程移到它后面。 @@ -94,7 +94,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | 起点与结束记录均未加载 | 保留过程行可见,不显示整轮控件。 | | 存在整轮控件,但没有过程内容 | 保留标题,不提供折叠操作。 | -最终答案取最后一个 Step 中已结算的 Assistant 回复,要求有可见回复内容且没有工具调用块。最终答案的回复正文不参与整轮折叠,推理仍属于过程。用户与 steering 输入、触发通知、终止错误、最大 token 提示及已完成轮次页脚保持独立。二级分组把模型重试视为分隔节点,但整轮折叠仍包含重试行。 +最终答案取最后一个 Step 中已结算的 Assistant 回复,要求有可见回复内容且没有工具调用块。最终答案的回复正文不参与整轮折叠,推理仍属于过程。[分组树位置](../contract/group-tree.ts)为 `turn` 或更外层的 Node 不参与整轮折叠。轮次输入位于 `input` 或 `exchange`:用户与 steering 消息、触发通知;过程开始后出现的输入使整个轮次保持展开。终止错误、最大 token 提示、已完成轮次页脚和整轮控件位于 `turn`。`process` 位置的 Node(如模型重试)分隔二级分组,但整轮折叠仍包含它。 加载更早的一页保留用户的组级开合选择。最终答案未变化且仍符合整轮折叠条件时,新加载的过程内容沿用该轮的开合状态;若新页揭示中途输入,则改用各组开合,不再整轮隐藏。真正的起点到达后才显示时长,是否已加载完全部历史不是额外折叠条件。若新内容只是接在旧组前面,旧组和旧消息行保留身份及已有展开状态。新页出现回复、steering 等真实边界时仍按规则分组,不把所有新增内容强并进旧组。 @@ -131,14 +131,16 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" - 旧值 `normal` 和 `expanded` 均按 `detailed` 展示,不自动改写磁盘中的值。 - 未配置、值为 `null` 或无效时,Desktop 使用 `standard`,非 Desktop Web 使用 `detailed`。Host 设置到达前也使用各自的默认值;其他设置的无效值仍会校验失败。 -读取默认值不会自动保存配置。用户切换模式时,通过 Host 设置保存所选值;内存模式不写磁盘。Desktop onboarding 完成或跳过时,按引导规则写入偏好。 +读取默认值不会自动保存配置。用户切换模式时,通过 Host 设置保存所选值;设置无法持久化写入时仅在当前进程内生效。Desktop onboarding 完成或跳过时,按引导规则写入偏好。 ### 收起时机 -设置 → 通用设置 → 过程收起时机位于工作步骤展示正下方,与展示模式独立。「完成时收起」是默认值,保留立即折叠和即时跟尾定位;「下次发送时收起」启用延后折叠及其协调过渡。选择仅在当前 Client 实例内有效:关闭设置面板后保留,刷新或重新打开应用时恢复为「完成时收起」。此偏好不写入 Host 设置、浏览器存储或 Session 数据。 +设置 → 通用设置 → 过程收起时机位于工作步骤展示正下方,与展示模式独立。「完成时收起」是默认值,保留立即折叠和即时跟尾定位;「下次发送时收起」启用延后折叠及其协调过渡。选择以 `ui-chat.collapseTiming` 保存到与工作步骤展示相同的 Host profile 设置,刷新或重新打开应用后会恢复。未保存此值的 profile 使用「完成时收起」,不会自动写入默认值。此偏好不写入浏览器存储或 Session 数据。 -选择「下次发送时收起」后,最新轮次完成时保留运行中的呈现:简洁和标准保留过程组头,详细保留平铺过程行。普通 transcript 输入进入待发送状态,或出现更新轮次时,取消这项例外,但保留用户对整轮的手动开合选择。只有外层过程实际隐藏时,才重置内部开合。排队输入和单独的 steering 不会取消例外。重新打开 Session 时,其最新轮次也享有例外;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。 +选择「下次发送时收起」后,最新交互段完成时保留运行中的呈现:简洁和标准保留过程组头,详细保留平铺过程行。交互段从首个 Step 接收真人输入的轮次开始,包含之后的所有轮次,直到下一个这样的轮次,因此由通知(子 agent 完成、Goal Round、定时任务或后台作业)唤醒的轮次不会收起同一交互段里更早的轮次。普通 transcript 输入进入待发送状态,或出现开启更新交互段的轮次时,取消这项例外,但保留用户对整轮的手动开合选择;仅出现新轮次的开始不会取消,因为 Host 先认领唤醒输入,再记录 Step 1。只有外层过程实际隐藏时,才重置内部开合。排队输入和单独的 steering 不会取消例外。重新打开 Session 时,其最新交互段也享有例外,已加载窗口中没有开启轮次时从第一个已加载轮次算起;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。 + +新消息触发折叠时,过程收起与跟随新消息合为一次向上运动,折叠结束后不再追加滚动。屏幕外的步骤不增加底部补白;只有保留的正文和新消息无法填满可见内容移动所需空间时,才保留必要差额。折叠期间新增的内容会更新剩余运动;重新展开全部正在收起的行时,取消该运动并释放失效的底部补白。阅读手势会中断自动跟随,折叠结束不会重新接管。 完全展开模式在两种时机下都保留平铺过程行。失败、停止和含插入输入的轮次沿用原有整轮折叠资格;延后折叠仍可推迟详细模式从平铺行转换为历史分组的时机。 @@ -183,7 +185,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 已展开的限高组仅在自己的滚动位置处于底部时跟随内容增长。滚离底部暂停该组跟随,滚回底部后恢复,与外层文本记录的跟随状态独立。手动展开未关闭的组时定位到底部并跟随增长;手动展开已关闭的组时从顶部开始且不启用跟随,即使初始内容无需滚动即可容纳。数据中的组变为已关闭,或通过模式切换恢复限高时,不重置已展开组的阅读位置。浏览器查找沿用自身的展开定位。 -单个推理初始收起,包括流式输出期间。四档都预览最近一个首行以换行结束的段落;未完成的单行没有预览,该段落后续文本不改变预览。结算后按上表展示。展开的推理使用紧凑 Markdown 排版。 +只有含非空白文本的推理块生成独立行,各自保留原文并独立展开。空白流式尾块不生成行,也不将运行状态转移给前面的块。单个推理初始收起,包括流式输出期间。四档都预览最近一个首行以换行结束的段落;未完成的单行没有预览,该段落后续文本不改变预览。结算后按上表展示。展开的推理使用紧凑 Markdown 排版。 切换模式保留手动展开的组和内部开合状态,只改变显隐与尺寸,不重建消息行。详细和完全展开都不表示自动打开每个内部控件;只有完全展开模式始终展开整轮。 @@ -196,15 +198,14 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 一组收集同一轮次内相邻的过程内容,仅 Step 编号变化不会拆组。四档模式使用同一分组结果。组的 `closed` 表示这段内容已经结束,不表示 UI 开合控件处于收起状态。 -当 `question-reply` 和 `turn-trigger` 投影自同一个消息 id 时,分组只引用问答回复。两个 Node 都保留在 Node Store 中;重复的触发节点既不显示,也不切开分组。整轮控件位于包含开场回复的分组之前。所属轮次尚未加载时也执行这一聚合。 +分组读取每个 Node 由其 Definition 设定的[分组树位置](../contract/group-tree.ts),而不是它的 kind。 | 输入 | 归属与切分 | |---|---| | 非空白 Assistant 推理 | 将该 Assistant 的一个 `reasoning` 引用加入当前组,必要时创建组。 | | Assistant 回复 | 结束前面的组,输出独立的 `response` 引用。同一 Node 含有推理时,先将推理加入组,再结束该组。 | -| `user`、`steering`、`turn-trigger`、`model-retry`、`turn-error`、`turn-max-tokens`、`turn-tail` | 结束前面的组,Node 作为独立根引用保留。 | -| `turn-process` | 控件作为独立根引用保留,不结束当前组。 | -| 轮次内其他可见 Node,包括工具 | 将整个 Node 加入当前组,必要时创建组。 | +| 位于 `process` 或更外层位置的 Node:`model-retry`、轮次输入、`turn-error`、`turn-max-tokens`、`turn-tail` 及 `turn-process` 控件 | 结束前面的组,Node 作为独立根引用保留。 | +| 轮次内没有位置的其他可见 Node,包括工具 | 将整个 Node 加入当前组,必要时创建组。 | | 另一轮次或没有轮次归属的 Node | 截断同轮次序列。无轮次 Node 保持独立,Assistant 也不拆分。 | | 隐藏 Node、系统提示词、普通 Context 注入及 `permission` 命令 | 不进入分组输入,既不入组,也不截断组。 | diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 0423e1ff20..933ae8f760 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -11,8 +11,10 @@ import type { ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ChatTurnProcessPresentation, LegacyConversationSlice, TurnNavigationItem, } from '../contract/snapshot.ts' -import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' +import { isAtOrOutside } from '../contract/group-tree.ts' +import { locationCoordinates } from './common.ts' import { sessionRecallLabels, skillInvocationName } from './event-projection.ts' +import { GroupTreeSegments } from './group-tree-segments.ts' import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts' import { ChatTurnProcessProjector } from './turn-process-presentation.ts' @@ -108,7 +110,8 @@ class MutableChatNodeStore implements ChatNodeStore { private readonly sources = new Map>() private readonly processSources = new Map>() private readonly bottomSources = new Map>() - private bottomTurn: number | undefined + /** Bottom membership: the latest loaded exchange. */ + private readonly exchanges = new GroupTreeSegments('exchange') private readonly dirtyKeys = new Set() private readonly dirtyBottomKeys = new Set() private readonly dirtyProcessKeys = new Set() @@ -131,19 +134,21 @@ class MutableChatNodeStore implements ChatNodeStore { bottomSource(key: string): ObservableSnapshot { return cachedSource(this.bottomSources, key, () => new MutableChatSource(() => { const node = this.get(key) as ChatNode | undefined - return this.bottomTurn !== undefined && node !== undefined && isVisibleChatNode(node) - && locationCoordinates(node.location).turn === this.bottomTurn + return node !== undefined && isVisibleChatNode(node) + && this.exchanges.inLatest(locationCoordinates(node.location).turn) }, `[ui-chat] node bottom source ${key}`)) } - setBottomTurn(turn: number | undefined, locations: ChatLocationNodeIndex): void { - if (turn === this.bottomTurn) return - for (const changed of [this.bottomTurn, turn]) { - if (changed !== undefined) { - for (const key of locations.getTurn(changed)) this.dirtyBottomKeys.add(key) - } + /** + * Settle the latest exchange for the loaded Turns and mark the Nodes of + * Turns that entered or left it. + * @param turnOrder - loaded Turns in ascending timeline order. + * @param locations - current Chat Location index. + */ + settleBottom(turnOrder: readonly number[], locations: ChatLocationNodeIndex): void { + for (const turn of this.exchanges.settle(turnOrder)) { + for (const key of locations.getTurn(turn)) this.dirtyBottomKeys.add(key) } - this.bottomTurn = turn } turnDataSource(turn: number, kind: Kind): ObservableSnapshot { @@ -196,6 +201,7 @@ class MutableChatNodeStore implements ChatNodeStore { this.byKey.set(node.key, node) if (previous.get(node.key) !== node) { this.updateTurnKind(previous.get(node.key), node) + this.exchanges.track(previous.get(node.key), node) this.dirtyKeys.add(node.key) this.dirtyProcessKeys.add(node.key) } @@ -203,6 +209,7 @@ class MutableChatNodeStore implements ChatNodeStore { } for (const key of previous.keys()) { this.updateTurnKind(previous.get(key), undefined) + this.exchanges.track(previous.get(key), undefined) this.dirtyKeys.add(key) this.dirtyProcessKeys.add(key) } @@ -215,6 +222,7 @@ class MutableChatNodeStore implements ChatNodeStore { for (const node of nodes) { if (this.byKey.get(node.key) === node) continue this.updateTurnKind(this.byKey.get(node.key), node) + this.exchanges.track(this.byKey.get(node.key), node) this.byKey.set(node.key, node) this.dirtyKeys.add(node.key) this.dirtyProcessKeys.add(node.key) @@ -408,12 +416,6 @@ function stepKey(turn: number, step: number): string { return `${turn}:${step}` } -function locationCoordinates(location: ConversationLocation): { turn?: number; step?: number } { - if (location.kind === 'step') return { turn: location.turn.turn, step: location.step.step } - if (location.kind === 'turn') return { turn: location.turn.turn } - return {} -} - function locationTurnStatus(location: ConversationLocation): string | undefined { return location.kind === 'turn' || location.kind === 'step' ? location.turn.status : undefined } @@ -455,7 +457,7 @@ function turnProcessPresentations( if (location.kind !== 'turn' && location.kind !== 'step') continue const current: TurnProcessPresentation = presentations.get(location.turn.turn) ?? {} const controlAnchor = current.control?.data.controlAnchorSeq - if ((node.kind === 'user' || node.kind === 'turn-trigger' || node.kind === 'steering') + if (isAtOrOutside(node, 'input') && controlAnchor !== undefined && (controlAnchor === location.turn.start?.seq || node.anchorSeq < controlAnchor)) { presentations.set(location.turn.turn, { @@ -464,7 +466,7 @@ function turnProcessPresentations( }) continue } - if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) continue + if (isAtOrOutside(node, 'turn')) continue presentations.set(location.turn.turn, { ...current, earliestProcessAnchor: Math.min(current.earliestProcessAnchor ?? node.anchorSeq, node.anchorSeq), @@ -495,7 +497,7 @@ function presentationPosition( const openingInputAnchor = presentation.openingInputAnchor if (openingInputAnchor !== undefined && node.anchorSeq < openingInputAnchor - && !TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) { + && !isAtOrOutside(node, 'turn')) { return { anchor: openingInputAnchor, rank: 2, originalAnchor: node.anchorSeq } } if (presentation.control !== undefined && node.key === presentation.control.key) { @@ -1114,7 +1116,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) this.locations.rebuild(this.order, this.store) this.store.replaceProcesses(this.order, this.locations) - this.store.setBottomTurn(input.timeline.turnOrder.at(-1), this.locations) + this.store.settleBottom(input.timeline.turnOrder, this.locations) this.navigation.rebuild(input.timeline, this.locations, this.store) this.timeline = input.timeline this.latestGroupInput = { @@ -1146,6 +1148,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder( @@ -45,6 +57,7 @@ export function chatNode( options: { readonly location?: ConversationLocation readonly visibility?: 'visible' | 'hidden' + readonly groupTree?: ChatGroupTree | undefined } = {}, ): ChatNode { return { @@ -55,6 +68,7 @@ export function chatNode( anchorSeq, location: options.location ?? contextLocation(context), visibility: options.visibility ?? 'visible', + groupTree: options.groupTree, data, } } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts b/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts new file mode 100644 index 0000000000..738b79ef29 --- /dev/null +++ b/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts @@ -0,0 +1,91 @@ +import type { ChatConversationViewNode } from '../contract/chat-nodes.ts' +import { isAtOrOutside, type ChatGroupTree } from '../contract/group-tree.ts' +import { locationCoordinates } from './common.ts' + +const NO_TURNS: readonly number[] = [] + +/** + * Loaded Turns split into consecutive segments at the Turns holding a Node at + * one group-tree position, visible or hidden. The latest segment starts at the + * last such Turn, or at the first loaded Turn when none is loaded. Node changes + * update per-Turn counts without scanning the loaded Nodes; `settle` returns at + * once while the start holds and otherwise reads loaded Turns only from the + * earlier of the previous and new starts. + */ +export class GroupTreeSegments { + /** Opening Node count by owning Turn. */ + private readonly openings = new Map() + private latestOpening: number | undefined + private latestStart: number | undefined + + /** @param position - group-tree position whose Nodes open a segment. */ + constructor(private readonly position: ChatGroupTree) {} + + /** + * Account one Node change. + * @param previous - replaced Node; absent for an addition. + * @param next - replacing Node; absent for a removal. + */ + track(previous: ChatConversationViewNode | undefined, next: ChatConversationViewNode | undefined): void { + const before = this.openingTurn(previous) + const after = this.openingTurn(next) + if (before === after) return + if (before !== undefined && this.adjust(before, -1) === 0 && before === this.latestOpening) { + this.latestOpening = this.lastOpeningTurn() + } + if (after !== undefined) { + this.adjust(after, 1) + if (this.latestOpening === undefined || after > this.latestOpening) this.latestOpening = after + } + } + + /** + * Test latest-segment membership as of the last `settle`. + * @param turn - owning Turn, when resolved. + * @returns whether the Turn belongs to the latest segment. + */ + inLatest(turn: number | undefined): boolean { + return turn !== undefined && this.latestStart !== undefined && turn >= this.latestStart + } + + /** + * Move the latest segment start for the currently loaded Turns. + * @param turnOrder - loaded Turns in ascending timeline order. + * @returns loaded Turns that entered or left the latest segment, in ascending order. + */ + settle(turnOrder: readonly number[]): readonly number[] { + const previous = this.latestStart + const start = this.latestOpening ?? turnOrder[0] + if (start === previous) return NO_TURNS + this.latestStart = start + const bounded = previous !== undefined && start !== undefined + const low = bounded ? Math.min(previous, start) : Number.NEGATIVE_INFINITY + const high = bounded ? Math.max(previous, start) : Number.POSITIVE_INFINITY + const changed: number[] = [] + for (const turn of turnOrder.slice(turnOrder.findLastIndex(turn => turn < low) + 1)) { + if (turn >= high) break + changed.push(turn) + } + return changed + } + + private openingTurn(node: ChatConversationViewNode | undefined): number | undefined { + return node !== undefined && isAtOrOutside(node, this.position) ? locationCoordinates(node.location).turn : undefined + } + + private lastOpeningTurn(): number | undefined { + let last: number | undefined + for (const turn of this.openings.keys()) { + if (last === undefined || turn > last) last = turn + } + return last + } + + /** @returns the Turn's opening count after the change; zero removes the Turn. */ + private adjust(turn: number, delta: number): number { + const next = (this.openings.get(turn) ?? 0) + delta + if (next > 0) this.openings.set(turn, next) + else this.openings.delete(turn) + return next + } +} diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index ddfffb0cca..8739466722 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -4,7 +4,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { InboxState } from './inbox.ts' -import { chatNode } from './common.ts' +import { chatNode, contextLocation } from './common.ts' import { contextForm, contextProducer } from './event-projection.ts' interface ReferencedUserMessageNode extends UserMessageNode { @@ -115,7 +115,18 @@ export const messageDefinition: ConversationNodeDefinition = { const waking = context.state.kind === 'context' && context.start?.event.type === 'user/message' && context.state.waking === true - return chatNode(context, waking ? 'turn-trigger' : context.state.kind, context.state.seq, context.state) + // Human input claimed for Step 1 opens an exchange; Notice-woken Turns and + // later steering stay in the current exchange, so they never fold earlier Turns. + const location = contextLocation(context) + const human = context.state.kind === 'user' || context.state.kind === 'steering' + const opensExchange = human && location.kind === 'step' && location.step.step === 1 + return chatNode( + context, + waking ? 'turn-trigger' : context.state.kind, + context.state.seq, + context.state, + { location, groupTree: opensExchange ? 'exchange' : (human || waking) ? 'input' : undefined }, + ) }, } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts b/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts index 09edc21006..201569449f 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts @@ -8,9 +8,9 @@ import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes. import type { ProcessActivitySummary, ProcessGroupData } from '../contract/process-groups.ts' import { hasAssistantReplyContent } from '../contract/assistant-content.ts' import { isVisibleChatNode } from '../contract/chat-visibility.ts' +import { isAtOrOutside } from '../contract/group-tree.ts' import { processActivity } from './process-activity.ts' -const INDEPENDENT = new Set(['user', 'steering', 'turn-trigger', 'model-retry', 'turn-error', 'turn-max-tokens', 'turn-tail']) type ProcessInput = ConversationGroupInput function turnOf(node: ChatNode): number | undefined { @@ -44,6 +44,7 @@ function structureChanged(previous: ChatNode | undefined, current: ChatNode): bo return previous === undefined || previous.kind !== current.kind || turnOf(previous) !== turnOf(current) || isVisibleChatNode(previous) !== isVisibleChatNode(current) + || previous.groupTree !== current.groupTree || reasoning(previous) !== reasoning(current) || reply(previous) !== reply(current) } @@ -53,15 +54,6 @@ function readNode(input: ProcessInput, key: NodeKey): ChatNode { return node } -function questionReplyIds(input: ProcessInput, keys: readonly NodeKey[]): ReadonlySet { - const ids = new Set() - for (const key of keys) { - const node: ChatConversationViewNode = readNode(input, key) - if (node.kind === 'question-reply') ids.add(node.id) - } - return ids -} - /** One group's members and cached summary, refreshed together when its content changes. */ class ProcessGroup { private nodes: readonly ChatNode[] = [] @@ -152,23 +144,17 @@ class TurnGroups { } let previous: NodeKey | undefined let followed = false - const keys = input.readTurn(this.turn) - const replies = questionReplyIds(input, keys) - for (const key of keys) { + for (const key of input.readTurn(this.turn)) { const position = readPosition(input, key) if (previous !== undefined && position.previous !== previous) flush(true) previous = key followed = position.next !== undefined const node = readNode(input, key) - // Both Definitions retain the same message id; only its question presentation renders. - if (node.kind === 'turn-trigger' && replies.has(node.id)) continue - if (INDEPENDENT.has(node.kind)) { + if (isAtOrOutside(node, 'process')) { flush(true) emit(key, { kind: 'node', key }) - } else if (node.kind === 'turn-process') { - // A coalesced opening reply can sort before the control that folds its group. - emit(pending[0]?.key ?? key, { kind: 'node', key }) } else if (node.kind === 'assistant-step') { + // One Node, two rows: reasoning joins the step run, a reply stands outside it. if (reasoning(node)) pending.push({ kind: 'node', key, groupPart: 'reasoning' }) if (reply(node)) { flush(true) @@ -291,14 +277,9 @@ export class ProcessState { } private rootEntries(input: ProcessInput): RenderEntry[] { - const unscoped = input.order.filter(key => readPosition(input, key).turn === undefined) - const replies = questionReplyIds(input, unscoped) return input.order.flatMap((key) => { const turn = readPosition(input, key).turn - if (turn === undefined) { - const node = readNode(input, key) - return node.kind === 'turn-trigger' && replies.has(node.id) ? [] : [{ kind: 'node', key }] - } + if (turn === undefined) return [{ kind: 'node', key }] const groups = this.turns.get(turn) if (groups === undefined) throw new Error(`Chat grouping order is missing Turn ${turn}`) return groups.references(key) diff --git a/packages/client/ui-chat/src/client/conversation-nodes/retry.ts b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts index d721821f22..644690f789 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts @@ -84,7 +84,7 @@ export const retryDefinition: ConversationNodeDefinition = { const current = attempts.at(-1) if (current === undefined) return null const data: RetryChatData = { attempts, current } - return chatNode(context, 'model-retry', attempts[0]?.seq ?? current.seq, data) + return chatNode(context, 'model-retry', attempts[0]?.seq ?? current.seq, data, { groupTree: 'process' }) }, } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts index 445d073639..1f8f190fc5 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts @@ -91,7 +91,7 @@ export const turnErrorDefinition: ConversationNodeDefinition = { message: failure.message, ...failure.code === undefined ? {} : { code: failure.code }, } - return chatNode(context, 'turn-error', node.seq, node) + return chatNode(context, 'turn-error', node.seq, node, { groupTree: 'turn' }) }, } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts index 746d00bc95..03127b85ae 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts @@ -69,7 +69,7 @@ export const turnMaxTokensDefinition: ConversationNodeDefinition openingHumanAnchor)) { hasInterleavedInput = true if (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq) compactAnswer = false } - if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind) + if (isAtOrOutside(node, 'turn') || node.anchorSeq < spec.processStartSeq || (spec.answerAnchorSeq !== null && node.anchorSeq >= spec.answerAnchorSeq)) continue if (node.kind !== 'assistant-step' || spec.answerStep === null || node.data.step !== spec.answerStep) { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts index f151676c6b..b38b937fea 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts @@ -296,6 +296,7 @@ export const turnProcessDefinition: ConversationNodeDefinition 'turn-process', data.controlAnchorSeq + CHAT_SYNTHETIC_SEQ_OFFSETS.processControl, data, + { groupTree: 'turn' }, ) }, } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts index c1bfdd0c4d..4ca63d5375 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts @@ -135,7 +135,7 @@ export const turnTailDefinition: ConversationNodeDefinition = { buildViewNode: (context) => { const turn = turnLocation(context) const data = turn?.data.get('turn-tail') - return data === undefined ? null : chatNode(context, 'turn-tail', data.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup, data) + return data === undefined ? null : chatNode(context, 'turn-tail', data.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup, data, { groupTree: 'turn' }) }, } diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index a725061f89..275d76b96b 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -27,6 +27,7 @@ export type { FinalAssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnProcessChatData, TurnTailChatData, } from './contract/chat-nodes.ts' +export type { ChatGroupTree } from './contract/group-tree.ts' export type { ChatStoreState, ToolCallId, TurnProcessViewEntry } from './contract/store.ts' export type { TranscriptViewRowInjected, TranscriptViewRowProps } from './settings/TranscriptViewRow.tsx' export type { TranscriptViewMode } from '../chat-settings.ts' @@ -37,7 +38,7 @@ export type { ChatFlowDataInjected, ChatFlowHookContext, ChatFlowInjected, ChatFlowOwnerProps, ChatFlowSlotProps, UseGroupHeaderAction, CommandRowOwnerProps, CommandRowProps, MessageImagesProps, OpenFileOptions, PresentationInjected, QuotaNoticeCode, QuotaNoticeHostProps, QuotaNoticeInjected, QuotaNoticeOwnerProps, QuotaNoticeState, - ReasoningBodyOwnerProps, ReasoningContentInput, + ReasoningBodyOwnerProps, ReasoningContentInput, UserContentAttachment, UserContentData, UserContentInput, TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, UseDisclosure, UsePresentation, UseGroupAction, } from './contract/slots.ts' export type { diff --git a/packages/client/ui-chat/src/client/performance-usage.ts b/packages/client/ui-chat/src/client/performance-usage.ts index b0313649a7..158860efb4 100644 --- a/packages/client/ui-chat/src/client/performance-usage.ts +++ b/packages/client/ui-chat/src/client/performance-usage.ts @@ -1,4 +1,4 @@ -/** Performance detail preference with process-local choices on memory-only settings scopes. */ +/** Performance detail preference, live immediately and reconciled with accepted Host settings. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client' import { DEFAULT_PERFORMANCE_USAGE, type ChatSettings, type PerformanceUsageMode } from '../chat-settings.ts' @@ -9,7 +9,7 @@ export class PerformanceUsagePolicy { /** Current choice, reconciled with accepted Host settings when available. */ readonly mode = createSnapshotStore(DEFAULT_PERFORMANCE_USAGE) - /** @param host - Chat settings scope, durable on loopback and memory-only elsewhere. */ + /** @param host - Chat settings scope persisted through the Host. */ constructor(private readonly host: ConfigForm) { const adopt = (): void => { const accepted = host.getSnapshot().value?.performanceUsage @@ -23,7 +23,7 @@ export class PerformanceUsagePolicy { dispose(): void { this.unsubscribe() } /** - * Publish a choice immediately and persist it when the scope supports writes. + * Publish a choice immediately and queue its Host write. * @param mode - Statistics detail selected by the user. */ setMode(mode: PerformanceUsageMode): void { diff --git a/packages/client/ui-chat/src/client/presentation-policy.ts b/packages/client/ui-chat/src/client/presentation-policy.ts index 04deade5dd..0bb38c7cdd 100644 --- a/packages/client/ui-chat/src/client/presentation-policy.ts +++ b/packages/client/ui-chat/src/client/presentation-policy.ts @@ -1,19 +1,14 @@ -/** Chat presentation derived from Host-backed detail and in-memory collapse timing. */ +/** Chat presentation derived from Host-backed detail and collapse timing. */ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { TranscriptViewMode } from '../chat-settings.ts' - -/** Available points when a completed Turn returns to its historical presentation. */ -export const COLLAPSE_TIMINGS = ['completion', 'next-input'] as const - -/** When a completed Turn returns to its historical presentation. */ -export type CollapseTiming = typeof COLLAPSE_TIMINGS[number] +import type { CollapseTiming, TranscriptViewMode } from '../chat-settings.ts' +export { COLLAPSE_TIMINGS, type CollapseTiming } from '../chat-settings.ts' /** Presentation capabilities that one work-details mode enables. */ export interface ChatPresentationPolicy { - /** Mode this policy was derived from; for diagnostics, never for branching in renderers. */ + /** Preference identity for layout resets and diagnostics; renderers select the capabilities below. */ readonly mode: TranscriptViewMode - /** Browser-local experiment; completion retains the default immediate folding and scrolling. */ + /** Saved collapse timing; completion retains the default immediate folding and scrolling. */ readonly collapseTiming: CollapseTiming /** Whether a normally completed Turn folds its process rows behind the whole-Turn control. */ readonly foldCompletedTurns: boolean @@ -73,7 +68,7 @@ const TIMING_POLICIES = { /** * Resolve a stable policy for one detail mode and collapse timing. * @param mode - persisted work-details mode. - * @param collapseTiming - in-memory timing; defaults to immediate completion folding. + * @param collapseTiming - persisted timing; defaults to immediate completion folding. * @returns the same policy object for the same pair of choices. */ export function presentationPolicyFor( @@ -84,9 +79,9 @@ export function presentationPolicyFor( } /** - * Combine Host-backed detail and in-memory timing without owning subscriptions. + * Combine Host-backed detail and persisted timing without owning subscriptions. * @param mode - live work-details mode. - * @param collapseTiming - live, in-memory collapse timing. + * @param collapseTiming - live Host-backed collapse timing. * @returns observable policy whose subscriptions follow both preferences. */ export function derivePresentationPolicy( diff --git a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx index a770217041..d438e25c1d 100644 --- a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx +++ b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx @@ -12,12 +12,12 @@ export interface TranscriptViewRowInjected { hooks: { /** Persisted work-details preference bound as useTranscriptView. */ transcriptView: SnapshotStore - /** Browser-local timing bound as useCollapseTiming. */ + /** Persisted collapse timing bound as useCollapseTiming. */ collapseTiming: SnapshotStore } /** Change the work-details presentation. */ setTranscriptView: (mode: TranscriptViewMode) => void - /** Retain collapse timing for this Client lifetime without changing Host settings. */ + /** Persist collapse timing independently of the work-details mode. */ setCollapseTiming: (timing: CollapseTiming) => void } @@ -40,7 +40,7 @@ const COLLAPSE_LABELS = { } as const satisfies Record /** - * Render work-details and browser-local collapse-timing selectors. + * Render the independent, Host-backed work-details and collapse-timing selectors. * @param props - composed Settings slot props. * @returns the preference row. */ diff --git a/packages/client/ui-chat/src/client/transcript-view.ts b/packages/client/ui-chat/src/client/transcript-view.ts index 4e311ff076..83928e2e8f 100644 --- a/packages/client/ui-chat/src/client/transcript-view.ts +++ b/packages/client/ui-chat/src/client/transcript-view.ts @@ -1,9 +1,10 @@ -/** Host-backed work details and independent, in-memory collapse timing. */ +/** Independent work-details preferences backed by the same Host configuration form. */ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client' import type { CollapseTiming } from './presentation-policy.ts' import { + COLLAPSE_TIMING_FIELD, DEFAULT_COLLAPSE_TIMING, DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE, LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD, type ChatSettings, type TranscriptViewMode, } from '../chat-settings.ts' @@ -13,8 +14,8 @@ export class TranscriptViewPolicy { private readonly unsubscribe: () => void /** Reactive current mode, including the client default before Host settings arrive. */ readonly mode: SnapshotStore - /** Independent Client-lifetime choice; each new instance starts with completion-time folding. */ - readonly collapseTiming: SnapshotStore = createSnapshotStore('completion') + /** Reactive collapse timing, defaulting to completion until Host settings arrive. */ + readonly collapseTiming: SnapshotStore = createSnapshotStore(DEFAULT_COLLAPSE_TIMING) /** * @param host - durable Chat settings scope. @@ -43,11 +44,13 @@ export class TranscriptViewPolicy { } /** - * Keep collapse timing in memory without writing Host or browser storage. + * Publish and persist collapse timing without changing the work-details mode. * @param timing - immediate completion folding or deferred folding at the next input. */ setCollapseTiming(timing: CollapseTiming): void { + if (this.collapseTiming.getSnapshot() === timing) return this.collapseTiming.set(timing) + void this.host.set(COLLAPSE_TIMING_FIELD, timing) } /** Adopt the latest accepted Host section without writing it back. */ @@ -58,5 +61,7 @@ export class TranscriptViewPolicy { const mode = saved === LEGACY_TRANSCRIPT_VIEW_MODE || saved === LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE ? 'detailed' : saved ?? this.defaultMode if (this.mode.getSnapshot() !== mode) this.mode.set(mode) + const timing = section.collapseTiming ?? DEFAULT_COLLAPSE_TIMING + if (this.collapseTiming.getSnapshot() !== timing) this.collapseTiming.set(timing) } } diff --git a/packages/client/ui-chat/src/index.ts b/packages/client/ui-chat/src/index.ts index 2380110127..e5876b8392 100644 --- a/packages/client/ui-chat/src/index.ts +++ b/packages/client/ui-chat/src/index.ts @@ -2,7 +2,7 @@ import type {} from '@deepseek-ai/dsh-settings' import type { Volatile, Context } from '@deepseek-ai/cordis' -import type { ChatSettings, LinkOpening, PerformanceUsageMode } from './chat-settings.ts' +import type { ChatSettings, CollapseTiming, LinkOpening, PerformanceUsageMode } from './chat-settings.ts' import z from '@deepseek-ai/schemastery' import { TRANSCRIPT_VIEW_FIELD } from './chat-settings.ts' @@ -18,6 +18,8 @@ export { export interface Config { /** Completed turn transcript presentation. */ transcriptView: Volatile + /** When completed work details return to their historical presentation. */ + collapseTiming: Volatile /** Performance and usage detail level. */ performanceUsage: Volatile /** Default destination for Chat HTTP(S) links. */ @@ -27,6 +29,7 @@ export interface Config { /** Live preferences projected to the browser. */ export const Config = z.object({ [TRANSCRIPT_VIEW_FIELD]: ChatSettingsFields[TRANSCRIPT_VIEW_FIELD].volatile(), + collapseTiming: ChatSettingsFields.collapseTiming.volatile(), performanceUsage: ChatSettingsFields['performanceUsage'].volatile(), linkOpening: ChatSettingsFields.linkOpening.volatile(), }) 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 7eee5bcff7..2898fab578 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -137,7 +137,7 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true, } describe('Chat inject API', () => { - it('projects independent Settings choices without saving timing to Host settings', async () => { + it('projects independent Settings choices and saves timing to Host settings', async () => { const b = await bench() try { const row = b.runtime.slots.entries('settings.general.item') @@ -149,12 +149,12 @@ describe('Chat inject API', () => { expect(presentation.getSnapshot()).toMatchObject({ mode: 'detailed', collapseTiming: 'completion' }) setCollapseTiming('next-input') expect(presentation.getSnapshot()).toMatchObject({ mode: 'detailed', collapseTiming: 'next-input' }) - expect(b.chatSettings.set).not.toHaveBeenCalled() - b.chatSettings.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'standard', performanceUsage: 'detailed' }, revision: 1, writable: true }) + expect(b.chatSettings.set).toHaveBeenCalledWith('collapseTiming', 'next-input') + b.chatSettings.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'standard', performanceUsage: 'detailed', collapseTiming: 'next-input' }, revision: 1, writable: true }) expect(presentation.getSnapshot()).toMatchObject({ mode: 'standard', collapseTiming: 'next-input' }) setCollapseTiming('completion') expect(presentation.getSnapshot()).toMatchObject({ mode: 'standard', collapseTiming: 'completion' }) - expect(b.chatSettings.set).not.toHaveBeenCalled() + expect(b.chatSettings.set).toHaveBeenLastCalledWith('collapseTiming', 'completion') expect(b.session.prompt).not.toHaveBeenCalled() expect(b.session.cancel).not.toHaveBeenCalled() } finally { @@ -368,7 +368,7 @@ describe('Chat inject API', () => { const open = vi.spyOn(window, 'open').mockImplementation(() => null) try { const { injected } = b.chatViewApi(b.rootReference) - b.chatSettings.publish({ mode: 'memory', status: 'unavailable' }) + b.chatSettings.publish({ status: 'unavailable' }) b.chatSettings.set.mockResolvedValueOnce(false) b.linkPreference.setLinkOpening('new-tab') b.chatSettings.publish({ value: undefined }) @@ -387,20 +387,14 @@ describe('Chat inject API', () => { } }) - it('routes sent skill previews through the viewed Session source and tolerates an absent provider', async () => { + it('leaves skill preview injection to the skill reference owner', async () => { const b = await bench() - const { injected } = b.chatViewApi(b.rootReference) - injected.openSkill('review') - const openReference = vi.fn(() => true) - const sessionOf = vi.fn(() => ({ openReference })) - b.runtime.ctx.provide('inputTriggers', { sessionOf } as never) - injected.openSkill('review') - expect(sessionOf).toHaveBeenCalledWith(b.runtime.sessions.scope(ROOT)) - expect(openReference).toHaveBeenCalledWith('skill', { ref: '/review' }) - vi.spyOn(b.runtime.sessions, 'scope').mockReturnValueOnce(undefined) - injected.openSkill('review') - expect(openReference).toHaveBeenCalledTimes(1) - await b.runtime.dispose() + try { + const { injected } = b.chatViewApi(b.rootReference) + expect(injected).not.toHaveProperty('openSkill') + } finally { + await b.runtime.dispose() + } }) it('keeps a relative path under the Session without a cwd, and addresses a path outside the workspace absolutely', async () => { diff --git a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx index e37c569f03..c366be70e6 100644 --- a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx +++ b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx @@ -3,15 +3,15 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { AssistantMarkdown, localPathMediaUrl } from '../src/client/chat/AssistantMarkdown.tsx' import { useDetailedPresentation } from './presentation-fixture.client.ts' +import { unusedImageLoader } from './reasoning-component-fixture.tsx' import { useDisclosure } from '../src/client/chat/use-disclosure.ts' import { useSearchableHidden } from '../src/client/chat/searchable-hidden.ts' -import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../src/client/contract/slots.ts' +import type { ChatViewSlotProps } from '../src/client/contract/slots.ts' import type { AssistantBlock } from '../src/client/contract/snapshot.ts' afterEach(() => { cleanup(); vi.restoreAllMocks() }) const t = ((_key: string) => 'label') as ChatViewSlotProps['t'] -const renderMessageImages = (() => null) as ChatNodeOwnerProps['renderMessageImages'] function textBlock(text: string): AssistantBlock { return { kind: 'text', text } @@ -63,7 +63,7 @@ describe('AssistantMarkdown local-path images', () => { usePresentation={useDetailedPresentation} blocks={[textBlock('See ![diagram](/tmp/graph.png) for the layout.')]} streaming={false} - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} t={t} />, ) @@ -81,7 +81,7 @@ describe('AssistantMarkdown local-path images', () => { usePresentation={useDetailedPresentation} blocks={[textBlock('See ![diagram](relative.png).')]} streaming={false} - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} t={t} />, ) diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index 46c0bda457..c449d30d89 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -30,6 +30,7 @@ import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings import { ActivityPill, UsagePill } from '../src/client/chat/StatsPills.tsx' import { createFlowMotion } from '../src/client/chat/flow-motion.ts' import { CHAT_FLOW_INJECT, CHAT_NODE_INJECT } from '../src/client/apply.ts' +import { UserContent } from '../src/client/chat/UserContent.tsx' declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ConversationTurnDataMap { @@ -101,6 +102,7 @@ describe('Chat apply wiring', () => { expect(entry).not.toHaveProperty('CHAT_FLOW_INJECT') expect(entry).not.toHaveProperty('CHAT_NODE_INJECT') expect(entry).not.toHaveProperty('ChatFlow') + expect(entry).not.toHaveProperty('UserContent') }) it('registers the frame-wide quota notice host and keeps the failure row injection-free', async () => { @@ -140,23 +142,37 @@ describe('Chat apply wiring', () => { await b.runtime.dispose() }) - it('owns node and image rendering through the flow slot with the shared Chat store', async () => { + it('owns node dispatch in the shared flow and image Slots in their domain renderers', async () => { const b = await bench() onTestFinished(() => b.runtime.dispose()) + b.runtime.renderRoot() expect(b.runtime.slots.spec('conversation.chat.flow')).toMatchObject({ kind: 'single', scope: 'session' }) const view = b.runtime.slots.entries('conversation.view')[0] const flow = b.runtime.slots.entries('conversation.chat.flow')[0] + const assistant = b.runtime.slots.entries('conversation.chat.node').find(entry => entry.options.key === 'assistant-step') + const userContent = b.runtime.factoryOf('conversation.chat.user.content') + expect(userContent).toMatchObject({ scope: 'session', locale: 'chat', component: UserContent }) + expect(userContent.children?.['conversation.chat.user.images']).toMatchObject({ kind: 'single', scope: 'session' }) + expect(assistant?.children?.['conversation.chat.message.images']).toMatchObject({ kind: 'single', scope: 'session' }) + expect(userContent.store).toBeUndefined() + expect(userContent.inject).toBeUndefined() expect(view?.children?.['conversation.chat.flow']).toBeDefined() expect(view?.children?.['conversation.chat.node']).toBeUndefined() - expect(view?.children?.['conversation.message.images']).toBeUndefined() + expect(view?.children?.['conversation.chat.message.images']).toBeUndefined() + expect(view?.children?.['conversation.chat.user.images']).toBeUndefined() expect(flow?.children?.['conversation.chat.node']).toBeDefined() - expect(flow?.children?.['conversation.message.images']).toBeDefined() + expect(flow?.children?.['conversation.chat.message.images']).toBeUndefined() + expect(flow?.children?.['conversation.chat.user.images']).toBeUndefined() + expect(userContent.children?.['conversation.chat.message.images']).toBeUndefined() + expect(assistant?.children?.['conversation.chat.user.images']).toBeUndefined() expect(flow?.store).toBeDefined() expect(flow?.store).toBe(view?.store) await b.chat.dispose() expect(b.runtime.slots.spec('conversation.chat.flow')).toBeUndefined() expect(b.runtime.slots.spec('conversation.chat.node')).toBeUndefined() - expect(b.runtime.slots.spec('conversation.message.images')).toBeUndefined() + expect(b.runtime.slots.spec('conversation.chat.message.images')).toBeUndefined() + expect(b.runtime.slots.spec('conversation.chat.user.images')).toBeUndefined() + expect(() => b.runtime.factoryOf('conversation.chat.user.content')).toThrow('no definition') }) it('lets another registrant replace one composer stats pill by id', async () => { @@ -200,7 +216,7 @@ describe('Chat apply wiring', () => { expect(face.hooks.transcriptView.getSnapshot()).toBe('compact') }) - it('resets in-memory collapse timing when the Chat plugin remounts', async () => { + it('restores saved collapse timing when the Chat plugin remounts', async () => { const b = await bench() try { const row = b.runtime.slots.entries('settings.general.item') @@ -211,8 +227,8 @@ describe('Chat apply wiring', () => { expect(hooks.collapseTiming.getSnapshot()).toBe('completion') setCollapseTiming('next-input') expect(hooks.collapseTiming.getSnapshot()).toBe('next-input') - expect(b.chatSettings.set).not.toHaveBeenCalled() - b.chatSettings.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'standard', performanceUsage: 'detailed' }, revision: 1, writable: true }) + expect(b.chatSettings.set).toHaveBeenCalledExactlyOnceWith('collapseTiming', 'next-input') + b.chatSettings.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'standard', performanceUsage: 'detailed', collapseTiming: 'next-input' }, revision: 1, writable: true }) expect(hooks.transcriptView.getSnapshot()).toBe('standard') expect(hooks.collapseTiming.getSnapshot()).toBe('next-input') expect(b.runtime.slots.entries('settings.general.item').map(entry => entry.options.id)) @@ -226,9 +242,9 @@ describe('Chat apply wiring', () => { const remounted = remountedRow.inject!() const remountedHooks = remounted.hooks as TranscriptViewRowInjected['hooks'] expect(remountedHooks.collapseTiming).not.toBe(hooks.collapseTiming) - expect(remountedHooks.collapseTiming.getSnapshot()).toBe('completion') + expect(remountedHooks.collapseTiming.getSnapshot()).toBe('next-input') expect(remountedHooks.transcriptView.getSnapshot()).toBe('standard') - expect(b.chatSettings.set).not.toHaveBeenCalled() + expect(b.chatSettings.set).toHaveBeenCalledExactlyOnceWith('collapseTiming', 'next-input') expect(b.runtime.slots.entries('settings.general.item').map(entry => entry.options.id)) .toEqual(['transcript-view', 'link-opening', 'composer-enter', 'performance-usage']) } finally { diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 7954e2cb45..fa922ee1e1 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -7,7 +7,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import type { ChatConversationViewNode, ConversationNode, } from '@deepseek-ai/dsh-client-ui-chat/client' -import type { ChatNodeViewProps } from '../src/client/contract/slots.ts' +import type { ChatNodeViewProps, UserContentInput } from '../src/client/contract/slots.ts' import { formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay, } from '../src/client/chat/message-chrome.ts' @@ -15,8 +15,10 @@ import { CompactionNodeView, ContextMessageNodeView, RetryNodeView, UnknownNodeView, UserMessageNodeView, } from '../src/client/chat/MessageItem.tsx' -import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' +import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' import { useDetailedPresentation } from './presentation-fixture.client.ts' +import { unusedImageLoader } from './reasoning-component-fixture.tsx' +import { createUserContentFixture } from './user-content-component-fixture.tsx' import { useDisclosure } from '../src/client/chat/use-disclosure.ts' import { useSearchableHidden } from '../src/client/chat/searchable-hidden.ts' import { ActivityPill, UsagePill, type StatPillProps } from '../src/client/chat/StatsPills.tsx' @@ -30,7 +32,9 @@ afterEach(() => { }) const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh) -const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null +const renderUserImages: Parameters>[1] = () => null +let renderUserContent: ReturnType +beforeEach(() => { renderUserContent = createUserContentFixture() }) const RETRY_ID = 'retry-fixture' as Extract['retryId'] // Recency scans the whole transcript; a detached fixture is its own latest row. @@ -67,9 +71,14 @@ function MessageItem({ node, t: translate, referenceLabels, skillNames }: Messag } : node, } - const props = { - node: viewNode, t: translate, renderMessageImages, openFile: vi.fn(), openSkill: vi.fn(), useChat: useDetachedChat, - } as unknown as ChatNodeViewProps + const renderUserFactory: ChatNodeViewProps['renderFactorySlot'] = (name, input) => { + if (name !== 'conversation.chat.user.content') throw new Error(`Unexpected user Factory: ${name}`) + return renderUserContent(input as UserContentInput, renderUserImages, translate) + } + const props: Partial> & { node: ChatConversationViewNode } = { + node: viewNode, t: translate, loadImage: unusedImageLoader, renderFactorySlot: renderUserFactory, + openFile: vi.fn(), useChat: useDetachedChat, + } switch (node.kind) { case 'user': case 'steering': @@ -168,16 +177,21 @@ describe('MessageItem arms', () => { // Same-day clock: construct "today at 14:24" so the label stays `HH:mm`. const now = new Date() const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime() - render( + const view = render( , ) expect(screen.getByText('14:24')).toBeTruthy() expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + const stack = view.getByText('hello bubble').closest('div')?.parentElement + expect(stack?.parentElement).toBe(view.container.firstElementChild) + expect(stack?.contains(screen.getByText('14:24'))).toBe(false) + expect(stack?.contains(screen.getByRole('button', { name: '复制' }))).toBe(false) + expect(stack?.nextElementSibling?.contains(screen.getByRole('button', { name: '复制' }))).toBe(true) expect(screen.queryByRole('button', { name: '在新对话中分支' })).toBeNull() expect(screen.queryByRole('button', { name: '编辑' })).toBeNull() fireEvent.click(screen.getByRole('button', { name: '复制' })) @@ -1051,7 +1065,7 @@ describe('small branch tails', () => { t={t} blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} />, ) expect(view.getByText('one-liner')).toBeTruthy() diff --git a/packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts b/packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts index ef52f7a258..d72295e212 100644 --- a/packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts @@ -18,15 +18,20 @@ function node(index: number, owner = turn(index), text = ''): ChatConversationVi } } +/** A Node whose Turn opens an exchange. */ +function opening(index: number, owner = turn(index), text = ''): ChatConversationViewNode { + return { ...node(index, owner, text), groupTree: 'exchange' } +} + function timeline(turns: readonly TurnLocation[]): ConversationTimelineSnapshot { return { turnOrder: turns.map(value => value.turn), turns: new Map(turns.map(value => [value.turn, value])) } } describe('assembler-owned Chat bottom membership', () => { - it('notifies only old and new bottom keys among 4,000 observed historical Nodes', () => { + it('notifies only the Turns leaving or entering the latest exchange among 4,000 observed historical Nodes', () => { const builder = new ChatSnapshotBuilder() const turns = Array.from({ length: 4_000 }, (_, index) => turn(index + 1)) - const nodes = turns.map(value => node(value.turn, value)) + const nodes = turns.map(value => opening(value.turn, value)) const snapshot = builder.replace({ nodes, timeline: timeline(turns) }) builder.publish() const sources = nodes.map(value => snapshot.nodes.bottomSource(value.key)) @@ -36,7 +41,7 @@ describe('assembler-owned Chat bottom membership', () => { return listener }) const nextTurn = turn(4_001) - const nextNode = node(4_001, nextTurn) + const nextNode = opening(4_001, nextTurn) const nextSource = snapshot.nodes.bottomSource(nextNode.key) const nextChanged = vi.fn() nextSource.subscribe(nextChanged) @@ -55,17 +60,17 @@ describe('assembler-owned Chat bottom membership', () => { expect(snapshot.nodes.bottomSource(nextNode.key)).toBe(nextSource) nextChanged.mockClear() - builder.apply({ upserts: [node(4_001, nextTurn, 'updated')], timeline: timeline([...turns, nextTurn]) }) + builder.apply({ upserts: [opening(4_001, nextTurn, 'updated')], timeline: timeline([...turns, nextTurn]) }) builder.publish() expect(nextChanged).not.toHaveBeenCalled() expect(listeners.at(-1)).toHaveBeenCalledOnce() }) - it('advances on a Turn boundary before its Node materializes and retains source identity across replacement', () => { + it('keeps a bare Turn boundary and continuation Turns in the latest exchange until an opening Node arrives', () => { const builder = new ChatSnapshotBuilder() const first = turn(1) const second = turn(2) - const initial = builder.replace({ nodes: [node(1, first)], timeline: timeline([first]) }) + const initial = builder.replace({ nodes: [opening(1, first)], timeline: timeline([first]) }) builder.publish() const oldBottom = initial.nodes.bottomSource('user:1') const nextBottom = initial.nodes.bottomSource('user:2') @@ -73,26 +78,92 @@ describe('assembler-owned Chat bottom membership', () => { const unsubscribe = oldBottom.subscribe(changed) builder.apply({ upserts: [], timeline: timeline([first, second]) }) builder.publish() - expect(changed).toHaveBeenCalledOnce() - expect(oldBottom.getSnapshot()).toBe(false) - expect(nextBottom.getSnapshot()).toBe(false) + expect(changed).not.toHaveBeenCalled() + expect(oldBottom.getSnapshot()).toBe(true) + builder.apply({ upserts: [node(2, second)], timeline: timeline([first, second]) }) builder.publish() - expect(nextBottom.getSnapshot()).toBe(true) + expect(changed).not.toHaveBeenCalled() + expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([true, true]) + + builder.apply({ upserts: [opening(2, second)], timeline: timeline([first, second]) }) + builder.publish() + expect(changed).toHaveBeenCalledOnce() + expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([false, true]) unsubscribe() - const replaced = builder.replace({ nodes: [node(1, first)], timeline: timeline([first]) }) + + const replaced = builder.replace({ nodes: [opening(1, first)], timeline: timeline([first]) }) builder.publish() expect(replaced.nodes.bottomSource('user:1')).toBe(oldBottom) - expect(oldBottom.getSnapshot()).toBe(true) - expect(nextBottom.getSnapshot()).toBe(false) - expect(changed).toHaveBeenCalledOnce() + expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([true, false]) + + builder.replace({ nodes: [node(1, first), node(2, second)], timeline: timeline([first, second]) }) + builder.publish() + expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([true, true]) + }) + + it('starts the latest exchange at the first loaded Turn while no opening Node is loaded', () => { + const builder = new ChatSnapshotBuilder() + const turns = [turn(3), turn(4), turn(5)] + const initial = builder.replace({ nodes: turns.map(value => node(value.turn, value)), timeline: timeline(turns) }) + builder.publish() + const bottoms = ['user:3', 'user:4', 'user:5'].map(key => initial.nodes.bottomSource(key)) + expect(bottoms.map(source => source.getSnapshot())).toEqual([true, true, true]) + + const older = [turn(1), turn(2), ...turns] + const loaded = builder.replace({ nodes: older.map(value => node(value.turn, value)), timeline: timeline(older) }) + builder.publish() + expect(['user:1', 'user:2'].map(key => loaded.nodes.bottomSource(key).getSnapshot())).toEqual([true, true]) + + builder.replace({ nodes: [], timeline: timeline([]) }) + builder.publish() + expect(bottoms.map(source => source.getSnapshot())).toEqual([false, false, false]) + }) + + it('follows the latest remaining opening Turn when opening Nodes leave, move, or repeat in one Turn', () => { + const builder = new ChatSnapshotBuilder() + const turns = [turn(1), turn(2), turn(3), turn(4)] + const second: ChatConversationViewNode = { ...opening(22, turns[1]), key: 'user:2b' } + const nodes = [opening(1, turns[0]), opening(2, turns[1]), second, node(3, turns[2]), opening(4, turns[3])] + const initial = builder.replace({ nodes, timeline: timeline(turns) }) + builder.publish() + const bottom = (key: string): boolean => initial.nodes.bottomSource(key).getSnapshot() + const membership = (): boolean[] => ['user:1', 'user:2', 'user:3', 'user:4'].map(bottom) + expect(membership()).toEqual([false, false, false, true]) + + const before = vi.fn() + const unchanged = vi.fn() + initial.nodes.bottomSource('user:2').subscribe(before) + initial.nodes.bottomSource('user:1').subscribe(unchanged) + builder.apply({ upserts: [node(4, turns[3])], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([false, true, true, true]) + expect(before).toHaveBeenCalledOnce() + expect(unchanged).not.toHaveBeenCalled() + + builder.apply({ upserts: [node(2, turns[1])], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([false, true, true, true]) + + builder.apply({ upserts: [{ ...second, location: { kind: 'session' } }], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([true, true, true, true]) + + builder.apply({ upserts: [{ ...opening(3, turns[2]), visibility: 'hidden' }], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([false, false, false, true]) + expect(bottom('user:2b')).toBe(false) + + builder.replace({ nodes: [opening(1, turns[0]), node(2, turns[1]), node(4, turns[3])], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([true, true, false, true]) }) it('updates same-key Turn moves without changing root order and excludes unplaced or hidden Nodes', () => { const builder = new ChatSnapshotBuilder() const first = turn(1) const second = turn(2) - const initial = builder.replace({ nodes: [node(1, first)], timeline: timeline([first, second]) }) + const initial = builder.replace({ nodes: [node(1, first), opening(2, second)], timeline: timeline([first, second]) }) builder.publish() const bottom = initial.nodes.bottomSource('user:1') expect(bottom.getSnapshot()).toBe(false) diff --git a/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx b/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx index 8af4596b19..b212f91940 100644 --- a/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx @@ -26,7 +26,7 @@ function ScrollHarness({ input, onMotion }: { const state = useChatScroll(input) useLayoutEffect(() => { onMotion(state.motion) }, [onMotion, state.motion]) return <> -
+
@@ -34,7 +34,7 @@ function ScrollHarness({ input, onMotion }: { } -function mountScroll(deferCompletedTurns = true) { +function mountScroll(deferCompletedTurns = true, initial: Partial = {}) { let input: ChatScrollInput = { ready: true, order: [], @@ -45,12 +45,14 @@ function mountScroll(deferCompletedTurns = true) { submissionId: null, running: false, deferCompletedTurns, + transcriptView: 'verbose', loadedTurns: [], hasMore: false, loadingOlder: false, loadOlder: vi.fn(), loadThrough: vi.fn(async () => {}), chatScroll: { read: () => null, save: vi.fn() }, + ...initial, } const callbacks: { motion?: FlowMotionRows; resize?: () => void } = {} class Observer implements ResizeObserver { @@ -98,7 +100,243 @@ function mountScroll(deferCompletedTurns = true) { } } +const visibleFoldCases = [ + { name: 'E/E', earlier: 2400, steps: 1000, body: 400, viewport: 800, top: 800, input: 80, target: 2080, room: 0 }, + { name: 'E/T', earlier: 600, steps: 1000, body: 100, viewport: 800, top: 200, input: 80, target: 200, room: 220 }, + { name: 'E/B', earlier: 500, steps: 200, body: 400, viewport: 800, top: 200, input: 80, target: 200, room: 20 }, + { name: 'T/T', earlier: 500, steps: 2000, body: 100, viewport: 800, top: 900, input: 80, target: 500, room: 620 }, + { name: 'T/B', earlier: 500, steps: 1200, body: 400, viewport: 800, top: 1200, input: 80, target: 500, room: 320 }, + { name: 'B/B', earlier: 2400, steps: 1600, body: 1000, viewport: 800, top: 4200, input: 80, target: 2680, room: 0 }, + { name: 'short content', earlier: 100, steps: 300, body: 100, viewport: 800, top: 0, input: 80, target: 0, room: 0 }, + { name: 'larger input', earlier: 2000, steps: 300, body: 600, viewport: 800, top: 2100, input: 400, target: 2200, room: 0 }, + { name: 'body fills viewport', earlier: 900, steps: 300, body: 800, viewport: 800, top: 1200, input: 40, target: 940, room: 0 }, + { name: 'steps fill viewport', earlier: 900, steps: 800, body: 0, viewport: 800, top: 900, input: 0, target: 900, room: 800 }, + { name: 'input fills visible steps', earlier: 900, steps: 300, body: 500, viewport: 800, top: 900, input: 300, target: 900, room: 0 }, + { name: 'no steps', earlier: 900, steps: 0, body: 500, viewport: 800, top: 600, input: 40, target: 640, room: 0 }, + { name: 'input taller than viewport', earlier: 900, steps: 1200, body: 100, viewport: 800, top: 1400, input: 1200, target: 1400, room: 0 }, + { name: 'small viewport', earlier: 200, steps: 900, body: 100, viewport: 300, top: 900, input: 30, target: 200, room: 170 }, +] as const + +function mountVisibleFold(test: typeof visibleFoldCases[number], reduced: boolean) { + let inputHeight = 0 + let submission = 0 + let top: number = test.top + let motion: FlowMotionRows | undefined + const onMotion = (value: FlowMotionRows): void => { motion = value } + let input: ChatScrollInput = { + ready: false, order: [], firstSeq: null, lastKey: null, lastIsUser: false, + steeringId: null, submissionId: null, running: false, deferCompletedTurns: true, + transcriptView: 'detailed', loadedTurns: [], hasMore: false, loadingOlder: false, + loadOlder: vi.fn(), loadThrough: vi.fn(async () => {}), chatScroll: { read: () => null, save: vi.fn() }, + } + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: reduced }))) + const view = render() + const scroller = within(view.container).getByTestId('scrollport') + const column = within(view.container).getByTestId('column') + const spacer = scroller.querySelector('[data-chat-turn-spacer]')! + const row = document.createElement('div') + const glyph = document.createElement('span') + glyph.textContent = 'step text' + row.append(glyph) + column.append(row) + const rowHeight = () => row.hidden ? 0 : (row.style.transition !== '' && row.style.transition !== 'none') || row.style.height === '' + ? test.steps : Number.parseFloat(row.style.height) + const room = () => Number.parseFloat(spacer.style.height) || 0 + const contentHeight = () => test.earlier + rowHeight() + test.body + inputHeight + const floor = () => Math.max(0, contentHeight() + room() - test.viewport) + const scrollTo = vi.fn((options: ScrollToOptions) => { + scroller.scrollTop = options.top ?? scroller.scrollTop + }) + Object.defineProperties(scroller, { + clientHeight: { get: () => test.viewport }, + scrollHeight: { get: () => Math.max(test.viewport, contentHeight() + room()) }, + scrollTop: { + get: () => { top = Math.max(0, Math.min(top, floor())); return top }, + set: (value: number) => { top = Math.max(0, Math.min(value, floor())) }, + }, + scrollTo: { value: scrollTo }, + getBoundingClientRect: { value: () => ({ top: 0, bottom: test.viewport, height: test.viewport }) }, + }) + Object.defineProperties(row, { + offsetHeight: { get: rowHeight }, + getBoundingClientRect: { value: () => ({ top: test.earlier - top, bottom: test.earlier - top + rowHeight(), height: rowHeight() }) }, + }) + Object.defineProperties(column, { + offsetHeight: { get: contentHeight }, + getBoundingClientRect: { value: () => ({ top: -top, bottom: contentHeight() - top, height: contentHeight() }) }, + }) + input = { ...input, ready: true } + view.rerender() + top = test.top + scrollTo.mockClear() + return { + scroller, row, room, scrollTo, + reopenAll(verbose = false) { + act(() => { motion?.cancelMotion(row) }) + row.hidden = false + if (verbose) { + input = { ...input, transcriptView: 'verbose' } + view.rerender() + } + }, + growDuringFold(height: number, newSubmission: boolean) { + inputHeight += height + input = newSubmission + ? { ...input, submissionId: 'second-distinct-input' } + : { ...input, order: ['new-stream-content'], running: true } + view.rerender() + }, + cancelReading() { + fireEvent.wheel(scroller, { deltaY: -20 }) + scroller.scrollTop -= 20 + fireEvent.scroll(scroller) + return scroller.scrollTop + }, + updateInput() { + input = { ...input, submissionId: null, lastKey: 'durable-input', lastIsUser: true } + view.rerender() + }, + bodyTop: () => test.earlier + rowHeight() - top, + submit() { + inputHeight += test.input + row.hidden = false + submission++ + act(() => { + motion?.collapse(row, () => { row.hidden = true }) + }) + input = { ...input, submissionId: `visible-fold-input-${submission}` } + view.rerender() + }, + } +} + +describe.each([false, true])('visible input-fold matrix (reduced=%s)', (reduced) => { + it.each(visibleFoldCases)('$name lands with only the required visible room', async (test) => { + const h = mountVisibleFold(test, reduced) + const positions = [h.bodyTop()] + h.submit() + await act(async () => { await Promise.resolve() }) + for (let frame = 0; frame < 24; frame++) { + act(() => { vi.advanceTimersByTime(16) }) + positions.push(h.bodyTop()) + } + expect(h.scroller.scrollTop).toBeCloseTo(test.target, 5) + expect(h.room()).toBeCloseTo(test.room, 5) + if (test.steps > 0) expect(h.scrollTo.mock.calls.some(([options]) => options.behavior === 'smooth')).toBe(false) + for (let i = 1; i < positions.length; i++) expect(positions[i]!).toBeLessThanOrEqual(positions[i - 1]! + 0.01) + const end = h.bodyTop() + act(() => { vi.advanceTimersByTime(500) }) + expect(h.bodyTop()).toBe(end) + }) +}) + +it('consumes a prior legal fold deficit instead of accumulating whole steps on the next input', async () => { + const test = visibleFoldCases.find(test => test.name === 'T/B')! + const h = mountVisibleFold(test, false) + h.submit() + await act(async () => { await Promise.resolve() }) + act(() => { vi.advanceTimersByTime(400) }) + expect(h.room()).toBe(320) + h.submit() + await act(async () => { await Promise.resolve() }) + act(() => { vi.advanceTimersByTime(400) }) + expect(h.scroller.scrollTop).toBe(500) + expect(h.room()).toBe(240) + expect(h.scrollTo).not.toHaveBeenCalled() +}) + +it('retains the reader position without clamping after an input fold is interrupted', async () => { + const test = visibleFoldCases.find(test => test.name === 'B/B')! + const h = mountVisibleFold(test, false) + h.submit() + await act(async () => { await Promise.resolve() }) + act(() => { vi.advanceTimersByTime(112) }) + const readingTop = h.cancelReading() + act(() => { vi.advanceTimersByTime(400) }) + expect(h.scroller.scrollTop).toBe(readingTop) + expect(h.scrollTo.mock.calls.some(([options]) => options.behavior === 'smooth')).toBe(false) +}) + +it.each([false, true])('stops obsolete viewport writes when every closing row reopens (verbose=%s)', async (verbose) => { + const test = visibleFoldCases.find(test => test.name === 'T/B')! + const h = mountVisibleFold(test, false) + h.submit() + await act(async () => { await Promise.resolve() }) + act(() => { vi.advanceTimersByTime(112) }) + h.reopenAll(verbose) + await act(async () => { await Promise.resolve() }) + const position = h.scroller.scrollTop + act(() => { vi.advanceTimersByTime(400) }) + expect(h.room()).toBe(0) + expect(h.scroller.scrollTop).toBe(position) + expect(h.row.hidden).toBe(false) + expect(h.row.style.height).toBe('') + expect(h.scroller.querySelector('[data-input-fold]')).toBeNull() + expect(h.scrollTo.mock.calls.some(([options]) => options.behavior === 'smooth')).toBe(false) +}) + +it.each([false, true])('absorbs new height before the active fold finishes (new submission=%s)', async (newSubmission) => { + const test = visibleFoldCases.find(test => test.name === 'B/B')! + const h = mountVisibleFold(test, false) + h.submit() + await act(async () => { await Promise.resolve() }) + act(() => { vi.advanceTimersByTime(112) }) + h.growDuringFold(320, newSubmission) + await act(async () => { await Promise.resolve() }) + const positions = [h.bodyTop()] + for (let frame = 0; frame < 12; frame++) { + act(() => { vi.advanceTimersByTime(16) }) + positions.push(h.bodyTop()) + } + expect(h.scroller.scrollTop).toBe(test.target + 320) + expect(h.room()).toBe(0) + for (let i = 1; i < positions.length; i++) expect(positions[i]!).toBeLessThanOrEqual(positions[i - 1]! + 0.01) + const settled = h.scroller.scrollTop + act(() => { vi.advanceTimersByTime(500) }) + expect(h.scroller.scrollTop).toBe(settled) + expect(h.scrollTo.mock.calls.some(([options]) => options.behavior === 'smooth')).toBe(false) +}) + +it('keeps one active fold when the local echo becomes its durable input', async () => { + const test = visibleFoldCases.find(test => test.name === 'B/B')! + const h = mountVisibleFold(test, false) + h.submit() + await act(async () => { await Promise.resolve() }) + act(() => { vi.advanceTimersByTime(96) }) + const halfway = h.bodyTop() + h.updateInput() + await act(async () => { await Promise.resolve() }) + act(() => { vi.advanceTimersByTime(400) }) + expect(h.scroller.scrollTop).toBe(test.target) + expect(h.room()).toBe(0) + expect(h.bodyTop()).toBeLessThanOrEqual(halfway) + expect(h.scrollTo.mock.calls.some(([options]) => options.behavior === 'smooth')).toBe(false) +}) + describe('Chat scroll collapse timing', () => { + it.each(['resize', 'scroll', 'history', 'input', 'preference'] as const)( + 'waits for initial history before initializing a running view after %s', (change) => { + const h = mountScroll(true, { ready: false, running: true }) + expect(h.scroller.dataset.initialized).toBe('false') + if (change === 'resize') h.resize() + else if (change === 'scroll') fireEvent.scroll(h.scroller) + else if (change === 'history') h.update({ order: ['history'], lastKey: 'history' }) + else if (change === 'input') h.update({ submissionId: 'pending-input' }) + else h.update({ transcriptView: 'standard' }) + expect(h.scroller.dataset.initialized).toBe('false') + expect(h.scroller.scrollTop).toBe(0) + expect(h.scrollTo).not.toHaveBeenCalled() + + h.update({ ready: true }) + expect(h.scroller.dataset.initialized).toBe('true') + expect(h.scroller.scrollTop).toBe(600) + expect(h.scrollTo).not.toHaveBeenCalled() + h.grow(1_200) + h.resize() + expect(h.scroller.scrollTop).toBe(800) + }, + ) + it('does not defer one viewport\'s submitted input behind another viewport\'s fold', () => { const first = mountScroll() const second = mountScroll() @@ -123,51 +361,90 @@ describe('Chat scroll collapse timing', () => { } }) - it('coalesces submitted inputs into one follow after the last row closes', () => { + it('coalesces submitted inputs into one frame-owned fold', async () => { const h = mountScroll() h.startFold() h.update({ submissionId: 'first' }) h.update({ submissionId: 'second' }) expect(h.scrollTo).not.toHaveBeenCalled() expect(h.scroller.scrollTop).toBe(0) - h.finishFold() - expect(h.scrollTo).toHaveBeenCalledExactlyOnceWith({ top: 600, behavior: 'smooth' }) + await act(async () => { await Promise.resolve() }) + act(() => { vi.advanceTimersByTime(400) }) + expect(h.scrollTo).not.toHaveBeenCalled() + expect(h.scroller.scrollTop).toBe(520) expect(h.scroller.style.overflowAnchor).toBe('') }) - it('keeps excess collapse as bottom room without reversing the scrollport', async () => { - const h = mountScroll() - h.scroller.scrollTop = 600 - h.startFold() + it('keeps only the visible deficit when submitted input is smaller than the fold', async () => { + const test = visibleFoldCases.find(test => test.name === 'T/B')! + const h = mountVisibleFold(test, false) + h.submit() await act(async () => { await Promise.resolve() }) - h.grow(1_060) - h.update({ submissionId: 'new-input' }) - h.grow(1_020) - act(() => { vi.advanceTimersByTime(20) }) - expect(h.scroller.scrollTop).toBe(600) - h.grow(980) - act(() => { vi.advanceTimersByTime(20) }) - expect(h.scroller.scrollTop).toBe(600) - fireEvent.scroll(h.scroller) - expect(h.scroller.scrollTop).toBe(600) - h.finishFold() - expect(h.scroller.scrollTop).toBe(600) + act(() => { vi.advanceTimersByTime(400) }) + expect(h.scroller.scrollTop).toBe(500) + expect(h.room()).toBe(320) expect(h.scrollTo).not.toHaveBeenCalled() - expect(h.scroller.querySelector('[data-chat-turn-spacer]')?.style.height).toBe('20px') }) - it('follows only the distance left after a smaller fold', async () => { + it.each([false, true])('clears mode-change bottom space after the fold (reader interrupts=%s)', async (interrupt) => { const h = mountScroll() - h.scroller.scrollTop = 600 + h.resize() + h.startFold() + h.update({ transcriptView: 'standard' }) + await act(async () => { await Promise.resolve() }) + const spacer = h.scroller.querySelector('[data-chat-turn-spacer]')! + expect(spacer.style.height).toBe('80px') + h.grow(920) + if (interrupt) { + fireEvent.wheel(h.scroller, { deltaY: -100 }) + h.scroller.scrollTop = 400 + fireEvent.scroll(h.scroller) + } + h.finishFold() + expect(spacer.style.height).toBe('') + expect(h.scroller.scrollHeight).toBe(920) + expect(h.scroller.scrollTop).toBe(interrupt ? 400 : 520) + expect(h.row.hidden).toBe(true) + h.resize() + expect(spacer.style.height).toBe('') + }) + + it('clears a previous fold reservation on an idle mode change', async () => { + const h = mountScroll() + h.resize() h.startFold() await act(async () => { await Promise.resolve() }) - h.grow(1_200) - h.update({ submissionId: 'new-input' }) - h.grow(1_120) - act(() => { vi.advanceTimersByTime(20) }) - expect(h.scroller.scrollTop).toBe(600) + h.grow(920) h.finishFold() - expect(h.scrollTo).toHaveBeenCalledExactlyOnceWith({ top: 720, behavior: 'smooth' }) + h.resize() + expect(h.scroller.querySelector('[data-chat-turn-spacer]')!.style.height).toBe('80px') + h.update({ transcriptView: 'standard' }) + expect(h.scroller.querySelector('[data-chat-turn-spacer]')!.style.height).toBe('') + expect(h.scroller.scrollTop).toBe(520) + }) + + it('keeps the latest mode reset when preferences change before a fold finishes', async () => { + const h = mountScroll() + h.resize() + h.startFold() + h.update({ transcriptView: 'standard' }) + await act(async () => { await Promise.resolve() }) + h.update({ transcriptView: 'detailed' }) + h.grow(920) + h.finishFold() + expect(h.scroller.querySelector('[data-chat-turn-spacer]')!.style.height).toBe('') + expect(h.scroller.scrollTop).toBe(520) + }) + + it('combines the remaining input distance with the smaller fold', async () => { + const test = visibleFoldCases.find(test => test.name === 'larger input')! + const h = mountVisibleFold(test, false) + h.submit() + await act(async () => { await Promise.resolve() }) + act(() => { vi.advanceTimersByTime(400) }) + expect(h.scroller.scrollTop).toBe(2200) + expect(h.room()).toBe(0) + expect(h.scrollTo).not.toHaveBeenCalled() }) it.each(['wheel', 'touchstart', 'pointerdown', 'keydown'] as const)( @@ -200,7 +477,9 @@ describe('Chat scroll collapse timing', () => { h.update({ order: ['streaming-change'], running: true }) if (newInput) h.update({ submissionId: 'second' }) h.finishFold() - if (newInput) expect(h.scrollTo).toHaveBeenCalledExactlyOnceWith({ top: 600, behavior: 'smooth' }) + await act(async () => { await Promise.resolve() }) + act(() => { vi.advanceTimersByTime(400) }) + if (newInput) expect(h.scroller.scrollTop).toBe(520) else { expect(h.scrollTo).not.toHaveBeenCalled() expect(h.scroller.scrollTop).toBe(420) diff --git a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts index 1a0ed20d21..780d2e1f49 100644 --- a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts +++ b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts @@ -16,9 +16,10 @@ import { orderedVisibleChatNodes } from '../src/client/conversation-nodes/chat-s import { ChatTurnProcessProjector } from '../src/client/conversation-nodes/turn-process-presentation.ts' import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts' import { - isSubagentDelegationTool, sameTurnProcessSpec, TURN_PROCESS_INDEPENDENT_KINDS, + isSubagentDelegationTool, sameTurnProcessSpec, type TurnProcessSpec, } from '../src/client/contract/turn-process.ts' +import { isAtOrOutside, type ChatGroupTree } from '../src/client/contract/group-tree.ts' const EMPTY: readonly never[] = [] @@ -106,7 +107,8 @@ class FixtureNodeStore implements ChatNodeStore { private readonly sources = new Map>() private readonly processSources = new Map>() private readonly bottomSources = new Map>() - bottomTurn: number | undefined + /** First Turn of the latest loaded exchange. */ + bottomStart: number | undefined private readonly dirtyKeys = new Set() private readonly dirtyProcessKeys = new Set() private list: readonly ChatConversationViewNode[] = EMPTY @@ -122,8 +124,8 @@ class FixtureNodeStore implements ChatNodeStore { bottomSource(key: string): ReturnType { return cachedSource(this.bottomSources, key, () => new FixtureSource(() => { const location = this.get(key)?.location - return this.bottomTurn !== undefined && (location?.kind === 'turn' || location?.kind === 'step') - && location.turn.turn === this.bottomTurn + return this.bottomStart !== undefined && (location?.kind === 'turn' || location?.kind === 'step') + && location.turn.turn >= this.bottomStart })) } @@ -243,6 +245,19 @@ class FixtureTurnDataStore implements ConversationLocationDataStore !TURN_PROCESS_INDEPENDENT_KINDS.has(candidate.kind)) + const processStart = inTurn.find(candidate => !isAtOrOutside(candidate, 'turn')) ?? controlAnchor const inlineReasoning = answer?.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '') === true const candidate: TurnProcessSpec = { @@ -459,6 +475,7 @@ export function chatSnapshotFixture(input: { anchorSeq: spec.controlAnchorSeq - 0.1, location: { kind: 'turn', turn }, visibility: 'visible', + groupTree: 'turn', data: spec, }) } @@ -499,6 +516,7 @@ export function chatSnapshotFixture(input: { anchorSeq: endSeq, location: { kind: 'turn', turn }, visibility: 'visible', + groupTree: 'turn', data: tailData, }) } @@ -527,7 +545,11 @@ export function chatSnapshotFixture(input: { && previous.legacy.turnEnds === legacy.turnEnds ? previous.timeline : { turnOrder: [...turns.keys()], turns } - store.bottomTurn = timeline.turnOrder.at(-1) + const openingTurns = store.values().flatMap((node) => { + const location = node.location + return isAtOrOutside(node, 'exchange') && (location.kind === 'turn' || location.kind === 'step') ? [location.turn.turn] : [] + }) + store.bottomStart = openingTurns.length === 0 ? timeline.turnOrder[0] : Math.max(...openingTurns) const derived = timeline.turnOrder .map(turn => turnNavigationItem(turn, locations, store)) .filter((item): item is TurnNavigationItem => item !== undefined) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 2357acedc9..e334bbc039 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -2,7 +2,8 @@ import type { ProcessGroupData } from '../src/client/contract/process-groups.ts' import type { InboxState } from '@deepseek-ai/dsh-agent/types' -import type { GlobalStandardProps, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { GlobalStandardProps, PropsRenderFactories, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { useEffect, useMemo } from 'react' @@ -12,7 +13,7 @@ import type { ChatViewSlotProps, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode, LegacyConversationSlice, ModelRetryNode, StartedToolCall, SteeringMessageNode, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, - TranscriptViewMode, UserMessageNode, + TranscriptViewMode, UserContentInput, UserMessageNode, } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionListState, SessionSnapshot, @@ -54,6 +55,7 @@ import type { TurnProcessSpec } from '../src/client/contract/turn-process.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' import { installTurnNavigatorObserver } from './turn-navigator-fixture.ts' import { renderReasoningSlot } from './reasoning-component-fixture.tsx' +import { createUserContentFixture } from './user-content-component-fixture.tsx' import { ConversationGroupStore } from '../../ui-conversation/src/client/conversation/group-store.ts' function installGroupedSnapshot( @@ -261,6 +263,13 @@ declare const flowDispatch: ChatViewSlotProps['renderSlot'] type NodeRenderer = typeof nodeDispatch<'conversation.chat.node'> type FlowRenderer = typeof flowDispatch<'conversation.chat.flow'> type ImageRenderer = (owner: MessageImagesOwnerProps) => React.ReactNode +type HarnessStandardProps = ChatViewSlotProps & PropsRenderFactories + +function isImageOwner(owner: object): owner is MessageImagesOwnerProps { + return 'images' in owner && Array.isArray(owner.images) + && 'loadImage' in owner && typeof owner.loadImage === 'function' + && 'align' in owner && (owner.align === 'start' || owner.align === 'end') +} type ToolOwner = Pick & { readonly callId: string readonly toolName: string @@ -270,12 +279,9 @@ type ToolOwner = Pick & { const renderCommandSlot: React.ComponentProps['renderSlot'] = (_key, _owner, opts) => opts?.fallback ?? null const renderTurnTailSlot: React.ComponentProps['renderSlot'] = () => null -const assistantSlots: Pick, 'renderSlot'> = { - renderSlot: renderReasoningSlot, -} function HarnessChatFlow({ standard, owner, hookContext, renderSlot }: { - readonly standard: ChatViewSlotProps + readonly standard: HarnessStandardProps readonly owner: ChatFlowOwnerProps readonly hookContext: ChatFlowHookContext readonly renderSlot: ChatFlowSlotProps['renderSlot'] @@ -288,13 +294,14 @@ function HarnessChatFlow({ standard, owner, hookContext, renderSlot }: { return } -function HarnessChatNode({ standard, owner, hookContext, usePerformanceUsage, toolOwners, fallback }: { - readonly standard: ChatViewSlotProps +function HarnessChatNode({ standard, owner, hookContext, usePerformanceUsage, toolOwners, fallback, renderAssistantSlot }: { + readonly standard: HarnessStandardProps readonly owner: RoutedChatNodeOwner readonly hookContext: ChatNodeHookContext readonly usePerformanceUsage: React.ComponentProps['usePerformanceUsage'] readonly toolOwners: ToolOwner[] readonly fallback: React.ReactNode + readonly renderAssistantSlot: React.ComponentProps['renderSlot'] }) { const hooks = useMemo(() => ({ useTurnData: CHAT_NODE_INJECT.hooks.turnData(standard, hookContext), @@ -311,7 +318,7 @@ function HarnessChatNode({ standard, owner, hookContext, usePerformanceUsage, to case 'context': return case 'assistant-step': - return + return case 'command': return case 'manual-compaction': @@ -392,7 +399,8 @@ function makeHarness( key => grouped?.groupSource(key as GroupKey) ?? absentGroup, ) const openFile = vi.fn<(path: string) => Promise>().mockResolvedValue(undefined) - const openSkill = vi.fn<(name: string) => void>() + const openUserFile = vi.fn<(path: string) => void>() + const renderUserContent = createUserContentFixture({ openFile: openUserFile }) const loadOlder = vi.fn() const loadThrough = vi.fn<(seq: number) => Promise>().mockResolvedValue(undefined) // Mutable outline holder: tests swap the value and drive a re-render via set(). @@ -415,11 +423,19 @@ function makeHarness( const usePerformanceUsage = bindSnapshotSelector(performanceUsage) let nodeSlotOverride: NodeRenderer | undefined let imageSlotOverride: ImageRenderer | undefined + const renderImages = (name: string, owner: object, options?: { fallback?: React.ReactNode }) => { + if ((name !== 'conversation.chat.user.images' && name !== 'conversation.chat.message.images') || !isImageOwner(owner)) { + throw new Error(`Unexpected Chat image Slot: ${name}`) + } + return imageSlotOverride?.(owner) ?? options?.fallback ?? null + } + const renderAssistantSlot: React.ComponentProps['renderSlot'] = (name, owner, options) => + name === 'conversation.chat.message.images' ? renderImages(name, owner, options) : renderReasoningSlot(name, owner, options) const renderNodeSlot: NodeRenderer = (key, owner, opts) => { if (nodeSlotOverride !== undefined) return nodeSlotOverride(key, owner, opts) return + toolOwners={toolOwners} fallback={opts.fallback} renderAssistantSlot={renderAssistantSlot} /> } const renderFlowSlot: ChatFlowSlotProps['renderSlot'] = (key: string, owner: object, opts?: { fallback?: React.ReactNode @@ -428,11 +444,12 @@ function makeHarness( if (key === 'conversation.chat.node') { return renderNodeSlot(key, owner as RoutedChatNodeOwner, opts as Parameters[2]) } - if (key === 'conversation.message.images') { - return imageSlotOverride?.(owner as MessageImagesOwnerProps) ?? opts?.fallback ?? null - } throw new Error(`Undeclared flow child: ${key}`) } + const renderFactorySlot: PropsRenderFactories['renderFactorySlot'] = (name, owner) => { + if (name !== 'conversation.chat.user.content') throw new Error(`Unexpected Chat Factory: ${name}`) + return renderUserContent(owner as UserContentInput, renderImages, props.t) + } const renderSlot: FlowRenderer = (key, owner, opts) => { if (key !== 'conversation.chat.flow') throw new Error(`Undeclared ChatView child: ${key}`) if ('renderSlot' in owner || 'useStore' in owner || 'actions' in owner) { @@ -443,7 +460,8 @@ function makeHarness( // SessionProvider seat arrives with the session-scope child declaration; // ChatView never invokes it (pass-through stub). const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children} - const props: ChatViewSlotProps = { + const props: HarnessStandardProps = { + renderFactorySlot, usePanelInfo: selector => selector({ activePanelId: null }), sessionId: SID, useSession: bindSnapshotSelector(session.source), @@ -487,7 +505,6 @@ function makeHarness( openView, completeViewRequest: () => {}, openFile, - openSkill, openExternalLink: vi.fn(), loadOlder, loadThrough, @@ -518,7 +535,7 @@ function makeHarness( } return { set, setSession: session.set, setChat: chatSource.set, ChatView, props, - openFile, openSkill, loadOlder, loadThrough, openView, + openFile, openUserFile, loadOlder, loadThrough, openView, setOutline: (value: unknown) => { outlineValue = value }, chatScroll, forkAt, toolOwners, setPerformanceUsage: (mode: 'compact' | 'detailed') => { performanceUsage.set(mode) }, @@ -1851,6 +1868,32 @@ describe('ChatView', () => { expect(h.forkAt).toHaveBeenCalledWith(3) })) + it('keeps pending file mentions non-interactive until admission enables the reference injector', () => { + const pending: InboxState['next-step'][number] = { + id: MessageId('pending-file-mention'), role: 'user', source: { kind: 'user' }, + content: [{ type: 'text', text: 'Read @src/review.ts' }], + } + const h = makeHarness({}, { testInbox: { 'next-turn': [], 'next-step': [pending] }, running: true }) + const view = render() + const mention = view.getByText('review.ts') + expect(mention.closest('[data-pending-steering]')).not.toBeNull() + expect(view.queryByRole('button', { name: 'review.ts' })).toBeNull() + fireEvent.click(mention) + expect(h.openUserFile).not.toHaveBeenCalled() + expect(h.openFile).not.toHaveBeenCalled() + act(() => { + h.setSession({ testInbox: { 'next-turn': [], 'next-step': [] } }) + h.setChat({ nodes: [{ + kind: 'steering', messageId: pending.id, seq: 1, time: 1_000, + content: pending.content, source: pending.source, + }] }) + }) + expect(view.container.querySelector('[data-pending-steering]')).toBeNull() + fireEvent.click(view.getByRole('button', { name: 'review.ts' })) + expect(h.openUserFile).toHaveBeenCalledWith('src/review.ts') + expect(h.openFile).not.toHaveBeenCalled() + }) + it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => { const pending = { id: 'steer-occurrence-later' as never, @@ -1962,6 +2005,23 @@ describe('ChatView', () => { expect(renderedFlowKinds(view.container)).toEqual(['user', 'turn-process']) }) + it('matches echoes past an input Node whose payload carries no message source', () => { + const base = chatSnapshotFixture({ nodes: [user(1, 'question')] }) + const snapshot = new ChatSnapshotBuilder().replace({ + nodes: [...base.nodes.values(), { + key: 'plugin-input', id: 'plugin-input', kind: 'plugin-input', target: 'chat', anchorSeq: 2, + location: { kind: 'session' }, visibility: 'visible', groupTree: 'input', data: null, + }], + timeline: base.timeline, + }) + const h = makeHarness({}, { pendingSubmissions: [{ + requestId: 'pending' as never, placement: 'transcript', time: 1_000, text: 'pending text', attachments: [], + }] }, snapshot) + const view = render() + expect(view.getByText('pending text')).toBeTruthy() + expect(view.container.querySelector('[data-submission-echo]')).not.toBeNull() + }) + it.each(['ABC', 'ACB', 'BAC', 'BCA', 'CAB', 'CBA'])( 'uses the first local opening echo until Host admission identifies the input (%s)', (hostOrder) => { const pending: SessionSnapshot['pendingSubmissions'] = ['A', 'B', 'C'].map(text => ({ diff --git a/packages/client/ui-chat/tests/config.host.spec.ts b/packages/client/ui-chat/tests/config.host.spec.ts index a7fa8fa80a..b3ebb9b698 100644 --- a/packages/client/ui-chat/tests/config.host.spec.ts +++ b/packages/client/ui-chat/tests/config.host.spec.ts @@ -14,9 +14,9 @@ describe('ui-chat Host settings', () => { const configuration = await liveConfig(ctx, { Config, apply }) const { fiber } = configuration - expect(plainConfig(configuration.fiber.config)).toEqual({ performanceUsage: 'detailed', linkOpening: 'sidebar' }) + expect(plainConfig(configuration.fiber.config)).toEqual({ performanceUsage: 'detailed', linkOpening: 'sidebar', collapseTiming: 'completion' }) await configuration.update({ transcriptView: 'normal' }) - expect(plainConfig(configuration.fiber.config)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar' }) + expect(plainConfig(configuration.fiber.config)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar', collapseTiming: 'completion' }) for (const mode of ['expanded', 'compact', 'standard', 'detailed', 'verbose']) { await configuration.update({ transcriptView: mode }) expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: mode }) @@ -35,6 +35,21 @@ describe('ui-chat Host settings', () => { await fiber.dispose() }) + it('validates collapse timing and restores its saved config independently of work details', async () => { + const ctx = new Context() + const configuration = await liveConfig(ctx, { Config, apply }) + expect(plainConfig(configuration.fiber.config)).toMatchObject({ collapseTiming: 'completion' }) + await configuration.update({ transcriptView: 'compact', collapseTiming: 'next-input' }) + const saved = plainConfig(configuration.fiber.config) + if (typeof saved !== 'object' || saved === null) throw new Error('expected saved configuration object') + expect(saved).toMatchObject({ transcriptView: 'compact', collapseTiming: 'next-input' }) + await expect(configuration.update({ collapseTiming: 'never' })).rejects.toThrow() + await configuration.fiber.dispose() + const restored = await liveConfig(ctx, { Config, apply }, saved) + expect(plainConfig(restored.fiber.config)).toMatchObject({ transcriptView: 'compact', collapseTiming: 'next-input' }) + await restored.fiber.dispose() + }) + it('loads without a settings provider', async () => { const ctx = new Context() await expect(ctx.plugin({ Config, apply }).await()).resolves.toBeDefined() diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 3823a0dbd5..19083ffac8 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -659,6 +659,62 @@ describe('built-in conversation node Definitions', () => { } }) + it('opens exchanges only for human input claimed for Step 1; every other input stays an input', () => { + const human = textMessage('block-human', 'first task') + const steer = textMessage('block-steer', 'adjust course') + const notice = { ...textMessage('block-notice', 'background work finished'), source: { kind: 'schedule' } } + const idle = textMessage('block-idle', 'next task') + const bottomTurns = (current: ChatSnapshot): number[] => [...new Set(current.order.flatMap((key) => { + const location = current.nodes.get(key)?.location + return current.nodes.bottomSource(key).getSnapshot() && (location?.kind === 'turn' || location?.kind === 'step') + ? [location.turn.turn] + : [] + }))] + const inputs = (current: ChatSnapshot) => current.order + .map(key => current.nodes.get(key)) + .filter(candidate => candidate?.kind === 'user' || candidate?.kind === 'steering' || candidate?.kind === 'turn-trigger') + .map(candidate => [candidate?.kind, candidate?.groupTree ?? null]) + const value = assembler([ + at(1, 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [human] }), + at(2, 'turn/start', { turn: 1 }), + at(3, 'agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }), + at(4, 'step/start', { turn: 1, step: 1 }), + at(5, 'user/message', human, { surfaceOp: 'append' }), + at(6, 'step/end', { turn: 1, step: 1 }), + at(7, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [steer] }), + at(8, 'agent/inbox/spliced', { target: 'next-step', start: 0, removedCount: 1, inserted: [] }), + at(9, 'step/start', { turn: 1, step: 2 }), + at(10, 'user/message', steer, { surfaceOp: 'append' }), + at(11, 'assistant/message', { turn: 1, step: 2, message: assistantMessage('block-answer-1', 'done') }, { surfaceOp: 'append' }), + at(12, 'step/end', { turn: 1, step: 2 }), + at(13, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + // The host assembles the system prompt between the waking claim and Step 1. + at(14, 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [notice] }), + at(15, 'turn/start', { turn: 2 }), + at(16, 'agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }), + ]) + expect(inputs(snapshot(value))).toEqual([['user', 'exchange'], ['steering', 'input']]) + expect(bottomTurns(snapshot(value))).toEqual([1, 2]) + + value.append(at(17, 'step/start', { turn: 2, step: 1 })) + value.append(at(18, 'user/message', notice, { surfaceOp: 'append' })) + value.append(at(19, 'assistant/message', { turn: 2, step: 1, message: assistantMessage('block-answer-2', 'noted') }, { surfaceOp: 'append' })) + value.append(at(20, 'step/end', { turn: 2, step: 1 })) + value.append(at(21, 'turn/end', { turn: 2, reason: { kind: 'completed' } })) + value.flush() + expect(inputs(snapshot(value))).toEqual([['user', 'exchange'], ['steering', 'input'], ['turn-trigger', 'input']]) + expect(bottomTurns(snapshot(value))).toEqual([1, 2]) + + value.append(at(22, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [idle] })) + value.append(at(23, 'turn/start', { turn: 3 })) + value.append(at(24, 'agent/inbox/spliced', { target: 'next-step', start: 0, removedCount: 1, inserted: [] })) + value.append(at(25, 'step/start', { turn: 3, step: 1 })) + value.append(at(26, 'user/message', idle, { surfaceOp: 'append' })) + value.flush() + expect(inputs(snapshot(value)).at(-1)).toEqual(['steering', 'exchange']) + expect(bottomTurns(snapshot(value))).toEqual([3]) + }) + it('replays pending splice chains and scopes steering to the current claim', () => { const first = textMessage('claim-first', 'first') const second = textMessage('claim-second', 'second') diff --git a/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx index cf7b24532c..241a0faded 100644 --- a/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx @@ -6,12 +6,12 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' import { useDetailedPresentation } from './presentation-fixture.client.ts' +import { unusedImageLoader } from './reasoning-component-fixture.tsx' import { useDisclosure } from '../src/client/chat/use-disclosure.ts' import { useSearchableHidden } from '../src/client/chat/searchable-hidden.ts' import { zh } from '../src/client/locale.ts' const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) -const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null afterEach(cleanup) @@ -27,7 +27,7 @@ describe('tails', () => { { kind: 'other', block: { type: 'mystery' } }, ]} streaming - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} />, ) expect(view.getByText('思考')).toBeTruthy() @@ -40,7 +40,7 @@ describe('tails', () => { blocks={[{ kind: 'text', text: 'partial words' }]} streaming={false} interrupted - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} />, ) expect(stopped.getByText('已停止')).toBeTruthy() @@ -55,14 +55,14 @@ describe('tails', () => { t={t} blocks={[{ kind: 'tool-call', callId: 'c', name: 'todo_write', argsRaw: '{}' }]} streaming={false} - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} />, ) expect(empty.container.firstChild).toBeNull() const blank = render( null} useDisclosure={useDisclosure} useGroupAction={useSearchableHidden} usePresentation={useDetailedPresentation} - t={t} blocks={[]} streaming={false} renderMessageImages={renderMessageImages} />, + t={t} blocks={[]} streaming={false} loadImage={unusedImageLoader} />, ) expect(blank.container.firstChild).toBeNull() }) diff --git a/packages/client/ui-chat/tests/flow-motion.client.spec.tsx b/packages/client/ui-chat/tests/flow-motion.client.spec.tsx index 38c771bdbc..5eb8c2f853 100644 --- a/packages/client/ui-chat/tests/flow-motion.client.spec.tsx +++ b/packages/client/ui-chat/tests/flow-motion.client.spec.tsx @@ -210,6 +210,22 @@ describe('flow motion', () => { expect(reserve).toHaveBeenCalledExactlyOnceWith(200) }) + it('does not rewrite a reopened row after its input-fold ownership is cancelled', async () => { + vi.useFakeTimers() + const row = rowWithHeight(180) + const done = vi.fn() + motion.collapse(row, done) + motion.followInput({ begin: vi.fn(), frame: () => -20, finish: vi.fn() }) + await Promise.resolve() + vi.advanceTimersByTime(96) + motion.cancelMotion(row) + expectCleared(row) + vi.advanceTimersByTime(400) + expectCleared(row) + expect(row.style.translate).toBe('') + expect(done).not.toHaveBeenCalled() + }) + it('clears pending reservations and idle work with its owner', async () => { const row = rowWithHeight(80) const idle = vi.fn() @@ -307,13 +323,14 @@ describe.each([ expect(vi.getTimerCount()).toBe(0) }) - it.each([false, true])('hides immediately without motion when reduced motion is %s', (reduced) => { + it.each([false, true])('hides before the next paint when reduced motion is %s', async (reduced) => { vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: reduced }))) const onReveal = vi.fn() const view = render(
) const row = view.getByTestId('motion-row') Object.defineProperty(row, 'offsetHeight', { value: 80 }) view.rerender(
) + await act(async () => { await Promise.resolve() }) expect(row.getAttribute('hidden')).toBe(hiddenValue) expectCleared(row) expect(motion.foldActive()).toBe(false) diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index 801bc01176..a15de80592 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -6,6 +6,7 @@ import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-tes import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' import { useDetailedPresentation } from './presentation-fixture.client.ts' +import { unusedImageLoader } from './reasoning-component-fixture.tsx' import { useDisclosure } from '../src/client/chat/use-disclosure.ts' import { useSearchableHidden } from '../src/client/chat/searchable-hidden.ts' import { ActivityPill, UsagePill, type StatPillProps } from '../src/client/chat/StatsPills.tsx' @@ -13,7 +14,6 @@ import { zh } from '../src/client/locale.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) -const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null afterEach(() => { cleanup() @@ -29,7 +29,7 @@ describe('render branch tails', () => { t={t} blocks={[{ kind: 'reasoning', text: 'done thinking' }, { kind: 'text', text: 'answer' }]} streaming - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} />, ) // reasoning at index 0 with a later block: running is false → ok state. @@ -72,7 +72,7 @@ describe('render branch tails', () => { t={t} blocks={[{ kind: 'reasoning', text: 'still thinking' }]} streaming - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} />, ) expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() diff --git a/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts b/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts new file mode 100644 index 0000000000..d1d948c7b4 --- /dev/null +++ b/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import type { TurnLocation } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatConversationViewNode } from '../src/client/contract/chat-nodes.ts' +import { isAtOrOutside, type ChatGroupTree } from '../src/client/contract/group-tree.ts' +import { GroupTreeSegments } from '../src/client/conversation-nodes/group-tree-segments.ts' + +function turn(index: number): TurnLocation { + return { + turn: index, start: undefined, end: undefined, status: 'closed', steps: [], + data: { get: () => undefined, source: () => ({ getSnapshot: () => undefined, subscribe: () => () => {} }) }, + } +} + +function node(key: string, owner: number | undefined, groupTree?: ChatGroupTree): ChatConversationViewNode { + return { + key, id: key, kind: 'user', target: 'chat', anchorSeq: 0, visibility: 'hidden', groupTree, + location: owner === undefined ? { kind: 'session' } : { kind: 'turn', turn: turn(owner) }, + data: null, + } +} + +function members(segments: GroupTreeSegments, turns: readonly number[]): number[] { + return turns.filter(value => segments.inLatest(value)) +} + +describe('Chat group tree', () => { + it('orders positions from the outermost exchange inward', () => { + const at = (groupTree?: ChatGroupTree) => (['exchange', 'input', 'turn', 'process'] as const) + .map(position => isAtOrOutside({ groupTree }, position)) + expect(at('exchange')).toEqual([true, true, true, true]) + expect(at('input')).toEqual([false, true, true, true]) + expect(at('turn')).toEqual([false, false, true, true]) + expect(at('process')).toEqual([false, false, false, true]) + expect(at(undefined)).toEqual([false, false, false, false]) + }) + + it('starts the latest segment at the last opening Turn and reports only the Turns that moved', () => { + const segments = new GroupTreeSegments('exchange') + const order = [1, 2, 3, 4, 5] + expect(segments.inLatest(1)).toBe(false) + expect(segments.settle(order)).toEqual(order) + expect(members(segments, order)).toEqual(order) + + segments.track(undefined, node('a', 2, 'exchange')) + expect(segments.settle(order)).toEqual([1]) + expect(members(segments, order)).toEqual([2, 3, 4, 5]) + expect(segments.settle(order)).toEqual([]) + + segments.track(undefined, node('b', 4, 'exchange')) + expect(segments.settle(order)).toEqual([2, 3]) + expect(members(segments, order)).toEqual([4, 5]) + expect(segments.inLatest(undefined)).toBe(false) + + segments.track(node('b', 4, 'exchange'), undefined) + expect(segments.settle(order)).toEqual([2, 3]) + expect(members(segments, order)).toEqual([2, 3, 4, 5]) + }) + + it('counts every opening Node of a Turn and falls back as openings leave', () => { + const segments = new GroupTreeSegments('exchange') + const order = [1, 2, 3] + const first = node('first', 1, 'exchange') + const twin = node('twin', 3, 'exchange') + const late = node('late', 3, 'exchange') + for (const value of [late, first, twin]) segments.track(undefined, value) + segments.settle(order) + expect(members(segments, order)).toEqual([3]) + + segments.track(late, node('late', 3)) + segments.settle(order) + expect(members(segments, order)).toEqual([3]) + + segments.track(first, undefined) + segments.settle(order) + expect(members(segments, order)).toEqual([3]) + + segments.track(twin, { ...twin, location: { kind: 'session' } }) + expect(segments.settle(order)).toEqual([1, 2]) + expect(members(segments, order)).toEqual(order) + }) + + it('falls back to the latest remaining opening Turn whatever order the openings arrived in', () => { + const segments = new GroupTreeSegments('exchange') + const order = [1, 2, 3, 4, 5] + const latest = node('latest', 5, 'exchange') + for (const value of [node('middle', 3, 'exchange'), node('earliest', 1, 'exchange'), latest]) { + segments.track(undefined, value) + } + segments.settle(order) + expect(members(segments, order)).toEqual([5]) + + segments.track(latest, undefined) + expect(segments.settle(order)).toEqual([3, 4]) + expect(members(segments, order)).toEqual([3, 4, 5]) + }) + + it('opens segments at Nodes of the configured position or an outer one', () => { + const segments = new GroupTreeSegments('turn') + const order = [1, 2, 3] + segments.track(undefined, node('process', 3, 'process')) + segments.track(undefined, node('turn', 2, 'turn')) + segments.track(undefined, node('exchange', 1, 'exchange')) + segments.track(node('none', 3), node('none', 3)) + segments.settle(order) + expect(members(segments, order)).toEqual([2, 3]) + }) + + it('clears the latest segment when no Turn is loaded', () => { + const segments = new GroupTreeSegments('exchange') + segments.track(undefined, node('a', 1, 'exchange')) + segments.settle([1]) + segments.track(node('a', 1, 'exchange'), undefined) + expect(segments.settle([])).toEqual([]) + expect(segments.inLatest(1)).toBe(false) + }) +}) diff --git a/packages/client/ui-chat/tests/image-labels.client.spec.tsx b/packages/client/ui-chat/tests/image-labels.client.spec.tsx index 9fdf281e40..e37b5ec2c6 100644 --- a/packages/client/ui-chat/tests/image-labels.client.spec.tsx +++ b/packages/client/ui-chat/tests/image-labels.client.spec.tsx @@ -1,12 +1,13 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' +import type { MessageImagesOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' import { useDetailedPresentation } from './presentation-fixture.client.ts' import { useDisclosure } from '../src/client/chat/use-disclosure.ts' import { useSearchableHidden } from '../src/client/chat/searchable-hidden.ts' @@ -25,10 +26,11 @@ const attachment = { name: 'history.png', } -type MessageImagesRenderOwner = Parameters[0] - -function imageRenderer(calls: MessageImagesRenderOwner[]): RenderMessageImages { - return (owner) => { +async function renderImages(blocks: AssistantMarkdownProps['blocks'], calls: MessageImagesOwnerProps[]) { + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + const loadImage = vi.fn(async () => 'blob:history') + runtime.slots.inject('conversation.chat.message.images', () => runtime.slots.register({ name: 'conversation.chat.message.images' }, (owner: MessageImagesOwnerProps) => { calls.push(owner) return (
@@ -39,63 +41,94 @@ function imageRenderer(calls: MessageImagesRenderOwner[]): RenderMessageImages { })}
) - } + })) + await runtime.sessions.add({ id: 'image-labels' }) + const session = runtime.sessions.retainFor(runtime.ctx, SessionId('image-labels')) + await runtime.root.declare({ + 'conversation.chat.reasoning.body': { kind: 'single', scope: 'session' }, + 'conversation.chat.message.images': { kind: 'single', scope: 'session' }, + }, ({ renderSlot, SessionProvider }) => ( + + + + )) + return { view: runtime.renderRoot(), loadImage } } describe('assistant image slot handoff', () => { - it('passes one image group and its message alignment to the renderer', () => { - const calls: MessageImagesRenderOwner[] = [] - const view = render( - null} useDisclosure={useDisclosure} useGroupAction={useSearchableHidden} - usePresentation={useDetailedPresentation} - t={t} - blocks={[{ kind: 'image', attachment }]} - streaming={false} - renderMessageImages={imageRenderer(calls)} - />, - ) + it('passes one image group and its message alignment to the renderer', async () => { + const calls: MessageImagesOwnerProps[] = [] + const { view, loadImage } = await renderImages([{ kind: 'image', attachment }], calls) expect(view.getByTestId('message-images').getAttribute('data-align')).toBe('start') expect(calls).toHaveLength(1) expect(calls[0]?.images).toEqual([{ attachment }]) + expect(calls[0]?.loadImage).toBe(loadImage) + expect(view.getByText('history.png')).toBeTruthy() }) - it('merges consecutive image blocks into one group and splits groups at text', () => { - const calls: MessageImagesRenderOwner[] = [] - const view = render( - null} useDisclosure={useDisclosure} useGroupAction={useSearchableHidden} - usePresentation={useDetailedPresentation} - t={t} - blocks={[ - { kind: 'image', attachment }, - { kind: 'image', attachment }, - { kind: 'text', text: 'between' }, - { kind: 'image', attachment }, - ]} - streaming={false} - renderMessageImages={imageRenderer(calls)} - />, + it('updates one message loader without changing its sibling or remounting either gallery', () => { + const firstLoader = vi.fn(async () => 'blob:first') + const nextLoader = vi.fn(async () => 'blob:next') + const siblingLoader = vi.fn(async () => 'blob:sibling') + const firstRenderer = vi.fn((_key: string, _owner: object) => first gallery) + const siblingRenderer = vi.fn((_key: string, _owner: object) => sibling gallery) + const props = { + blocks: [{ kind: 'image', attachment }], streaming: false, t, + useDisclosure, useGroupAction: useSearchableHidden, + usePresentation: useDetailedPresentation, + } satisfies Omit + const content = (loadImage: AssistantMarkdownProps['loadImage']) => ( + <> + + + ) + const view = render(content(firstLoader)) + const firstGallery = view.getByText('first gallery') + const siblingGallery = view.getByText('sibling gallery') + const owner = { images: [{ attachment }], align: 'start' } + expect(firstRenderer).toHaveBeenLastCalledWith('conversation.chat.message.images', { + ...owner, loadImage: firstLoader, + }) + expect(siblingRenderer).toHaveBeenLastCalledWith('conversation.chat.message.images', { + ...owner, loadImage: siblingLoader, + }) + + view.rerender(content(nextLoader)) + + expect(firstRenderer).toHaveBeenLastCalledWith('conversation.chat.message.images', { + ...owner, loadImage: nextLoader, + }) + expect(siblingRenderer).toHaveBeenLastCalledWith('conversation.chat.message.images', { + ...owner, loadImage: siblingLoader, + }) + expect(view.getByText('first gallery')).toBe(firstGallery) + expect(view.getByText('sibling gallery')).toBe(siblingGallery) + }) + + it('merges consecutive image blocks into one group and splits groups at text', async () => { + const calls: MessageImagesOwnerProps[] = [] + const { view } = await renderImages([ + { kind: 'image', attachment }, + { kind: 'image', attachment }, + { kind: 'text', text: 'between' }, + { kind: 'image', attachment }, + ], calls) const galleries = view.getAllByTestId('message-images') expect(galleries).toHaveLength(2) expect(galleries.map(gallery => gallery.getAttribute('data-count'))).toEqual(['2', '1']) expect(calls.map(call => call.images.length)).toEqual([2, 1]) }) - it('keeps the renderer output at the image block position between text blocks', () => { - const calls: MessageImagesRenderOwner[] = [] - const view = render( - null} useDisclosure={useDisclosure} useGroupAction={useSearchableHidden} - usePresentation={useDetailedPresentation} - t={t} - blocks={[ - { kind: 'text', text: 'before' }, - { kind: 'image', attachment }, - { kind: 'text', text: 'after' }, - ]} - streaming={false} - renderMessageImages={imageRenderer(calls)} - />, - ) + it('keeps the renderer output at the image block position between text blocks', async () => { + const calls: MessageImagesOwnerProps[] = [] + const { view } = await renderImages([ + { kind: 'text', text: 'before' }, + { kind: 'image', attachment }, + { kind: 'text', text: 'after' }, + ], calls) const image = view.getByTestId('message-images') const before = view.getByText('before') const after = view.getByText('after') diff --git a/packages/client/ui-chat/tests/performance-usage.client.spec.ts b/packages/client/ui-chat/tests/performance-usage.client.spec.ts index 0ab9bde440..42a8e8f4e9 100644 --- a/packages/client/ui-chat/tests/performance-usage.client.spec.ts +++ b/packages/client/ui-chat/tests/performance-usage.client.spec.ts @@ -5,9 +5,9 @@ import type { ChatSettings } from '../src/chat-settings.ts' import { PerformanceUsagePolicy } from '../src/client/performance-usage.ts' describe('PerformanceUsagePolicy', () => { - it('keeps explicit choices live when a memory-only scope cannot persist them', () => { + it('keeps an explicit choice live while the Host write is pending or refused', () => { const host = stubConfigForm() - host.publish({ mode: 'memory', status: 'ready' }) + host.publish({ status: 'ready' }) const policy = new PerformanceUsagePolicy(host.scope) expect(policy.mode.getSnapshot()).toBe('detailed') policy.setMode('compact') diff --git a/packages/client/ui-chat/tests/process-groups.client.spec.ts b/packages/client/ui-chat/tests/process-groups.client.spec.ts index 947932fc00..f3d2ba643d 100644 --- a/packages/client/ui-chat/tests/process-groups.client.spec.ts +++ b/packages/client/ui-chat/tests/process-groups.client.spec.ts @@ -45,23 +45,13 @@ function separator(seq: number, inTurn: boolean): ChatNode<'user'> | ChatNode<'s const user: ChatNode<'user'> = { key: `user:${seq}`, id: `user:${seq}`, kind: 'user', target: 'chat', anchorSeq: seq, location: inTurn ? { kind: 'turn', turn } : { kind: 'session' }, visibility: 'visible', + // Neither location is the Step 1 that opens an exchange. + groupTree: 'input', data: { kind: 'user', seq, time: seq, content: [{ type: 'text', text: 'continue' }], source: null }, } return inTurn ? { ...user, kind: 'steering', data: { ...user.data, kind: 'steering', messageId: 'steer' as MessageId } } : user } -function replyNodes(inTurn: boolean): readonly [ChatConversationViewNode, ChatConversationViewNode] { - const trigger: ChatConversationViewNode = { - key: 'generic-reply', id: 'reply-message', kind: 'turn-trigger', target: 'chat', anchorSeq: 6, - location: inTurn ? { kind: 'turn', turn } : { kind: 'session' }, visibility: 'visible', - data: { - kind: 'context', seq: 6, time: 6, content: [{ type: 'text', text: 'answer' }], - source: { kind: 'user-question-reply' }, producer: { role: 'inject', label: null }, form: null, - }, - } - return [trigger, { ...trigger, key: 'question-reply', kind: 'question-reply', data: {} }] -} - function harness(nodes: readonly ChatConversationViewNode[], currentTimeline = timeline) { const builder = new ChatSnapshotBuilder() const state = new ProcessState() @@ -243,69 +233,51 @@ describe('Definition-owned Chat process groups', () => { expect(source.getSnapshot()?.data.summary).toEqual({ counts: [{ kind, count: 1 }], running: undefined, runningDetail: '' }) }) - it.each(['a-trigger', 'z-trigger'])('renders one question reply without splitting its group for duplicate %s', (key) => { - const [generic, reply] = replyNodes(true) - const trigger = { ...generic, key } - const h = harness([tool('before', 4), trigger, reply, tool('after', 8)]) - expect(h.store.entries.map(entry => entry.kind)).toEqual(['group']) - const group = h.store.entries[0]! - if (group.kind !== 'group') throw new Error('expected group') - expect(h.store.groupSource(group.key).getSnapshot()?.members.map(member => member.key)) - .toEqual(['before', reply.key, 'after']) - expect(h.snapshot.nodes.get(trigger.key)).toBeDefined() - expect(h.snapshot.nodes.get(reply.key)).toBeDefined() + it.each(['process', 'turn', 'input'] as const)('ends the group at a Node declared at %s, whatever its kind', (position) => { + const h = harness([tool('before', 4), { ...tool('standalone', 5), groupTree: position }, tool('after', 8)]) + expect(h.store.entries.map(entry => entry.kind === 'group' ? 'group' : entry.key)).toEqual(['group', 'standalone', 'group']) }) - it.each([0.9, 5])('keeps the Turn control before its coalesced opening reply at seq %s', (anchorSeq) => { + it('regroups when a Node changes its position without changing its kind', () => { + const middle = tool('middle', 5) + const h = harness([tool('before', 4), middle, tool('after', 8)]) + expect(h.store.entries.map(entry => entry.kind)).toEqual(['group']) + h.builder.apply({ upserts: [{ ...middle, groupTree: 'process' }], timeline }) + h.commit() + expect(h.store.entries.map(entry => entry.kind === 'group' ? 'group' : entry.key)).toEqual(['group', 'middle', 'group']) + }) + + it('keeps the Turn control as an independent root before the process it folds', () => { const control: ChatNode<'turn-process'> = { - key: 'control', id: '1', kind: 'turn-process', target: 'chat', anchorSeq, - location: { kind: 'turn', turn }, visibility: 'visible', + key: 'control', id: '1', kind: 'turn-process', target: 'chat', anchorSeq: 0.9, + location: { kind: 'turn', turn }, visibility: 'visible', groupTree: 'turn', data: { turn: 1, controlAnchorSeq: 1, processStartSeq: 1, answerAnchorSeq: null, answerStep: null, inlineReasoning: false, messageCount: 0, toolCallCount: 1, subagentCount: 0, }, } - const h = harness([control, ...replyNodes(true), tool('after', 8)]) + const h = harness([control, tool('first', 6), tool('after', 8)]) expect(h.store.entries.map(entry => entry.kind)).toEqual(['node', 'group']) expect(h.store.entries[0]).toEqual({ kind: 'node', key: control.key }) }) - it('aggregates a newly projected reply with its existing trigger', () => { - const [trigger, reply] = replyNodes(true) - const h = harness([tool('before', 4), trigger, tool('after', 8)]) - h.builder.apply({ upserts: [reply], timeline }) - h.commit() - expect(h.store.entries.map(entry => entry.kind)).toEqual(['group']) - const group = h.store.entries[0]! + it('sorts a later-anchored Turn control before the process of a Turn without opening input', () => { + // A Turn opened by a reply that declares no position: the control's own anchor follows the reply. + const control: ChatNode<'turn-process'> = { + key: 'control', id: '1', kind: 'turn-process', target: 'chat', anchorSeq: 6.9, + location: { kind: 'turn', turn }, visibility: 'visible', groupTree: 'turn', + data: { + turn: 1, controlAnchorSeq: 6, processStartSeq: 1, answerAnchorSeq: null, + answerStep: null, inlineReasoning: false, messageCount: 0, toolCallCount: 1, subagentCount: 0, + }, + } + const h = harness([tool('reply', 6), control, tool('after', 8)]) + expect(h.snapshot.order).toEqual(['control', 'reply', 'after']) + expect(h.store.entries.map(entry => entry.kind)).toEqual(['node', 'group']) + expect(h.store.entries[0]).toEqual({ kind: 'node', key: control.key }) + const group = h.store.entries[1]! if (group.kind !== 'group') throw new Error('expected group') - expect(h.store.groupSource(group.key).getSnapshot()?.members.map(member => member.key)) - .toEqual(['before', reply.key, 'after']) - - h.builder.apply({ upserts: [{ ...reply, visibility: 'hidden' }], timeline }) - h.commit() - expect(h.store.entries.map(entry => entry.kind)).toEqual(['group', 'node', 'group']) - expect(h.store.entries[1]).toEqual({ kind: 'node', key: trigger.key }) - }) - - it('aggregates reply projections before their Turn has been loaded', () => { - const [trigger, reply] = replyNodes(false) - const h = harness([trigger, reply]) - expect(h.store.entries).toEqual([{ kind: 'node', key: reply.key }]) - expect(h.snapshot.nodes.get(trigger.key)).toBeDefined() - }) - - it('keeps unscoped aggregation current when a reply arrives', () => { - const [trigger, reply] = replyNodes(false) - const h = harness([trigger]) - h.builder.apply({ upserts: [reply], timeline }) - h.commit() - expect(h.store.entries).toEqual([{ kind: 'node', key: reply.key }]) - }) - - it.each([true, false])('does not merge identical text from different messages, scoped: %s', (inTurn) => { - const [trigger, reply] = replyNodes(inTurn) - const h = harness([trigger, { ...reply, id: 'different-message', data: trigger.data }]) - expect(h.store.entries).toContainEqual({ kind: 'node', key: trigger.key }) + expect(h.store.groupSource(group.key).getSnapshot()?.members.map(member => member.key)).toEqual(['reply', 'after']) }) it('retains a group through repeated prepends and a later append', () => { diff --git a/packages/client/ui-chat/tests/reasoning-component-fixture.tsx b/packages/client/ui-chat/tests/reasoning-component-fixture.tsx index fb2f6ea879..c2e0543f3c 100644 --- a/packages/client/ui-chat/tests/reasoning-component-fixture.tsx +++ b/packages/client/ui-chat/tests/reasoning-component-fixture.tsx @@ -10,15 +10,21 @@ import { zh } from '../src/client/locale.ts' const t = makeTranslate(zh, commonZh) +/** Reject attachment loads in component fixtures without durable images. */ +export const unusedImageLoader: AssistantMarkdownProps['loadImage'] = async () => { + throw new Error('This component fixture has no durable image attachments') +} + function content(input: ReasoningContentInput) { // The component only reads these inputs; unused framework seats are exercised in assembly tests. return createElement(ReasoningContent, { ...input, t } as ComponentProps) } /** Render the production child component without emulating a registry or its dispatch. */ -export const renderReasoningSlot = ( - (_key: 'conversation.chat.reasoning.body', input: ReasoningContentInput) => content(input) -) as AssistantMarkdownProps['renderSlot'] +export const renderReasoningSlot: AssistantMarkdownProps['renderSlot'] = (key: string, input: object) => { + if (key !== 'conversation.chat.reasoning.body') throw new Error(`Unexpected reasoning Slot: ${key}`) + return content(input as ReasoningContentInput) +} /** Supply the official component at the Factory call seam of a standalone component unit. */ export const renderReasoningFactory: PropsRenderFactories['renderFactorySlot'] = (name, input) => { diff --git a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx index fbd21baf11..29ad9a4e4d 100644 --- a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { renderReasoningSlot as renderSlot } from './reasoning-component-fixture.tsx' +import { renderReasoningSlot as renderSlot, unusedImageLoader } from './reasoning-component-fixture.tsx' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -19,7 +19,119 @@ afterEach(() => { }) const t = makeTranslate(zh, commonZh) -const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null + +describe('AssistantMarkdown reasoning blocks', () => { + const props = { + renderSlot, useDisclosure, useGroupAction: useSearchableHidden, + usePresentation: useDetailedPresentation, loadImage: unusedImageLoader, t, + } + + it.each([false, true])('omits blank blocks without trimming body text when streaming=%s', (streaming) => { + const text = '\n **Visible reasoning**\n\nOriginal body \n' + const texts = ['', ' \t\r\n', text, ''] + const blocks = Object.freeze(texts.map(text => Object.freeze({ kind: 'reasoning' as const, text }))) + const calls = vi.fn() + const extension: AssistantMarkdownProps['renderSlot'] = (key, owner, options) => { + calls(key, owner) + return renderSlot(key, owner, options) + } + const view = render() + expect(view.getAllByRole('button')).toHaveLength(1) + expect(view.container.querySelectorAll('[data-variant="think"]')).toHaveLength(1) + fireEvent.click(view.getByRole('button')) + expect(calls).toHaveBeenCalledWith('conversation.chat.reasoning.body', { text, running: false }) + expect(view.getByText('Visible reasoning').tagName).toBe('STRONG') + expect(blocks.map(block => block.text)).toEqual(texts) + }) + + it.each([false, true])('waits for non-whitespace reasoning when streaming=%s', (streaming) => { + const view = render() + expect(view.queryAllByRole('button')).toHaveLength(0) + for (const text of [' ', '\t', '\r\n', ' \t\r\n', '\u00a0']) { + view.rerender() + expect(view.queryAllByRole('button')).toHaveLength(0) + } + view.rerender() + expect(view.getAllByRole('button')).toHaveLength(1) + }) + + it('retains an expanded earlier row when a blank streaming tail receives text', () => { + const first = { kind: 'reasoning' as const, text: 'First thought\n\nEarlier body' } + const blank = { kind: 'reasoning' as const, text: ' \t\r\n' } + const view = render() + const earlierToggle = view.getByRole('button') + const earlierRow = earlierToggle.closest('[data-variant="think"]') + expect(earlierRow?.getAttribute('data-state')).toBe('ok') + expect(view.queryByText('运行中')).toBeNull() + fireEvent.click(earlierToggle) + const earlierBody = view.getByText('Earlier body') + + const tail = { kind: 'reasoning' as const, text: 'An unfinished tail' } + const blocks = [first, blank, tail] + view.rerender() + const toggles = view.getAllByRole('button') + expect(toggles).toHaveLength(2) + expect(toggles[0]).toBe(earlierToggle) + expect(view.getByText('Earlier body')).toBe(earlierBody) + expect(earlierToggle.getAttribute('aria-expanded')).toBe('true') + expect(earlierRow?.getAttribute('data-state')).toBe('ok') + const tailToggle = toggles[1]! + const tailRow = tailToggle.closest('[data-variant="think"]') + expect(tailRow?.getAttribute('data-state')).toBe('running') + expect(view.queryByText(tail.text)).toBeNull() + fireEvent.click(tailToggle) + expect(view.getByText(tail.text)).toBeTruthy() + expect(earlierToggle.getAttribute('aria-expanded')).toBe('true') + + view.rerender() + const settledToggles = view.getAllByRole('button') + expect(settledToggles).toHaveLength(2) + expect(settledToggles[0]).toBe(earlierToggle) + expect(settledToggles[1]).toBe(tailToggle) + expect(view.getByText('Earlier body')).toBe(earlierBody) + expect(tailRow?.getAttribute('data-state')).toBe('ok') + expect(tailToggle.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(tailToggle) + expect(earlierToggle.getAttribute('aria-expanded')).toBe('true') + expect(tailToggle.getAttribute('aria-expanded')).toBe('false') + }) + + it.each(['Partial thought', '', ' \t\r\n'])('retains the stopped marker with blank reasoning around %j', (text) => { + const blocks = ['', text, ' \t\r\n'].map(text => ({ kind: 'reasoning' as const, text })) + const view = render() + expect(view.queryAllByRole('button')).toHaveLength(text.trim() === '' ? 0 : 1) + expect(view.getByText(t('message.stopped'))).toBeTruthy() + expect(view.queryByText('运行中')).toBeNull() + }) + + it('keeps non-blank rows and their expanded bodies across modes, excluding them from the response part', () => { + const mode = createSnapshotStore('compact') + const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode, createSnapshotStore('completion'))) + const blocks: AssistantMarkdownProps['blocks'] = [ + { kind: 'reasoning', text: '' }, + { kind: 'reasoning', text: 'Mode thought\n\nMode body' }, + { kind: 'reasoning', text: ' \t\r\n' }, + { kind: 'text', text: 'Answer' }, + ] + const view = render() + const toggle = view.getByRole('button') + fireEvent.click(toggle) + const body = view.getByText('Mode body') + for (const next of ['standard', 'detailed', 'verbose', 'compact'] as const) { + act(() => { mode.set(next) }) + expect(view.getAllByRole('button')).toHaveLength(1) + expect(view.getByRole('button')).toBe(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(view.getByText('Mode body')).toBe(body) + expect(view.queryByText('Answer')).toBeNull() + } + view.rerender() + expect(view.queryAllByRole('button')).toHaveLength(0) + expect(view.getByText('Answer')).toBeTruthy() + }) +}) describe('ReasoningRow', () => { it('offers only text and streaming state to the standard Body Slot after expansion', () => { @@ -111,20 +223,20 @@ describe('ReasoningRow', () => { const view = render( , + t={t} blocks={[reasoning]} streaming loadImage={unusedImageLoader} />, ) expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false') fireEvent.click(view.getByText('思考')) view.rerender( , + t={t} blocks={[reasoning, nextBlock]} streaming loadImage={unusedImageLoader} />, ) expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') view.rerender( , + t={t} blocks={[reasoning, nextBlock]} streaming={false} loadImage={unusedImageLoader} />, ) expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') expect(view.getByText(/Check persistence/)).toBeTruthy() @@ -139,7 +251,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: 'Inspect the session\nDetails\n\nNewest reasoning tokens\n' }]} streaming - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} />, ) expect(view.getByText('运行中')).toBeTruthy() @@ -153,7 +265,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: 'Inspect the session\nDetails\n\nNewest reasoning tokens\nMore details\n\nChecking boundaries' }]} streaming - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} />, ) expect(view.getByText('Newest reasoning tokens').closest('[data-streaming]') @@ -167,7 +279,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text }]} streaming - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} />, ) expect(view.getByText('Checking boundaries')).toBeTruthy() @@ -179,7 +291,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text }]} streaming={false} - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} />, ) const settledSummary = view.getByText('Inspect the session') @@ -195,7 +307,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]} streaming={false} - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} />, ) const row = view.getByRole('button') @@ -226,7 +338,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text }]} streaming={streaming} - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} />, ) @@ -247,7 +359,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text }]} streaming={false} - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} />, ) const summary = view.getByText('# Section 1') @@ -273,7 +385,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: first }]} streaming - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} />, ) fireEvent.click(view.getByText('思考')) @@ -286,7 +398,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text }]} streaming - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} />, ) expect(view.getByRole('heading', { name: 'Investigation' })).toBe(heading) @@ -301,7 +413,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]} streaming={false} - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} />, ) fireEvent.click(view.getByText('思考')) @@ -321,7 +433,7 @@ describe('ReasoningRow', () => { { kind: 'text', text: 'Answer' }, ]} streaming={false} - renderMessageImages={renderMessageImages} + loadImage={unusedImageLoader} />, ) // Collapsed: no `data-open`, so the sticky rule's gate never matches. diff --git a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts index e7c84f9a42..05fc6401ed 100644 --- a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts +++ b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts @@ -7,7 +7,7 @@ import { TranscriptViewPolicy } from '../src/client/transcript-view.ts' afterEach(() => { vi.restoreAllMocks() }) describe('TranscriptViewPolicy', () => { - it('defaults to completion and changes timing without reading or writing browser storage', () => { + it('defaults to completion and persists explicit timing without reading or writing browser storage', () => { const storageReads = vi.spyOn(Storage.prototype, 'getItem').mockReturnValue('"next-input"') const storageWrites = vi.spyOn(Storage.prototype, 'setItem') const storageRemovals = vi.spyOn(Storage.prototype, 'removeItem') @@ -32,7 +32,9 @@ describe('TranscriptViewPolicy', () => { expect(timingChanged).toHaveBeenCalledTimes(2) expect(policy.mode.getSnapshot()).toBe('detailed') expect(modeChanged).not.toHaveBeenCalled() - expect(host.set).not.toHaveBeenCalled() + expect(host.set).toHaveBeenCalledTimes(2) + expect(host.set).toHaveBeenNthCalledWith(1, 'collapseTiming', 'next-input') + expect(host.set).toHaveBeenNthCalledWith(2, 'collapseTiming', 'completion') expect(storageReads).not.toHaveBeenCalled() expect(storageWrites).not.toHaveBeenCalled() expect(storageRemovals).not.toHaveBeenCalled() @@ -44,18 +46,19 @@ describe('TranscriptViewPolicy', () => { } }) - it('starts each instance with completion timing while adopting the accepted Host mode', () => { + it('restores both saved preferences in each new instance', () => { const host = stubConfigForm() host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'standard', performanceUsage: 'detailed' }, revision: 1, writable: true }) const first = new TranscriptViewPolicy(host.scope) try { first.setCollapseTiming('next-input') + host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'standard', performanceUsage: 'detailed', collapseTiming: 'next-input' }, revision: 2 }) const second = new TranscriptViewPolicy(host.scope) try { expect(first.collapseTiming.getSnapshot()).toBe('next-input') - expect(second.collapseTiming.getSnapshot()).toBe('completion') + expect(second.collapseTiming.getSnapshot()).toBe('next-input') expect(second.mode.getSnapshot()).toBe('standard') - expect(host.set).not.toHaveBeenCalled() + expect(host.set).toHaveBeenCalledExactlyOnceWith('collapseTiming', 'next-input') } finally { second.dispose() } @@ -65,21 +68,21 @@ describe('TranscriptViewPolicy', () => { expect(host.listenerCount()).toBe(0) }) - it('adopts Host mode updates without overriding the local timing choice', () => { + it('adopts independent Host mode and timing updates without writing them back', () => { const host = stubConfigForm() const policy = new TranscriptViewPolicy(host.scope) try { policy.setCollapseTiming('next-input') - host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 1, writable: true }) + host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed', collapseTiming: 'next-input' }, revision: 1, writable: true }) expect(policy.mode.getSnapshot()).toBe('compact') expect(policy.collapseTiming.getSnapshot()).toBe('next-input') policy.setMode('verbose') expect(policy.collapseTiming.getSnapshot()).toBe('next-input') - expect(host.set).toHaveBeenCalledExactlyOnceWith('transcriptView', 'verbose') + expect(host.set).toHaveBeenNthCalledWith(2, 'transcriptView', 'verbose') host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'standard', performanceUsage: 'detailed' }, revision: 2 }) expect(policy.mode.getSnapshot()).toBe('standard') - expect(policy.collapseTiming.getSnapshot()).toBe('next-input') - expect(host.set).toHaveBeenCalledTimes(1) + expect(policy.collapseTiming.getSnapshot()).toBe('completion') + expect(host.set).toHaveBeenCalledTimes(2) } finally { policy.dispose() } diff --git a/packages/client/ui-chat/tests/user-content-component-fixture.tsx b/packages/client/ui-chat/tests/user-content-component-fixture.tsx new file mode 100644 index 0000000000..eb6670fb33 --- /dev/null +++ b/packages/client/ui-chat/tests/user-content-component-fixture.tsx @@ -0,0 +1,27 @@ +/** Production user content for component units; registry integration uses SlotTestRuntime. */ +import { createElement, type ComponentProps } from 'react' +import type { PropsLocale, PropsRenderFactories, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type { UserTextInput } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { createUserTextFixture } from '../../ui-conversation/tests/user-text-component-fixture.client.tsx' +import type { UserContentInput } from '../src/client/contract/slots.ts' +import { UserContent } from '../src/client/chat/UserContent.tsx' + +/** + * Bind real text parsing and business renderers for one component-test lifetime. + * @param actions - reference operations supplied by the test's node-injection fixture. + * @returns renderer accepting prepared content, the user-image Slot seat, and Chat locale. + */ +export function createUserContentFixture(actions?: Parameters[0]) { + const renderUserText = createUserTextFixture(actions) + return ( + input: UserContentInput, + renderSlot: PropsRenderSlots<'conversation.chat.user.images'>['renderSlot'], + t: PropsLocale<'chat'>['t'], + ) => { + const renderFactory: PropsRenderFactories['renderFactorySlot'] = (name, owner) => { + if (name !== 'conversation.user.text') throw new Error(`Unexpected user content Factory: ${name}`) + return renderUserText(owner as UserTextInput) + } + return createElement(UserContent, { ...input, renderSlot, renderFactorySlot: renderFactory, t } as ComponentProps) + } +} diff --git a/packages/client/ui-chat/tests/user-content.client.spec.tsx b/packages/client/ui-chat/tests/user-content.client.spec.tsx new file mode 100644 index 0000000000..1a93864a41 --- /dev/null +++ b/packages/client/ui-chat/tests/user-content.client.spec.tsx @@ -0,0 +1,279 @@ +// @vitest-environment jsdom +import { useState, type ReactNode } from 'react' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { act, cleanup, fireEvent, type RenderResult, within } from '@testing-library/react' +import { AttachmentId, type FileAttachmentRef, type ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import type { PropsRenderFactories, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { UserContentAttachment, UserContentData, UserContentInput } from '../src/client/contract/slots.ts' +import { UserContent } from '../src/client/chat/UserContent.tsx' +import { en, NS, zh } from '../src/client/locale.ts' +import type { UserTextInjected } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { UserTextRegistry } from '../../ui-conversation/src/client/conversation/user-text-registry.ts' +import { UserText, PlainUserText, SessionReference } from '../../ui-conversation/src/client/user-text/UserText.tsx' +import { sessionReferenceRule } from '../../ui-conversation/src/client/user-text/session-reference-rule.ts' +import { collapseWhitespaceRule, trimWhitespaceRule } from '../../ui-conversation/src/client/user-text/whitespace-rules.ts' +import { fileReferenceRule, folderReferenceRule } from '../../ui-reference/src/client/user-text-rules.ts' +import { FileReference, FolderReference } from '../../ui-reference/src/client/FileReference.tsx' +import { en as referenceEn, zh as referenceZh } from '../../ui-reference/src/client/locales.ts' +import { skillReferenceRule } from '../../ui-skill/src/client/user-text-rules.ts' +import { SkillReference } from '../../ui-skill/src/client/SkillReference.tsx' + +afterEach(cleanup) + +const image: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', bytes: 68, width: 640, height: 320, name: 'history.png', +} +const file: FileAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), name: 'notes.txt', bytes: 23, +} +const durable: UserContentAttachment = { type: 'image', image: { attachment: image } } +const preview: UserContentAttachment = { + type: 'image', image: { preview: { url: 'blob:local-preview', name: 'preview.png', width: 4, height: 3 } }, +} +const filePart: UserContentAttachment = { type: 'file', file } +const empty: UserContentData = { text: '', attachments: [], extraBlocks: [] } +type RenderSeats = PropsRenderSlots<'conversation.approval.detail'> & PropsRenderFactories + +async function bench() { + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + const locale = new LocaleRuntime(runtime.ctx) + runtime.ctx.provide('locale', locale) + locale.register('common', { en: commonEn, zh: commonZh }) + locale.register(NS, { en, zh }) + locale.register('reference', { en: referenceEn, zh: referenceZh }) + locale.setLocale('en') + runtime.slots.installLocale(locale) + const imageCalls: PropsRuntime<'conversation.chat.user.images'>[] = [] + const openFile = vi.fn<(path: string) => void>() + const openSkill = vi.fn<(name: string) => void>() + await runtime.mount({ inject: ['slots'], apply: (ctx) => { + const userText = new UserTextRegistry(ctx) + for (const rule of [ + sessionReferenceRule, fileReferenceRule, folderReferenceRule, skillReferenceRule, + collapseWhitespaceRule, trimWhitespaceRule, + ]) { + userText.register(rule) + } + ctx.slots.registerFactory({ + name: 'conversation.user.text', scope: 'session', + children: { 'conversation.user.text.node': { kind: 'keyed', scope: 'session', inline: true } }, + inject: (): UserTextInjected => ({ + hooks: { userTextRevision: { + getSnapshot: () => userText.getSnapshot(), subscribe: listener => userText.subscribe(listener), + } }, + createUserTextEvaluator: () => userText.createEvaluator(), + reportTransformFailure: (error: unknown): never => { throw error }, + }), + }, UserText) + ctx.slots.register({ name: 'conversation.user.text.node', key: 'text' }, PlainUserText) + ctx.slots.register({ name: 'conversation.user.text.node', key: 'session' }, SessionReference) + ctx.slots.register({ name: 'conversation.user.text.node', key: 'file', locale: 'reference', + inject: () => ({ openFile }) }, FileReference) + ctx.slots.register({ name: 'conversation.user.text.node', key: 'folder' }, FolderReference) + ctx.slots.register({ name: 'conversation.user.text.node', key: 'skill', inject: () => ({ openSkill }) }, SkillReference) + ctx.slots.inject('conversation.chat.user.images', () => ctx.slots.register({ name: 'conversation.chat.user.images' }, function ImageProbe(props) { + imageCalls.push(props) + const source = props.images[0] + if (source === undefined) throw new Error('User content must pass one image to the image Slot') + const name = 'attachment' in source ? source.attachment.name : source.preview.name + return snapshot.sessionId)}>{name} + })) + } }) + const mountContent = () => runtime.mount({ inject: ['slots'], apply: (ctx) => { + ctx.slots.registerFactory({ + name: 'conversation.chat.user.content', scope: 'session', locale: NS, + children: { 'conversation.chat.user.images': { kind: 'single', scope: 'session' } }, + }, UserContent) + } }) + const owner = await mountContent() + await runtime.sessions.add({ id: 'user-content' }) + const session = runtime.sessions.retainFor(runtime.ctx, SessionId('user-content')) + const captured: { seats?: RenderSeats; setContent?: (content: ReactNode) => void } = {} + await runtime.root.declare({ + 'conversation.approval.detail': { kind: 'single', scope: 'session' }, + }, function TestRoot(props) { + const [content, setContent] = useState(null) + captured.seats = props + captured.setContent = setContent + return content + }) + const view = runtime.renderRoot() + const { seats, setContent } = captured + if (seats === undefined || setContent === undefined) throw new Error('User content fixture root did not render') + const { SessionProvider } = seats + const factory = (input: UserContentInput) => seats.renderFactorySlot('conversation.chat.user.content', input) + const render = (content: ReactNode, scoped = true): RenderResult => { + const update = (next: ReactNode): void => { + act(() => { setContent(scoped ? {next} : next) }) + } + update(content) + return { ...view, rerender: update } + } + const loadImage = vi.fn(async () => 'blob:loaded') + return { runtime, locale, owner, mountContent, factory, render, loadImage, imageCalls, SessionProvider, openFile, openSkill } +} + +describe('user Content Factory', () => { + it('renders prepared text directly without a row, admission markers, time, or copy controls', async () => { + const b = await bench() + const text = '**literal user text**\nsecond line' + const view = b.render(b.factory({ content: { ...empty, text }, facts: {}, loadImage: b.loadImage })) + const textNode = view.getByText('**literal user text** second line') + expect(textNode.textContent).toBe(text) + const root = view.container.querySelector('[data-slot="root"]') + expect(textNode.closest('div')?.parentElement).toBe(root?.firstElementChild) + expect(root?.children).toHaveLength(1) + expect(view.container.querySelector('strong, time, [role="status"], [data-pending-steering], [data-submission-echo]')).toBeNull() + expect(view.queryAllByRole('button')).toHaveLength(0) + expect(view.container.querySelector('[data-message-attachments]')).toBeNull() + expect(b.runtime.factoryOf('conversation.chat.user.content')).toMatchObject({ + component: UserContent, scope: 'session', locale: 'chat', + }) + }) + + it('keeps mixed files, durable images, and local previews in their prepared order through its user-image Slot', async () => { + const b = await bench() + const content: UserContentData = { + ...empty, text: 'Prepared text', attachments: [filePart, durable, { type: 'file', file: { ...file, name: 'last.md' } }, preview], + } + const view = b.render(b.factory({ content, facts: {}, loadImage: b.loadImage })) + const attachments = view.container.querySelector('[data-message-attachments]') + expect([...attachments?.children ?? []].map(child => child.textContent)).toEqual([ + 'notes.txtTXT 23B', 'history.png', 'last.mdMD 23B', 'preview.png', + ]) + expect(view.getByTitle('notes.txt')).toBeTruthy() + expect(view.getByText('Prepared text')).toBeTruthy() + const durableCall = b.imageCalls.find(call => call.images[0] === durable.image) + const previewCall = b.imageCalls.find(call => call.images[0] === preview.image) + expect(durableCall?.images).toEqual([durable.image]) + expect(previewCall?.images).toEqual([preview.image]) + for (const call of [durableCall, previewCall]) { + expect(call).toMatchObject({ align: 'end', compact: true, loadImage: b.loadImage }) + } + expect(b.loadImage).not.toHaveBeenCalled() + }) + + it.each([ + { label: 'one durable image', attachments: [durable], compact: false }, + { label: 'one preview', attachments: [preview], compact: false }, + { label: 'one file and one image', attachments: [filePart, durable], compact: true }, + { label: 'one preview and one file', attachments: [preview, filePart], compact: true }, + ])('uses all attachments to choose compact images: $label', async ({ attachments, compact }) => { + const b = await bench() + const view = b.render(b.factory({ content: { ...empty, attachments }, facts: {}, loadImage: b.loadImage })) + expect(view.getAllByTestId('image')).toHaveLength(1) + expect(b.imageCalls[0]).toMatchObject({ compact, align: 'end', loadImage: b.loadImage }) + expect(view.queryAllByRole('button')).toHaveLength(0) + expect(view.container.querySelector('[data-slot="root"]')?.firstElementChild?.children).toHaveLength(1) + }) + + it('reuses one prepared value in unrelated parents while nested images inherit each Session', async () => { + const b = await bench() + await b.runtime.sessions.add({ id: 'other-user-content' }) + const other = b.runtime.sessions.retainFor(b.runtime.ctx, SessionId('other-user-content')) + const content: UserContentData = Object.freeze({ + text: 'Already prepared', attachments: Object.freeze([preview]), extraBlocks: Object.freeze([]), + }) + const input: UserContentInput = { content, facts: {}, loadImage: b.loadImage } + const { SessionProvider } = b + const view = b.render(<> +
{b.factory(input)}
+ + ) + for (const [parent, sessionId] of [['first', 'user-content'], ['second', 'other-user-content']] as const) { + const section = within(view.getByTestId(parent)) + expect(section.getByText('Already prepared')).toBeTruthy() + expect(section.getByTestId('image').getAttribute('data-session')).toBe(sessionId) + expect(section.getByTestId('image').getAttribute('data-current-session')).toBe(sessionId) + } + expect(b.imageCalls.every(call => call.images[0] === preview.image)).toBe(true) + }) + + it('binds live Chat locale labels for reference summaries and opaque extra blocks', async () => { + const b = await bench() + const block = { type: 'future-block', value: 'x'.repeat(20_001) } + const content: UserContentData = { ...empty, extraBlocks: [block], referenceLabels: ['Alpha', 'Beta'] } + const view = b.render(b.factory({ content, facts: {}, loadImage: b.loadImage })) + expect(view.getByText('Referenced session · Alpha, Beta')).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: '▸ Extra content block' })) + expect(view.container.querySelector('pre')?.textContent).toContain('"type": "future-block"') + expect(view.container.querySelector('pre')?.textContent).toContain( + `… truncated, ${JSON.stringify(block, null, 2).length} characters total`, + ) + act(() => { b.locale.setLocale('zh') }) + expect(view.getByText('引用会话 · Alpha、Beta')).toBeTruthy() + expect(view.getByRole('button', { name: '▾ 附加内容块' })).toBeTruthy() + expect(view.container.querySelector('pre')?.textContent).toContain( + `… 已截断,共 ${JSON.stringify(block, null, 2).length} 字符`, + ) + }) + + it('opens file and skill references through their own injectors only in interactive content', async () => { + const b = await bench() + const labels = ['Alpha'] + const content: UserContentData = { + ...empty, text: 'Read @"src/my file.ts" with /review and @Alpha', referenceLabels: labels, + } + const facts = { sessionLabels: labels, skillNames: ['review'] } + const view = b.render(b.factory({ content, facts, loadImage: b.loadImage, interactive: true })) + fireEvent.click(view.getByRole('button', { name: 'my file.ts' })) + fireEvent.click(view.getByRole('button', { name: '/review' })) + expect(b.openFile).toHaveBeenCalledWith('src/my file.ts') + expect(b.openSkill).toHaveBeenCalledWith('review') + expect(view.queryByRole('button', { name: 'Alpha' })).toBeNull() + view.rerender(b.factory({ content, facts, loadImage: b.loadImage })) + expect(view.queryAllByRole('button')).toHaveLength(0) + for (const label of ['my file.ts', '/review', 'Alpha']) fireEvent.click(view.getByText(label, { exact: true })) + expect(b.openFile).toHaveBeenCalledTimes(1) + expect(b.openSkill).toHaveBeenCalledTimes(1) + expect(view.container.querySelectorAll('[data-ref-chip]')).toHaveLength(3) + }) + + it('keeps an empty prepared value empty', async () => { + const b = await bench() + const view = b.render(b.factory({ content: empty, facts: {}, loadImage: b.loadImage })) + expect(view.container.textContent).toBe('') + const root = view.container.querySelector('[data-slot="root"]') + expect(root?.children).toHaveLength(1) + expect(root?.firstElementChild?.children).toHaveLength(0) + expect(view.container.querySelector('[data-message-attachments]')).toBeNull() + expect(view.queryAllByRole('button')).toHaveLength(0) + expect(b.imageCalls).toHaveLength(0) + }) + + it('removes live content on owner disposal and renders it again after registration', async () => { + const b = await bench() + const view = b.render(b.factory({ content: { ...empty, text: 'Reusable content' }, facts: {}, loadImage: b.loadImage })) + expect(view.getByText('Reusable content')).toBeTruthy() + await b.owner.dispose() + expect(view.queryByText('Reusable content')).toBeNull() + expect(() => b.runtime.factoryOf('conversation.chat.user.content')).toThrow('no definition') + await b.mountContent() + expect(view.getByText('Reusable content')).toBeTruthy() + }) + + it('requires a Session binding even when the prepared value has no images', async () => { + const b = await bench() + const message = "strict session factory 'conversation.chat.user.content' rendered without a scope binding" + const suppressExpectedError = (event: ErrorEvent): void => { + if (event.error instanceof Error && event.error.message === message) event.preventDefault() + } + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + window.addEventListener('error', suppressExpectedError) + try { + expect(() => b.render(b.factory({ content: { ...empty, text: 'Unbound content' }, facts: {}, loadImage: b.loadImage }), false)) + .toThrow(message) + } finally { + window.removeEventListener('error', suppressExpectedError) + error.mockRestore() + } + }) +}) diff --git a/packages/client/ui-chat/tests/views-type-chain.client.spec.tsx b/packages/client/ui-chat/tests/views-type-chain.client.spec.tsx index 5fe31820f6..44ac8f2362 100644 --- a/packages/client/ui-chat/tests/views-type-chain.client.spec.tsx +++ b/packages/client/ui-chat/tests/views-type-chain.client.spec.tsx @@ -17,8 +17,8 @@ describe('Chat View type chain', () => { void chat.openFile({ turnSeq: 1, callId: 'c' }) // @ts-expect-error ChatView renders only its flow child. chat.renderSlot('conversation.chat.node', {}, { hookContext: nodeContext }) - // @ts-expect-error Image rendering belongs to the flow. - chat.renderSlot('conversation.message.images', {}) + // @ts-expect-error Output images belong to the Assistant node entry. + chat.renderSlot('conversation.chat.message.images', {}) // @ts-expect-error Flow owner data cannot override the flow's render authority. void flowOwner.renderSlot // @ts-expect-error Node renderers receive the bound hook, not the viewport controller. diff --git a/packages/client/ui-chat/tsdown.config.ts b/packages/client/ui-chat/tsdown.config.ts index 7787b8fe34..0364805433 100644 --- a/packages/client/ui-chat/tsdown.config.ts +++ b/packages/client/ui-chat/tsdown.config.ts @@ -2,7 +2,7 @@ import { clientBundle } from '../tsdown.client.ts' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' -const bundle = clientBundle('@deepseek-ai/dsh-client-ui-chat', ['lib/types/index.js']) +const bundle = clientBundle(import.meta.dirname, ['lib/types/index.js']) const stylesheet = fileURLToPath(new URL('./src/client/chat/ChatView.module.css', import.meta.url)).replaceAll('\\', '/') const whaleImage = fileURLToPath(new URL('./src/client/chat/running-whale@2x.png', import.meta.url)) diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index 6177afb95c..97fd762cc1 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/README.i18n.yaml @@ -27,8 +27,8 @@ en: 4feecdbec0c3f39e zh: 618944ecade8a3a2 /deepseek-ai-dsh-client-ui-commands/understand-the-implementation: - en: 3aeeba6c91c3ef19 - zh: d3afb3df6d15e17b + en: dbd2d83fb5e57ff3 + zh: 6e2e837f6a08f2fb /deepseek-ai-dsh-client-ui-commands/further-exploration: en: 409c04f534d23de7 zh: 165022d7a0a554eb diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index 8bf2cdc958..a3f56ab147 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -59,6 +59,8 @@ Popup selection panels fill the composer overlay's width, matching the slash men Each command-directory fetch requires an existing retained Client Session and waits for its initial history open to succeed before sending `commands.list`. A temporary `commandCatalog` reference holds that Session until the fetch settles. An unretained Session or a failed open rejects without sending the RPC, so background catalog refreshes do not reopen closed Sessions. +Popup selection uses the [standard menu inset and item radii](../../../docs/ui-radius.md#nested-hover). + ----- diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index 792611b54e..8c36427041 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -59,6 +59,8 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen 每次命令目录拉取都要求客户端已持有该会话,并等待首次历史打开成功后才发送 `commands.list`。临时的 `commandCatalog` 引用持续持有会话,直到拉取结束。未被持有的会话或打开失败会直接拒绝,不发送 RPC,因此后台目录刷新不会重新打开已关闭的会话。 +弹出选择面板使用[标准菜单的内缩与菜单项圆角](../../../docs/ui-radius.zh.md#nested-hover)。 + ----- diff --git a/packages/client/ui-commands/locale/en.json b/packages/client/ui-commands/locale/en.json new file mode 100644 index 0000000000..e7d16ed4e8 --- /dev/null +++ b/packages/client/ui-commands/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-commands" + } +} diff --git a/packages/client/ui-commands/locale/zh.json b/packages/client/ui-commands/locale/zh.json new file mode 100644 index 0000000000..ea7b9303f5 --- /dev/null +++ b/packages/client/ui-commands/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "命令选择界面", + "description": "在输入框中提供斜杠命令菜单和相关操作界面。" + } +} diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index ecedb1d9f8..d7bfa638d8 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -54,7 +55,6 @@ "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-commands": "workspace:*", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "@deepseek-ai/dsh-api-session-controller": "workspace:*", @@ -66,6 +66,7 @@ "clsx": "^2.0.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-commands/src/client/PopupSelectView.module.css b/packages/client/ui-commands/src/client/PopupSelectView.module.css index f500af280b..bf44e4afa8 100644 --- a/packages/client/ui-commands/src/client/PopupSelectView.module.css +++ b/packages/client/ui-commands/src/client/PopupSelectView.module.css @@ -9,7 +9,7 @@ right: 0; box-sizing: border-box; z-index: 100; - padding: 3px; + padding: 4px; display: flex; flex-direction: column; min-width: min(220px, 100%); diff --git a/packages/client/ui-commands/tsdown.config.ts b/packages/client/ui-commands/tsdown.config.ts index 83f95f8a8c..7aa03cebc1 100644 --- a/packages/client/ui-commands/tsdown.config.ts +++ b/packages/client/ui-commands/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-commands', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index a22be7582c..7713ce057f 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -12,14 +12,17 @@ en: bac0bf0d7330f3b1 zh: 84a895f4b26d8490 /deepseek-ai-dsh-client-ui-conversation/table-of-contents: - en: 9c1c4905952fea5e - zh: d1506dd6fc1018d1 + en: eef4f18c3972ccc4 + zh: 6be65c6ac84ce497 /deepseek-ai-dsh-client-ui-conversation/conversation-assembly: - en: 1d2a3a7f98c2cc9b - zh: f895dd5e9f91e0a9 + en: ef43c4f149195939 + zh: 49fc6c83fd3a05ce +/deepseek-ai-dsh-client-ui-conversation/sent-user-text: + en: 9912b286384c41d7 + zh: 3e994887c1948677 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: 50b5e00a5fb7c2e5 - zh: 91333db3947f395d + en: ddabf2f192114e62 + zh: 31370ed6d5149409 /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 ec4221995f..11c7490a7f 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -16,6 +16,7 @@ Desktop product events use the optional [product analytics service](../product-a ## Table of Contents - [Conversation assembly](#conversation-assembly) +- [Sent user text](#sent-user-text) - [Shell and standard props](#shell-and-standard-props) - [Temporary composer entries](#temporary-composer-entries) - [Model Experience](#model-experience) @@ -37,7 +38,7 @@ Definitions may share the same read-only Match and Location for one input and li Registration accepts the existing callable `match` or a read-only event-type-to-handler table. The Registry copies table entries and precomputes ordered candidate Sets on registration changes. Function-form Definitions keep their callable API and receive every event; table handlers receive only their declared types. Changing a table requires unregistering and registering again. Legacy assembler adapters with only `entries()` and `fallbackEntry()` retain their original dispatch path. -All accepted Matches update State in order. Repeated updates to one Context are coalesced between flushes. A Context retains its key while kind and id match; Definition-facing Context snapshots remain distinct objects. The fallback handles only inputs not claimed by an ordinary Definition for its target. +All accepted Matches update State in order. Repeated updates to one Context are coalesced between flushes. A Context retains its key while kind and id match; Definition-facing Context snapshots remain distinct objects. The fallback handles only inputs that no ordinary Definition of its target matched. A Definition can return `exclusive: true` from `match()` to claim an event on its target: other Definitions of that target do not receive the event and the target's fallback does not run ([semantics](../../../docs/subsystems/conversation.md#definition-and-typed-chat-payload)). Location lookup indexes Steps by Turn and Step number, so a non-boundary append does not scan the Turn's Step array. Each input's numeric coordinates and resolved Location share one private index record; published Location values remain immutable. Boundary updates, replacement, and prepend keep the indexes aligned with the current timeline. @@ -49,13 +50,24 @@ Group registration rejects a missing View target. Removing an already registered Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry. The shared request inspection serves every target: `ctx.uiConversation.inspectSystemPrompt(previous, event)` interprets system messages and positional replacements as immutable loaded-surface state. It selects the last nonempty surviving system node in surface order, retains only surviving replacement positions for chained rewrites, and withholds the prompt after an unindexed older endpoint until prepend replay supplies its order. Target-owned Definitions retain historical cards independently. `ctx.uiConversation.inspectRequestPrompt(previous, header, system)` classifies request changes against that effective prompt; ordinary messages and stream chunks require no system-state work. + +## Sent user text + +`UiConversation.userText` registers synchronous `match(text, from, context)` rules. Each call offers one business or Text candidate; the evaluator selects the earliest start and uses `order` and stable ID only for same-start ties. Each caller owns its query cache. `Context.read(key)` invalidates only readers of changed Facts, while future candidates and no-match results are reused within their recorded search ranges. Inputs and outputs use readonly types without runtime freezing. See the [UserText types](src/client/contract/user-text.ts) and [design decision](../../../.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.md). + +Businesses extend `UserTextDataMap` and `UserTextFacts` on this package's `/client` entry, register a matcher through `ctx.uiConversation.userText`, and contribute their renderer to `conversation.user.text.node`. The session-scoped `conversation.user.text` Factory owns this inline keyed Slot. Whitespace rules also return ordinary Text candidates through the same scheduler; trim wins same-start edge collisions with collapse. Node registrations supply their own actions; `interactive` and CSS `ellipsis` remain React Props. Missing renderers display the original fragment; matching failures retain the complete original text and report the rule error. + +Queue previews enable whitespace collapsing and document-edge trimming, retain emoji, and use CSS ellipsis without truncating the input. Editing and copying retain the original text; native attachments use their separate presentation. + ## Shell and standard props -The shared image slot props keep display choices separate from durable references: `thumbnail` requests a contained attachment-list thumbnail, while `compact` requests a cropped gallery tile. An optional per-image `label` supplies the accessible display name; loading and cache identity still use the original attachment reference. [ui-attachment](../ui-attachment/README.md) owns rendering and the lightbox. +The shared image slot props keep display choices separate from durable references: `thumbnail` requests a contained attachment-list thumbnail, while `compact` requests a cropped gallery tile. An optional per-image `label` supplies the accessible display name; loading and cache identity still use the original attachment reference. `MessageImagesContentInput` adds optional complete `MessageImageLabels` for the root-scoped `conversation.images.content` Factory. Message, tool, and Factory inputs carry the view's authorized `loadImage` callback explicitly; loading and URL caching remain with the resource owner. [ui-attachment](../ui-attachment/README.md) provides this Factory and owns rendering and the lightbox. 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 context meter follows the [statistics-control radius](../../../docs/ui-radius.md#radius-scale). + The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available. 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. @@ -82,10 +94,12 @@ Active transcripts in the main occurrence expose content-width drag handles in t The width-handle indicator follows the pointer only during a captured drag; ordinary hover leaves its position unchanged. -The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's text and semantic references are persisted by the per-Session Conversation store. QueueDock reads `next-turn` from the Session `inbox` projection, including cold recovered messages, and excludes only requestIds still owned by local transcript submissions. Other queued rows retain their normal display and actions. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in the ui-primitives `InlineEditor`, so line breaks survive re-editing. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. +The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's text and semantic references are persisted by the per-Session Conversation store. QueueDock reads `next-turn` from the Session `inbox` projection, including cold recovered messages, and excludes only requestIds still owned by local transcript submissions. Other queued rows retain their normal display and actions. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews use the [UserText Factory](#sent-user-text) (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in the ui-primitives `InlineEditor`, so line breaks survive re-editing. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. Plain Enter uses the configured delivery mode, exactly Ctrl+Enter or Cmd+Enter uses its complement, and Shift+Enter inserts a line break. Enter with Alt, AltGraph, both Ctrl and Cmd, or Shift plus Ctrl/Cmd leaves the draft and command menu unchanged and keeps the DOM event available to application shortcuts. The Conversation plugin registers the fixed send, newline, complementary-delivery, command-menu, and reference-menu rows and reserves their bindings until it unloads. +Clicking unused space inside an editable composer preserves its current caret or selection when focused, or restores its previous caret or selection when unfocused. Text selection, scrollbars, controls, and embedded overlays keep their own pointer behavior. + Each composer attempt carries its original occurrence time, gesture, delivery intent, and Session model/run-state snapshot through asynchronous command arbitration. The ordinary-message notification fires once before reference serialization, including attachment-only submissions; handled or claimed commands do not notify. Snapshot and notification failures cannot block sending. Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents accept pasted and dropped PNG, JPEG, WebP, and GIF images with the same projected limits; a batch containing a generic file, unsupported image format, or directory is refused before intake. They skip local echoes because their transport does not preserve the browser request id. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index bb0c423a36..cf6a73df3b 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -16,6 +16,7 @@ kind: "package-reference" ## 目录 - [Conversation 组装](#conversation-assembly) +- [已发送用户文本](#sent-user-text) - [Shell 与标准 props](#shell-and-standard-props) - [临时 composer entry](#temporary-composer-entries) - [模型体验](#model-experience) @@ -37,7 +38,7 @@ kind: "package-reference" 注册入口接受原有可调用的 `match`,或只读的事件类型到处理函数表。Registry 在注册关系变化时复制表条目并预计算有序候选 Set。函数形式的 Definition 保持原有可调用 API,接收全部事件;表中的处理函数只接收已声明类型。修改表需要注销后重新注册。只有 `entries()` 与 `fallbackEntry()` 的旧 assembler 适配器保留原有分发路径。 -所有接受的 Match 按顺序更新 State。两次 flush 之间,同一 Context 的重复更新合批发布。kind 和 id 相同时,Context 保持原有 key;传给 Definition 的 Context 快照仍为独立对象。fallback 只处理未被其目标的普通 Definition 认领的输入。 +所有接受的 Match 按顺序更新 State。两次 flush 之间,同一 Context 的重复更新合批发布。kind 和 id 相同时,Context 保持原有 key;传给 Definition 的 Context 快照仍为独立对象。fallback 只处理其目标上没有普通 Definition 匹配的输入。Definition 可以从 `match()` 返回 `exclusive: true`,在其目标上认领一个事件:该目标的其他 Definition 收不到这个事件,该目标的 fallback 也不运行([语义](../../../docs/subsystems/conversation.zh.md#definition-and-typed-chat-payload))。 Location 查询按 Turn 和 Step 编号索引 Step,因此非边界 append 不扫描 Turn 的 Step 数组。每个输入的数值坐标与解析出的 Location 共用一条私有索引记录;已发布的 Location 值保持不可变。边界更新、完整替换和向前补页使这些索引与当前时间线保持一致。 @@ -49,13 +50,24 @@ Group 注册时缺少 View 目标会报错。已注册的 View Definition 被移 target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。共享的请求检查服务于每个 target:`ctx.uiConversation.inspectSystemPrompt(previous, event)` 将系统消息与位置替换解释为不可变的已加载 surface 状态。它按 surface 顺序选择最后一个非空的存活系统节点,为连续重写只保留存活的替换位置;遇到未建立索引的更早端点后,提示词保持不可用,直到向前补页回放提供其顺序。target 自有的 Definition 独立保留历史卡片。`ctx.uiConversation.inspectRequestPrompt(previous, header, system)` 根据该有效提示词分类请求变更;普通消息与流式分片无需处理系统状态。 + +## 已发送用户文本 + +`UiConversation.userText` 注册同步 `match(text, from, context)` 规则。每次调用提供一个业务或 Text 候选;计算器选择起点最早的候选,仅在同起点时使用 `order` 和稳定 ID。每个调用者拥有自己的查询缓存。`Context.read(key)` 只使变化 Facts 的读取者失效,未来候选和无匹配结果在已记录的搜索范围内复用。输入和输出使用 readonly 类型,不在运行时冻结。详见 [UserText 类型](src/client/contract/user-text.ts)与[设计决策](../../../.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.zh.md)。 + +业务扩展本包 `/client` 入口的 `UserTextDataMap` 和 `UserTextFacts`,通过 `ctx.uiConversation.userText` 注册匹配器,并向 `conversation.user.text.node` 贡献 renderer。Session 作用域的 `conversation.user.text` Factory 拥有这个行内 keyed Slot。空白规则也通过同一调度器返回普通 Text 候选,trim 在同起点的边缘空白冲突中胜过 collapse。节点注册自行提供动作,`interactive` 和 CSS `ellipsis` 仍为 React Props。缺少 renderer 时显示原文片段,匹配失败时保留完整原文并报告规则错误。 + +Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CSS 省略而不截断输入。编辑和复制保留原文;原生附件使用独立的展示路径。 + ## Shell 与标准 props -共享图片插槽属性将展示选择与持久化引用分开:`thumbnail` 请求完整缩放的附件列表缩略图,`compact` 请求裁剪的图片方块。每张图片可通过可选的 `label` 提供无障碍展示名称;加载和缓存标识仍使用原始附件引用。[ui-attachment](../ui-attachment/README.zh.md) 负责渲染与灯箱。 +共享图片插槽属性将展示选择与持久化引用分开:`thumbnail` 请求完整缩放的附件列表缩略图,`compact` 请求裁剪的图片方块。每张图片可通过可选的 `label` 提供无障碍展示名称;加载和缓存标识仍使用原始附件引用。`MessageImagesContentInput` 为 root 作用域的 `conversation.images.content` Factory 增加可选的完整 `MessageImageLabels`。消息、工具及 Factory 输入显式携带视图提供的授权 `loadImage` 回调;读取和 URL 缓存仍由资源所有者负责。[ui-attachment](../ui-attachment/README.zh.md) 提供该 Factory,负责渲染与灯箱。 控件组的尺寸、内容、可见性或字体加载状态变化后,composer 测量展开状态下的控件组。若无法排在同一行,控制栏为模型位设置 `--dsh-composer-model-text-display: none` 和 `--dsh-composer-model-icon-display: block`;两者默认值分别为 `block` 和 `none`。若连图标也放不下,仍允许换行。 +上下文占用率入口遵循[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。 + 上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。 关闭代码工作工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭代码工作工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。 @@ -82,10 +94,12 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 宽度拖拽条的指示线只在已捕获指针的拖拽期间跟随指针,普通悬停不改变其位置。 -常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿文字和引用语义字段由逐 Session Conversation store 持久化。QueueDock 从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息,仅排除仍由本地 transcript 提交承接的 requestId。其他排队行保留正常展示和操作。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在 ui-primitives 的 `InlineEditor` 中展示字面发送文本,因此重新编辑不会丢失换行。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿文字和引用语义字段由逐 Session Conversation store 持久化。QueueDock 从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息,仅排除仍由本地 transcript 提交承接的 requestId。其他排队行保留正常展示和操作。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览使用 [UserText Factory](#sent-user-text)(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在 ui-primitives 的 `InlineEditor` 中展示字面发送文本,因此重新编辑不会丢失换行。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 普通 Enter 使用已配置的投递模式,严格的 Ctrl+Enter 或 Cmd+Enter 使用互补模式,Shift+Enter 插入换行。带 Alt、AltGraph、同时带 Ctrl 与 Cmd,或 Shift 与 Ctrl/Cmd 的 Enter 保持草稿和指令菜单不变,并将 DOM 事件留给应用快捷键。Conversation 插件注册发送、换行、互补投递、命令菜单和引用菜单的固定条目,并在卸载前保留这些键位。 +点击可编辑输入框内的留白时,已聚焦的输入框保留当前光标或选区,未聚焦的输入框恢复上次的光标或选区。文本选择、滚动条、控件和内嵌浮层保持各自的指针交互行为。 + 每次输入框提交都携带原始发生时间、操作来源、投递意图以及会话模型和运行状态快照,贯穿异步命令裁决。普通消息通知在引用序列化前发出一次,纯附件提交也遵循这一规则;已处理或认领的命令不发出通知。快照读取与通知消费异常不能阻断发送。 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理允许粘贴和拖入 PNG、JPEG、WebP、GIF 图片,并沿用相同的投影限制;包含通用文件、不支持的图片格式或目录的批次会在进入草稿前整批拒绝。它们不创建本地回显,因为其 transport 不保留浏览器 request id。 diff --git a/packages/client/ui-conversation/locale/en.json b/packages/client/ui-conversation/locale/en.json new file mode 100644 index 0000000000..918f717ecd --- /dev/null +++ b/packages/client/ui-conversation/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-conversation" + } +} diff --git a/packages/client/ui-conversation/locale/zh.json b/packages/client/ui-conversation/locale/zh.json new file mode 100644 index 0000000000..a48a705f37 --- /dev/null +++ b/packages/client/ui-conversation/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "会话界面框架", + "description": "组织对话内容、输入框、消息队列和视图导航。" + } +} diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index af91420381..48a3f73af5 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -82,12 +83,10 @@ "@deepseek-ai/dsh-workspace": "workspace:*", "@deepseek-ai/dsh-file-reference": "workspace:*", "@deepseek-ai/dsh-util-workspace-path": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0", "@lexical/headless": "^0.49.0", "react-dom": "^18.2.0", - "@types/react-dom": "~18.3.0", - "zod": "^4.4.3", + "zod": "4.4.3", "lexical": "^0.49.0", "clsx": "^2.0.0", "@lexical/utils": "^0.49.0", @@ -100,6 +99,7 @@ "@deepseek-ai/dsh-client-product-analytics": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 9994c4e9ae..3e34d83b4b 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -14,10 +14,13 @@ import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type { ShortcutCommandId, ShortcutFixedCommand } from '@deepseek-ai/dsh-client-shortcuts/client' import { UiConversation } from './conversation/assembly.ts' +import { UserText, PlainUserText, SessionReference } from './user-text/UserText.tsx' +import { sessionReferenceRule } from './user-text/session-reference-rule.ts' +import { collapseWhitespaceRule, trimWhitespaceRule } from './user-text/whitespace-rules.ts' import type { ViewTab } from './contract/views.ts' import type { ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected, - ConversationSessionInjected, DraftFileUploads, + ConversationSessionInjected, DraftFileUploads, UserTextInjected, } from './contract/slots.ts' import type { InputNotice } from './contract/input.ts' import type { ReferenceInsert } from './contract/draft-editor.ts' @@ -156,6 +159,30 @@ export function apply(ctx: Context, config: Config = Config({})): void { const maxConcurrentFileUploads = config.maxConcurrentFileUploads as number const workspaceNavigation = ctx.get('uiWorkspace') as unknown as WorkspaceNavigation const uiConversation = new UiConversation(ctx, sessions) + uiConversation.userText.register(sessionReferenceRule) + uiConversation.userText.register(collapseWhitespaceRule) + uiConversation.userText.register(trimWhitespaceRule) + slots.registerFactory({ + name: 'conversation.user.text', + scope: 'session', + children: { + 'conversation.user.text.node': { kind: 'keyed', scope: 'session', inline: true }, + }, + inject: (): UserTextInjected => ({ + hooks: { + userTextRevision: { + getSnapshot: () => uiConversation.userText.getSnapshot(), + subscribe: listener => uiConversation.userText.subscribe(listener), + }, + }, + createUserTextEvaluator: () => uiConversation.userText.createEvaluator(), + reportTransformFailure: (error) => { console.error('[ui-conversation] user-text transformation failed:', error) }, + }), + }, UserText) + slots.inject('conversation.user.text.node', function* () { + yield slots.register({ name: 'conversation.user.text.node', key: 'text', priority: 100 }, PlainUserText) + yield slots.register({ name: 'conversation.user.text.node', key: 'session', priority: 100 }, SessionReference) + }) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries') const t = ctx.locale.bind(NS) diff --git a/packages/client/ui-conversation/src/client/contract/conversation.ts b/packages/client/ui-conversation/src/client/contract/conversation.ts index 8230bda46e..2d871871fc 100644 --- a/packages/client/ui-conversation/src/client/contract/conversation.ts +++ b/packages/client/ui-conversation/src/client/contract/conversation.ts @@ -8,6 +8,16 @@ import type { export interface ConversationMatchResult { readonly id: string readonly role: 'start' | 'update' + /** + * Claim this event on the Definition's target: no other Definition of the + * same target receives it, and that target's unmatched-event fallback does + * not run. Definitions of other targets and Definitions without a target + * still receive it. Two claims on one target, or a claim from a Definition + * without a target, throw during dispatch. A claim returned by the fallback + * itself has no effect, because the fallback runs only when no Definition of + * its target matched. + */ + readonly exclusive?: true } /** Merge-extensible business values published against one Turn. */ diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 5564aea9b6..9abf5afb1c 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -3,6 +3,7 @@ import type { ReactNode, RefObject } from 'react' import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/client' +import type { ImageLightboxLabels } from '@deepseek-ai/dsh-client-ui-primitives' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { MaybeSnapshotSelectorHook, ObservableSnapshot, SnapshotSelectorHook, @@ -22,6 +23,7 @@ import type { createConversationStore } from '../stores.ts' import type { BusyEnterBehavior } from './composer-submission.ts' import type { ConversationSnapshot } from './snapshot.ts' import type { ViewTab } from './views.ts' +import type { UserTextDocumentInput, UserTextEvaluator, UserTextId, UserTextNode } from './user-text.ts' /** Browser-owned draft attachment that has not crossed the durable Host boundary. */ export type ComposerAttachment = ComposerImageAttachment | ComposerFileAttachment @@ -117,8 +119,53 @@ export interface MessageImagesOwnerProps { thumbnail?: boolean } -/** Slot-backed renderer used by Conversation targets without importing an attachment implementation. */ -export type RenderMessageImages = (owner: Omit) => ReactNode +/** Message-image strings the owner resolves from its own locale namespace. */ +export interface MessageImageLabels { + /** Fallback display name for an unnamed image. */ + image: string + /** Thumbnail tooltip inviting the original-image preview. */ + open: string + /** Accessible thumbnail label; receives the image's display name. */ + openNamed: (label: string) => string + /** Loading placeholder shown until bytes resolve. */ + loading: string + /** Retry-control label shown when the load fails. */ + loadFailed: string + /** Lightbox strings forwarded to the opened preview. */ + lightbox: ImageLightboxLabels +} + +/** Root-scoped image gallery input with explicit reading authority and optional caller copy. */ +export interface MessageImagesContentInput extends MessageImagesOwnerProps { + readonly labels?: MessageImageLabels +} + +/** Transform inputs and independently controlled rendering behavior. */ +export interface UserTextInput extends UserTextDocumentInput { + /** Enable renderer-owned reference actions; omitted values keep references read-only. */ + readonly interactive?: boolean + /** Use CSS single-line overflow ellipsis without shortening source text. */ + readonly ellipsis?: boolean +} + +/** Rule invalidation and document evaluation supplied by the data layer. */ +export interface UserTextInjected { + hooks: { userTextRevision: ObservableSnapshot } + /** Each invocation creates a caller-owned data-layer cache without registry subscriptions. */ + createUserTextEvaluator: () => UserTextEvaluator + /** Report a failed rule while the original text remains visible. */ + reportTransformFailure: (error: unknown) => void +} + +/** JSON-only owner data common to every business text node. */ +export interface UserTextNodeOwnerProps { + readonly raw: string + readonly interactive: boolean +} + +/** Complete props for the selected business node, including its Session scope. */ +export type UserTextNodeProps = + PropsRuntime<'conversation.user.text.node', Id> /** Selector hook over the current Session's assembled Conversation. */ export type UseConversation = SnapshotSelectorHook @@ -126,7 +173,33 @@ export type UseConversation = SnapshotSelectorHook export type UseConversationViews = SnapshotSelectorHook declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotFactoryMap { + /** Parse one original text and render its business nodes through the owned keyed Slot. */ + 'conversation.user.text': { + scope: 'session' + props: UserTextInput + inject: UserTextInjected + children: { + 'conversation.user.text.node': { kind: 'keyed'; scope: 'session'; inline: true } + } + } + /** Official reusable gallery, with explicit authorized loading and optional caller copy. */ + 'conversation.images.content': { + scope: 'root' + props: MessageImagesContentInput + locale: 'conversation' + } + } + interface SlotMap { + /** One business-owned inline renderer for a pure JSON text node. */ + 'conversation.user.text.node': { + kind: 'keyed' + scope: 'session' + inline: true + owner: UserTextNodeOwnerProps + keyProps: { [Id in UserTextId]: { node: UserTextNode } } + } /** Conversation shell beneath its root-scoped main-panel entry. */ 'main.conversation': { kind: 'single'; scope: 'session-maybe' } /** Strict per-Session Conversation body. */ diff --git a/packages/client/ui-conversation/src/client/contract/user-text.ts b/packages/client/ui-conversation/src/client/contract/user-text.ts new file mode 100644 index 0000000000..4b6f68e621 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/user-text.ts @@ -0,0 +1,63 @@ +/** Readonly JSON candidates, forward matching, and tracked caller Facts. */ +import type { UserTextDataMap, UserTextFacts } from '../index.ts' + +/** Registered business IDs plus the document-owned `text` ID. */ +export type UserTextId = Extract + +type JsonPayload = T extends string | number | boolean | null ? T + : T extends (...args: never[]) => unknown ? never + : T extends readonly unknown[] ? { readonly [K in keyof T]: JsonPayload } + : T extends object ? { readonly [K in keyof T]: JsonPayload } + : never + +/** Original UTF-16 text and readonly Facts; absence is observable through `context.read`. */ +export interface UserTextDocumentInput { + readonly text: string + readonly facts: Readonly> +} + +/** Discriminated JSON node over a nonempty half-open UTF-16 range in the original text. */ +export type UserTextNode = { + [Id in K]: { + readonly id: Id + readonly start: number + readonly end: number + readonly data: JsonPayload + } +}[K] + +/** Facts reader valid only during its owning synchronous query; never retain it. */ +export interface UserTextTransformContext { + /** + * Record a field dependency, including an absent value; throws after the call returns or throws. + * @param key - field of the caller's readonly Facts snapshot. + * @returns the borrowed readonly value, or undefined when absent. + */ + read(key: K): UserTextFacts[K] | undefined +} + +/** One forward matcher for business references or Text replacements; order only resolves same-start candidates. */ +export interface UserTextRule { + readonly id: Id + /** Finite ascending priority; equal values use stable ID ordering, never plugin load order. */ + readonly order: number + /** + * Find one nearest original-text candidate at or after `from`, resolving own same-start alternatives deterministically. + * Search uses original lexical boundaries, not the beginning of a sliced suffix. Queries are independent of accepted nodes. + * @param text - complete original text; neither this string nor caller Facts may be mutated. + * @param from - inclusive original UTF-16 start offset, advancing within one reconciliation. + * @param context - synchronous Facts reader; all retained queries contribute dependencies. + * @returns one nonempty node using this rule's ID or `text`, or undefined when no later candidate exists. + * Text candidates preserve the exact source substring and may supply `data.display`. + */ + match(this: void, text: string, from: number, context: UserTextTransformContext): (UserTextNode & { readonly id: Id }) | UserTextNode<'text'> | undefined +} + +/** + * Reconcile one document using current rules and independently cached candidate queries. + * Reentrant calls throw. Failures carry ruleId and cause without publishing partial output; + * failed queries retry when their offset, definition, original text, or read Facts change. + * @param input - original text and readonly JSON Facts; fields are compared with Object.is. + * @returns readonly nodes covering the text, or [] for empty text; equivalent results retain identities. + */ +export type UserTextEvaluator = (input: UserTextDocumentInput) => readonly UserTextNode[] diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index 6ead68c793..d50d7d5eaf 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -3,7 +3,7 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/client' import type { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand' import type { - ConversationContextReader, ConversationLocationData, ConversationMatch, + ConversationContextReader, ConversationLocationData, ConversationMatch, ConversationMatchResult, ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext, ConversationLocationDataScope, ConversationPublication, ConversationViewBuilder, ConversationStartMatch, @@ -186,6 +186,9 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore private readonly groups = new Map() private readonly pendingGroupStores = new Set>() private readonly activeTargets = new Set() + /** Matches of the event in dispatch, kept by index; dispatch is not reentrant and reuses both arrays. */ + private readonly matchedDefinitions: ConversationNodeDefinition[] = [] + private readonly matchedResults: ConversationMatchResult[] = [] private hasMore = false private replacePending = true private timelineDirty = true @@ -493,37 +496,58 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore ? startMatch ??= conversationMatch(input, role, location) : updateMatch ??= conversationMatch(input, role, location) } - let firstTarget: string | undefined - let secondTarget: string | undefined - let otherTargets: Set | undefined - let publication: ConversationPublication = 'none' + const definitions = this.matchedDefinitions + const results = this.matchedResults + definitions.length = 0 + results.length = 0 const routes = this.eventDefinitions.forEvent?.(event.type) if (routes === undefined) { for (const definition of this.eventDefinitions.entries()) { const result = definition.match(event) if (result === null) continue - // Inline target tracking avoids an additional closure allocation for each event. - /* jscpd:ignore-start */ - if (definition.target !== undefined && definition.target !== firstTarget && definition.target !== secondTarget) { - if (firstTarget === undefined) firstTarget = definition.target - else if (secondTarget === undefined) secondTarget = definition.target - else (otherTargets ??= new Set()).add(definition.target) - } - publication = maximumPublication(publication, accept.call(this, definition, result.id, matchFor(result.role))) - /* jscpd:ignore-end */ + definitions.push(definition) + results.push(result) } } else { for (const { definition, match } of routes) { const result = match(event) if (result === null) continue - if (definition.target !== undefined && definition.target !== firstTarget && definition.target !== secondTarget) { - if (firstTarget === undefined) firstTarget = definition.target - else if (secondTarget === undefined) secondTarget = definition.target - else (otherTargets ??= new Set()).add(definition.target) - } - publication = maximumPublication(publication, accept.call(this, definition, result.id, matchFor(result.role))) + definitions.push(definition) + results.push(result) } } + // Claims are resolved before any acceptance, so the outcome does not depend on registration order. + let claims: Map | undefined + for (let index = 0; index < results.length; index++) { + if ((results[index] as ConversationMatchResult).exclusive !== true) continue + const definition = definitions[index] as ConversationNodeDefinition + const target = definition.target + if (target === undefined) { + throw new Error(`conversation Definition "${definition.kind}" claims ${event.type} at seq ${String(event.seq)} without a target`) + } + const claimant = claims?.get(target) + if (claimant !== undefined) { + throw new Error(`conversation Definitions "${claimant}" and "${definition.kind}" both claim ${event.type} at seq ${String(event.seq)} on target "${target}"`) + } + (claims ??= new Map()).set(target, definition.kind) + } + let firstTarget: string | undefined + let secondTarget: string | undefined + let otherTargets: Set | undefined + let publication: ConversationPublication = 'none' + for (let index = 0; index < results.length; index++) { + const definition = definitions[index] as ConversationNodeDefinition + const result = results[index] as ConversationMatchResult + const target = definition.target + if (target !== undefined && result.exclusive !== true && claims?.has(target) === true) continue + // Inline target tracking avoids an additional closure allocation for each event. + if (target !== undefined && target !== firstTarget && target !== secondTarget) { + if (firstTarget === undefined) firstTarget = target + else if (secondTarget === undefined) secondTarget = target + else (otherTargets ??= new Set()).add(target) + } + publication = maximumPublication(publication, accept.call(this, definition, result.id, matchFor(result.role))) + } const fallback = this.eventDefinitions.fallbackEntry() const target = fallback?.target if (fallback !== undefined && target !== undefined diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index edc182f530..b6cfcd6318 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -24,6 +24,7 @@ import { ConversationEventRegistry } from './event-registry.ts' import { HistoricalImageCache } from './historical-images.ts' import { ConversationViewRegistry } from './view-registry.ts' import { ConversationGroupRegistry } from './group-registry.ts' +import { UserTextRegistry } from './user-text-registry.ts' /** Observable faces published for one Session's Conversation assembly. */ export interface ConversationBinding { @@ -190,6 +191,8 @@ export class UiConversation extends Service { readonly views: ConversationViewRegistry /** Business grouping rules over already materialized target Nodes. */ readonly groups: ConversationGroupRegistry + /** Pure text rules and parser snapshots, independent of durable Session assembly. */ + readonly userText: UserTextRegistry private readonly bindings = new WeakMapWithValues() private readonly images: HistoricalImageCache @@ -202,6 +205,7 @@ export class UiConversation extends Service { this.events = new ConversationEventRegistry(ctx) this.views = new ConversationViewRegistry(ctx) this.groups = new ConversationGroupRegistry(ctx, this.views) + this.userText = new UserTextRegistry(ctx) this.images = new HistoricalImageCache(ctx, sessions) const rebuild = (): void => { for (const record of this.bindings.values) record.binding.rebuild() diff --git a/packages/client/ui-conversation/src/client/conversation/user-text-document.ts b/packages/client/ui-conversation/src/client/conversation/user-text-document.ts new file mode 100644 index 0000000000..e8c6b19b8a --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation/user-text-document.ts @@ -0,0 +1,244 @@ +/** Per-document candidate reconciliation, query coverage, tracked Facts and JSON identity reuse. */ +import type { UserTextFacts } from '../index.ts' +import type { + UserTextDocumentInput, UserTextNode, UserTextRule, UserTextTransformContext, +} from '../contract/user-text.ts' + +type FactKey = keyof UserTextFacts + +class UserTextTransformError extends Error { + constructor(readonly ruleId: string, cause: unknown) { + super(`user-text rule "${ruleId}" failed to evaluate`, { cause }) + this.name = 'UserTextTransformError' + } +} + +interface Query { + readonly from: number + readonly node: UserTextNode | undefined + readonly error: UserTextTransformError | undefined +} + +interface Matcher { + readonly definition: UserTextRule + readonly reads: Map + queries: readonly Query[] +} + +interface Traversal { + readonly matcher: Matcher + readonly added: Query[] + index: number + head: Query | undefined +} + +/** Data-layer cache owned by one caller; initialization and refresh share one reconciliation. */ +export class UserTextDocument { + private text: string | undefined + private rules: readonly UserTextRule[] | undefined + private initial: readonly UserTextNode[] = [] + private output: readonly UserTextNode[] = [] + private readonly matchers = new Map() + private complete = false + private evaluating = false + + /** + * Reconcile original text using current rule definitions and valid query records. + * @param input - original text and readonly Facts, compared by fields actually read. + * @param rules - unique definitions ordered by ascending order and stable ID. + * @returns complete readonly nodes; failures retain caches but publish no partial result. + */ + evaluate(input: UserTextDocumentInput, rules: readonly UserTextRule[]): readonly UserTextNode[] { + if (this.evaluating) throw new Error('user-text document evaluation cannot be reentrant') + this.evaluating = true + try { + const { changed, matchers } = this.prepare(input, rules) + if (!changed && this.complete) return this.output + this.complete = false + this.output = alignNodes(this.reconcile(input, matchers), this.output, this.initial) + this.complete = true + return this.output + } finally { + this.evaluating = false + } + } + + private prepare(input: UserTextDocumentInput, rules: readonly UserTextRule[]): { changed: boolean; matchers: Matcher[] } { + let changed = this.rules !== rules + if (this.text !== input.text) { + changed = true + this.text = input.text + this.matchers.clear() + this.initial = input.text.length === 0 ? [] : [textNode(input.text, 0, input.text.length)] + this.output = this.initial + } + const ids = new Set(rules.map(rule => rule.id)) + const matchers: Matcher[] = [] + for (const definition of rules) { + let matcher = this.matchers.get(definition.id) + if (matcher === undefined || matcher.definition !== definition || factsChanged(matcher.reads, input)) { + matcher = { definition, queries: [], reads: new Map() } + this.matchers.set(definition.id, matcher) + changed = true + } + matchers.push(matcher) + } + for (const id of this.matchers.keys()) if (!ids.has(id)) this.matchers.delete(id) + this.rules = rules + return { changed, matchers } + } + + private reconcile(input: UserTextDocumentInput, matchers: readonly Matcher[]): readonly UserTextNode[] { + const traversals: Traversal[] = matchers.map(matcher => ({ matcher, added: [], index: -1, head: undefined })) + const nodes: UserTextNode[] = [] + let cursor = 0 + try { + while (true) { + let winner: UserTextNode | undefined + for (const traversal of traversals) { + const node = nextCandidate(traversal, input, cursor) + // Definitions arrive in stable priority order; earlier starts always take precedence. + if (node !== undefined && (winner === undefined || node.start < winner.start)) winner = node + } + if (winner === undefined) { + if (cursor < input.text.length) nodes.push(textNode(input.text, cursor, input.text.length)) + return nodes + } + if (cursor < winner.start) nodes.push(textNode(input.text, cursor, winner.start)) + nodes.push(winner) + cursor = winner.end + } + } finally { + for (const { matcher, added } of traversals) { + if (added.length > 0) matcher.queries = mergeQueries(matcher.queries, added) + } + } + } + +} + +function factsChanged(reads: ReadonlyMap, input: UserTextDocumentInput): boolean { + for (const [key, value] of reads) if (!Object.is(value, input.facts[key])) return true + return false +} + +function withFacts(input: UserTextDocumentInput, reads: Map, run: (context: UserTextTransformContext) => T): T { + let active = true + try { + return run({ + read: (key: K): UserTextFacts[K] | undefined => { + if (!active) throw new Error('user-text transform context is closed; read is synchronous only') + const value = input.facts[key] + reads.set(key, value) + return value + }, + }) + } finally { + active = false + } +} + +function nextCandidate(traversal: Traversal, input: UserTextDocumentInput, cursor: number): UserTextNode | undefined { + const { matcher } = traversal + if (traversal.head === undefined || (traversal.head.node !== undefined && traversal.head.node.start < cursor)) { + let next = matcher.queries[traversal.index + 1] + while (next !== undefined && next.from <= cursor) { + traversal.index++ + next = matcher.queries[traversal.index + 1] + } + const recorded = matcher.queries[traversal.index] + if (recorded !== undefined && (recorded.error !== undefined + ? recorded.from === cursor : recorded.node === undefined || recorded.node.start >= cursor)) { + traversal.head = recorded + } else { + let node: UserTextNode | undefined + let error: UserTextTransformError | undefined + try { + node = withFacts(input, matcher.reads, context => matcher.definition.match(input.text, cursor, context)) + if (node !== undefined) validateCandidate(input.text, matcher.definition.id, cursor, node) + } catch (cause) { + error = new UserTextTransformError(matcher.definition.id, cause) + } + traversal.head = { from: cursor, node, error } + traversal.added.push(traversal.head) + } + } + if (traversal.head.error !== undefined) throw traversal.head.error + return traversal.head.node +} + +function validateCandidate(text: string, ruleId: string, from: number, node: UserTextNode): void { + if (!Number.isInteger(node.start) || !Number.isInteger(node.end) + || node.start < from || node.end <= node.start || node.end > text.length) { + throw new Error(`invalid candidate range [${node.start}, ${node.end}); expected a nonempty range from ${from} to ${text.length}`) + } + if (node.id === 'text') { + if (node.data.text !== text.slice(node.start, node.end)) throw new Error('text candidate must preserve its original substring') + } else if (node.id !== ruleId) { + throw new Error(`candidate must use rule ID "${ruleId}" or text`) + } +} + +function mergeQueries(previous: readonly Query[], added: readonly Query[]): readonly Query[] { + if (previous.length === 0) return added + const merged: Query[] = [] + const remaining = previous.values() + let old = remaining.next().value + // New queries only fill offsets not covered by retained records. + for (const query of added) { + while (old !== undefined && old.from < query.from) { + merged.push(old) + old = remaining.next().value + } + merged.push(query) + } + while (old !== undefined) { + merged.push(old) + old = remaining.next().value + } + return merged +} + +function textNode(text: string, start: number, end: number): UserTextNode<'text'> { + return { id: 'text', start, end, data: { text: text.slice(start, end) } } +} + +function alignNodes( + output: readonly UserTextNode[], previous: readonly UserTextNode[], input: readonly UserTextNode[], +): readonly UserTextNode[] { + let previousIndex = 0 + let inputIndex = 0 + let aligned: UserTextNode[] | undefined + for (const [index, node] of output.entries()) { + let inputNode = input[inputIndex] + while (inputNode && inputNode.start < node.start) inputNode = input[++inputIndex] + let previousNode = previous[previousIndex] + while (previousNode && previousNode.start < node.start) previousNode = previous[++previousIndex] + const reused = inputNode && equalNode(node, inputNode) ? inputNode + : previousNode && equalNode(node, previousNode) ? previousNode : node + if (reused !== node && !aligned) aligned = output.slice(0, index) + aligned?.push(reused) + } + const nodes = aligned ?? output + if (equalElements(nodes, previous)) return previous + return equalElements(nodes, input) ? input : nodes +} + +function equalElements(left: readonly UserTextNode[], right: readonly UserTextNode[]): boolean { + return left === right || (left.length === right.length && left.every((node, index) => node === right[index])) +} + +function equalNode(left: UserTextNode, right: UserTextNode): boolean { + return Object.is(left, right) || (left.id === right.id && left.start === right.start && left.end === right.end + && equalJson(left.data, right.data)) +} + +function equalJson(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true + if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object') return false + if (Array.isArray(left) !== Array.isArray(right)) return false + const keys = Object.keys(left) + if (keys.length !== Object.keys(right).length) return false + return keys.every(key => Object.hasOwn(right, key) + && equalJson(Reflect.get(left, key), Reflect.get(right, key))) +} diff --git a/packages/client/ui-conversation/src/client/conversation/user-text-registry.ts b/packages/client/ui-conversation/src/client/conversation/user-text-registry.ts new file mode 100644 index 0000000000..3dd1f04c61 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation/user-text-registry.ts @@ -0,0 +1,51 @@ +/** Effect-owned user-text rules and their revision; document caches belong only to evaluator callers. */ +import type { UserTextEvaluator, UserTextRule } from '../contract/user-text.ts' +import { ConversationDefinitionRegistry } from './definition-registry.ts' +import { UserTextDocument } from './user-text-document.ts' + +/** Observable rule revision with no retained documents or document subscriptions. */ +export class UserTextRegistry extends ConversationDefinitionRegistry { + private rules: readonly UserTextRule[] = [] + private revision = 0 + + /** + * Register a uniquely named rule; invalid IDs, duplicate IDs and non-finite order throw before publication. + * @param rule - readonly synchronous candidate matcher with a nonempty ID other than the reserved `text` ID. + * @returns idempotent disposer; removal synchronously publishes a new revision. + */ + register(rule: UserTextRule): () => void { + if (rule.id.length === 0) throw new Error('user-text rule ID must not be empty') + if (rule.id === 'text') throw new Error('user-text rule "text" is reserved for original text') + if (!Number.isFinite(rule.order)) throw new Error(`user-text rule "${rule.id}" order must be finite`) + return this.registerDefinition( + rule.id, + rule, + `user-text rule "${rule.id}" is already registered`, + `uiConversation.userText.register(${JSON.stringify(rule.id)})`, + ) + } + + /** + * Create an independent document evaluator; it reads current rules at each call and never subscribes. + * @returns callback owning its document cache, released when the caller releases the callback. + */ + createEvaluator(): UserTextEvaluator { + const document = new UserTextDocument() + return input => document.evaluate(input, this.rules) + } + + /** + * Read the observable rule revision. + * @returns monotonic revision, stable until a successful registration or disposal. + */ + getSnapshot(): number { + return this.revision + } + + protected override refresh(): void { + this.rules = [...this.definitions.values()].sort((a, b) => + a.order - b.order || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + this.revision++ + super.refresh() + } +} diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 2b9e30d332..229aea31cc 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -1,4 +1,28 @@ -/** Browser Conversation assemble core, React adapter, shell, and input plugin. */ +/** Browser Conversation assemble core, React adapter, shell, input, and text projection plugin. */ +export type {} from './user-text/session-reference-rule.ts' + +/** Declaration-merged node payloads; IDs also identify the corresponding keyed renderer. */ +export interface UserTextDataMap { + text: { + /** Exact original substring; display transforms never change source text or UTF-16 coordinates. */ + readonly text: string + /** Optional display-only text; absence renders the original substring, while an empty string hides it. */ + readonly display?: string + } +} + +/** Caller-supplied JSON business facts and text-transform flags, never services or actions; absent flags are disabled. */ +export interface UserTextFacts { + /** Collapse whitespace runs in ordinary display text to one space. */ + readonly collapseWhitespace: boolean + /** Remove display whitespace only at the document's outer edges, without crossing business nodes. */ + readonly trimWhitespace: boolean +} + +export type { + UserTextDocumentInput, UserTextEvaluator, UserTextId, UserTextNode, UserTextRule, UserTextTransformContext, +} from './contract/user-text.ts' +export type { UserTextRegistry } from './conversation/user-text-registry.ts' export { apply, Config, inject } from './apply.ts' export type { Config as ConversationConfig } from './apply.ts' export { UiConversation } from './conversation/assembly.ts' @@ -67,7 +91,8 @@ export type { ConversationWidthControlsInputProps, ConversationWidthControlsProps, ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps, HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone, - MessageImageLoader, MessageImageSource, MessageImagesOwnerProps, RenderMessageImages, UseConversation, + MessageImageLabels, MessageImageLoader, MessageImageSource, MessageImagesContentInput, MessageImagesOwnerProps, UseConversation, + UserTextInput, UserTextInjected, UserTextNodeOwnerProps, UserTextNodeProps, UseConversationViews, } from './contract/slots.ts' export type { diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 4703ae3440..f1f708073b 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -312,6 +312,8 @@ export const zh = { 'row.input': '输入', 'row.output': '输出', 'row.inspect': '查看', + 'row.imageSize': '{width}x{height}px,{bytes} 字节', + 'row.imageSizeDownscaled': '{width}x{height}px(由 {originalWidth}x{originalHeight}px 缩小),{bytes} 字节', 'tool.title.search': '搜索', 'tool.title.read': '读取', 'tool.title.bash': '运行命令', @@ -690,6 +692,8 @@ export const en = { 'row.input': 'IN', 'row.output': 'OUT', 'row.inspect': 'Inspect', + 'row.imageSize': '{width}x{height}px, {bytes} bytes', + 'row.imageSizeDownscaled': '{width}x{height}px (downscaled from {originalWidth}x{originalHeight}px), {bytes} bytes', 'tool.title.search': 'Search', 'tool.title.read': 'Read', 'tool.title.bash': 'Bash', diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index f5c12385d0..59d0bfdf6a 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -199,14 +199,7 @@ min-width: 0; font: var(--dsw-font-xs-13); font-family: Inter, var(--dsw-font-family); -} - -.preview { - overflow: hidden; color: var(--dsw-alias-label-primary-dimmed); - text-overflow: ellipsis; - white-space: nowrap; - word-break: break-word; } .actions { diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index e98187bb1d..2ce7a20eef 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -1,29 +1,24 @@ import type { Context } from '@deepseek-ai/cordis' import { useEffect, useId, useMemo, useState } from 'react' import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRenderFactories, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular, FileTypeIcon, fileSizeText, IconEditOutlineRegular, InlineEditor, IconQueueOutlineRegular, IconSendOutlineRegular, - IconTrashOutlineRegular, projectUserText, Tooltip, + IconTrashOutlineRegular, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InboxState } from '@deepseek-ai/dsh-agent/types' import type { QueueAction } from '@deepseek-ai/dsh-api-session-controller/types' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { UserTextFacts } from '../index.ts' import { NS } from '../locales.ts' import css from './QueueDock.module.css' const EMPTY_QUEUE = [] as const -const QUEUE_PREVIEW_CHARS = 200 - -function previewOf(content: InboxState['next-turn'][number]['content']): string { - const flat = content - .filter(block => block.type !== 'image' && block.type !== 'file') - .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) - .join(' ').replace(/\s+/g, ' ').trim() - const chars = Array.from(flat) - return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat +const QUEUE_TEXT_FACTS: Readonly> = { + collapseWhitespace: true, + trimWhitespace: true, } function textOf(content: InboxState['next-turn'][number]['content']): string | null { @@ -100,13 +95,14 @@ function QueueThumb({ attachment, loadImage, label }: { /** Full props of a dock entry: InputZone owner share + session standard kit + global seat + the locale seat. */ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected & PropsLocale<'conversation'> + & PropsRenderFactories /** * Queue strip: one item renders directly; multiple items default to a * collapsible count header; an empty queue renders nothing. Local queued submissions * show sending status and disabled actions until their Host queue rows arrive. */ -export function QueueDock({ useSession, useProjection, updateQueue, notify, loadImage, t }: QueueDockProps) { +export function QueueDock({ useSession, useProjection, updateQueue, notify, loadImage, renderFactorySlot, t }: QueueDockProps) { const inbox = useProjection('inbox') as unknown as InboxState | undefined const pendingSubmissions = useSession(s => s.pendingSubmissions) const queue = useMemo(() => { @@ -231,7 +227,10 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load ))} )} - {projectUserText(previewOf(row.content), [])} + {renderFactorySlot('conversation.user.text', { + text: row.content.filter(block => block.type === 'text').map(block => block.text).join(''), + facts: QUEUE_TEXT_FACTS, interactive: false, ellipsis: true, + })} )} {queueMutable &&
@@ -345,7 +344,9 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load ))} )} - {projectUserText(submission.text, [])} + {renderFactorySlot('conversation.user.text', { + text: submission.text, facts: QUEUE_TEXT_FACTS, interactive: false, ellipsis: true, + })} {t('queue.sending')} {queueMutable &&
, + leftItems: { event.preventDefault() }}>Canceled, + }) + const lexicalFocus = vi.spyOn(shell.editor, 'focus') + const scrollport = view.container.querySelector('[data-input-scroll]')! + const card = view.container.querySelector('[data-composer-card]')! + + expect(fireEvent.mouseDown(textarea, { button: 0 })).toBe(true) + expect(fireEvent.mouseDown(scrollport, { button: 0 })).toBe(true) + expect(fireEvent.mouseDown(view.getByText('Model'), { button: 0 })).toBe(true) + expect(fireEvent.mouseDown(view.getByText('Canceled'), { button: 0 })).toBe(false) + expect(fireEvent.mouseDown(card, { button: 2 })).toBe(true) + expect(lexicalFocus).not.toHaveBeenCalled() + }) + + it('leaves embedded overlay options, status text and scrollbars with the focused search field', () => { + const { view, shell } = bench({ + overlay:
+ +
Model option
+
Loading models
+
, + }) + const search = view.getByRole('textbox', { name: 'Filter models' }) + act(() => { search.focus() }) + const lexicalFocus = vi.spyOn(shell.editor, 'focus') + + for (const target of [ + view.getByRole('option'), view.getByRole('listbox'), view.getByText('Loading models'), + view.container.querySelector('[data-composer-overlay]')!, + ]) { + expect(fireEvent.mouseDown(target, { button: 0 })).toBe(true) + expect(document.activeElement).toBe(search) + } + expect(lexicalFocus).not.toHaveBeenCalled() + }) + + it('keeps a focused editor\'s native caret without restoring an older Lexical selection', () => { + const { view, shell, textarea } = bench() + act(() => { shell.focus() }) + expect(document.activeElement).toBe(textarea) + const lexicalFocus = vi.spyOn(shell.editor, 'focus') + + expect(fireEvent.mouseDown(view.container.querySelector('[data-composer-card]')!, { button: 0 })).toBe(false) + expect(document.activeElement).toBe(textarea) + expect(lexicalFocus).not.toHaveBeenCalled() + }) + + it('leaves portaled menu background presses outside the card alone', () => { + const { view, shell } = bench({ modelEntry: createPortal(
Menu background
, document.body) }) + const lexicalFocus = vi.spyOn(shell.editor, 'focus') + + expect(fireEvent.mouseDown(view.getByText('Menu background'), { button: 0 })).toBe(true) + expect(lexicalFocus).not.toHaveBeenCalled() + }) + + it.each([{ inert: true }, { disabled: true }, { blocked: { reason: 'unavailable' } }])( + 'does not focus an unavailable composer: %j', (options) => { + const { view, shell } = bench(options) + const lexicalFocus = vi.spyOn(shell.editor, 'focus') + + expect(fireEvent.mouseDown(view.container.querySelector('[data-composer-card]')!, { button: 0 })).toBe(true) + expect(lexicalFocus).not.toHaveBeenCalled() + }, + ) + it('focus() returns the keyboard to the editor through Lexical, not a bare DOM focus', () => { const { shell, textarea } = bench() writeDraft(shell, 'draft text') diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 756199f8b4..be31f70fb4 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -24,6 +24,8 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import type { InputState } from '../src/client/contract/input.ts' import { zh } from '../src/client/locales.ts' import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps } from '../src/client/queue/QueueDock.tsx' +import type { UserTextInput } from '../src/client/index.ts' +import { createUserTextFixture } from './user-text-component-fixture.client.tsx' // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] @@ -98,8 +100,14 @@ const t: QueueDockProps['t'] = makeTranslate(zh, commonZh) const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) function kitFor(snapshot: SessionSnapshot, injected: Partial = {}) { + const renderText = createUserTextFixture() + const renderFactorySlot: QueueDockProps['renderFactorySlot'] = (name, owner) => { + if (name !== 'conversation.user.text') throw new Error(`Unexpected queue Factory: ${name}`) + return renderText(owner as UserTextInput) + } return { sessionId: SID, + renderFactorySlot, t, usePanelInfo, useSessions: (() => { throw new Error('unused') }) as SnapshotSelectorHook, @@ -434,12 +442,17 @@ describe('QueueDock', () => { expect(view.queryByText('three')).toBeNull() }) - it('flattens and caps previews at 200 code points while preserving complete editable text', () => { - const text = ` before ${'🙂'.repeat(201)} after` + it('displays complete previews with compressed whitespace and CSS ellipsis while preserving editable text', () => { + const text = ` before \n ${'🙂'.repeat(201)} after` const snap = snapshotWith([row('long-preview', text)]) const source = liveSession(snap) - const view = render() - expect(view.getByText(`before ${'🙂'.repeat(193)}…`)).toBeTruthy() + const props = kitFor(snap) + const renderFactory = vi.spyOn(props, 'renderFactorySlot') + const view = render() + expect(renderFactory).toHaveBeenCalledWith('conversation.user.text', { + text, facts: { collapseWhitespace: true, trimWhitespace: true }, interactive: false, ellipsis: true, + }) + expect(view.getByRole('listitem').textContent).toBe(`before ${'🙂'.repeat(201)} after`) fireEvent.click(view.getByLabelText('编辑排队消息')) expect((view.getByRole('textbox') as HTMLTextAreaElement).value).toBe(text) }) diff --git a/packages/client/ui-conversation/tests/user-text-component-fixture.client.tsx b/packages/client/ui-conversation/tests/user-text-component-fixture.client.tsx new file mode 100644 index 0000000000..8a475bc33d --- /dev/null +++ b/packages/client/ui-conversation/tests/user-text-component-fixture.client.tsx @@ -0,0 +1,69 @@ +/** Production UserText assembly for component units that supply their own outer Slot seats. */ +import { Context } from '@deepseek-ai/cordis' +import { createElement, type ComponentProps, type ReactNode } from 'react' +import { onTestFinished } from 'vitest' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import type { UserTextInput, UserTextNodeOwnerProps } from '../src/client/contract/slots.ts' +import type { UserTextNode } from '../src/client/contract/user-text.ts' +import { UserTextRegistry } from '../src/client/conversation/user-text-registry.ts' +import { UserText, PlainUserText, SessionReference } from '../src/client/user-text/UserText.tsx' +import { sessionReferenceRule } from '../src/client/user-text/session-reference-rule.ts' +import { collapseWhitespaceRule, trimWhitespaceRule } from '../src/client/user-text/whitespace-rules.ts' +import { skillReferenceRule } from '../../ui-skill/src/client/user-text-rules.ts' +import { SkillReference } from '../../ui-skill/src/client/SkillReference.tsx' +import { fileReferenceRule, folderReferenceRule } from '../../ui-reference/src/client/user-text-rules.ts' +import { FileReference, FolderReference } from '../../ui-reference/src/client/FileReference.tsx' +import { zh as referenceZh } from '../../ui-reference/src/client/locales.ts' +import { goalCommandReferenceRule } from '../../ui-goal/src/client/user-text-rules.ts' +import { GoalCommandReference } from '../../ui-goal/src/client/GoalCommandReference.tsx' + +/** Actions supplied by the unit's business-injection fixture, not by UserText owner data. */ +interface UserTextFixtureActions { + openFile?: (path: string) => void + openSkill?: (name: string) => void +} + +type UserTextProps = ComponentProps + +/** + * Bind the production evaluator and node components without replacing matching or display behavior. + * Integration tests install the actual Slot registrations separately. + * @param actions - reference operations owned by the surrounding test fixture. + * @returns renderer accepting the production Factory's input fields. + */ +export function createUserTextFixture(actions: UserTextFixtureActions = {}): (input: UserTextInput) => ReactNode { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + const registry = new UserTextRegistry(ctx) + for (const rule of [ + sessionReferenceRule, skillReferenceRule, fileReferenceRule, folderReferenceRule, + goalCommandReferenceRule, collapseWhitespaceRule, trimWhitespaceRule, + ]) registry.register(rule) + const useUserTextRevision = bindSnapshotSelector({ + getSnapshot: () => registry.getSnapshot(), + subscribe: listener => registry.subscribe(listener), + }) + const createUserTextEvaluator = () => registry.createEvaluator() + const reportTransformFailure = (error: unknown): never => { throw error } + const openFile = actions.openFile ?? (() => { throw new Error('This fixture has no file navigation action') }) + const openSkill = actions.openSkill ?? (() => { throw new Error('This fixture has no skill navigation action') }) + const t = makeTranslate(referenceZh, commonZh) + const renderSlot: UserTextProps['renderSlot'] = (name: string, input: object, options?: { fallback?: ReactNode }) => { + if (name !== 'conversation.user.text.node') throw new Error(`Unexpected UserText Slot: ${name}`) + const owner = input as UserTextNodeOwnerProps & { node: UserTextNode } + switch (owner.node.id) { + // The production components only read these owner, action and locale seats in these units. + case 'text': return createElement(PlainUserText, owner as ComponentProps) + case 'session': return createElement(SessionReference, owner as ComponentProps) + case 'file': return createElement(FileReference, { ...owner, openFile, t } as ComponentProps) + case 'folder': return createElement(FolderReference, owner as ComponentProps) + case 'skill': return createElement(SkillReference, { ...owner, openSkill } as ComponentProps) + case 'command': return createElement(GoalCommandReference, owner as ComponentProps) + default: return options?.fallback ?? null + } + } + return input => createElement(UserText, { + ...input, useUserTextRevision, createUserTextEvaluator, reportTransformFailure, renderSlot, + } as UserTextProps) +} diff --git a/packages/client/ui-conversation/tests/user-text-reference-composition.client.spec.ts b/packages/client/ui-conversation/tests/user-text-reference-composition.client.spec.ts new file mode 100644 index 0000000000..ec34939582 --- /dev/null +++ b/packages/client/ui-conversation/tests/user-text-reference-composition.client.spec.ts @@ -0,0 +1,112 @@ +/** Real business matchers compose by original position through the data-layer scheduler. */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { UserTextFacts, UserTextRule, UserTextNode, UserTextTransformContext } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { UserTextRegistry } from '../src/client/conversation/user-text-registry.ts' +import { sessionReferenceRule } from '../src/client/user-text/session-reference-rule.ts' +import { fileReferenceRule, folderReferenceRule } from '../../ui-reference/src/client/user-text-rules.ts' +import { skillReferenceRule } from '../../ui-skill/src/client/user-text-rules.ts' +import { goalCommandReferenceRule } from '../../ui-goal/src/client/user-text-rules.ts' + +const RULES = [sessionReferenceRule, fileReferenceRule, folderReferenceRule, skillReferenceRule, goalCommandReferenceRule] + +function setup() { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const registry = new UserTextRegistry(ctx) + const matches = RULES.map((rule) => { + const match = vi.fn((text: string, from: number, context: UserTextTransformContext) => rule.match(text, from, context)) + registry.register({ ...rule, match } satisfies UserTextRule) + return { id: rule.id, match } + }) + return { registry, evaluate: registry.createEvaluator(), matches } +} + +function references(text: string, nodes: readonly UserTextNode[]) { + return nodes.filter(node => node.id !== 'text').map(node => [node.id, text.slice(node.start, node.end), node.data]) +} + +describe('original-text reference competition', () => { + it('keeps an earlier complete file instead of a Session label inside its path', () => { + const { evaluate } = setup() + const text = '参照 @types 修复 @src/@types/react/index.d.ts' + expect(references(text, evaluate({ text, facts: { sessionLabels: ['types'] } }))).toEqual([ + ['session', '@types', { label: 'types' }], + ['file', '@src/@types/react/index.d.ts', { path: 'src/@types/react/index.d.ts', label: 'index.d.ts' }], + ]) + }) + + it('keeps an earlier complete file instead of a wire Session inside its path', () => { + const { evaluate } = setup() + const text = '@src/@[会话一](dsh-session:x)' + expect(references(text, evaluate({ text, facts: {} }))).toEqual([ + ['file', text, { path: 'src/@[会话一](dsh-session:x)', label: '@[会话一](dsh-session:x)' }], + ]) + }) + + it('keeps the quoted file and only recognizes the outside skill', () => { + const { evaluate } = setup() + const text = '/review @"docs /review notes.md"' + expect(references(text, evaluate({ text, facts: { skillNames: ['review'] } }))).toEqual([ + ['skill', '/review', { name: 'review' }], + ['file', '@"docs /review notes.md"', { path: 'docs /review notes.md', label: 'review notes.md' }], + ]) + }) + + it.each([ + ['@会话一@src/a.ts', '@src/a.ts'], ['@会话一/plan', '/plan'], + ])('does not turn the suffix into an original word boundary in %s', (text, tail) => { + const { evaluate } = setup() + expect(evaluate({ text, facts: { sessionLabels: ['会话一'], skillNames: ['plan'] } })).toEqual([ + { id: 'session', start: 0, end: 4, data: { label: '会话一' } }, + { id: 'text', start: 4, end: text.length, data: { text: tail } }, + ]) + }) + + it('preserves real whitespace boundaries and stable same-start Session precedence', () => { + const { evaluate } = setup() + const facts = { sessionLabels: ['会话一', 'types'], skillNames: ['plan'] } + const text = '@会话一 @src/a.ts /plan' + expect(references(text, evaluate({ text, facts }))).toEqual([ + ['session', '@会话一', { label: '会话一' }], ['file', '@src/a.ts', { path: 'src/a.ts', label: 'a.ts' }], + ['skill', '/plan', { name: 'plan' }], + ]) + expect(evaluate({ text: '@types/react/index.d.ts', facts })).toEqual([ + { id: 'session', start: 0, end: 6, data: { label: 'types' } }, + { id: 'text', start: 6, end: 23, data: { text: '/react/index.d.ts' } }, + ]) + }) + + it('queries only Skill after its Facts update, reusing file, folder, Session and command searches', () => { + const { evaluate, matches, registry } = setup() + const text = '/review @"docs /review notes.md"' + const before = evaluate({ text, facts: {} }) + for (const { match } of matches) match.mockClear() + const facts = { skillNames: ['review'] } + const after = evaluate({ text, facts }) + for (const { id, match } of matches) { + expect(match.mock.calls.map(([, from]) => from)).toEqual(id === 'skill' ? [0, 7, text.length] : []) + } + expect(after.find(node => node.id === 'file')).toBe(before.find(node => node.id === 'file')) + expect(after).toEqual(registry.createEvaluator()({ text, facts })) + }) + + it('matches a new evaluator through additions and withdrawals of multiple business Facts', () => { + const { evaluate, registry } = setup() + const texts = [ + '🙂 @会话一 @src/@types/react/index.d.ts /review @docs/', + '/review @"docs /review notes.md" @types/react/index.d.ts', + '@会话一@src/a.ts @会话一/plan @src/@[会话一](dsh-session:x)', + '@多词 /review @src/a.ts /goal', + ] + const factsList: readonly Partial[] = [ + {}, { sessionLabels: ['会话一', 'types'] }, { skillNames: ['review', 'plan'] }, + { sessionLabels: ['会话一', 'types'], skillNames: ['review', 'plan'] }, + { sessionLabels: ['多词 /review', '会话一'], skillNames: ['review'], commands: [{ start: 0, end: 7, name: 'review' }] }, + {}, + ] + for (const text of texts) for (const facts of factsList) { + expect(evaluate({ text, facts })).toEqual(registry.createEvaluator()({ text, facts })) + } + }) +}) diff --git a/packages/client/ui-conversation/tests/user-text-registry.client.spec.ts b/packages/client/ui-conversation/tests/user-text-registry.client.spec.ts new file mode 100644 index 0000000000..0685f7db45 --- /dev/null +++ b/packages/client/ui-conversation/tests/user-text-registry.client.spec.ts @@ -0,0 +1,311 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, expectTypeOf, it, onTestFinished, vi } from 'vitest' +import type { + UserTextDataMap, UserTextEvaluator, UserTextNode, UserTextRule, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { UserTextRegistry } from '../src/client/conversation/user-text-registry.ts' + +type JsonValue = string | number | null | readonly string[] | { readonly value: string } + +interface AnnotationData { + readonly label: string + readonly nested?: { readonly values: readonly (string | number | boolean | null)[] } +} + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface UserTextDataMap { + 'test.annotation': AnnotationData + 'test.json': { readonly value: JsonValue } + 'test.A': { readonly label: string } + 'test.a': { readonly label: string } + 'test.z': { readonly label: string } + '': { readonly label: string } + 'test.function': { readonly callback: () => void } + 'test.date': { readonly date: Date } + 'test.map': { readonly map: Map } + 'test.symbol': { readonly symbol: symbol } + 'test.bigint': { readonly bigint: bigint } + 'test.unknown': { readonly value: unknown } + 'test.nested': { readonly values: readonly { readonly action: () => void }[] } + } + interface UserTextFacts { + readonly testEnabled: boolean + readonly testLabel: string + readonly testNested: NonNullable + readonly testOther: number + readonly testJson: JsonValue + readonly skillNames: readonly string[] + } +} + +declare module '@deepseek-ai/cordis' { + interface Context { userTextTestRegistry: UserTextRegistry } +} + +function setup(): { ctx: Context; registry: UserTextRegistry } { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + return { ctx, registry: new UserTextRegistry(ctx) } +} + +type TestId = 'test.A' | 'test.a' | 'test.z' | '' + +function plain(text: string, start = 0): UserTextNode<'text'> { + return { id: 'text', start, end: start + text.length, data: { text } } +} + +function rule(id: TestId, order: number, needle: string): UserTextRule { + return { id, order, match(text, from) { + const start = text.indexOf(needle, from) + return start < 0 ? undefined : { id, start, end: start + needle.length, data: { label: id } } + } } +} + +function assertCoverage(text: string, nodes: readonly UserTextNode[]): void { + let cursor = 0 + for (const node of nodes) { + expect(node.start).toBe(cursor) + expect(node.end).toBeGreaterThan(node.start) + if (node.id === 'text') expect(node.data.text).toBe(text.slice(node.start, node.end)) + cursor = node.end + } + expect(cursor).toBe(text.length) + expect(JSON.parse(JSON.stringify(nodes))).toEqual(nodes) +} + +describe('UserTextRegistry candidate rules', () => { + it('queries every matcher for empty and nonempty original text', () => { + const { registry } = setup() + const match = vi.fn((_text: string, _from: number) => undefined) + registry.register({ id: 'test.a', order: 0, match }) + const evaluate = registry.createEvaluator() + expectTypeOf(evaluate).toEqualTypeOf() + expect(evaluate({ text: '', facts: {} })).toEqual([]) + const text = ' 原文\r\n\t🙂e\u0301 /path @name ' + const nodes = evaluate({ text, facts: {} }) + expect(nodes).toEqual([plain(text)]) + expect(match.mock.calls.map(([text, from]) => [text, from])).toEqual([['', 0], [text, 0]]) + assertCoverage(text, nodes) + }) + + it.each([ + [rule('test.a', 10, 'bc'), rule('test.z', -1, 'cde'), 'test.a'], + [rule('test.a', -1, 'bc'), rule('test.z', 0, 'bcde'), 'test.a'], + [rule('test.a', 0, 'bc'), rule('test.z', 0, 'bcde'), 'test.a'], + [rule('test.A', 0, 'bc'), rule('test.a', 0, 'bcde'), 'test.A'], + ] as const)('selects by start, then order and code-unit ID independently of load order (%j, %j)', (a, z, id) => { + const { registry: forward } = setup() + const { registry: reverse } = setup() + forward.register(a) + forward.register(z) + reverse.register(z) + reverse.register(a) + const input = { text: 'abcde', facts: {} } + const nodes = forward.createEvaluator()(input) + expect(nodes.filter(node => node.id !== 'text').map(node => node.id)).toEqual([id]) + expect(reverse.createEvaluator()(input)).toEqual(nodes) + }) + + it('has one candidate interface for business and Text nodes', () => { + expectTypeOf().not.toHaveProperty('phase') + expectTypeOf().not.toHaveProperty('transform') + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf[1]>().toEqualTypeOf() + const { registry } = setup() + registry.register({ id: 'text.replacement', order: 10, match: (text, from) => from > 1 ? undefined : { + id: 'text', start: 1, end: 2, data: { text: text.slice(1, 2), display: 'B' }, + } }) + const nodes = registry.createEvaluator()({ text: 'abc', facts: {} }) + expect(nodes).toEqual([plain('a'), { ...plain('b', 1), data: { text: 'b', display: 'B' } }, plain('c', 2)]) + assertCoverage('abc', nodes) + }) + + it.each(['xyz', '', 'ab'])('rejects a Text candidate whose raw value is %j for the original abc', (raw) => { + const { registry } = setup() + registry.register({ id: 'text.bad', order: 0, match: () => ({ id: 'text', start: 0, end: 3, data: { text: raw, display: 'abc' } }) }) + expect(() => registry.createEvaluator()({ text: 'abc', facts: {} })).toThrow(/text.bad/) + }) + + it('keeps nodes readonly without freezing borrowed payloads', () => { + expectTypeOf>().toEqualTypeOf>>() + expectTypeOf['data']>().toEqualTypeOf<{ readonly text: string; readonly display?: string }>() + const { registry } = setup() + const nested = { values: ['borrowed'] } + const definition: UserTextRule<'test.annotation'> = { + id: 'test.annotation', order: 0, + match: (_text, from, context) => { + if (from > 0) return undefined + const value = context.read('testNested') + if (value === undefined) throw new Error('Missing test payload') + return { id: 'test.annotation', start: 0, end: 1, data: { label: 'x', nested: value } } + }, + } + registry.register(definition) + const nodes = registry.createEvaluator()({ text: 'x', facts: { testNested: nested } }) + if (nodes[0]?.id !== 'test.annotation') throw new Error('Missing annotation') + expect(nodes[0].data.nested).toBe(nested) + for (const value of [definition, nodes, nodes[0], nested, nested.values]) expect(Object.isFrozen(value)).toBe(false) + }) + + it('reuses equivalent nested JSON despite key order and changes only unequal payloads', () => { + const { registry } = setup() + const match = vi.fn((_text: string, from: number, context: Parameters[2]): UserTextNode<'test.annotation'> | undefined => { + if (from > 0) return undefined + const label = context.read('testLabel') ?? 'same' + const version = context.read('testOther') ?? 0 + const nested = { values: ['文字', 3, true, null] } + return { id: 'test.annotation', start: 0, end: 3, data: version % 2 ? { nested, label } : { label, nested } } + }) + registry.register({ id: 'test.annotation', order: 0, match }) + const evaluate = registry.createEvaluator() + const original = evaluate({ text: 'abc', facts: {} }) + expect(evaluate({ text: 'abc', facts: { testOther: 1 } })).toBe(original) + const changed = evaluate({ text: 'abc', facts: { testOther: 2, testLabel: 'changed' } }) + expect(changed[0]?.data).toMatchObject({ label: 'changed' }) + expect(changed).not.toBe(original) + expect(evaluate({ text: 'abc', facts: { testOther: 3, testLabel: 'changed' } })).toBe(changed) + expect(match.mock.calls.map(([, from]) => from)).toEqual([0, 3, 0, 3, 0, 3, 0, 3]) + }) + + it('distinguishes arrays, objects, null and primitive payloads while reusing equal values', () => { + const { registry } = setup() + const match = vi.fn((_text: string, from: number, context: Parameters[2]): UserTextNode<'test.json'> | undefined => { + if (from > 0) return undefined + return { id: 'test.json', start: 0, end: 1, data: { value: context.read('testJson') ?? null } } + }) + registry.register({ id: 'test.json', order: 0, match }) + const evaluate = registry.createEvaluator() + let previous = evaluate({ text: 'x', facts: { testJson: ['value'] } }) + for (const value of [{ value: 'value' }, ['value'], null, 'value', 1] satisfies JsonValue[]) { + const nodes = evaluate({ text: 'x', facts: { testJson: value } }) + expect(nodes).not.toBe(previous) + expect(nodes[0]?.data).toEqual({ value }) + expect(evaluate({ text: 'x', facts: { testJson: value } })).toBe(nodes) + previous = nodes + } + expect(match.mock.calls.map(([, from]) => from)).toEqual([0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]) + }) + + it('constrains declaration-merged JSON payloads recursively', () => { + expectTypeOf['data']>().toExtend() + expectTypeOf().not.toExtend['data']>() + expectTypeOf().not.toExtend['data']>() + expectTypeOf().not.toExtend['data']>() + expectTypeOf().not.toExtend['data']>() + expectTypeOf().not.toExtend['data']>() + expectTypeOf().not.toExtend['data']>() + expectTypeOf().not.toExtend['data']>() + }) + + it.each([ + [-1, 1], [0, 0], [2, 1], [0, 4], [0.5, 1], [0, 1.5], [NaN, 1], [0, Infinity], + ])('rejects invalid UTF-16 range [%s, %s)', (start, end) => { + const { registry } = setup() + registry.register({ id: 'test.a', order: 0, match: () => ({ id: 'test.a', start, end, data: { label: 'bad' } }) }) + expect(() => registry.createEvaluator()({ text: 'abc', facts: {} })).toThrow(/test\.a/) + }) + + it.each([Infinity, -Infinity, NaN])('rejects invalid order %s before publication', (order) => { + const { registry } = setup() + const revision = registry.getSnapshot() + const listener = vi.fn() + registry.subscribe(listener) + expect(() => registry.register(rule('test.a', order, 'x'))).toThrow(/test\.a/) + expect(registry.getSnapshot()).toBe(revision) + expect(listener).not.toHaveBeenCalled() + }) + + it('rejects empty, reserved and duplicate IDs without changing revision', () => { + const { registry } = setup() + registry.register(rule('test.a', 0, 'x')) + const revision = registry.getSnapshot() + expect(() => registry.register(rule('', 0, 'x'))).toThrow(/id|ID/) + expect(() => registry.register({ id: 'text', order: 0, match: () => undefined })).toThrow(/text/) + expect(() => registry.register(rule('test.a', 1, 'x'))).toThrow(/test\.a/) + expect(registry.getSnapshot()).toBe(revision) + }) + + it('publishes monotonic revisions and evaluates current rules without document subscriptions', () => { + const { registry } = setup() + const subscribe = vi.spyOn(registry, 'subscribe') + const evaluate = registry.createEvaluator() + expect(subscribe).not.toHaveBeenCalled() + const revision = registry.getSnapshot() + expectTypeOf(revision).toEqualTypeOf() + const seen: number[] = [] + const unsubscribe = registry.subscribe(() => { seen.push(registry.getSnapshot()) }) + const dispose = registry.register(rule('test.a', 0, 'a')) + expect(registry.getSnapshot()).toBeGreaterThan(revision) + const input = { text: 'abc', facts: {} } + expect(evaluate(input)[0]?.id).toBe('test.a') + dispose() + expect(evaluate(input)).toEqual([plain('abc')]) + expect(seen[1]).toBeGreaterThan(seen[0]!) + dispose() + expect(seen).toHaveLength(2) + unsubscribe() + unsubscribe() + registry.register(rule('test.a', 0, 'b')) + dispose() + expect(evaluate(input)[1]?.id).toBe('test.a') + expect(seen).toHaveLength(2) + }) + + it('removes only the contributing fiber rules and cleans root registrations on disposal', async () => { + const { ctx, registry } = setup() + ctx.provide('userTextTestRegistry', registry) + registry.register(rule('test.a', 0, 'a')) + const feature = ctx.inject(['userTextTestRegistry'], (featureCtx) => { + featureCtx.userTextTestRegistry.register(rule('test.z', 0, 'b')) + }) + await feature.await() + const evaluate = registry.createEvaluator() + expect(evaluate({ text: 'ab', facts: {} }).map(node => node.id)).toEqual(['test.a', 'test.z']) + await feature.dispose() + expect(evaluate({ text: 'ab', facts: {} }).map(node => node.id)).toEqual(['test.a', 'text']) + await ctx.fiber.dispose() + expect(evaluate({ text: 'ab', facts: {} })).toEqual([plain('ab')]) + }) +}) + +describe('sessionReferenceRule', () => { + it('folds wire syntax to JSON label data while retaining the full original range', async () => { + const { sessionReferenceRule } = await import('../src/client/user-text/session-reference-rule.ts') + const { registry } = setup() + registry.register(sessionReferenceRule) + const wire = '@[查看并分析图片](dsh-session:InNlc3Npb24tNDM0)' + const text = `看看 ${wire} 的结论` + const nodes = registry.createEvaluator()({ text, facts: {} }) + expect(nodes).toEqual([ + plain('看看 '), { id: 'session', start: 3, end: 3 + wire.length, data: { label: '查看并分析图片' } }, + plain(' 的结论', 3 + wire.length), + ]) + assertCoverage(text, nodes) + }) + + it('uses exact supplied labels, keeps the longest spelling and preserves repeated occurrences', async () => { + const { sessionReferenceRule } = await import('../src/client/user-text/session-reference-rule.ts') + const { registry } = setup() + registry.register(sessionReferenceRule) + const evaluate = registry.createEvaluator() + const text = '@会话一 收尾 @会话一, @会话 @未知' + const nodes = evaluate({ text, facts: { sessionLabels: ['会话', '会话一', '会话一'] } }) + expect(nodes.filter(node => node.id === 'session').map(node => ({ raw: text.slice(node.start, node.end), data: node.data }))) + .toEqual([ + { raw: '@会话一', data: { label: '会话一' } }, { raw: '@会话一', data: { label: '会话一' } }, + { raw: '@会话', data: { label: '会话' } }, + ]) + assertCoverage(text, nodes) + expect(evaluate({ text: '@会话', facts: {} })).toEqual([plain('@会话')]) + }) + + it('prefers complete wire syntax over an overlapping supplied label', async () => { + const { sessionReferenceRule } = await import('../src/client/user-text/session-reference-rule.ts') + const { registry } = setup() + registry.register(sessionReferenceRule) + const text = '@[a](dsh-session:x)' + expect(registry.createEvaluator()({ text, facts: { sessionLabels: ['[a]'] } })).toEqual([ + { id: 'session', start: 0, end: text.length, data: { label: 'a' } }, + ]) + }) +}) diff --git a/packages/client/ui-conversation/tests/user-text-scheduling.client.spec.ts b/packages/client/ui-conversation/tests/user-text-scheduling.client.spec.ts new file mode 100644 index 0000000000..bc2d2bdd90 --- /dev/null +++ b/packages/client/ui-conversation/tests/user-text-scheduling.client.spec.ts @@ -0,0 +1,435 @@ +/** Candidate scheduling is measured by calls and query offsets, never clocks. */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { + UserTextFacts, UserTextRule, UserTextNode, UserTextTransformContext, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { UserTextRegistry } from '../src/client/conversation/user-text-registry.ts' + +interface Range { readonly start: number; readonly end: number; readonly label: string } +type Id = 'schedule.a' | 'schedule.b' | 'schedule.c' +type Field = 'scheduleA' | 'scheduleB' | 'scheduleC' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface UserTextDataMap { + 'schedule.a': { readonly label: string } + 'schedule.b': { readonly label: string } + 'schedule.c': { readonly label: string } + } + interface UserTextFacts { + readonly scheduleA: readonly Range[] + readonly scheduleB: readonly Range[] + readonly scheduleC: readonly Range[] + readonly scheduleEnabled: boolean + readonly scheduleOther: number + } +} + +const TEXT = '0123456789'.repeat(4) +const range = (start: number, end: number, label = `${start}:${end}`): Range => ({ start, end, label }) + +function setup(rules: readonly UserTextRule[] = []) { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const registry = new UserTextRegistry(ctx) + for (const rule of rules) registry.register(rule) + return { registry, evaluate: registry.createEvaluator() } +} + +function tracked(id: Id, field: Field, order: number) { + const match = vi.fn((text: string, from: number, context: UserTextTransformContext): UserTextNode | undefined => { + expect(text).toBe(TEXT) + const candidates = context.read(field) ?? [] + let best: Range | undefined + for (const item of candidates) { + if (item.start >= from && (best === undefined || item.start < best.start + || (item.start === best.start && item.end > best.end))) best = item + } + return best === undefined ? undefined : { id, start: best.start, end: best.end, data: { label: best.label } } + }) + const rule: UserTextRule = { id, order, match } + return { rule, match, calls: () => match.mock.calls.map(([, from]) => from) } +} + +function kit() { + const a = tracked('schedule.a', 'scheduleA', 0) + const b = tracked('schedule.b', 'scheduleB', 1) + const c = tracked('schedule.c', 'scheduleC', 2) + const fixture = setup([c.rule, b.rule, a.rule]) + const evaluate = (facts: Readonly>) => fixture.evaluate({ text: TEXT, facts }) + const clear = () => { a.match.mockClear(); b.match.mockClear(); c.match.mockClear() } + return { ...fixture, a, b, c, evaluate, clear } +} + +function business(nodes: readonly UserTextNode[]) { + return nodes.filter(node => node.id !== 'text').map(node => [node.id, node.start, node.end, node.data]) +} + +function coverage(nodes: readonly UserTextNode[], text = TEXT) { + let cursor = 0 + for (const node of nodes) { + expect(node.start).toBe(cursor) + expect(node.end).toBeGreaterThan(cursor) + if (node.id === 'text') expect(node.data.text).toBe(text.slice(node.start, node.end)) + cursor = node.end + } + expect(cursor).toBe(text.length) + expect(JSON.parse(JSON.stringify(nodes))).toEqual(nodes) +} + +describe('UserText candidate scheduling', () => { + it('keeps future candidates and exhausted matchers while advancing only consumed heads', () => { + const k = kit() + const nodes = k.evaluate({ scheduleA: [range(2, 4), range(12, 15)], scheduleB: [range(7, 9), range(18, 20)] }) + expect(k.a.calls()).toEqual([0, 4, 15]) + expect(k.b.calls()).toEqual([0, 9, 20]) + expect(k.c.calls()).toEqual([0]) + expect(nodes.filter(node => node.id !== 'text').map(node => node.start)).toEqual([2, 7, 12, 18]) + coverage(nodes) + }) + + it('queries overlapping heads at the winner end, without enumerating covered candidates', () => { + const k = kit() + const nodes = k.evaluate({ scheduleA: [range(2, 10)], scheduleB: [range(4, 6), range(8, 9), range(12, 14)] }) + expect(k.a.calls()).toEqual([0, 10]) + expect(k.b.calls()).toEqual([0, 10, 14]) + expect(k.c.calls()).toEqual([0]) + expect(nodes.filter(node => node.id !== 'text').map(node => [node.id, node.start])).toEqual([ + ['schedule.a', 2], ['schedule.b', 12], + ]) + coverage(nodes) + }) + + it('retains a candidate exactly at the new cursor and queries once at a consumed text end', () => { + const k = kit() + k.evaluate({ scheduleA: [range(0, 4)], scheduleB: [range(4, TEXT.length)] }) + expect(k.a.calls()).toEqual([0, 4]) + expect(k.b.calls()).toEqual([0, TEXT.length]) + expect(k.c.calls()).toEqual([0]) + }) + + it.each(['', 'plain'])('initializes Facts-free matchers even for %j', (text) => { + const a = vi.fn((_text: string, _from: number) => undefined) + const b = vi.fn((_text: string, _from: number) => undefined) + const { evaluate } = setup([{ id: 'schedule.a', order: 0, match: a }, { id: 'schedule.b', order: 1, match: b }]) + const first = evaluate({ text, facts: {} }) + expect(a.mock.calls.map(([, from]) => from)).toEqual([0]) + expect(b.mock.calls.map(([, from]) => from)).toEqual([0]) + expect(evaluate({ text, facts: { scheduleOther: 1 } })).toBe(first) + expect(a).toHaveBeenCalledOnce() + expect(b).toHaveBeenCalledOnce() + coverage(first, text) + }) + + it('ignores unrelated fields and outer Facts identity without querying any matcher', () => { + const k = kit() + const facts = { scheduleA: [range(2, 4)], scheduleB: [range(7, 9)] } + const before = k.evaluate(facts) + k.clear() + expect(k.evaluate({ ...facts, scheduleOther: 1 })).toBe(before) + expect(k.evaluate({ ...facts, scheduleOther: 2 })).toBe(before) + expect([k.a.calls(), k.b.calls(), k.c.calls()]).toEqual([[], [], []]) + }) + + it('replays only one changed matcher and retains equivalent output identities', () => { + const k = kit() + const facts = { scheduleA: [range(2, 4), range(12, 15)], scheduleB: [range(7, 9), range(18, 20)] } + const before = k.evaluate(facts) + k.clear() + const after = k.evaluate({ ...facts, scheduleA: facts.scheduleA.map(item => ({ ...item })) }) + expect(after).toBe(before) + expect(k.a.calls()).toEqual([0, 4, 15]) + expect(k.b.calls()).toEqual([]) + expect(k.c.calls()).toEqual([]) + }) + + it('changes one payload without calling other matchers or replacing unrelated nodes', () => { + const k = kit() + const facts = { scheduleA: [range(2, 4)], scheduleB: [range(7, 9)] } + const before = k.evaluate(facts) + k.clear() + const after = k.evaluate({ ...facts, scheduleA: [range(2, 4, 'new')] }) + expect(after[1]?.data).toEqual({ label: 'new' }) + expect(after[1]).not.toBe(before[1]) + for (const index of [0, 2, 3, 4]) expect(after[index]).toBe(before[index]) + expect([k.a.calls(), k.b.calls(), k.c.calls()]).toEqual([[0, 4], [], []]) + }) + + it('inserts into old plain text without querying unaffected matchers', () => { + const k = kit() + const facts = { scheduleA: [range(2, 4), range(12, 15)], scheduleB: [range(7, 9), range(18, 20)] } + const before = k.evaluate(facts) + k.clear() + const next = { ...facts, scheduleA: [range(2, 4), range(5, 6), range(12, 15)] } + const after = k.evaluate(next) + expect([k.a.calls(), k.b.calls(), k.c.calls()]).toEqual([[0, 4, 6, 15], [], []]) + for (const node of before.filter(node => node.id === 'schedule.b')) expect(after).toContain(node) + expect(after).toEqual(k.registry.createEvaluator()({ text: TEXT, facts: next })) + coverage(after) + }) + + it('rejects refreshed candidates covered by an unchanged earlier winner without querying that winner', () => { + const k = kit() + const facts = { scheduleA: [range(0, 10)], scheduleB: [] } + const before = k.evaluate(facts) + k.clear() + expect(k.evaluate({ ...facts, scheduleB: [range(4, 6)] })).toBe(before) + expect([k.a.calls(), k.b.calls(), k.c.calls()]).toEqual([[], [0, 10], []]) + }) + + it('queries only an uncovered offset when a refreshed winner extends across another head', () => { + const k = kit() + const facts = { scheduleA: [range(2, 4), range(12, 15)], scheduleB: [range(7, 9), range(18, 20)] } + const before = k.evaluate(facts) + k.clear() + const next = { ...facts, scheduleA: [range(2, 8), range(12, 15)] } + const after = k.evaluate(next) + expect([k.a.calls(), k.b.calls(), k.c.calls()]).toEqual([[0, 8, 15], [8], []]) + expect(after.filter(node => node.id === 'schedule.b').map(node => node.start)).toEqual([18]) + expect(after).toEqual(k.registry.createEvaluator()({ text: TEXT, facts: next })) + k.clear() + expect(k.evaluate(facts)).toEqual(before) + expect(k.b.calls()).toEqual([]) + expect(k.c.calls()).toEqual([]) + }) + + it.each(['remove', 'shrink'] as const)('exposes cached losers and unsearched gaps when a winner changes: %s', (action) => { + const k = kit() + const facts = { + scheduleA: [range(0, 10)], scheduleB: [range(2, 3), range(5, 6), range(12, 14)], scheduleC: [range(4, 5)], + } + k.evaluate(facts) + expect(k.b.calls()).toEqual([0, 10, 14]) + expect(k.c.calls()).toEqual([0, 10]) + k.clear() + const next = { ...facts, scheduleA: action === 'remove' ? [] : [range(0, 2)] } + const after = k.evaluate(next) + expect(k.a.calls()).toEqual(action === 'remove' ? [0] : [0, 2]) + expect(k.b.calls()).toEqual([3, 6]) + expect(k.c.calls()).toEqual([5]) + expect(after.filter(node => node.id === 'schedule.b').map(node => node.start)).toEqual([2, 5, 12]) + expect(after).toEqual(k.registry.createEvaluator()({ text: TEXT, facts: next })) + k.clear() + k.evaluate(facts) + k.clear() + k.evaluate(next) + expect(k.b.calls()).toEqual([]) + expect(k.c.calls()).toEqual([]) + coverage(after) + }) + + it('removes an old early winner even when the new first candidate occurs much later', () => { + const k = kit() + const facts = { scheduleA: [range(2, 4), range(12, 15)], scheduleB: [range(7, 9)] } + k.evaluate(facts) + k.clear() + const next = { ...facts, scheduleA: [range(12, 15)] } + const after = k.evaluate(next) + expect([k.a.calls(), k.b.calls(), k.c.calls()]).toEqual([[0, 15], [], []]) + expect(after[0]).toEqual({ id: 'text', start: 0, end: 7, data: { text: TEXT.slice(0, 7) } }) + expect(after).toEqual(k.registry.createEvaluator()({ text: TEXT, facts: next })) + }) + + it('reconciles two changed matchers together instead of giving update order precedence', () => { + const k = kit() + k.evaluate({ scheduleA: [range(2, 4)], scheduleB: [range(7, 9)] }) + k.clear() + const facts = { scheduleB: [range(2, 5)], scheduleA: [range(7, 10)] } + const after = k.evaluate(facts) + expect([k.a.calls(), k.b.calls(), k.c.calls()]).toEqual([[0, 10], [0, 5], []]) + expect(after.filter(node => node.id !== 'text').map(node => node.id)).toEqual(['schedule.b', 'schedule.a']) + expect(after).toEqual(k.registry.createEvaluator()({ text: TEXT, facts })) + }) + + it('refreshes a matcher once when two of its read fields change', () => { + const match = vi.fn((_text: string, _from: number, context: UserTextTransformContext) => { + context.read('scheduleA') + context.read('scheduleB') + return undefined + }) + const { evaluate } = setup([{ id: 'schedule.a', order: 0, match }]) + evaluate({ text: TEXT, facts: {} }) + match.mockClear() + evaluate({ text: TEXT, facts: { scheduleA: [], scheduleB: [] } }) + expect(match.mock.calls.map(([, from]) => from)).toEqual([0]) + }) + + it('tracks missing fields and replaces conditional dependencies when the cache generation changes', () => { + const match = vi.fn((_text: string, from: number, context: UserTextTransformContext): UserTextNode<'schedule.a'> | undefined => { + if (!context.read('scheduleEnabled')) return undefined + const item = context.read('scheduleA')?.find(item => item.start >= from) + return item === undefined ? undefined : { id: 'schedule.a', start: item.start, end: item.end, data: { label: item.label } } + }) + const { evaluate } = setup([{ id: 'schedule.a', order: 0, match }]) + evaluate({ text: TEXT, facts: {} }) + evaluate({ text: TEXT, facts: { scheduleEnabled: true } }) + const facts = { scheduleEnabled: true, scheduleA: [range(2, 4)] } + expect(business(evaluate({ text: TEXT, facts }))).toEqual([['schedule.a', 2, 4, { label: '2:4' }]]) + expect(match.mock.calls.map(([, from]) => from)).toEqual([0, 0, 0, 4]) + evaluate({ text: TEXT, facts: { ...facts, scheduleEnabled: false } }) + match.mockClear() + evaluate({ text: TEXT, facts: { scheduleEnabled: false, scheduleA: [range(8, 9)] } }) + expect(match).not.toHaveBeenCalled() + }) + + it('tracks reads of losing and no-match queries across a generation', () => { + const k = kit() + const facts = { scheduleA: [range(0, 10)], scheduleB: [range(3, 5)] } + const original = k.evaluate(facts) + k.clear() + const changed = k.evaluate({ ...facts, scheduleB: [range(12, 14)], scheduleC: [range(20, 22)] }) + expect(k.a.calls()).toEqual([]) + expect(k.b.calls()).toEqual([0, 14]) + expect(k.c.calls()).toEqual([0, 22]) + expect(changed[0]).toBe(original[0]) + expect(changed.filter(node => node.id !== 'text').map(node => node.start)).toEqual([0, 12, 20]) + }) + + it('keeps exact-offset failures cached and retries after a missing dependency appears', () => { + const cause = new Error('not ready') + const match = vi.fn((_text: string, from: number, context: UserTextTransformContext): UserTextNode<'schedule.a'> | undefined => { + if (from > 0) return undefined + if (!context.read('scheduleEnabled')) throw cause + return { id: 'schedule.a', start: 0, end: 1, data: { label: 'ready' } } + }) + const { evaluate } = setup([{ id: 'schedule.a', order: 0, match }]) + let failure: unknown + try { evaluate({ text: TEXT, facts: {} }) } catch (error) { failure = error } + expect(failure).toHaveProperty('ruleId', 'schedule.a') + expect(failure).toHaveProperty('cause', cause) + expect(() => evaluate({ text: TEXT, facts: { scheduleOther: 1 } })).toThrow(failure) + expect(match.mock.calls.map(([, from]) => from)).toEqual([0]) + match.mockClear() + expect(evaluate({ text: TEXT, facts: { scheduleEnabled: true } })[0]?.data).toEqual({ label: 'ready' }) + expect(match.mock.calls.map(([, from]) => from)).toEqual([0, 1]) + }) + + it('reuses a failed query only at its exact offset, not as a negative range answer', () => { + const a = tracked('schedule.a', 'scheduleA', 0) + const cause = new Error('offset-three failure') + const b = vi.fn((_text: string, from: number): UserTextNode<'schedule.b'> | undefined => { + if (from === 3) throw cause + return from === 0 ? { id: 'schedule.b', start: 1, end: 2, data: { label: 'hidden' } } : undefined + }) + const { evaluate } = setup([a.rule, { id: 'schedule.b', order: 1, match: b }]) + const old = { scheduleA: [range(0, 3)] } + expect(() => evaluate({ text: TEXT, facts: old })).toThrow(/schedule.b/) + expect(b.mock.calls.map(([, from]) => from)).toEqual([0, 3]) + a.match.mockClear(); b.mockClear() + const moved = evaluate({ text: TEXT, facts: { scheduleA: [range(0, 4)] } }) + expect(moved[0]?.end).toBe(4) + expect(b.mock.calls.map(([, from]) => from)).toEqual([4]) + b.mockClear() + expect(() => evaluate({ text: TEXT, facts: old })).toThrow(/schedule.b/) + expect(b).not.toHaveBeenCalled() + }) + + it('rejects a matcher that moves behind the cursor or produces another rule ID', () => { + const behind = vi.fn((_text: string, _from: number): UserTextNode<'schedule.a'> => ({ + id: 'schedule.a', start: 0, end: 1, data: { label: 'stuck' }, + })) + const first = setup([{ id: 'schedule.a', order: 0, match: behind }]) + expect(() => first.evaluate({ text: TEXT, facts: {} })).toThrow(/schedule.a/) + expect(behind.mock.calls.map(([, from]) => from)).toEqual([0, 1]) + const foreign = setup([{ id: 'schedule.a', order: 0, match: () => ({ + id: 'schedule.b', start: 0, end: 1, data: { label: 'foreign' }, + }) }]) + expect(() => foreign.evaluate({ text: TEXT, facts: {} })).toThrow(/schedule.a/) + }) + + it('closes matcher read contexts on success, throws and invalid asynchronous output', () => { + let escaped: UserTextTransformContext | undefined + const { evaluate } = setup([{ id: 'schedule.a', order: 0, match: (_text, _from, context) => { + escaped = context + if (context.read('scheduleEnabled')) throw new Error('failed') + return undefined + } }]) + evaluate({ text: TEXT, facts: {} }) + expect(() => escaped?.read('scheduleA')).toThrow(/closed/) + expect(() => evaluate({ text: TEXT, facts: { scheduleEnabled: true } })).toThrow(/schedule.a/) + expect(() => escaped?.read('scheduleA')).toThrow(/closed/) + const asyncCase = setup([{ id: 'schedule.a', order: 0, + // @ts-expect-error A matcher must return one synchronous candidate or undefined. + match: (_text, _from, context) => { escaped = context; return Promise.resolve(undefined) }, + }]) + expect(() => asyncCase.evaluate({ text: TEXT, facts: {} })).toThrow(/schedule.a/) + expect(() => escaped?.read('scheduleA')).toThrow(/closed/) + }) + + it('rejects matcher reentrancy without losing a later Facts-driven retry', () => { + const { registry, evaluate } = setup() + const match = vi.fn((_text: string, _from: number, context: UserTextTransformContext) => { + if (context.read('scheduleEnabled')) evaluate({ text: 'nested', facts: {} }) + return undefined + }) + registry.register({ id: 'schedule.a', order: 0, match }) + expect(() => evaluate({ text: TEXT, facts: { scheduleEnabled: true } })).toThrow(/schedule.a/) + expect(evaluate({ text: TEXT, facts: { scheduleEnabled: false } })).toEqual([ + { id: 'text', start: 0, end: TEXT.length, data: { text: TEXT } }, + ]) + expect(match.mock.calls.map(([, from]) => from)).toEqual([0, 0]) + }) + + it('keeps invalidated but unqueried matchers pending across an earlier failure', () => { + const first = vi.fn((_text: string, _from: number, context: UserTextTransformContext) => { + if (context.read('scheduleEnabled')) throw new Error('blocked') + return undefined + }) + const later = tracked('schedule.b', 'scheduleB', 1) + const { evaluate } = setup([{ id: 'schedule.a', order: 0, match: first }, later.rule]) + evaluate({ text: TEXT, facts: { scheduleEnabled: false, scheduleB: [range(2, 4, 'old')] } }) + later.match.mockClear() + const ranges = [range(2, 4, 'new')] + expect(() => evaluate({ text: TEXT, facts: { scheduleEnabled: true, scheduleB: ranges } })).toThrow(/schedule.a/) + expect(later.calls()).toEqual([]) + expect(() => evaluate({ text: TEXT, facts: { scheduleEnabled: true, scheduleB: ranges } })).toThrow(/schedule.a/) + expect(evaluate({ text: TEXT, facts: { scheduleEnabled: false, scheduleB: ranges } })[1]?.data).toEqual({ label: 'new' }) + expect(later.calls()).toEqual([0, 4]) + }) + + it('keeps evaluator query caches isolated and discards them on text changes', () => { + const match = vi.fn((_text: string, _from: number) => undefined) + const { registry, evaluate } = setup([{ id: 'schedule.a', order: 0, match }]) + const other = registry.createEvaluator() + const first = evaluate({ text: 'abc', facts: {} }) + expect(other({ text: 'abc', facts: {} })).not.toBe(first) + expect(match).toHaveBeenCalledTimes(2) + expect(evaluate({ text: 'abc', facts: {} })).toBe(first) + evaluate({ text: 'abcd', facts: {} }) + evaluate({ text: '', facts: {} }) + expect(match.mock.calls.map(([text, from]) => [text, from])).toEqual([['abc', 0], ['abc', 0], ['abcd', 0], ['', 0]]) + }) + + it('reuses independent caches across registration, removal and priority changes', () => { + const a = tracked('schedule.a', 'scheduleA', 0) + const b = tracked('schedule.b', 'scheduleB', 1) + const { registry, evaluate } = setup([a.rule]) + const input = { text: TEXT, facts: { scheduleA: [range(2, 4)], scheduleB: [range(2, 7)] } } + evaluate(input) + a.match.mockClear() + let remove = registry.register(b.rule) + expect(evaluate(input).filter(node => node.id !== 'text').map(node => node.id)).toEqual(['schedule.a']) + expect(a.calls()).toEqual([]) + expect(b.calls()).toEqual([0, 4]) + remove() + evaluate(input) + remove = registry.register({ ...b.rule, order: -1 }) + a.match.mockClear(); b.match.mockClear() + expect(evaluate(input).filter(node => node.id !== 'text').map(node => node.id)).toEqual(['schedule.b']) + expect(a.calls()).toEqual([]) + expect(b.calls()).toEqual([0, 7]) + remove() + expect(evaluate(input).filter(node => node.id !== 'text').map(node => node.id)).toEqual(['schedule.a']) + }) + + it('matches fresh evaluation throughout deterministic additions, removals and overlapping refreshes', () => { + const k = kit() + const variants = [[], [range(0, 10)], [range(2, 4), range(12, 15)], [range(5, 6), range(20, 25)]] + for (const scheduleA of variants) for (const scheduleB of variants) for (const scheduleC of variants) { + const facts = { scheduleA, scheduleB, scheduleC } + const nodes = k.evaluate(facts) + expect(nodes).toEqual(k.registry.createEvaluator()({ text: TEXT, facts })) + coverage(nodes) + } + }) +}) diff --git a/packages/client/ui-conversation/tests/user-text-whitespace.client.spec.ts b/packages/client/ui-conversation/tests/user-text-whitespace.client.spec.ts new file mode 100644 index 0000000000..e254e61ddb --- /dev/null +++ b/packages/client/ui-conversation/tests/user-text-whitespace.client.spec.ts @@ -0,0 +1,190 @@ +/** Whitespace is ordinary Text-producing matching, including scheduling and Facts invalidation. */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, expectTypeOf, it, onTestFinished, vi } from 'vitest' +import type { UserTextFacts, UserTextNode, UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { UserTextRegistry } from '../src/client/conversation/user-text-registry.ts' +import { collapseWhitespaceRule, trimWhitespaceRule } from '../src/client/user-text/whitespace-rules.ts' +import { sessionReferenceRule } from '../src/client/user-text/session-reference-rule.ts' + +function setup(extra: readonly UserTextRule[] = []) { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const registry = new UserTextRegistry(ctx) + const collapse = vi.fn(collapseWhitespaceRule.match) + const trim = vi.fn(trimWhitespaceRule.match) + registry.register({ ...collapseWhitespaceRule, match: collapse }) + registry.register({ ...trimWhitespaceRule, match: trim }) + for (const rule of extra) registry.register(rule) + return { registry, evaluate: registry.createEvaluator(), collapse, trim } +} + +function visible(nodes: readonly UserTextNode[]): string { + return nodes.map(node => node.id === 'text' ? node.data.display ?? node.data.text : `[${node.id}]`).join('') +} + +function assertSource(text: string, nodes: readonly UserTextNode[]): void { + let cursor = 0 + for (const node of nodes) { + expect(node.start).toBe(cursor) + expect(node.end).toBeGreaterThan(cursor) + if (node.id === 'text') expect(node.data.text).toBe(text.slice(node.start, node.end)) + cursor = node.end + } + expect(cursor).toBe(text.length) + expect(JSON.parse(JSON.stringify(nodes))).toEqual(nodes) +} + +describe('ordinary whitespace matchers', () => { + it('uses the sole rule interface and keeps switches distinct from React props', () => { + expectTypeOf(collapseWhitespaceRule).toExtend() + expectTypeOf(trimWhitespaceRule).toExtend() + expectTypeOf().not.toHaveProperty('transform') + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().not.toHaveProperty('ellipsis') + expectTypeOf().not.toHaveProperty('interactive') + expect(trimWhitespaceRule.order).toBeLessThan(collapseWhitespaceRule.order) + }) + + it.each(['', ' 原文\r\n\t🙂e\u0301 '])('keeps missing switches as a cached no-match result for %j', (text) => { + const k = setup() + const original = k.evaluate({ text, facts: {} }) + expect(visible(original)).toBe(text) + expect(k.trim.mock.calls.map(([, from]) => from)).toEqual([0]) + expect(k.collapse.mock.calls.map(([, from]) => from)).toEqual([0]) + expect(k.evaluate({ text, facts: { sessionLabels: ['new'] } })).toBe(original) + expect(k.trim).toHaveBeenCalledOnce() + expect(k.collapse).toHaveBeenCalledOnce() + assertSource(text, original) + }) + + it('resolves edge collisions in favor of trim and keeps raw substrings in every Text candidate', () => { + const k = setup() + const text = ' \tA\n B \r' + const nodes = k.evaluate({ text, facts: { collapseWhitespace: true, trimWhitespace: true } }) + expect(nodes).toEqual([ + { id: 'text', start: 0, end: 2, data: { text: ' \t', display: '' } }, + { id: 'text', start: 2, end: 3, data: { text: 'A' } }, + { id: 'text', start: 3, end: 6, data: { text: '\n ', display: ' ' } }, + { id: 'text', start: 6, end: 7, data: { text: 'B' } }, + { id: 'text', start: 7, end: 9, data: { text: ' \r', display: '' } }, + ]) + expect(visible(nodes)).toBe('A B') + expect(k.trim.mock.calls.map(([, from]) => from)).toEqual([0, 2, 9]) + expect(k.collapse.mock.calls.map(([, from]) => from)).toEqual([0, 2, 6, 9]) + assertSource(text, nodes) + }) + + it('refreshes only the switch reader while reusing the competing whitespace query sequence', () => { + const k = setup() + const text = ' \tA\n B \r' + const collapseOnly = k.evaluate({ text, facts: { collapseWhitespace: true } }) + expect(visible(collapseOnly)).toBe(' A B ') + k.trim.mockClear(); k.collapse.mockClear() + const both = k.evaluate({ text, facts: { collapseWhitespace: true, trimWhitespace: true } }) + expect(visible(both)).toBe('A B') + expect(k.trim.mock.calls.map(([, from]) => from)).toEqual([0, 2, 9]) + expect(k.collapse).not.toHaveBeenCalled() + k.trim.mockClear() + expect(k.evaluate({ text, facts: { collapseWhitespace: true, trimWhitespace: false } })).toEqual(collapseOnly) + expect(k.trim.mock.calls.map(([, from]) => from)).toEqual([0]) + expect(k.collapse).not.toHaveBeenCalled() + }) + + it('lets cached collapse candidates win after trim is disabled without repeating their search', () => { + const k = setup() + const text = ' \t\n' + const original = k.evaluate({ text, facts: { collapseWhitespace: true, trimWhitespace: true } }) + expect(visible(original)).toBe('') + expect(k.trim.mock.calls.map(([, from]) => from)).toEqual([0, text.length]) + expect(k.collapse.mock.calls.map(([, from]) => from)).toEqual([0, text.length]) + k.trim.mockClear(); k.collapse.mockClear() + expect(visible(k.evaluate({ text, facts: { collapseWhitespace: true } }))).toBe(' ') + expect(k.trim.mock.calls.map(([, from]) => from)).toEqual([0]) + expect(k.collapse).not.toHaveBeenCalled() + }) + + it('keeps reference whitespace atomic through earlier-start competition, not a processing phase', () => { + const reference = vi.fn(sessionReferenceRule.match) + const k = setup([{ ...sessionReferenceRule, order: 100_000, match: reference }]) + const wire = '@[two words](dsh-session:x)' + const text = ` \t🙂甲\n ${wire} \r\n乙 \t` + const original = k.evaluate({ text, facts: {} }) + reference.mockClear() + const nodes = k.evaluate({ text, facts: { collapseWhitespace: true, trimWhitespace: true } }) + expect(visible(nodes)).toBe('🙂甲 [session] 乙') + expect(nodes.find(node => node.id === 'session')).toBe(original.find(node => node.id === 'session')) + expect(nodes.find(node => node.id === 'session')?.data).toEqual({ label: 'two words' }) + expect(reference).not.toHaveBeenCalled() + assertSource(text, nodes) + }) + + it.each([ + [' \t🙂 \n甲 👨‍👩‍👧‍👦\t 乙🇨🇳 \t', '🙂 甲 👨‍👩‍👧‍👦 乙🇨🇳'], + ['\u00a0甲\r\n乙\u3000', '甲 乙'], + ['甲 乙123', '甲 乙123'], + ['', ''], + ])('preserves emoji and collapses original whitespace in %j', (text, expected) => { + const k = setup() + const facts = { collapseWhitespace: true, trimWhitespace: true } + const nodes = k.evaluate({ text, facts }) + expect(visible(nodes)).toBe(expected) + assertSource(text, nodes) + expect(visible(k.evaluate({ text, facts: { trimWhitespace: true } }))).toBe(text.trim()) + expect(visible(k.evaluate({ text, facts: {} }))).toBe(text) + expect(k.evaluate({ text, facts })).toEqual(k.registry.createEvaluator()({ text, facts })) + }) + + it('ignores unrelated Facts without limiting long original text', () => { + const k = setup() + const text = ` ${'原文🙂\n'.repeat(100)} ` + const facts = { collapseWhitespace: true, trimWhitespace: true } + const nodes = k.evaluate({ text, facts }) + k.trim.mockClear(); k.collapse.mockClear() + expect(k.evaluate({ text, facts: { ...facts, sessionLabels: ['changed'] } })).toBe(nodes) + expect(k.trim).not.toHaveBeenCalled() + expect(k.collapse).not.toHaveBeenCalled() + assertSource(text, nodes) + }) + + it('keeps unchanged output identity when enabled matchers find no replacement', () => { + const k = setup() + const input = { text: '甲 乙123', facts: {} } + const original = k.evaluate(input) + expect(k.evaluate({ ...input, facts: { collapseWhitespace: true, trimWhitespace: true } })).toBe(original) + }) + + it.each([ + [' \n@[a](dsh-session:x) \t @[b](dsh-session:y) \t', '[session] \t [session]'], + ['@[a](dsh-session:x) \t 甲 \n @[b](dsh-session:y)', '[session] \t 甲 \n [session]'], + ['@[a](dsh-session:x) \t 甲 \n', '[session] \t 甲'], + [' \t 甲 \n @[b](dsh-session:y)', '甲 \n [session]'], + ])('does not trim through references or delete internal spacing in %s', (text, expected) => { + const k = setup([sessionReferenceRule]) + const nodes = k.evaluate({ text, facts: { trimWhitespace: true } }) + expect(visible(nodes)).toBe(expected) + assertSource(text, nodes) + }) + + it('handles long interior and trailing whitespace without changing source ranges', () => { + const k = setup() + const middle = `a${' '.repeat(80_000)}b` + expect(visible(k.evaluate({ text: middle, facts: { trimWhitespace: true } }))).toBe(middle) + expect(k.trim.mock.calls.map(([, from]) => from)).toEqual([0]) + k.trim.mockClear() + const trailing = `a${' \t\u00a0'.repeat(20_000)}` + const nodes = k.evaluate({ text: trailing, facts: { trimWhitespace: true } }) + expect(visible(nodes)).toBe('a') + expect(nodes.at(-1)).toEqual({ id: 'text', start: 1, end: trailing.length, + data: { text: trailing.slice(1), display: '' } }) + expect(k.trim.mock.calls.map(([, from]) => from)).toEqual([0, trailing.length]) + assertSource(trailing, nodes) + }) + + it('does not invent another original whitespace-run start inside a consumed run', () => { + const facts: Partial = { collapseWhitespace: true, trimWhitespace: true } + const read = (key: K) => facts[key] + expect(collapseWhitespaceRule.match(' x', 1, { read })).toBeUndefined() + expect(trimWhitespaceRule.match(' x', 1, { read })).toBeUndefined() + }) +}) diff --git a/packages/client/ui-primitives/tests/user-text.client.spec.tsx b/packages/client/ui-conversation/tests/user-text.client.spec.tsx similarity index 65% rename from packages/client/ui-primitives/tests/user-text.client.spec.tsx rename to packages/client/ui-conversation/tests/user-text.client.spec.tsx index 0068ddce56..6a19a22fb4 100644 --- a/packages/client/ui-primitives/tests/user-text.client.spec.tsx +++ b/packages/client/ui-conversation/tests/user-text.client.spec.tsx @@ -3,9 +3,32 @@ * Inline projection of sent user text: decoration adds no block containers, * preserves whitespace, and folds wire session forms to their label. */ -import { describe, expect, it, vi } from 'vitest' -import { fireEvent, render } from '@testing-library/react' -import { projectUserText } from '../src/user-text.tsx' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { Context } from '@deepseek-ai/cordis' +import type { ComponentProps } from 'react' +import { UserText } from '../src/client/user-text/UserText.tsx' +import { UserTextRegistry } from '../src/client/conversation/user-text-registry.ts' +import { createUserTextFixture } from './user-text-component-fixture.client.tsx' + +afterEach(cleanup) + +function textView( + text: string, + labels: readonly string[] = [], + slashNames: readonly string[] = [], + slashKind: 'skill' | 'command' = 'skill', + actions?: Parameters[0], +) { + const head = text.split(/\s/u, 1)[0] + const command = slashNames.find(name => head === `/${name}`) + const facts = { + sessionLabels: labels, + ...(slashKind === 'skill' ? { skillNames: slashNames } + : { commands: command === undefined ? [] : [{ start: 0, end: command.length + 1, name: command }] }), + } + return createUserTextFixture(actions)({ text, facts, interactive: actions !== undefined }) +} const project = ( text: string, @@ -13,9 +36,42 @@ const project = ( slashNames: readonly string[] = [], slashKind: 'skill' | 'command' = 'skill', ) => - render(
{projectUserText(text, labels, slashNames, slashKind)}
).container.querySelector('[data-host]')! + render(
{textView(text, labels, slashNames, slashKind)}
).container.querySelector('[data-host]')! + +describe('UserText projection', () => { + it('retains complete original text, reports a cached matching failure once, and recovers', () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const registry = new UserTextRegistry(ctx) + const cause = new Error('matching unavailable') + registry.register({ id: 'session', order: 0, match: (text, from, context) => { + if (from > 0) return undefined + if (context.read('sessionLabels') === undefined) throw cause + return { id: 'session', start: 0, end: text.length, data: { label: 'ready' } } + } }) + const evaluate = registry.createEvaluator() + const report = vi.fn() + const renderSlot = vi.fn(() => recognized output) + const text = ' complete /original\n@reference ' + const props = { + text, facts: {}, createUserTextEvaluator: () => evaluate, + useUserTextRevision: selector => selector(registry.getSnapshot()), + reportTransformFailure: report, renderSlot, + } satisfies Partial> + const view = render(} />) + expect(view.container.textContent).toBe(text) + expect(renderSlot).not.toHaveBeenCalled() + expect(report).toHaveBeenCalledOnce() + expect(report.mock.calls[0]?.[0]).toMatchObject({ ruleId: 'session', cause }) + view.rerender(} facts={{ collapseWhitespace: false }} />) + expect(view.container.textContent).toBe(text) + expect(report).toHaveBeenCalledOnce() + view.rerender(} facts={{ sessionLabels: ['ready'] }} />) + expect(view.getByText('recognized output')).toBeTruthy() + expect(renderSlot).toHaveBeenCalledOnce() + expect(report).toHaveBeenCalledOnce() + }) -describe('projectUserText', () => { it('keeps decorated text inline and preserves whitespace between references', () => { const host = project('反反复复 /dsh-acp-test @执行几个命令测试', ['执行几个命令测试'], ['dsh-acp-test']) expect(host.querySelectorAll('div').length).toBe(0) @@ -23,7 +79,8 @@ describe('projectUserText', () => { const chips = host.querySelectorAll('[data-ref-chip]') expect([...chips].map(c => c.getAttribute('data-ref-chip'))).toEqual(['skill', 'session']) // The whitespace between tokens survives as its own inline run. - const runs = [...host.querySelectorAll('span')].filter(s => !s.hasAttribute('data-ref-chip') && s.closest('[data-ref-chip]') === null) + const runs = [...host.querySelectorAll('span')].filter(s => + s.childElementCount === 0 && !s.hasAttribute('data-ref-chip') && s.closest('[data-ref-chip]') === null) expect(runs.map(r => r.textContent)).toEqual(['反反复复 ', ' ']) }) @@ -108,7 +165,7 @@ describe('projectUserText', () => { it('opens decoded files and loaded skills without activating session, folder, or command references', () => { const openFile = vi.fn() const openSkill = vi.fn() - const view = render(
{projectUserText( + const view = render(
{textView( '@src/a.ts @"notes a.md" /review @history @dir/ @"dir a/"', ['history'], ['review'], 'skill', { openFile, openSkill }, )}
) @@ -118,13 +175,13 @@ describe('projectUserText', () => { expect(openFile.mock.calls).toEqual([['src/a.ts'], ['notes a.md']]) expect(openSkill).toHaveBeenCalledWith('review') expect(view.container.querySelectorAll('button')).toHaveLength(3) - const command = render(
{projectUserText('/help', [], ['help'], 'command', { openFile, openSkill })}
) + const command = render(
{textView('/help', [], ['help'], 'command', { openFile, openSkill })}
) expect(command.container.querySelector('button')).toBeNull() }) it('preserves text-selection gestures and keyboard activation', () => { const openFile = vi.fn() - const view = render(
{projectUserText('@notes.md', [], [], 'skill', { openFile, openSkill: vi.fn() })}
) + const view = render(
{textView('@notes.md', [], [], 'skill', { openFile, openSkill: vi.fn() })}
) const button = view.getByRole('button', { name: 'notes.md' }) const selection = document.getSelection()! const range = document.createRange() diff --git a/packages/client/ui-conversation/tsdown.config.ts b/packages/client/ui-conversation/tsdown.config.ts index 6ccfee9612..7aa03cebc1 100644 --- a/packages/client/ui-conversation/tsdown.config.ts +++ b/packages/client/ui-conversation/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-conversation', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-deliverables/locale/en.json b/packages/client/ui-deliverables/locale/en.json new file mode 100644 index 0000000000..109082cbc5 --- /dev/null +++ b/packages/client/ui-deliverables/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-deliverables" + } +} diff --git a/packages/client/ui-deliverables/locale/zh.json b/packages/client/ui-deliverables/locale/zh.json new file mode 100644 index 0000000000..a353e74262 --- /dev/null +++ b/packages/client/ui-deliverables/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "文件变更与交付展示", + "description": "展示修改过的文件、内容对比和交付文件入口。" + } +} diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 174fcf3f72..8fb7a29a1b 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -61,7 +62,6 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", @@ -83,6 +83,7 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-deliverables/tsdown.config.ts b/packages/client/ui-deliverables/tsdown.config.ts index 51d0ff8a1e..7aa03cebc1 100644 --- a/packages/client/ui-deliverables/tsdown.config.ts +++ b/packages/client/ui-deliverables/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-deliverables', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-directory-picker-browse/locale/en.json b/packages/client/ui-directory-picker-browse/locale/en.json new file mode 100644 index 0000000000..66fe08e059 --- /dev/null +++ b/packages/client/ui-directory-picker-browse/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-directory-picker-browse" + } +} diff --git a/packages/client/ui-directory-picker-browse/locale/zh.json b/packages/client/ui-directory-picker-browse/locale/zh.json new file mode 100644 index 0000000000..8504fe7c3e --- /dev/null +++ b/packages/client/ui-directory-picker-browse/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "应用内目录选择界面", + "description": "在应用中浏览、创建并选择工作区目录。" + } +} diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 557ca70801..0b24a8a9eb 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -53,13 +54,13 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", "clsx": "^2.0.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-directory-picker-browse/tsdown.config.ts b/packages/client/ui-directory-picker-browse/tsdown.config.ts index 840d57706f..7aa03cebc1 100644 --- a/packages/client/ui-directory-picker-browse/tsdown.config.ts +++ b/packages/client/ui-directory-picker-browse/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-directory-picker-browse', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-directory-picker-native/locale/en.json b/packages/client/ui-directory-picker-native/locale/en.json new file mode 100644 index 0000000000..766bf26c75 --- /dev/null +++ b/packages/client/ui-directory-picker-native/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-directory-picker-native" + } +} diff --git a/packages/client/ui-directory-picker-native/locale/zh.json b/packages/client/ui-directory-picker-native/locale/zh.json new file mode 100644 index 0000000000..37a5e9e944 --- /dev/null +++ b/packages/client/ui-directory-picker-native/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "系统目录选择界面", + "description": "连接工作区选择入口与系统目录选择对话框。" + } +} diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 727c9f22b2..9a80407630 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -47,12 +48,12 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-directory-picker-native/tsdown.config.ts b/packages/client/ui-directory-picker-native/tsdown.config.ts index 23e5247c7e..7aa03cebc1 100644 --- a/packages/client/ui-directory-picker-native/tsdown.config.ts +++ b/packages/client/ui-directory-picker-native/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-directory-picker-native', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-dockkit/package.json b/packages/client/ui-dockkit/package.json index e42a8d828f..13bfb47625 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -27,8 +27,6 @@ "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "react-dom": "^18.2.0", "react": "^18.2.0", "clsx": "^2.0.0" diff --git a/packages/client/ui-dockkit/tsdown.config.ts b/packages/client/ui-dockkit/tsdown.config.ts index 7e4e7ba807..a6264e0b21 100644 --- a/packages/client/ui-dockkit/tsdown.config.ts +++ b/packages/client/ui-dockkit/tsdown.config.ts @@ -1,6 +1,6 @@ import { staticLinked } from '../tsdown.client.ts' export default staticLinked( - '@deepseek-ai/dsh-client-ui-dockkit', + import.meta.dirname, ['lib/types/index.js'], ) diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 628c67f401..0d819456e1 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/README.i18n.yaml @@ -18,8 +18,8 @@ en: 4f6e6353d793a0de zh: cde6e470f7dd9057 /deepseek-ai-dsh-client-ui-goal/use-this-package/the-command-input-bubble: - en: 05c5db0eb58dffef - zh: 54e7bea0b810b1b8 + en: 05f0360b6c6dd3e9 + zh: d14c3f15dc4cea5b /deepseek-ai-dsh-client-ui-goal/use-this-package/failures: en: 097bbdd748dc17e5 zh: 867d341d046ba609 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index c8fe5c0491..ecdd666241 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation` and the goal domain package; the s ### The command-input bubble -Each durable `/goal` run projects as a right-aligned user-style bubble labeled `Command input` (or `指令输入`), rendered before the generic command result row; the leading `/goal` token renders as a command reference chip in the code face through ui-primitives `projectUserText`, and the objective stays plain body text. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run. +Each durable `/goal` run projects as a right-aligned user-style bubble labeled `Command input` (or `指令输入`), rendered before the generic command result row; the leading `/goal` token renders as a command reference chip in the code face through the shared `conversation.user.text` Factory and the Goal-owned `command` renderer, using the executed token range in `facts.commands`, and the objective stays plain body text. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run. ### Failures diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index 83d33fe35a..e3c8ef955e 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -29,7 +29,7 @@ Web GUI 的 goal 界面同时显示持久 goal 状态及当前的进程本地激 ### 指令输入气泡 -每条持久的 `/goal` 运行都投影为一个右对齐的用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前;开头的 `/goal` token 经 ui-primitives 的 `projectUserText` 以等宽代码字体渲染为指令引用 chip,目标文本保持正文字体。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。 +每条持久的 `/goal` 运行都投影为一个右对齐的用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前;开头的 `/goal` token 经共享的 `conversation.user.text` Factory 与 Goal 自有的 `command` renderer,使用 `facts.commands` 中的实际执行 token 范围, 以等宽代码字体渲染为指令引用 chip,目标文本保持正文字体。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。 ### 失败 diff --git a/packages/client/ui-goal/locale/en.json b/packages/client/ui-goal/locale/en.json new file mode 100644 index 0000000000..adb8f958d3 --- /dev/null +++ b/packages/client/ui-goal/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-goal" + } +} diff --git a/packages/client/ui-goal/locale/zh.json b/packages/client/ui-goal/locale/zh.json new file mode 100644 index 0000000000..b3b7b06c6c --- /dev/null +++ b/packages/client/ui-goal/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "会话目标栏", + "description": "在输入框上方展示当前会话的目标和状态。" + } +} diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index b1cac92875..43497a51fe 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -61,7 +62,6 @@ "@deepseek-ai/dsh-commands": "workspace:*", "@deepseek-ai/dsh-goal": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -69,6 +69,7 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-goal/src/client/GoalCommandInputView.tsx b/packages/client/ui-goal/src/client/GoalCommandInputView.tsx index 8d9742d45d..ee5c8e38cf 100644 --- a/packages/client/ui-goal/src/client/GoalCommandInputView.tsx +++ b/packages/client/ui-goal/src/client/GoalCommandInputView.tsx @@ -1,12 +1,14 @@ import { memo } from 'react' -import { projectUserText } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRenderFactories, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { GOAL_COMMAND, type GoalCommandInputData } from './goal-command-input.ts' import css from './GoalCommandInputView.module.css' +const GOAL_COMMANDS = [{ start: 0, end: GOAL_COMMAND.length + 1, name: GOAL_COMMAND }] as const + type GoalCommandInputViewProps = PropsRuntime<'conversation.chat.node', 'command-input'> & PropsLocale<'goal'> + & PropsRenderFactories /** * Right-aligned `/goal` input bubble without ordinary message actions. The @@ -15,14 +17,9 @@ type GoalCommandInputViewProps = * the objective, `/goal` mentions included, as plain text. */ export const GoalCommandInputView = memo(function GoalCommandInputView({ - node, t, + node, t, renderFactorySlot, }: GoalCommandInputViewProps) { const data: GoalCommandInputData = node.data - // Only the leading token is the executed command; the rest of the line is - // the objective, where a further `/goal` is prose. - const split = data.text.search(/\s/u) - const head = split === -1 ? data.text : data.text.slice(0, split) - const rest = split === -1 ? '' : data.text.slice(split) return (
- {projectUserText(head, [], [GOAL_COMMAND], 'command')} - {rest !== '' && projectUserText(rest, [])} + {renderFactorySlot('conversation.user.text', { + text: data.text, + facts: { commands: GOAL_COMMANDS }, + interactive: false, + })}
diff --git a/packages/client/ui-goal/src/client/GoalCommandReference.tsx b/packages/client/ui-goal/src/client/GoalCommandReference.tsx new file mode 100644 index 0000000000..cfad9369f5 --- /dev/null +++ b/packages/client/ui-goal/src/client/GoalCommandReference.tsx @@ -0,0 +1,12 @@ +/** Display-only command references; command execution stays with the original command input. */ +import type { UserTextNodeProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { ReferenceChip } from '@deepseek-ai/dsh-client-ui-primitives' + +/** + * Render one confirmed command token without adding an activation action. + * @param props - resolved command node and its original spelling. + * @returns a noninteractive inline command chip. + */ +export function GoalCommandReference({ node, raw }: UserTextNodeProps<'command'>) { + return +} diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index e6feba92f5..ba15d921e2 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -31,7 +31,10 @@ import type { GoalActionResult, GoalBarInjected } from './slots.ts' import { createGoalActivationSource } from './activation-source.ts' import { GoalDock } from './GoalBar.tsx' import { GoalCommandInputView } from './GoalCommandInputView.tsx' +import { GoalCommandReference } from './GoalCommandReference.tsx' import { goalCommandInputDefinition } from './goal-command-input.ts' +import { goalCommandReferenceRule } from './user-text-rules.ts' +export type {} from './user-text-rules.ts' import { en, zh, type GoalKey } from './locales.ts' export { GoalBar, GoalDock } from './GoalBar.tsx' @@ -66,8 +69,15 @@ export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', */ export function apply(ctx: ClientContext): void { ctx.uiConversation.events.register(goalCommandInputDefinition) + ctx.uiConversation.userText.register(goalCommandReferenceRule) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries') + ctx.slots.inject('conversation.user.text.node', () => ctx.slots.register({ + name: 'conversation.user.text.node', + key: 'command', + priority: 100, + }, GoalCommandReference)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'command-input', diff --git a/packages/client/ui-goal/src/client/user-text-rules.ts b/packages/client/ui-goal/src/client/user-text-rules.ts new file mode 100644 index 0000000000..67c4dbe81e --- /dev/null +++ b/packages/client/ui-goal/src/client/user-text-rules.ts @@ -0,0 +1,36 @@ +/** Original-text command candidates restricted to confirmed execution ranges. */ +import type { UserTextRule, UserTextNode } from '@deepseek-ai/dsh-client-ui-conversation/client' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface UserTextDataMap { + /** The executed command name, without the leading slash. */ + command: { readonly name: string } + } + + interface UserTextFacts { + /** Confirmed command ranges; repeated command spelling outside them stays plain text. */ + readonly commands: readonly { readonly start: number; readonly end: number; readonly name: string }[] + } +} + +/** Return the nearest exact confirmed command, checking boundaries against the original string. */ +export const goalCommandReferenceRule: UserTextRule<'command'> = { + id: 'command', + order: 20, + match(text, from, context) { + const slash = /(? | undefined + for (const { start, end, name } of commands ?? []) { + if (start < from || end > text.length || text.slice(start, end) !== `/${name}` + || (start !== 0 && !/\s/u.test(text.slice(start - 1, start))) + || (end !== text.length && !/\s/u.test(text.slice(end, end + 1)))) continue + if (best === undefined || start < best.start || (start === best.start && end > best.end)) { + best = { id: 'command', start, end, data: { name } } + } + } + return best + }, +} diff --git a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx index ad03572d4c..a6344d47a3 100644 --- a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx +++ b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx @@ -3,13 +3,15 @@ import { cleanup, render, within } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client' import type { - ConversationNodeDefinition, ConversationViewDefinition, + ConversationNodeDefinition, ConversationViewDefinition, UserTextInput, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatConversationViewNode, ChatSnapshot, } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { PropsRenderFactories } from '@deepseek-ai/dsh-client-ui-slots' +import { createUserTextFixture } from '../../ui-conversation/tests/user-text-component-fixture.client.tsx' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { commandDefinition } from '@deepseek-ai/dsh-client-ui-chat/src/client/conversation-nodes/command.ts' @@ -22,6 +24,15 @@ import { zh } from '../src/client/locales.ts' afterEach(cleanup) +function textFactories(onInput?: (input: UserTextInput) => void): PropsRenderFactories['renderFactorySlot'] { + const renderText = createUserTextFixture() + return (name, input) => { + if (name !== 'conversation.user.text') throw new Error(`Unexpected Goal Factory: ${name}`) + onInput?.(input as UserTextInput) + return renderText(input as UserTextInput) + } +} + class TestEventDefinitions { entries(): readonly ConversationNodeDefinition[] { return [commandDefinition, goalCommandInputDefinition] @@ -127,7 +138,8 @@ describe('goal command input projection', () => { data: { commandId: 'command-goal', text: '/goal ship it', time: 1_700_000_000_000 }, }, t, - } as unknown as Parameters[0] + renderFactorySlot: textFactories(), + } as Parameters[0] const view = render() const bubble = view.getByRole('group', { name: '指令输入' }) @@ -138,6 +150,21 @@ describe('goal command input projection', () => { expect(chips.map(chip => [chip.getAttribute('data-ref-chip'), chip.textContent])).toEqual([['command', '/goal']]) }) + it('keeps command facts stable when the input row rerenders', () => { + const commands: UserTextInput['facts']['commands'][] = [] + const props = { + node: { key: 'goal-command-input:stable', data: { commandId: 'command-goal', text: '/goal ship it', time: 1 } }, + t: makeTranslate(zh, commonZh), + renderFactorySlot: textFactories((input) => { commands.push(input.facts.commands) }), + } as Parameters[0] + const view = render() + view.rerender() + expect(commands).toHaveLength(2) + expect(commands[0]).toEqual([{ start: 0, end: 5, name: 'goal' }]) + expect(commands[1]).toBe(commands[0]) + expect(view.getByRole('group', { name: '指令输入' }).textContent).toBe('/goal ship it') + }) + it('renders a bare /goal as one command chip and nothing else', () => { const t = makeTranslate(zh, commonZh) const props = { @@ -146,7 +173,8 @@ describe('goal command input projection', () => { data: { commandId: 'command-goal', text: '/goal', time: 1_700_000_000_000 }, }, t, - } as unknown as Parameters[0] + renderFactorySlot: textFactories(), + } as Parameters[0] const view = render() const bubble = view.getByRole('group', { name: '指令输入' }) expect(bubble.textContent).toBe('/goal') @@ -161,7 +189,8 @@ describe('goal command input projection', () => { data: { commandId: 'command-goal', text: '/goal 检查 /goal 的语法', time: 1_700_000_000_000 }, }, t, - } as unknown as Parameters[0] + renderFactorySlot: textFactories(), + } as Parameters[0] const view = render() const bubble = view.getByRole('group', { name: '指令输入' }) expect(bubble.textContent).toBe('/goal 检查 /goal 的语法') diff --git a/packages/client/ui-goal/tests/user-text-rules.client.spec.ts b/packages/client/ui-goal/tests/user-text-rules.client.spec.ts new file mode 100644 index 0000000000..7a1d1fc7c6 --- /dev/null +++ b/packages/client/ui-goal/tests/user-text-rules.client.spec.ts @@ -0,0 +1,99 @@ +import { Context } from '@deepseek-ai/cordis' +import { UserTextRegistry } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/user-text-registry.ts' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { UserTextFacts, UserTextNode, UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { goalCommandReferenceRule } from '../src/client/user-text-rules.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface UserTextDataMap { + 'test.command.blocker': { readonly label: string } + } +} + +function setup(rules: readonly UserTextRule[] = [goalCommandReferenceRule]) { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + const registry = new UserTextRegistry(ctx) + for (const rule of rules) registry.register(rule) + return registry.createEvaluator() +} + +describe('goalCommandReferenceRule', () => { + it('does not read command facts or rerun without an original slash-token boundary', () => { + const read = vi.fn() + const match = vi.fn((text, from, context) => goalCommandReferenceRule.match(text, from, { + read: (key) => { read(key); return context.read(key) }, + })) + const evaluate = setup([{ ...goalCommandReferenceRule, match }]) + const text = 'plain text prefix/goal' + const before = evaluate({ text, facts: {} }) + expect(evaluate({ text, facts: { commands: [{ start: 0, end: 5, name: 'goal' }] } })).toBe(before) + expect(read).not.toHaveBeenCalled() + expect(match.mock.calls.map(([, from]) => from)).toEqual([0]) + }) + + it('offers confirmed ranges while the scheduler resolves an earlier same-start winner', () => { + const text = '/goal /goal /goal' + const blocker: UserTextNode<'test.command.blocker'> = { + id: 'test.command.blocker', start: 0, end: 5, data: { label: 'claimed' }, + } + const match = vi.fn(goalCommandReferenceRule.match) + const evaluate = setup([ + { id: 'test.command.blocker', order: 0, match: (_text, from) => from === 0 ? blocker : undefined }, + { ...goalCommandReferenceRule, match }, + ]) + const nodes = evaluate({ + text, facts: { commands: [{ start: 0, end: 5, name: 'goal' }, { start: 6, end: 11, name: 'goal' }] }, + }) + expect(nodes[0]).toBe(blocker) + expect(nodes.filter(node => node.id === 'command').map(node => [node.start, node.end])).toEqual([[6, 11]]) + expect(nodes.at(-1)).toEqual({ id: 'text', start: 11, end: text.length, data: { text: ' /goal' } }) + expect(match.mock.calls.map(([, from]) => from)).toEqual([0, 5, 11]) + expect(evaluate({ text, facts: { commands: [{ start: 6, end: 10, name: 'goal' }] } }).filter(node => node.id === 'command')).toEqual([]) + }) + + it('marks only actual command ranges, not repeated command spelling in the body', () => { + const evaluate = setup() + const text = '/goal ship it\nthen discuss /goal again' + const commands = [{ start: 0, end: 5, name: 'goal' }] + const input = { text, facts: { commands } } + const nodes = evaluate(input) + expect(nodes.filter(node => node.id === 'command')).toEqual([ + { id: 'command', start: 0, end: 5, data: { name: 'goal' } }, + ]) + expect(evaluate({ text, facts: { commands } })).toBe(nodes) + expect(JSON.parse(JSON.stringify(nodes))).toEqual(nodes) + expect(evaluate({ text, facts: {} }).filter(node => node.id === 'command')).toEqual([]) + }) + + it('returns one nearest valid range from unordered Facts at each requested offset', () => { + const text = '/goal /goal /goal plain' + const commands = [ + { start: 12, end: 17, name: 'goal' }, { start: 6, end: 17, name: 'goal' }, + { start: 0, end: 5, name: 'goal' }, { start: 6, end: 11, name: 'goal' }, + { start: 0, end: 5, name: 'goal' }, + ] + const facts: Partial = { commands } + const context = { read: (key: K) => facts[key] } + expect(goalCommandReferenceRule.match(text, 0, context)?.start).toBe(0) + expect(goalCommandReferenceRule.match(text, 5, context)?.start).toBe(6) + expect(goalCommandReferenceRule.match(text, 11, context)?.start).toBe(12) + expect(goalCommandReferenceRule.match(text, 17, context)).toBeUndefined() + }) + + it.each([ + [' /goalx', 1, 6], [' /other x/goal', 9, 14], [' /goal', 1, 7], [' /goal', 20, 25], + ] as const)('ignores a nonmatching confirmed range in %s', (text, start, end) => { + const facts: Partial = { commands: [{ start, end, name: 'goal' }] } + expect(goalCommandReferenceRule.match(text, 0, { read: key => facts[key] })).toBeUndefined() + }) + + it('uses original UTF-16 offsets without creating boundaries at the search cursor', () => { + const evaluate = setup() + const text = '🙂 /goal body /goal' + expect(evaluate({ text, facts: { commands: [{ start: 3, end: 8, name: 'goal' }] } }) + .filter(node => node.id === 'command')).toEqual([{ id: 'command', start: 3, end: 8, data: { name: 'goal' } }]) + const facts: Partial = { commands: [{ start: 1, end: 6, name: 'goal' }] } + expect(goalCommandReferenceRule.match('x/goal', 1, { read: key => facts[key] })).toBeUndefined() + }) +}) diff --git a/packages/client/ui-goal/tsdown.config.ts b/packages/client/ui-goal/tsdown.config.ts index 8b16051076..7aa03cebc1 100644 --- a/packages/client/ui-goal/tsdown.config.ts +++ b/packages/client/ui-goal/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-goal', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-input-trigger/locale/en.json b/packages/client/ui-input-trigger/locale/en.json new file mode 100644 index 0000000000..9f4b0f089e --- /dev/null +++ b/packages/client/ui-input-trigger/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-input-trigger" + } +} diff --git a/packages/client/ui-input-trigger/locale/zh.json b/packages/client/ui-input-trigger/locale/zh.json new file mode 100644 index 0000000000..98e40ff417 --- /dev/null +++ b/packages/client/ui-input-trigger/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "输入联想服务", + "description": "识别输入中的 / 和 @,展示并选择对应候选项。" + } +} diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 280dbdaaad..e2f1fa71e3 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -51,7 +52,6 @@ "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "@deepseek-ai/dsh-file-reference": "workspace:*", @@ -63,6 +63,7 @@ "clsx": "^2.0.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-input-trigger/tsdown.config.ts b/packages/client/ui-input-trigger/tsdown.config.ts index 86f4bfad7d..7aa03cebc1 100644 --- a/packages/client/ui-input-trigger/tsdown.config.ts +++ b/packages/client/ui-input-trigger/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-input-trigger', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index 0b6fe4ebae..460e719a36 100644 --- a/packages/client/ui-jobs/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -24,8 +24,8 @@ en: c34dad62fee63306 zh: 167d9da7928a9488 /deepseek-ai-dsh-client-ui-jobs/understand-the-implementation: - en: 98daaa0c15166ea5 - zh: a37b5db4be2e40fe + en: 4b520a2726f1a600 + zh: e0324fd12713be6e /deepseek-ai-dsh-client-ui-jobs/further-exploration: en: 01eaaecb5850f391 zh: 2eabd14cde191ab8 diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index 713488561d..740ecc1876 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -45,7 +45,7 @@ Expanding an observable row opens that job's output observation stream from `ctx
Implementation internals — click to expand -One slot entry in the header actions band (after the preset label) renders the trigger and popover; the popover portals to the document body, so the clipping header column and the right sidebar cannot cut it off, and is placed from its anchor and clamped inside the viewport. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close. +One slot entry in the header actions band (after the preset label) renders the trigger; its popover uses a body-mounted `MenuSurface` with fixed positioning so dock panels and clipping ancestors cannot cover it. The shared anchoring hook tracks scrolling, resizing, and panel-size changes. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close. | File | Role | |---|---| @@ -53,6 +53,8 @@ One slot entry in the header actions band (after the preset label) renders the t | [`src/client/index.ts`](src/client/index.ts) | Slot registration and the dictionaries | | [`src/client/locales.ts`](src/client/locales.ts) | The `job` namespace copy (zh source of truth) | +Background-job rows follow the [standard menu edge geometry](../../../docs/ui-radius.md#nested-hover); controls inside a filled running row retain compact corners. +
----- diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index b593a3773e..25b792f58c 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference"
实现内幕——点击展开 -头部操作带里的一个 slot 条目(preset 标签之后)渲染触发器与弹出层;弹出层挂载到 document body,因此不会被裁剪的头部列或右侧边栏截断,并按锚点定位、限制在视口内。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。 +头部操作带里的一个 slot 条目(preset 标签之后)渲染触发器;弹出层通过挂载到 body 的 `MenuSurface` 固定定位,避免被停靠面板遮挡或被祖先容器裁剪。共享锚点 hook 跟踪滚动、窗口尺寸及面板尺寸变化。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。 | 文件 | 角色 | |---|---| @@ -53,6 +53,8 @@ kind: "package-reference" | [`src/client/index.ts`](src/client/index.ts) | slot 注册与词典 | | [`src/client/locales.ts`](src/client/locales.ts) | `job` 命名空间文案(zh 为事实源) | +后台任务行遵循[标准菜单边缘几何](../../../docs/ui-radius.zh.md#nested-hover);带底色运行行内的控件保留紧凑圆角。 +
----- diff --git a/packages/client/ui-jobs/locale/en.json b/packages/client/ui-jobs/locale/en.json new file mode 100644 index 0000000000..7df9995cd8 --- /dev/null +++ b/packages/client/ui-jobs/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-jobs" + } +} diff --git a/packages/client/ui-jobs/locale/zh.json b/packages/client/ui-jobs/locale/zh.json new file mode 100644 index 0000000000..1f9b7ce1fd --- /dev/null +++ b/packages/client/ui-jobs/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "后台任务界面", + "description": "在会话中查看后台任务列表、状态和实时输出。" + } +} diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index 7cd9351e30..5eee006551 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-alpha.1", + "version": "0.2.1-alpha.2", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -15,7 +15,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "scripts": { "bundle": "tsdown", @@ -45,12 +46,11 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-jobs": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-jobs/src/client/JobListAction.module.css b/packages/client/ui-jobs/src/client/JobListAction.module.css index 549d34f63c..a2cee8bf77 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.module.css +++ b/packages/client/ui-jobs/src/client/JobListAction.module.css @@ -43,24 +43,27 @@ box-sizing: border-box; display: flex; flex-direction: column; - gap: 1px; width: 500px; max-width: min(560px, calc(100vw - 32px)); max-height: min(480px, calc(100vh - 140px)); - margin: 0; - padding: 3px; - overflow: auto; - list-style: none; - border: 0; - border-radius: var(--dsw-radius-lg); - background: var(--dsw-specific-menu); - backdrop-filter: var(--dsw-menu-backdrop-filter); + overflow: hidden; --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); } +.menuBody { + display: flex; + flex-direction: column; + gap: 1px; + min-height: 0; + margin: 0; + padding: 4px; + overflow: auto; + list-style: none; +} + /* Section labels over the live rows and the settled tail. */ .sectionHeader { display: flex; @@ -155,6 +158,10 @@ background: transparent; } +.rowLineLive .row { + border-radius: var(--dsw-radius-sm); +} + .rowLineLive .row:focus-visible { background: color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent); } @@ -229,7 +236,7 @@ min-height: 28px; padding: 4px 7px; border: 0; - border-radius: var(--dsw-radius-sm); + border-radius: var(--dsw-radius-md); background: transparent; color: var(--dsw-alias-label-primary); font-size: 12px; diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx index ed60e3ca5b..7027483b96 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -3,7 +3,8 @@ import { createPortal } from 'react-dom' import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - IconChevronDownOutlineRegular, IconStopFillRegular, StateDot, TerminalBlock, useAnchoredPosition, useDismissOnOutsidePointer, + IconChevronDownOutlineRegular, IconStopFillRegular, MenuSurface, StateDot, TerminalBlock, + useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState, type TerminalBlockLabels, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' @@ -349,7 +350,7 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, const [killPhase, setKillPhase] = useState<{ key: string; state: Exclude } | undefined>(undefined) const rootRef = useRef(null) const triggerRef = useRef(null) - const menuRef = useRef(null) + const menuRef = useRef(null) const rows = useMemo(() => ordered(jobs), [jobs]) const liveRows = useMemo(() => rows.filter(isLive), [rows]) @@ -537,36 +538,33 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, {open ? createPortal( -
    - {liveRows.length > 0 - ? - : null} - {liveRows.map(item)} - {settledRows.length > 0 - ? ( -
  • - - -
  • - ) - : null} - {settledExpanded ? settledRows.map(item) : null} -
, + +
    + {liveRows.length > 0 + ? + : null} + {liveRows.map(item)} + {settledRows.length > 0 + ? ( +
  • + + +
  • + ) + : null} + {settledExpanded ? settledRows.map(item) : null} +
+
, document.body, ) : null} diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index ab673c2274..e6866c4182 100644 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -10,6 +10,7 @@ import { zh } from '../src/client/locales.ts' afterEach(() => { cleanup() vi.restoreAllMocks() + vi.unstubAllGlobals() vi.useRealTimers() }) @@ -282,19 +283,86 @@ describe('JobListAction observation', () => { expect(writeText).toHaveBeenCalledWith('pnpm run build') }) - it('portals the open list below its trigger and keeps it open on pointer presses inside', () => { - vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({ left: 344, bottom: 40 } as DOMRect) + it('anchors the portaled list in viewport coordinates and follows resize and ancestor scroll', () => { + vi.stubGlobal('innerWidth', 700) + vi.stubGlobal('innerHeight', 600) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(440) + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(120) const { container } = render() - openList() + const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' }) + const anchor = vi.spyOn(trigger.parentElement!, 'getBoundingClientRect') + .mockReturnValue(new DOMRect(344, 40, 180, 28)) + fireEvent.click(trigger) + const list = screen.getByRole('list', { name: zh['list.aria'] }) + const menu = list.closest('[data-menu-material]') + expect(menu?.parentElement).toBe(document.body) + expect(container.contains(list)).toBe(false) + expect(menu?.style.left).toBe('248px') + expect(menu?.style.top).toBe('73px') + + vi.stubGlobal('innerWidth', 900) + fireEvent(window, new Event('resize')) + expect(menu?.style.left).toBe('344px') + anchor.mockReturnValue(new DOMRect(80, 110, 180, 28)) + fireEvent.scroll(container) + expect(menu?.style.left).toBe('80px') + expect(menu?.style.top).toBe('143px') + }) + + it('keeps a near-edge anchored list inside both viewport margins', () => { + vi.stubGlobal('innerWidth', 700) + vi.stubGlobal('innerHeight', 600) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(600) + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(120) + render() + const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' }) + vi.spyOn(trigger.parentElement!, 'getBoundingClientRect').mockReturnValue(new DOMRect(4, 540, 180, 28)) + fireEvent.click(trigger) const menu = screen.getByRole('list', { name: zh['list.aria'] }) - // Outside the header's clipping column, so the right sidebar cannot cover it. - expect(container.contains(menu)).toBe(false) - expect(menu.style.left).toBe('344px') - expect(menu.style.top).toBe('45px') - fireEvent.pointerDown(menu) - expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined() + .closest('[data-menu-material]') + expect(menu?.style.left).toBe('12px') + expect(menu?.style.top).toBe('468px') + }) + + it('keeps portaled controls interactive and releases observation on dismissal and unmount', () => { + const stop = vi.fn() + const observe = vi.fn(() => stop) + const { container, unmount } = render() + const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' }) + trigger.focus() + fireEvent.click(trigger) + const list = screen.getByRole('list', { name: zh['list.aria'] }) + expect(container.contains(list)).toBe(false) + const row = screen.getByRole('button', { + name: zh['row.expandAria'].replace('{label}', 'pnpm run build'), + }) + fireEvent.pointerDown(row) + fireEvent.click(row) + expect(screen.getByRole('list', { name: zh['list.aria'] })).toBe(list) + expect(observe).toHaveBeenCalledTimes(1) + expect(stop).not.toHaveBeenCalled() + const kill = screen.getByTitle(zh['kill.stop'].replace('{label}', 'pnpm run build')) + fireEvent.pointerDown(kill) + fireEvent.click(kill) + expect(kill.getAttribute('data-kill-state')).toBe('armed') + expect(screen.getByRole('list', { name: zh['list.aria'] })).toBe(list) + + row.focus() + fireEvent.keyDown(row, { key: 'Escape' }) + expect(screen.queryByRole('list', { name: zh['list.aria'] })).toBeNull() + expect(document.activeElement).toBe(trigger) + expect(stop).toHaveBeenCalledTimes(1) + fireEvent.click(trigger) + expect(observe).toHaveBeenCalledTimes(2) fireEvent.pointerDown(document.body) expect(screen.queryByRole('list', { name: zh['list.aria'] })).toBeNull() + expect(stop).toHaveBeenCalledTimes(2) + fireEvent.click(trigger) + expect(observe).toHaveBeenCalledTimes(3) + const reopened = screen.getByRole('list', { name: zh['list.aria'] }) + unmount() + expect(document.body.contains(reopened)).toBe(false) + expect(stop).toHaveBeenCalledTimes(3) }) it('ignores other keys while open and closes once the roster empties', () => { @@ -342,7 +410,7 @@ describe('JobListAction observation', () => { render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) - expect(document.body.querySelector('[data-running]')).not.toBeNull() + expect(screen.getByRole('list', { name: zh['list.aria'] }).querySelector('[data-running]')).not.toBeNull() expect(screen.queryByText(zh['terminal.noOutput'])).toBeNull() }) diff --git a/packages/client/ui-jobs/tsdown.config.ts b/packages/client/ui-jobs/tsdown.config.ts index 550a1fcf58..7aa03cebc1 100644 --- a/packages/client/ui-jobs/tsdown.config.ts +++ b/packages/client/ui-jobs/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-jobs', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-layout/locale/en.json b/packages/client/ui-layout/locale/en.json new file mode 100644 index 0000000000..4ade97b55d --- /dev/null +++ b/packages/client/ui-layout/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-layout" + } +} diff --git a/packages/client/ui-layout/locale/zh.json b/packages/client/ui-layout/locale/zh.json new file mode 100644 index 0000000000..2c3d490308 --- /dev/null +++ b/packages/client/ui-layout/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "应用布局", + "description": "组织导航、会话和侧边面板,并支持拖动调整布局。" + } +} diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 9c04a714ca..8eb5ba21d3 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -53,12 +54,12 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-client-ui-theme": "workspace:*", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "@deepseek-ai/dsh-client-shortcuts": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-layout/tests/apply.client.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts index e1b267dc4c..96aac84fd1 100644 --- a/packages/client/ui-layout/tests/apply.client.spec.ts +++ b/packages/client/ui-layout/tests/apply.client.spec.ts @@ -56,11 +56,8 @@ async function bench() { await owner.await() if (ctx === undefined) throw new Error('the fixture owner did not activate') const slotsFiber = ctx.plugin(SlotRegistry) - // Theme registers its Appearance settings row and requires the connection - // seam for persistence; model this bench as a remote, memory-only browser. ctx.provide('locale', new LocaleRuntime(ctx)) await ctx.plugin(ShortcutsService).await() - ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) // ui-theme's Appearance row binds a durable scope through these two. ctx.provide('remote', { $on: () => () => {} } as never) ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) }, get: () => stubConfigForm().scope } as never) diff --git a/packages/client/ui-layout/tsdown.config.ts b/packages/client/ui-layout/tsdown.config.ts index 27224c5803..7aa03cebc1 100644 --- a/packages/client/ui-layout/tsdown.config.ts +++ b/packages/client/ui-layout/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-layout', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-message-feedback/locale/en.json b/packages/client/ui-message-feedback/locale/en.json new file mode 100644 index 0000000000..b49bdc23e2 --- /dev/null +++ b/packages/client/ui-message-feedback/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-message-feedback" + } +} diff --git a/packages/client/ui-message-feedback/locale/zh.json b/packages/client/ui-message-feedback/locale/zh.json new file mode 100644 index 0000000000..b984fc9e3e --- /dev/null +++ b/packages/client/ui-message-feedback/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "消息反馈界面", + "description": "提供回复的赞踩按钮及会话反馈弹窗。" + } +} diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index df6cb6895e..e186780ee2 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -60,8 +61,6 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", "@deepseek-ai/dsh-client-ui-chat": "workspace:*", @@ -71,6 +70,7 @@ "@deepseek-ai/dsh-client-store": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-message-feedback/tsdown.config.ts b/packages/client/ui-message-feedback/tsdown.config.ts index 2137857d99..7aa03cebc1 100644 --- a/packages/client/ui-message-feedback/tsdown.config.ts +++ b/packages/client/ui-message-feedback/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-message-feedback', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 4d2f30c8c5..f9e88d76d0 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -9,14 +9,14 @@ en: 4cb6feb12e46f3a0 zh: eea39dd76fe1fa8b /deepseek-ai-dsh-client-ui-model-selection/summary: - en: 2fb361ef704528fb - zh: 2537d2afb4d2cb45 + en: 14a351e14b69ca33 + zh: 2f2686efe4832266 /deepseek-ai-dsh-client-ui-model-selection/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-model-selection/use-this-package: - en: 62235f8c8c9e1e7e - zh: c18dc9c38ef9d4f9 + en: ef50e68e7493d0a9 + zh: 3b1faea17b606173 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort: en: 48f8157ffdfe6e69 zh: 64ec9a533e2aa8f4 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index a073d9ec27..a32460cdb9 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -11,7 +11,7 @@ Desktop product events use the optional [product analytics service](../product-a ## Summary -The Web GUI lets users switch the model and reasoning effort for an existing session through either the `/model` popup or the composer's model control. Both surfaces present the same provider-grouped choices, and the selected model determines the available effort names and default. A complete selection applies to the next request; a running step keeps the model and effort it started with. If the selected model is unavailable, the composer stays disabled until the user selects an available model or that exact model becomes available again. +The Web GUI lets users switch the model and reasoning effort for an existing session through either the `/model` popup or the composer's model control. Both surfaces present the same provider-grouped choices, and the selected model determines the available effort names and default. A complete selection applies to the next request; a running step keeps the model and effort it started with. ## Table of Contents @@ -29,9 +29,9 @@ The Web GUI lets users switch the model and reasoning effort for an existing ses DeepSeek account and API-key routes appear as separate provider groups, each exposing the same configured model catalog. -The unselected model label uses the same regular weight as an available model name and retains the saved reasoning effort caption for existing and new sessions; effort editing requires an available model. Clicking the unselected trigger opens the model list directly; Escape closes it. +When no model is selected or the saved selection is absent from the catalog, the composer shows “Select model” in regular weight and hides the reasoning effort caption. Clicking it opens the model list directly; Escape and Shift+Tab close it. An empty catalog displays “No models available.” -Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus through root and effort rows, or move the model-list highlight without leaving search. Enter and Tab settle the highlighted model or focused row; Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling focuses model search when shown, otherwise the current model or effort row, and going back lands on the cell that opened the pane left. The composer shows the catalog name while the selected model is available, and its saved `provider/model` ID when the model or provider is removed, including account sign-out. The stored provider, model, and reasoning effort remain unchanged. +Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus through root and effort rows, or move the model-list highlight without leaving search. Enter and Tab settle the highlighted model or focused row; Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling focuses model search when shown, otherwise the current model or effort row, and going back lands on the cell that opened the pane left. The composer shows the catalog name while the selected model is available. Removing a model or provider, including account sign-out, preserves the stored provider, model, and reasoning effort without displaying their IDs. Restoring the catalog entry restores its name and effort caption. Mouse selection uses native browser clicks, including their cancellation behavior; a press alone never selects. Opening the root menu focuses its trigger, and clicking the trigger again closes the menu and returns focus there. Root rows and the search-clear button show the same fill for keyboard focus and hover, without a native outline. Selecting a model or effort returns focus to the trigger without a focus ring; leaving the trigger or reopening the menu restores its normal focus indication. While a selection from either entry is pending, focus stays on the trigger, the trigger shows a spinner in place of its chevron, and each row whose value the selection carries shows one in place of its check; a rejected selection leaves the menu open. Tab returns to model search with the current model highlighted when search is shown, otherwise to the current row. diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index bf719d839d..21b13ec2d5 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -11,7 +11,7 @@ kind: "package-reference" ## 概述 -Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有会话使用的模型与推理(reasoning)强度。两个界面呈现同一组按提供方分组的选择;所选模型决定可用的推理强度名称与默认值。完整选择从下一次请求开始生效;运行中的步骤保留其启动时的模型与推理强度。所选模型不可用时,composer 保持停用,直到用户选择可用模型或同一模型恢复可用。 +Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有会话使用的模型与推理(reasoning)强度。两个界面呈现同一组按提供方分组的选择;所选模型决定可用的推理强度名称与默认值。完整选择从下一次请求开始生效;运行中的步骤保留其启动时的模型与推理强度。 ## 目录 @@ -29,9 +29,9 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有 DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示相同的已配置模型目录。 -未选模型文案与可用模型名使用相同的常规字重,并在既有会话与新会话中保留已保存的推理强度显示;调整推理强度需要先选择可用模型。点击未选模型控件直接打开模型列表,Escape 关闭列表。 +未选择模型或已保存的选择不在目录中时,composer 以常规字重显示“请选择模型”,并隐藏推理强度文案。点击后直接打开模型列表,Escape 和 Shift+Tab 关闭列表。目录为空时显示“暂无可用模型” -与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在根菜单和推理等级行间移动焦点,或在模型列表中移动高亮并让焦点保留在搜索框。Enter 和 Tab 选定高亮模型或聚焦行;Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻时若显示模型搜索框则聚焦搜索框,否则聚焦当前模型或推理强度行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称;模型或提供方被删除时(包括账号退登),改为显示已保存的 `provider/model` ID。保存的提供方、模型和推理强度均保持不变。 +与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在根菜单和推理等级行间移动焦点,或在模型列表中移动高亮并让焦点保留在搜索框。Enter 和 Tab 选定高亮模型或聚焦行;Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻时若显示模型搜索框则聚焦搜索框,否则聚焦当前模型或推理强度行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称。模型或提供方被删除时(包括账号退登),保留已保存的提供方、模型和推理强度,但不展示其 ID。目录条目恢复后重新显示名称和推理强度。 鼠标选择沿用浏览器原生点击及其取消行为;仅按下按钮不会选定。打开根菜单时聚焦触发按钮,再次点击触发按钮会关闭菜单并把焦点还给它。根菜单行和搜索清除按钮的键盘焦点与悬停使用相同底色,不显示原生焦点轮廓。选定模型或推理等级后,焦点返回触发按钮但不显示焦点环;焦点离开按钮或重新打开菜单后恢复正常焦点提示。等待任一入口发起的选择结果时,焦点停在触发按钮,触发按钮以加载图标代替下拉箭头,该选择包含的值所在行以加载图标代替勾选标记;选择被拒绝后菜单保持打开。显示模型搜索框时,Tab 返回搜索框并高亮当前模型,否则返回当前选中行。 diff --git a/packages/client/ui-model-selection/locale/en.json b/packages/client/ui-model-selection/locale/en.json new file mode 100644 index 0000000000..5289394629 --- /dev/null +++ b/packages/client/ui-model-selection/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-model-selection" + } +} diff --git a/packages/client/ui-model-selection/locale/zh.json b/packages/client/ui-model-selection/locale/zh.json new file mode 100644 index 0000000000..66c2779ca6 --- /dev/null +++ b/packages/client/ui-model-selection/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "模型选择界面", + "description": "查看可用模型,并切换当前会话使用的模型。" + } +} diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index 90a4f1dbcb..0762afbd44 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -55,11 +56,9 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", - "@types/react-dom": "~18.3.0", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", @@ -69,6 +68,7 @@ "@deepseek-ai/dsh-client-product-analytics": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index fbeda5d31e..8d37dab5e3 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -119,11 +119,13 @@ export function ModelSelect( const currentChoice = choices[selectedIndex] const reasoning = currentChoice?.model.reasoning const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort - const effortLabel = reasoning === undefined - ? state.retainedEffort - : effectiveEffort === undefined - ? t('effort.providerDefault') - : reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort + const effortLabel = currentChoice === undefined + ? undefined + : reasoning === undefined + ? state.retainedEffort + : effectiveEffort === undefined + ? t('effort.providerDefault') + : reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort const effortChoices = useMemo(() => reasoning === undefined ? [] : [ @@ -244,8 +246,8 @@ export function ModelSelect( triggerRef.current?.focus() setQuery('') setHighlightedIndex(null) - if (state.current === null) paneFocus.current = 'drill' - setPane(state.current === null ? 'model' : 'root') + if (currentChoice === undefined) paneFocus.current = 'drill' + setPane(currentChoice === undefined ? 'model' : 'root') setOpen(true) reload() } @@ -297,7 +299,7 @@ export function ModelSelect( if (event.key === 'Escape' && open) { event.preventDefault() // Escape backs out of a drilled pane first, then closes. - if (pane !== 'root' && state.current !== null) back(pane) + if (pane !== 'root' && currentChoice !== undefined) back(pane) else close(true) return } @@ -325,7 +327,7 @@ export function ModelSelect( if (event.key === 'Tab') { if (event.shiftKey) { event.preventDefault() - if (pane !== 'root' && state.current !== null) back(pane) + if (pane !== 'root' && currentChoice !== undefined) back(pane) else close(true) return } @@ -411,15 +413,15 @@ export function ModelSelect( submit(selection) } - const waiting = state.current === null && state.status === 'loading' + const waiting = currentChoice === undefined && state.status === 'loading' const modelLabel = waiting ? t('trigger.loading') : currentChoice?.model.name - ?? (state.current === null ? t('trigger.fallback') : `${state.current.provider}/${state.current.model}`) + ?? t('trigger.fallback') const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}` const triggerAria = waiting ? t('trigger.loading') - : state.current === null + : currentChoice === undefined ? t('trigger.selectAria') : effortLabel === undefined ? t('trigger.aria', { model: modelLabel }) diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts index a522b55da1..63602c2780 100644 --- a/packages/client/ui-model-selection/src/client/locales.ts +++ b/packages/client/ui-model-selection/src/client/locales.ts @@ -31,7 +31,7 @@ export const zh = { 'search.placeholder': '搜索模型…', 'search.clear': '清除搜索', 'search.empty': '没有匹配的模型。', - 'empty.models': '没有可用的模型。', + 'empty.models': '暂无可用模型', 'empty.efforts': '当前模型未提供推理等级。', } satisfies Record diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index b27163fb16..389b9cfc28 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -238,7 +238,7 @@ describe('ui-model-selection dual entry', () => { expect(options[0]?.active).toBe(true) expect(options[1]?.active).toBeUndefined() expect(b.popup().searchLabels?.()).toEqual(locale === 'zh' - ? { placeholder: '搜索模型…', empty: '没有可用的模型。', noResults: '没有匹配的模型。' } + ? { placeholder: '搜索模型…', empty: '暂无可用模型', noResults: '没有匹配的模型。' } : { placeholder: 'Search models…', empty: 'No models available.', noResults: 'No matching models.' }) }) diff --git a/packages/client/ui-model-selection/tests/expected/unselected-model.txt b/packages/client/ui-model-selection/tests/expected/unselected-model.txt index 2e251b01d4..d6d4beb3b1 100644 --- a/packages/client/ui-model-selection/tests/expected/unselected-model.txt +++ b/packages/client/ui-model-selection/tests/expected/unselected-model.txt @@ -1 +1 @@ -请选择模型High +请选择模型 diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 470c852ec8..b077cc9093 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -143,7 +143,7 @@ describe('ModelSelect reasoning effort', () => { .toEqual(['Default', 'Standard']) }) - it('shows the durable model id when the catalog has no matching display name', () => { + it('opens model choices without exposing a saved route absent from the catalog', () => { const directory = createSnapshotStore(state({ current: { provider: 'deepseek-official', model: 'removed-model' }, })) @@ -157,17 +157,21 @@ describe('ModelSelect reasoning effort', () => { t={t} />) - const trigger = screen.getByRole('button', { name: '选择模型,当前 deepseek-official/removed-model' }) - expect(trigger.textContent).toContain('deepseek-official/removed-model') + const trigger = screen.getByRole('button', { name: '请选择模型' }) + expect(trigger.textContent).toBe('请选择模型') fireEvent.click(trigger) expect(screen.queryByRole('menuitem', { name: /推理等级/ })).toBeNull() - fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + expect(screen.queryByRole('menuitem', { name: /模型/ })).toBeNull() expect(screen.queryByRole('menuitemradio', { name: 'removed-model' })).toBeNull() expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy() expect(screen.queryByText('Fast catalog description')).toBeNull() + expect(select).not.toHaveBeenCalled() + expect(directory.getSnapshot().current).toEqual({ provider: 'deepseek-official', model: 'removed-model' }) + fireEvent.keyDown(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' }), { key: 'Escape' }) + expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() }) - it.each(['model', 'provider'])('keeps the saved id and effort when the selected %s disappears', (removed) => { + it.each(['model', 'provider'])('hides the saved id and effort when the selected %s disappears', (removed) => { const directory = createSnapshotStore(state({ retainedEffort: 'High' })) render() expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toContain('DeepSeek-V4-Flash') @@ -175,14 +179,18 @@ describe('ModelSelect reasoning effort', () => { snapshot.groups = removed === 'provider' ? [] : snapshot.groups.map(group => ({ ...group, models: [] })) snapshot.routable = false }) }) - expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent) - .toMatchInlineSnapshot('"deepseek-official/deepseek-v4-flashHigh"') + const trigger = screen.getByRole('button', { name: '请选择模型' }) + expect(trigger.textContent).toBe('请选择模型') expect(directory.getSnapshot().current).toEqual(state().current) + fireEvent.click(trigger) + expect(screen.getByRole('status').textContent).toBe(zh['empty.models']) + fireEvent.keyDown(trigger, { key: 'Tab', shiftKey: true }) + expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() }) - it('shows loading until the catalog and Session projection are both ready', async () => { + it.each([null, state().current])('shows loading until the catalog and Session projection are both ready (%j)', async (current) => { const directory = createSnapshotStore(state({ - current: null, + current, routable: null, groups: [], status: 'loading', @@ -494,7 +502,6 @@ describe('ModelSelect keyboard walk', () => { />) const trigger = screen.getByRole('button', { name: /选择模型/ }) fireEvent.click(trigger) - fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) expect(screen.queryByRole('searchbox')).toBeNull() expect(document.activeElement).toBe(trigger) @@ -506,12 +513,8 @@ describe('ModelSelect keyboard walk', () => { retry.focus() // A control that is not a row keeps the browser's traversal. expect(fireEvent.keyDown(retry, { key: 'Tab' })).toBe(true) - // Escape still backs out of the pane and then closes the card. fireEvent.keyDown(retry, { key: 'Escape' }) - // Back on the root pane, whose only cell remains (no model means no effort row). - const cell = screen.getAllByRole('menuitem')[0]! - fireEvent.keyDown(cell, { key: 'Escape' }) - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() }) it('focuses the checked model in a small catalog', () => { @@ -557,7 +560,6 @@ describe('ModelSelect keyboard walk', () => { t={t} />) fireEvent.click(screen.getByRole('button', { name: /选择模型/ })) - fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) const rows = screen.getAllByRole('menuitemradio') expect(rows.every(row => row.getAttribute('aria-checked') === 'false')).toBe(true) expect(screen.queryByRole('searchbox')).toBeNull() @@ -573,7 +575,7 @@ describe('ModelSelect catalog size', () => { load={vi.fn()} select={vi.fn()} t={t} />) const trigger = screen.getByRole('button', { name: /选择模型/ }) fireEvent.click(trigger) - fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) + if (count > 0) fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) const rows = screen.queryAllByRole('menuitemradio') expect(rows).toHaveLength(count) if (count > 4) { @@ -608,7 +610,7 @@ describe('ModelSelect catalog size', () => { const directory = createSnapshotStore(state({ groups: modelGroups(5), current })) render() fireEvent.click(screen.getByRole('button', { name: /选择模型/ })) - fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) + if (checked) fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) const search = screen.getByRole('searchbox') fireEvent.change(search, { target: { value: 'Model 5' } }) expect(screen.getAllByRole('menuitemradio')).toHaveLength(1) @@ -852,13 +854,13 @@ describe('ModelSelect search', () => { }) }) -it('shows the unselected model control with the inherited effort', async () => { +it('hides the inherited effort until a listed model is selected', async () => { const directory = createSnapshotStore(state({ current: null, routable: false, retainedEffort: 'High' })) render() const trigger = screen.getByRole('button', { name: '请选择模型' }) expect(trigger.hasAttribute('disabled')).toBe(false) await expect(`${trigger.textContent}\n`).toMatchFileSnapshot('./expected/unselected-model.txt') - expect(trigger.textContent).toContain('High') + expect(trigger.textContent).not.toContain('High') fireEvent.click(trigger) expect(screen.queryByRole('menuitem', { name: /模型/ })).toBeNull() expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy() @@ -911,8 +913,7 @@ it('restores the account model name after login without changing the saved route render() expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toBe('DeepSeek FlashHigh') act(() => { directory.update((snapshot) => { snapshot.groups = []; snapshot.routable = false }) }) - expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent) - .toMatchInlineSnapshot('"deepseek-account/deepseek-flashHigh"') + expect(screen.getByRole('button', { name: '请选择模型' }).textContent).toBe('请选择模型') act(() => { directory.update((snapshot) => { snapshot.groups = groups; snapshot.routable = true }) }) expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toBe('DeepSeek FlashHigh') expect(directory.getSnapshot().current).toEqual(selected) diff --git a/packages/client/ui-model-selection/tsdown.config.ts b/packages/client/ui-model-selection/tsdown.config.ts index eba18ff11c..7aa03cebc1 100644 --- a/packages/client/ui-model-selection/tsdown.config.ts +++ b/packages/client/ui-model-selection/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-model-selection', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-open-in-app/locale/en.json b/packages/client/ui-open-in-app/locale/en.json new file mode 100644 index 0000000000..7365851f6e --- /dev/null +++ b/packages/client/ui-open-in-app/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-open-in-app" + } +} diff --git a/packages/client/ui-open-in-app/locale/zh.json b/packages/client/ui-open-in-app/locale/zh.json new file mode 100644 index 0000000000..078d8a6924 --- /dev/null +++ b/packages/client/ui-open-in-app/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "外部应用打开界面", + "description": "在会话和文档预览中提供「打开方式」入口。" + } +} diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json index e1b15cdf72..2dee006328 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,9 +23,11 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" @@ -73,7 +75,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-host-open-in-app": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:*", "@deepseek-ai/dsh-native-command": "workspace:*", diff --git a/packages/client/ui-open-in-app/tsdown.config.ts b/packages/client/ui-open-in-app/tsdown.config.ts index 1153b561f6..7aa03cebc1 100644 --- a/packages/client/ui-open-in-app/tsdown.config.ts +++ b/packages/client/ui-open-in-app/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-open-in-app', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-permission-presets/README.i18n.yaml b/packages/client/ui-permission-presets/README.i18n.yaml index 3f24ef0979..f9ae7348ed 100644 --- a/packages/client/ui-permission-presets/README.i18n.yaml +++ b/packages/client/ui-permission-presets/README.i18n.yaml @@ -10,7 +10,7 @@ zh: 74b8ded4da556e81 /deepseek-ai-dsh-client-ui-permission-presets/summary: en: 2591adc4721c0564 - zh: 311292f58621adb6 + zh: 558413d3f819d3af /deepseek-ai-dsh-client-ui-permission-presets/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -18,14 +18,14 @@ en: 91606bff7da02253 zh: ef68e813d64c89be /deepseek-ai-dsh-client-ui-permission-presets/use-this-package/the-picker: - en: d851eab85fe002bd - zh: 0fdf6eeb1cbd609b + en: 4e5a070ad4321548 + zh: 010063336bd2b8df /deepseek-ai-dsh-client-ui-permission-presets/use-this-package/the-settings-row: en: 5067f6aa12496225 zh: cfa6796684f97535 /deepseek-ai-dsh-client-ui-permission-presets/understand-the-implementation: en: 4089d5bebf3e8bf0 - zh: bc5c3652b0612784 + zh: 22071e6cd6de0ffc /deepseek-ai-dsh-client-ui-permission-presets/further-exploration: en: 9bdbdea03d0e2a96 zh: cc3bf480a0706ef2 @@ -37,7 +37,7 @@ zh: 4fcbc876952d240d /deepseek-ai-dsh-client-ui-permission-presets/known-limitations-and-deferred-work: en: 08cdc0ad196d12c1 - zh: 1b65f5d30e219197 + zh: 157bee9d5b29f470 /deepseek-ai-dsh-client-ui-permission-presets/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-permission-presets/README.md b/packages/client/ui-permission-presets/README.md index 6798cc7f47..50d8b4918c 100644 --- a/packages/client/ui-permission-presets/README.md +++ b/packages/client/ui-permission-presets/README.md @@ -29,7 +29,7 @@ Mount this plugin alongside the settings, commands, and conversation packages. G ### The picker -A pick submits the `/permission ` command line. The argument-bearing form (`/permission ` typed directly) still switches directly; the decoration replaces only the bare invocation. The built-in labels are `Read Only`, `Workspace Write`, `Full access`, and `Auto review` in English and `仅可查看`, `工作区内修改`, `完全权限`, and `Auto review` in Chinese. Explicit host labels remain unchanged, unknown kebab-case names render in title case, and `auto` carries an `EXP` badge plus an experimental-risk confirmation. `custom` is display state, never a target. +A pick submits the `/permission ` command line. The argument-bearing form (`/permission ` typed directly) still switches directly; the decoration replaces only the bare invocation. The built-in labels are `Read Only`, `Workspace Write`, `Full access`, and `Auto review` in English and `仅可查看`, `工作区内修改`, `完全权限`, and `自动审查` in Chinese. Explicit host labels remain unchanged, unknown kebab-case names render in title case, and `auto` carries an `EXP` badge in menu options plus an experimental-risk confirmation. The selected composer control shows the mode without the badge. `custom` is display state, never a target. When the live catalog withdraws a preset, the composer closes its pending confirmation and shows the Session's current value instead of an unavailable optimistic pick. Every catalog invalidation — the payload-free notification or a connection-generation change — closes an open slash picker or its confirmation without consuming the draft, while publishing the result of a read the picker waits for leaves it open with its failure and retry state; reopening reads the current catalog. A submitted command remains busy until its response settles. diff --git a/packages/client/ui-permission-presets/README.zh.md b/packages/client/ui-permission-presets/README.zh.md index 1c603b5eb5..df37cea7ec 100644 --- a/packages/client/ui-permission-presets/README.zh.md +++ b/packages/client/ui-permission-presets/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -为当前 Web 会话或未来会话选择权限预设。通用设置行只更改之后创建会话所用的默认值;composer 与 `/permission` 选择器切换当前会话。默认 Web 提供仅可查看、工作区内修改与完全权限。显式加载实验 Auto integration 后,当前会话选择器会增加带 `EXP` 标记的 Auto review。通过可见选项选择完全权限或 Auto 时,需要分别确认对应风险;完整的 `/permission ` 命令直接执行。宿主通过 Session 投影确认每次变更。 +为当前 Web 会话或未来会话选择权限预设。通用设置行只更改之后创建会话所用的默认值;composer 与 `/permission` 选择器切换当前会话。默认 Web 提供仅可查看、工作区内修改与完全权限。显式加载实验 Auto integration 后,当前会话选择器会增加带 `EXP` 标记的自动审查。通过可见选项选择完全权限或 Auto 时,需要分别确认对应风险;完整的 `/permission ` 命令直接执行。宿主通过 Session 投影确认每次变更。 ## 目录 @@ -29,7 +29,7 @@ kind: "package-reference" ### 选择器 -选中即提交 `/permission ` 命令行。带参形式(直接键入 `/permission `)仍直接切换;装饰只替换裸调用。内置标签在英文界面中是 `Read Only`、`Workspace Write`、`Full access` 和 `Auto review`,在中文界面中是「仅可查看」「工作区内修改」「完全权限」和 `Auto review`。显式 host 标签保持原样,未知 kebab-case 名称渲染为 Title Case;`auto` 带有 `EXP` badge,并在可见选择时要求实验风险确认。`custom` 只是显示状态,绝非目标。 +选中即提交 `/permission ` 命令行。带参形式(直接键入 `/permission `)仍直接切换;装饰只替换裸调用。内置标签在英文界面中是 `Read Only`、`Workspace Write`、`Full access` 和 `Auto review`,在中文界面中是「仅可查看」「工作区内修改」「完全权限」和「自动审查」。显式 host 标签保持原样,未知 kebab-case 名称渲染为 Title Case;`auto` 在菜单选项中带有 `EXP` 标记,并在可见选择时要求实验风险确认。选中后的 composer 控件显示模式,不展示该标记。`custom` 只是显示状态,绝非目标。 实时目录撤销某个预设时,composer 关闭对应的待确认对话框,并用 Session 的当前值替代已不可用的乐观选择。每次目录失效(无 payload 的通知或连接代际变更)会关闭已打开的 slash 选择器或其确认对话框,不消费草稿;而发布某个 picker 正在等待的读取结果时,该 picker 保持打开并保留失败与重试状态;重新打开时读取当前目录。已经提交的命令在响应结束前继续保持忙碌状态。 @@ -45,7 +45,7 @@ kind: "package-reference"
实现细节——点击展开 -General Settings 行经 `ctx.configForms` 读取显式暴露的 `permission` Settings 描述符,并携带描述符 revision 写入一条 `settings.mutate` 路径操作;其 observable 经 slot 系统的 `hooks` compartment 传递,因此 React 钩子绑定归渲染器,推送失效通知会重新获取描述符。该值只在之后创建会话时读取。当前会话界面是挂在宿主 `/permission` 命令上的 popupSelect 装饰(`ctx.commandUi.decorate`):宿主命令保留斜杠菜单行、带参形式与持久生命周期记账,装饰只把裸调用替换为选择器。一个进程级目录会在首次 Remote 读取前订阅无 payload 的目录通知,并且只发布当前连接代际中最新的完整成功结果。胜出的读取失败或连接 reset 会清空旧快照:composer seat 因此隐藏,直到后续既有触发重试,而 slash 选择器保持可用并在弹窗内显示失败与重试;旧连接代际与 dispose 后才返回的结果会被忽略。它的公共 observable 是 `{ value }` 与 `invalidations`(每次目录通知或连接代际变更打一个点);失败仅供命令式加载内部使用。slash popup 与 composer seat 共用这份目录,而 Session `permissions` 投影只提供 `currentValue`。Full access 与 Auto review 各自携带本地化确认文案;Auto 还携带由共享 popup 外壳渲染的 badge。 +General Settings 行经 `ctx.configForms` 读取显式暴露的 `permission` Settings 描述符,并携带描述符 revision 写入一条 `settings.mutate` 路径操作;其 observable 经 slot 系统的 `hooks` compartment 传递,因此 React 钩子绑定归渲染器,推送失效通知会重新获取描述符。该值只在之后创建会话时读取。当前会话界面是挂在宿主 `/permission` 命令上的 popupSelect 装饰(`ctx.commandUi.decorate`):宿主命令保留斜杠菜单行、带参形式与持久生命周期记账,装饰只把裸调用替换为选择器。一个进程级目录会在首次 Remote 读取前订阅无 payload 的目录通知,并且只发布当前连接代际中最新的完整成功结果。胜出的读取失败或连接 reset 会清空旧快照:composer seat 因此隐藏,直到后续既有触发重试,而 slash 选择器保持可用并在弹窗内显示失败与重试;旧连接代际与 dispose 后才返回的结果会被忽略。它的公共 observable 是 `{ value }` 与 `invalidations`(每次目录通知或连接代际变更打一个点);失败仅供命令式加载内部使用。slash popup 与 composer seat 共用这份目录,而 Session `permissions` 投影只提供 `currentValue`。完全权限与自动审查各自携带本地化确认文案;Auto 还携带由共享 popup 外壳渲染的 badge。
@@ -80,7 +80,7 @@ General Settings 行经 `ctx.configForms` 读取显式暴露的 `permission` Set 这些限制界定了当前权限界面。它们是当前包约束,不是通用策略对比或任务积压。 - **设置行仅限 Web**——非 Web 客户端仍可经 `/permission` 切换当前会话,但不会获得这项浏览器贡献。 -- **Auto review 仅限当前会话**——General Settings 行有意省略它,且只有通过可见选择器选择时才显示实验确认;显式键入 `/permission auto` 已经构成明确同意。 +- **自动审查仅限当前会话**——General Settings 行有意省略它,且只有通过可见选择器选择时才显示实验确认;显式键入 `/permission auto` 已经构成明确同意。 - **预设描述来自宿主**——本地化的内置标签旁边可能显示另一种语言编写的描述。 diff --git a/packages/client/ui-permission-presets/locale/en.json b/packages/client/ui-permission-presets/locale/en.json new file mode 100644 index 0000000000..1b460cc8ed --- /dev/null +++ b/packages/client/ui-permission-presets/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-permission-presets" + } +} diff --git a/packages/client/ui-permission-presets/locale/zh.json b/packages/client/ui-permission-presets/locale/zh.json new file mode 100644 index 0000000000..4ea78d980f --- /dev/null +++ b/packages/client/ui-permission-presets/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "权限模式界面", + "description": "设置新会话的默认权限,并切换当前会话的权限模式。" + } +} diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 48e196a2eb..7f037eab8d 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -63,13 +64,13 @@ "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-permission-presets": "workspace:*", - "@types/react": "~18.3.1", "clsx": "^2.0.0", "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-session": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css b/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css index b40748e22e..a3add30afb 100644 --- a/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css +++ b/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css @@ -52,7 +52,7 @@ .optionLabel { display: inline-flex; - align-items: baseline; + align-items: center; gap: 4px; min-width: 0; max-width: 100%; @@ -67,12 +67,10 @@ .badge { flex: none; - align-self: flex-start; - margin-top: -1px; color: var(--dsw-alias-label-tertiary); font-size: 8px; line-height: 10px; - font-weight: 600; + font-weight: 500; letter-spacing: 0.2px; } @@ -85,8 +83,8 @@ transition: transform 120ms ease; } -/* Narrow composer: the trigger collapses to icon + chevron so the row keeps - fitting. Only triggers that actually carry a mode glyph drop their label — +/* Narrow composer: mode text collapses while icons stay visible. + Only triggers that actually carry a mode glyph drop their label — a host-configured mode without one keeps its text as the sole identifier. The 460px cut is the point where the row (attach + modes + model + send) starts squeezing labels; the container is the composer row (InputBar .row — diff --git a/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx b/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx index f34e2b16e9..0281c41a24 100644 --- a/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx +++ b/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import type { ReactNode } from 'react' import clsx from 'clsx' import { - IconChevronDownOutlineRegular, Menu, PermissionIconFullAccessRegular, + IconChevronDownOutlineRegular, Menu, PermissionIconAutoReviewRegular, PermissionIconFullAccessRegular, PermissionIconReadOnlyRegular, PermissionIconWorkspaceWriteRegular, RiskConfirmation, } from '@deepseek-ai/dsh-client-ui-primitives' import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' @@ -29,6 +29,7 @@ const permissionGlyphs = new Map([ ['read-only', ], ['workspace-write', ], [FULL_ACCESS, ], + [AUTO_REVIEW, ], ]) /** Glyph for a permission option value; host-configured names outside the design set get none. */ @@ -112,7 +113,7 @@ export function PermissionSelect({ : ( {label} - {badge} + {badge} ), ...icon === undefined ? {} : { icon }, @@ -159,8 +160,6 @@ export function PermissionSelect({ const confirmationEnable = confirmation === AUTO_REVIEW ? t('auto.confirm.enable') : t('confirm.enable') - const currentBadge = optionBadge(currentValue, t) - const currentAccessibleLabel = currentBadge === undefined ? currentLabel : `${currentLabel} ${currentBadge}` return ( <> @@ -176,7 +175,7 @@ export function PermissionSelect({ diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 6f32ecab90..a15899be3c 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -14,8 +14,8 @@ import { pointerModality } from './input-modality.ts' export type TooltipSide = 'right' | 'bottom' | 'top' /** - * Suppression channel for enclosing tooltip and hover-card anchors: a visible - * tooltip within an anchor withdraws the enclosing preview while its bubble is shown. + * Suppression channel for nested tooltips: a visible descendant tooltip + * withdraws the enclosing tooltip while its bubble is shown. */ export const TooltipSuppression = createContext<((suppressed: boolean) => void) | null>(null) diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 7fe8783d6a..18f9544405 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -33,6 +33,7 @@ export { useAnchoredPosition } from './useAnchoredPosition.ts' export type { AnchoredPositionOptions } from './useAnchoredPosition.ts' export { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts' export { HoverCard } from './HoverCard.tsx' +export { HoverCardGroup } from './HoverCardGroup.tsx' export { ShortcutKeys } from './ShortcutKeys.tsx' export { observeComposition } from './keyboard-composition.ts' export { focusWithoutRing } from './focus.ts' @@ -46,6 +47,7 @@ export { FishLogo, FISH_LOGO_PATH, FISH_LOGO_VIEWBOX } from './FishLogo.tsx' export { BrandWordmark } from './BrandWordmark.tsx' export type { BrandWordmarkProps } from './BrandWordmark.tsx' export { + PermissionIconAutoReviewMedium, PermissionIconAutoReviewRegular, PermissionIconFullAccessMedium, PermissionIconFullAccessRegular, PermissionIconReadOnlyMedium, PermissionIconReadOnlyRegular, PermissionIconWorkspaceWriteMedium, PermissionIconWorkspaceWriteRegular, @@ -58,7 +60,7 @@ export { FileTypeIcon, classifyFileType, fileExtension } from './FileTypeIcon.ts export type { CodeFileType, FileType, FileTypeIconProps, FileTypeKind, FileTypeProjectContext, } from './FileTypeIcon.tsx' -export { projectUserText, type UserTextReferences } from './user-text.tsx' +export { UserTextRun, ReferenceChip, type UserTextRunProps, type ReferenceChipProps } from './user-text.tsx' export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' export { pointerModality } from './input-modality.ts' diff --git a/packages/client/ui-primitives/src/user-text.tsx b/packages/client/ui-primitives/src/user-text.tsx index 01289a4334..20d29f3b84 100644 --- a/packages/client/ui-primitives/src/user-text.tsx +++ b/packages/client/ui-primitives/src/user-text.tsx @@ -1,156 +1,56 @@ -/** - * Display projection of reference forms in sent user text (bubble and queue - * rows). The logged model text remains the single truth; this is presentation - * only. Inline references follow the consumer's wrapping policy and keep long - * labels within its width. Four decoration sources, by precedence: the wire session form - * `@[label](dsh-session:...)` folds to its label; exact session labels - * supplied by an adjacent recall decorate their bare `@label` mention; plain - * `@name` word-boundary tokens decorate by shape alone; and a plain `/name` - * token decorates only when the caller names it — a skill the host actually - * loaded for that message (ui-chat reads the step's `skill-invocation` - * injections) or the command a command-input bubble echoes — so `/123` or a - * stray `/word` stays plain text. A `/name` token is whitespace-bounded like - * the host skill gesture (`dsh-tool-skill`): it ends at whitespace or the - * text end, so slash paths (`/nfs-hg/xxx`, `/plan.md`) and punctuation-glued - * tokens (`/plan。`) stay plain even for a loaded name. - */ +/** Inline text and reference presentation; business rules supply resolved JSON data and actions. */ import type { ReactNode } from 'react' import clsx from 'clsx' -import { ReferenceIconRegular } from './ReferenceIcon.tsx' +import { ReferenceIconRegular, type ReferenceIconKind } from './ReferenceIcon.tsx' import css from './user-text.module.css' import markdownCss from './markdown/MarkdownText.module.css' -/** The wire form a session chip serializes to; label is the display text. */ -const SESSION_WIRE_RE = /@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/gu - -/** Sentence punctuation a bare `@name` token may carry without being part of the reference. */ -const TRAILING_PUNCTUATION_RE = /[.,;:!?,。;:!?]+$/u - -interface DecorationRange { - readonly start: number - readonly end: number - /** Matched source text (hover title). */ - readonly label: string - readonly kind: 'session' | 'plain' - /** Pre-resolved display text (wire folds); derived from label when absent. */ - readonly display?: string -} - -/** Optional navigation supplied by consumers that can preview references. */ -export interface UserTextReferences { - /** Open a file path decoded from an `@` mention. */ - openFile: (path: string) => void - /** Open the source of a skill loaded for this message. */ - openSkill: (name: string) => void +/** Plain text follows the wrapping and typography of its host. */ +export interface UserTextRunProps { + readonly text: string } /** - * Split one sent text into inline plain runs and reference chips. - * @param text - the logged model text of the message or queue row. - * @param sessionLabels - exact session mention labels associated by an adjacent recall. - * @param slashNames - names a `/name` token may decorate as: the skills the - * host loaded for this message, or the command a command bubble echoes - * (unsent queue rows pass none). - * @param slashKind - the chip kind those tokens render as. - * @param references - optional file and skill preview actions; session and command tokens stay labels. - * @returns inline nodes covering the whole text. + * Render an uninterpreted inline text run. + * @param props - text retained verbatim from the caller's document. + * @returns one inline run without Markdown or reference recognition. */ -export function projectUserText( - text: string, - sessionLabels: readonly string[], - slashNames: readonly string[] = [], - slashKind: 'skill' | 'command' = 'skill', - references?: UserTextReferences, -): ReactNode { - const ranges: DecorationRange[] = [] - SESSION_WIRE_RE.lastIndex = 0 - let wire: RegExpExecArray | null - while ((wire = SESSION_WIRE_RE.exec(text)) !== null) { - ranges.push({ - start: wire.index, - end: wire.index + wire[0].length, - label: wire[0], - kind: 'session', - display: wire[1] as string, // non-optional capture in SESSION_WIRE_RE - }) - } - for (const rawLabel of [...new Set(sessionLabels)].sort((a, b) => b.length - a.length)) { - const label = `@${rawLabel}` - let start = text.indexOf(label) - while (start >= 0) { - ranges.push({ start, end: start + label.length, label, kind: 'session' }) - start = text.indexOf(label, start + label.length) - } - } - // A `/` token ends at whitespace or the text end like the host skill - // gesture; only `@` tokens shed sentence punctuation below. - const re = /(^|\s)(\/[\w-]+(?=\s|$)|@"[^"\n]+"|@[^\s]+)/gu - let m: RegExpExecArray | null - while ((m = re.exec(text)) !== null) { - const tokenStart = m.index + (m[1] as string).length // (^|\s) captures '' at line start - const rawLabel = m[2] as string // non-optional alternation capture - const label = rawLabel.startsWith('@"') - ? rawLabel - : rawLabel.replace(TRAILING_PUNCTUATION_RE, '') - if (label.length <= 1) continue - if (label.startsWith('/') && !slashNames.includes(label.slice(1))) continue - ranges.push({ start: tokenStart, end: tokenStart + label.length, label, kind: 'plain' }) - } - const rankOf = (range: DecorationRange): number => range.kind === 'session' ? 0 : 1 - ranges.sort((a, b) => a.start - b.start || rankOf(a) - rankOf(b) || b.end - a.end) - const parts: ReactNode[] = [] - let cursor = 0 - const pushPlain = (from: number, to: number): void => { - parts.push({text.slice(from, to)}) - } - for (const range of ranges) { - if (range.start < cursor) continue - const { start: tokenStart, end, label, kind } = range - if (tokenStart > cursor) pushPlain(cursor, tokenStart) - const referenceKind = kind === 'session' - ? 'session' - : label.startsWith('@') - ? label.replace(/^@"|"$/gu, '').endsWith('/') ? 'folder' : 'file' - : undefined - const displayLabel = range.display - ?? (referenceKind === undefined - ? label - : referenceKind === 'session' - ? label.slice(1) - : label.slice(1).replace(/^"|"$/gu, '').split(/[\\/]/u).filter(Boolean).at(-1) ?? label.slice(1)) - const contents = <> - {referenceKind !== undefined && ( - - )} - {displayLabel} - - const open = references === undefined ? undefined - : referenceKind === 'file' - ? () => { references.openFile(label.slice(1).replace(/^"|"$/gu, '')) } - : referenceKind === undefined && slashKind === 'skill' - ? () => { references.openSkill(label.slice(1)) } - : undefined - const className = clsx(css.refChip, referenceKind === undefined && css.slashChip) - parts.push(open === undefined - ? - {contents} - - : ) - cursor = end - } - if (parts.length === 0) return {text} - if (cursor < text.length) pushPlain(cursor, text.length) - return <>{parts} +export function UserTextRun({ text }: UserTextRunProps): ReactNode { + return {text} +} + +/** Presentation selected by a reference renderer, independent of its matching grammar. */ +export interface ReferenceChipProps { + readonly appearance: ReferenceIconKind | 'skill' | 'command' + readonly label: string + readonly title: string + /** Absent for display-only references and previews. */ + readonly onActivate?: () => void +} + +/** + * Render a reference label or its explicitly supplied activation action. + * Text selection and repeated pointer clicks do not activate the reference. + * @param props - resolved visual fields and optional action from the owning renderer. + * @returns an inline label, or a keyboard-accessible button when activation is supplied. + */ +export function ReferenceChip({ appearance, label, title, onActivate }: ReferenceChipProps): ReactNode { + const slash = appearance === 'skill' || appearance === 'command' + const contents = <> + {!slash && } + {label} + + const className = clsx(css.refChip, slash && css.slashChip) + return onActivate === undefined + ? {contents} + : } diff --git a/packages/client/ui-primitives/tests/hover-card-group.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card-group.client.spec.tsx new file mode 100644 index 0000000000..da4b665096 --- /dev/null +++ b/packages/client/ui-primitives/tests/hover-card-group.client.spec.tsx @@ -0,0 +1,330 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { HoverCard } from '../src/HoverCard.tsx' +import { HoverCardGroup } from '../src/HoverCardGroup.tsx' +import { Tooltip } from '../src/Tooltip.tsx' + +beforeEach(() => { vi.useFakeTimers() }) +afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks() }) + +// jsdom lacks PointerEvent; MouseEvent preserves relatedTarget for physical and React ancestry checks. +function move(from: Element, to: Element): void { + fireEvent(from, new MouseEvent('pointerout', { bubbles: true, relatedTarget: to })) + fireEvent(to, new MouseEvent('pointerover', { bubbles: true, relatedTarget: from })) + fireEvent(from, new MouseEvent('mouseout', { bubbles: true, relatedTarget: to })) + fireEvent(to, new MouseEvent('mouseover', { bubbles: true, relatedTarget: from })) +} +function tick(ms: number): void { act(() => { vi.advanceTimersByTime(ms) }) } +function Card({ name, grouped = true, disabled = false }: { name: string; grouped?: boolean; disabled?: boolean }) { + return {name}} + content={details {name}} copyText={name} copyLabel="Copy" copiedLabel="Copied" /> +} + +describe('HoverCardGroup', () => { + it('does not warm the next pointer visit when keyboard focus displays an inline card outside a pointer visit', () => { + const view = render(<> + + + A} + content={details A} /> + + + ) + const outside = screen.getByRole('button', { name: 'outside' }) + const a = screen.getByRole('button', { name: 'A' }) + act(() => { outside.focus() }) + fireEvent.keyDown(outside, { key: 'Tab' }) + // jsdom does not move focus for Tab, so apply its native focus transition without a pointer event. + act(() => { a.focus() }) + expect(document.activeElement).toBe(a) + expect(screen.getByText('details A')).toBeTruthy() + move(document.body, screen.getByText('B')) + expect(screen.queryByText('details B')).toBeNull() + tick(799) + expect(screen.queryByText('details B')).toBeNull() + tick(1) + expect(screen.getByText('details B')).toBeTruthy() + expect(screen.queryByText('details A')).toBeNull() + view.unmount() + expect(vi.getTimerCount()).toBe(0) + }) + + it('waits for the first actual display, then switches instantly within one physical list', () => { + render(gap) + const a = screen.getByText('A') + const b = screen.getByText('B') + const gap = screen.getByText('gap') + move(document.body, a) + tick(799) + expect(screen.queryByText('details A')).toBeNull() + tick(1) + expect(screen.getByText('details A')).toBeTruthy() + move(a, b) + expect(screen.queryByText('details A')).toBeNull() + expect(screen.getByText('details B')).toBeTruthy() + // Repositioning the old anchor must not let it claim ownership again. + fireEvent(window, new Event('resize')) + tick(300) + expect(screen.queryByText('details A')).toBeNull() + expect(screen.getByText('details B')).toBeTruthy() + move(b, gap) + tick(300) + move(gap, a) + expect(screen.getByText('details A')).toBeTruthy() + move(a, document.body) + tick(300) + move(document.body, b) + expect(screen.queryByText('details B')).toBeNull() + tick(799) + expect(screen.queryByText('details B')).toBeNull() + tick(1) + expect(screen.getByText('details B')).toBeTruthy() + }) + + it.each(['pointer exit', 'disabled'] as const)('replaces a preview fading after %s before displaying the next card', (reason) => { + const markup = (disabled = false) => + A} content={details A} /> + gap + + + const view = render(markup()) + const a = screen.getByText('A') + const gap = screen.getByText('gap') + move(document.body, a) + tick(800) + expect(screen.getByText('details A')).toBeTruthy() + move(a, gap) + // Pointer grace expires after 200ms; disabling closes immediately. Both retain the 100ms fade. + if (reason === 'disabled') view.rerender(markup(true)) + else tick(200) + expect(screen.getByText('details A')).toBeTruthy() + move(gap, screen.getByText('B')) + expect(screen.getByText('details B')).toBeTruthy() + expect(screen.queryByText('details A')).toBeNull() + tick(100) + expect(screen.getByText('details B')).toBeTruthy() + }) + + it('keeps cancelled openings cold and shares displayed cards independently of action tooltips', () => { + render() + const a = screen.getByText('A') + const b = screen.getByText('B') + move(document.body, a) + tick(400) + move(a, b) + tick(799) + expect(screen.queryByText('details A')).toBeNull() + expect(screen.queryByText('details B')).toBeNull() + tick(1) + const card = screen.getByText('details B') + const action = screen.getByRole('button', { name: 'action B' }) + move(b, action) + tick(500) + expect(screen.getByRole('tooltip').textContent).toBe('Archive B') + expect(screen.getByText('details B')).toBe(card) + move(action, a) + expect(screen.queryByRole('tooltip')).toBeNull() + expect(screen.getByText('details A')).toBeTruthy() + expect(screen.queryByText('details B')).toBeNull() + }) + + it('shares warmth through readable, copyable portals until the pointer leaves both surfaces', async () => { + const previous = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + const writeText = vi.fn(async () => {}) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + try { + render() + const a = screen.getByText('A') + const b = screen.getByText('B') + move(document.body, a) + tick(800) + const portal = screen.getByRole('button', { name: 'Copy: A' }) + move(a, portal) + tick(500) + expect(portal.isConnected).toBe(true) + await act(async () => { fireEvent.click(portal); await Promise.resolve() }) + expect(writeText).toHaveBeenCalledWith('A') + expect(screen.getByRole('status').textContent).toBe('Copied') + move(portal, b) + expect(screen.getByText('details B')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Copy: A' })).toBeNull() + const secondPortal = screen.getByRole('button', { name: 'Copy: B' }) + move(b, secondPortal) + move(secondPortal, document.body) + tick(300) + expect(screen.queryByText('details B')).toBeNull() + move(document.body, a) + tick(799) + expect(screen.queryByText('details A')).toBeNull() + tick(1) + expect(screen.getByText('details A')).toBeTruthy() + } finally { + if (previous === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', previous) + } + }) + + it('bridges the list-to-card gaps with the active card existing pointer grace', () => { + render() + const a = screen.getByText('A') + const b = screen.getByText('B') + move(document.body, a) + tick(800) + const portal = screen.getByRole('button', { name: 'Copy: A' }) + move(a, document.body) + tick(100) + move(document.body, portal) + tick(500) + expect(portal.isConnected).toBe(true) + move(portal, document.body) + tick(100) + move(document.body, b) + expect(screen.getByText('details B')).toBeTruthy() + expect(screen.queryByText('details A')).toBeNull() + move(b, document.body) + tick(300) + move(document.body, a) + tick(799) + expect(screen.queryByText('details A')).toBeNull() + tick(1) + expect(screen.getByText('details A')).toBeTruthy() + }) + + it('warms a keyboard-open card only when the pointer visits that card', () => { + render( + A} + content={details A} /> + + ) + fireEvent.keyDown(document, { key: 'Tab' }) + act(() => { screen.getByRole('button', { name: 'A' }).focus() }) + const portal = screen.getByText('details A') + move(document.body, portal) + move(portal, screen.getByText('B')) + expect(screen.getByText('details B')).toBeTruthy() + expect(screen.queryByText('details A')).toBeNull() + }) + + it('rejects stale clipboard feedback after a group handoff and return', async () => { + const previous = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + let finishWrite: (() => void) | undefined + const writeText = vi.fn(() => new Promise((resolve) => { finishWrite = resolve })) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + try { + render() + const a = screen.getByText('A') + const b = screen.getByText('B') + move(document.body, a) + tick(800) + fireEvent.keyDown(screen.getByRole('button', { name: 'Copy: A' }), { key: 'Enter' }) + expect(writeText).toHaveBeenCalledWith('A') + move(a, b) + expect(screen.getByText('details B')).toBeTruthy() + move(b, a) + expect(screen.getByText('details A')).toBeTruthy() + await act(async () => { finishWrite?.(); await Promise.resolve() }) + expect(screen.queryByText('Copied')).toBeNull() + expect(screen.getByText('details A')).toBeTruthy() + } finally { + finishWrite?.() + if (previous === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', previous) + } + }) + + it('keeps separate groups cold and does not enroll ungrouped cards', () => { + render(<> + ) + const a = screen.getByText('A') + const project = screen.getByText('Project') + const b = screen.getByText('B') + move(document.body, a) + tick(800) + move(a, project) + expect(screen.queryByText('details Project')).toBeNull() + tick(800) + expect(screen.getByText('details Project')).toBeTruthy() + move(project, b) + expect(screen.queryByText('details B')).toBeNull() + tick(800) + expect(screen.getByText('details B')).toBeTruthy() + }) + + it('does not warm a group through a portal belonging to an ungrouped card', () => { + render() + const project = screen.getByText('Project') + const b = screen.getByText('B') + move(document.body, project) + tick(800) + const portal = screen.getByRole('button', { name: 'Copy: Project' }) + move(project, portal) + tick(500) + expect(portal.isConnected).toBe(true) + move(portal, b) + expect(screen.queryByText('details B')).toBeNull() + tick(799) + expect(screen.queryByText('details B')).toBeNull() + tick(1) + expect(screen.getByText('details B')).toBeTruthy() + }) + + it('cools when the active portal is removed from under the pointer', () => { + const markup = (showA: boolean) => + {showA && } + + const view = render(markup(true)) + const a = screen.getByText('A') + move(document.body, a) + tick(800) + const portal = screen.getByRole('button', { name: 'Copy: A' }) + move(a, portal) + view.rerender(markup(false)) + move(portal, screen.getByText('B')) + expect(screen.queryByText('details B')).toBeNull() + tick(800) + expect(screen.getByText('details B')).toBeTruthy() + }) + + it('cools when keyboard focus replaces the portal under the pointer', () => { + render( + A} content={details A} /> + B} content={details B} /> + + ) + const a = screen.getByRole('button', { name: 'A' }) + move(document.body, a) + tick(500) + const portal = screen.getByText('details A') + move(a, portal) + fireEvent.keyDown(document, { key: 'Tab' }) + act(() => { screen.getByRole('button', { name: 'B' }).focus() }) + expect(screen.getByText('details B')).toBeTruthy() + move(portal, screen.getByText('C')) + expect(screen.queryByText('details C')).toBeNull() + tick(800) + expect(screen.getByText('details C')).toBeTruthy() + }) + + it('releases an unmounted active card without cooling its still-mounted list', () => { + const markup = (showA: boolean, groupKey = 'first') => + {showA && } + + const view = render(markup(true)) + const a = screen.getByText('A') + move(document.body, a) + tick(800) + view.rerender(markup(false)) + move(a, screen.getByText('B')) + expect(screen.getByText('details B')).toBeTruthy() + view.rerender(markup(false, 'new')) + move(document.body, screen.getByText('B')) + expect(screen.queryByText('details B')).toBeNull() + tick(800) + expect(screen.getByText('details B')).toBeTruthy() + view.unmount() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 322cfcb526..e1f76fcd4b 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -54,7 +54,7 @@ function installClipboard(writeText: (text: string) => Promise): () => voi } describe('HoverCard', () => { - it.each(['disabled', 'unmounted'] as const)('restores its preview after a nested tooltip is %s', (released) => { + it.each(['disabled', 'unmounted'] as const)('keeps its preview mounted while a nested tooltip shows and is then %s', (released) => { const markup = (state: 'active' | 'disabled' | 'unmounted') => ( row{state !== 'unmounted' && ( @@ -70,11 +70,12 @@ describe('HoverCard', () => { act(() => { vi.advanceTimersByTime(500) }) fireEvent.mouseEnter(screen.getByRole('button', { name: 'action' })) expect(screen.getByRole('tooltip').textContent).toBe('Create') - expect(screen.queryByText('card body')).toBeNull() + const card = screen.getByText('card body') + expect(card.isConnected).toBe(true) view.rerender(markup(released)) expect(screen.queryByRole('tooltip')).toBeNull() - expect(screen.getByText('card body')).toBeTruthy() + expect(screen.getByText('card body')).toBe(card) }) it('keeps card content mounted while its own tooltip is visible', () => { diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index 5c59ff3593..afd9eaea28 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -82,6 +82,7 @@ describe('product icon set', () => { [primitives.PermissionIconReadOnlyRegular, primitives.PermissionIconReadOnlyMedium], [primitives.PermissionIconWorkspaceWriteRegular, primitives.PermissionIconWorkspaceWriteMedium], [primitives.PermissionIconFullAccessRegular, primitives.PermissionIconFullAccessMedium], + [primitives.PermissionIconAutoReviewRegular, primitives.PermissionIconAutoReviewMedium], ] as const for (const [Regular, Medium] of pairs) { const regular = render() diff --git a/packages/client/ui-primitives/tests/terminal-block-styles.client.spec.ts b/packages/client/ui-primitives/tests/terminal-block-styles.client.spec.ts new file mode 100644 index 0000000000..0a809a7fa4 --- /dev/null +++ b/packages/client/ui-primitives/tests/terminal-block-styles.client.spec.ts @@ -0,0 +1,17 @@ +/** Terminal status positioning relative to the command's first visual line. */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' + +const css = readFileSync(fileURLToPath(new URL('../src/TerminalBlock.module.css', import.meta.url)), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, ' ') + +it('centers the status dot on one command line rather than the wrapped prompt height', () => { + const state = /\.runState\s*\{([^{}]*)\}/.exec(css)?.[1] + const prompt = /\.promptLine\s*\{([^{}]*)\}/.exec(css)?.[1] + expect(state).toBeDefined() + expect(prompt).toBeDefined() + expect(state).toContain('top: calc(var(--dsl-terminal-line-height) / 2)') + expect(state).toContain('transform: translateY(-50%)') + expect(prompt).toContain('line-height: var(--dsl-terminal-line-height)') +}) diff --git a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx index 432a20fde2..b801dd6e91 100644 --- a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx @@ -89,6 +89,42 @@ describe('TerminalBlock prompt label', () => { }) describe('TerminalBlock states', () => { + it.each([ + { output: undefined, running: false, exitCode: undefined }, + { output: 'launch acknowledgement', running: false, exitCode: 7, signal: 'SIGTERM' }, + { output: 'launch acknowledgement', running: false, exitCode: 0 }, + { output: '', running: false, exitCode: 0 }, + ])('background suppresses execution presentation for %j', (execution) => { + const view = render() + expect(promptRows(view.container)).toEqual(['appworker --watch', '$printf ready']) + expect(runStateOf(view.container)).toEqual({ state: null, label: undefined }) + expect(outputLines(view.container)).toEqual([]) + expect(view.queryByText('launch acknowledgement')).toBeNull() + expect(view.queryByText('无输出')).toBeNull() + expect(view.queryByText('信号 SIGTERM')).toBeNull() + expect(view.queryByRole('button')).toBeNull() + expect(view.container.firstElementChild?.hasAttribute('data-running')).toBe(false) + expect(view.container.firstElementChild?.hasAttribute('data-body')).toBe(false) + }) + + it('keeps command and prompt DOM when a background launch returns', () => { + const view = render() + const command = view.container.querySelector('[data-command-text]') + expect(runStateOf(view.container).state).toBe('ongoing') + view.rerender() + expect(view.container.querySelector('[data-command-text]')).toBe(command) + expect(runStateOf(view.container)).toEqual({ state: null, label: undefined }) + expect(view.queryByText('无输出')).toBeNull() + expect(view.queryByRole('button')).toBeNull() + }) + + it('keeps an explicit copyText override available in background mode', () => { + const view = render() + expect(view.getByRole('button', { name: '复制' })).toBeTruthy() + expect(view.queryByText('无输出')).toBeNull() + expect(runStateOf(view.container)).toEqual({ state: null, label: undefined }) + }) + it('running with no output shows the command line only: no output box, no placeholder, no copy', () => { const view = render() expect(view.getByText('sleep 5')).toBeTruthy() diff --git a/packages/client/ui-primitives/tsdown.config.ts b/packages/client/ui-primitives/tsdown.config.ts index 7f6262ae9f..a6264e0b21 100644 --- a/packages/client/ui-primitives/tsdown.config.ts +++ b/packages/client/ui-primitives/tsdown.config.ts @@ -1,6 +1,6 @@ import { staticLinked } from '../tsdown.client.ts' export default staticLinked( - '@deepseek-ai/dsh-client-ui-primitives', + import.meta.dirname, ['lib/types/index.js'], ) diff --git a/packages/client/ui-reference/README.i18n.yaml b/packages/client/ui-reference/README.i18n.yaml index e96368df0e..a6706a5349 100644 --- a/packages/client/ui-reference/README.i18n.yaml +++ b/packages/client/ui-reference/README.i18n.yaml @@ -30,8 +30,8 @@ en: 6c77da1e946f72e1 zh: b7b8e1e1d4f5a102 /deepseek-ai-dsh-client-ui-reference/understand-the-implementation/serialization: - en: dce929ccd4f0e349 - zh: e68bc0f79e184057 + en: c3fe8963ed80e403 + zh: ba3e057cd63e9189 /deepseek-ai-dsh-client-ui-reference/further-exploration: en: 157b1bcf84b5c11b zh: 6b7e4f3326b19036 diff --git a/packages/client/ui-reference/README.md b/packages/client/ui-reference/README.md index b39bf52141..50a711758b 100644 --- a/packages/client/ui-reference/README.md +++ b/packages/client/ui-reference/README.md @@ -59,6 +59,8 @@ Candidate requests require an existing retained Client Session and share one tem File picks preserve the natural text defined by the shared `@path` grammar as the hidden serialized and clipboard form. Session picks use the canonical `@[label](dsh-session:…)` mention; serialization never reconstructs identity from the visible title. +The plugin also registers file and folder matchers with [UserText](../ui-conversation/README.md#sent-user-text) and owns their `conversation.user.text.node` renderers. Each query returns the next complete reference at an original-text offset without file discovery; the shared evaluator resolves overlaps by source position. Interactive file nodes receive a Session-bound `openFile` injection; navigation failures keep the reference visible and offer a local retry/close dialog. Folder nodes remain labels. + ----- diff --git a/packages/client/ui-reference/README.zh.md b/packages/client/ui-reference/README.zh.md index b291958195..0ccf954688 100644 --- a/packages/client/ui-reference/README.zh.md +++ b/packages/client/ui-reference/README.zh.md @@ -59,6 +59,8 @@ Web 用户需要从同一个 `@` 补全菜单提及文件、文件夹或会话 文件选择把共享 `@path` 语法所定义的自然文本保留为隐藏的序列化与剪贴板形式。会话选择使用规范的 `@[label](dsh-session:…)` mention;序列化永远不会根据可见标题重建身份。 +插件还向 [UserText](../ui-conversation/README.zh.md#sent-user-text) 注册文件和目录匹配器,并拥有它们的 `conversation.user.text.node` renderer。每次查询返回原文偏移之后的下一个完整引用,不执行文件发现;共享计算器按原文位置裁定重叠。可交互的文件节点通过 Injection 接收绑定 Session 的 `openFile`;导航失败时保留引用,提供局部重试/关闭对话框。目录节点仍为标签。 + ----- diff --git a/packages/client/ui-reference/locale/en.json b/packages/client/ui-reference/locale/en.json new file mode 100644 index 0000000000..983a68eb3c --- /dev/null +++ b/packages/client/ui-reference/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-reference" + } +} diff --git a/packages/client/ui-reference/locale/zh.json b/packages/client/ui-reference/locale/zh.json new file mode 100644 index 0000000000..c42cc948b1 --- /dev/null +++ b/packages/client/ui-reference/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "文件与会话引用界面", + "description": "通过 @ 在输入框中引用文件或其他会话。" + } +} diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index cee0b99508..10b52588aa 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -32,6 +33,8 @@ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-input-trigger", "@deepseek-ai/dsh-client-ui-sidebar-right" ], @@ -52,8 +55,11 @@ "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-file-reference": "workspace:*", "@deepseek-ai/dsh-session-reference": "workspace:*", @@ -63,6 +69,7 @@ "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-reference/src/client/FileReference.tsx b/packages/client/ui-reference/src/client/FileReference.tsx new file mode 100644 index 0000000000..7e0e4ffa52 --- /dev/null +++ b/packages/client/ui-reference/src/client/FileReference.tsx @@ -0,0 +1,62 @@ +/** File navigation feedback belongs to the individual inline reference that requested it. */ +import { useState } from 'react' +import type { UserTextNodeProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import { Button, Modal, ReferenceChip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { NS } from './locales.ts' +import type {} from './user-text-rules.ts' + +/** Session-authorized synchronous navigation; document reading belongs to the preview host. */ +export interface FileReferenceInjected { + /** + * Open or reveal a file in the current Sidebar using the originating Session's authority. + * @param path - decoded path from the reference. + * @throws if navigation fails; the reference keeps a local retry/close dialog. + */ + readonly openFile: (path: string) => void +} + +type FileReferenceProps = UserTextNodeProps<'file'> & InjectFace & PropsLocale + +/** + * Render a file chip and contain synchronous navigation failures without hiding the reference. + * @param props - JSON node, interaction policy, Session-authorized navigation, and locale seat. + * @returns an inline chip and, after a navigation failure, its body-portaled dialog. + */ +export function FileReference({ node, raw, interactive, openFile, t }: FileReferenceProps) { + const [failedPath, setFailedPath] = useState(null) + const { path, label } = node.data + const close = () => { setFailedPath(null) } + const open = () => { + try { + openFile(path) + } catch { + setFailedPath(path) + return + } + setFailedPath(null) + } + return <> + + {interactive && failedPath === path && + + + } + />} + +} + +/** + * Render a directory as a label, even when the containing text permits file navigation. + * @param props - resolved directory data and the original reference spelling. + * @returns an inline non-interactive chip. + */ +export function FolderReference({ node, raw }: UserTextNodeProps<'folder'>) { + return +} diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index 6204478f75..6bc52b59d7 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -18,9 +18,14 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' import { relativeTime } from '@deepseek-ai/dsh-client-ui-primitives' +import type { UserTextNodeProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { FileReference, FolderReference, type FileReferenceInjected } from './FileReference.tsx' +import { fileReferenceRule, folderReferenceRule } from './user-text-rules.ts' +export type {} from './user-text-rules.ts' import type { ClientSessionContext, InputTriggerCrumb, InputTriggerServiceContract, InputTriggerSource, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' @@ -40,17 +45,31 @@ declare module '@deepseek-ai/dsh-api-session-controller/client' { /** Required services: the trigger registry, the Remote namespaces, and the copy. */ export const inject = [ 'inputTriggers', 'locale', 'sessions', 'remote', 'remote.fileReferences', - 'remote.sessionReferenceResolver', 'sidebarRight', + 'remote.sessionReferenceResolver', 'sidebarRight', 'slots', 'uiConversation', ] /** - * Register the combined `@file` / `@session` source. + * Register the combined `@file` / `@session` source and independent file/folder text providers. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-reference: dictionaries') const t = ctx.locale.bind(NS) const sessions = ctx.get('sessions') as ISessions + ctx.uiConversation.userText.register(fileReferenceRule) + ctx.uiConversation.userText.register(folderReferenceRule) + ctx.slots.inject('conversation.user.text.node', function* () { + yield ctx.slots.register({ + name: 'conversation.user.text.node', key: 'file', priority: 100, locale: NS, + inject: (sessionId: UserTextNodeProps<'file'>['sessionId']): FileReferenceInjected => ({ + openFile: (path) => { + const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd + ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path)) + }, + }), + }, FileReference) + yield ctx.slots.register({ name: 'conversation.user.text.node', key: 'folder', priority: 100 }, FolderReference) + }) const source: InputTriggerSource = { trigger: '@', name: 'reference', diff --git a/packages/client/ui-reference/src/client/locales.ts b/packages/client/ui-reference/src/client/locales.ts index 7d185550cc..2d48030d5e 100644 --- a/packages/client/ui-reference/src/client/locales.ts +++ b/packages/client/ui-reference/src/client/locales.ts @@ -1,4 +1,4 @@ -/** `reference` namespace dictionaries for the unified `@` source. */ +/** `reference` dictionaries for the unified `@` source and inline file navigation. */ import type {} from '@deepseek-ai/dsh-client-ui-slots' @@ -18,6 +18,10 @@ export const zh = { 'section.sessions': '对话', 'candidate.noCwd': '(无工作目录)', 'crumb.root': '工作区', + 'fileOpen.title': '无法打开文件', + 'fileOpen.failed': '无法打开“{path}”,请重试或关闭此提示', + 'fileOpen.close': '关闭', + 'fileOpen.retry': '重试', 'time.now': '刚刚', 'time.minutes': '{n}分钟', 'time.hours': '{n}小时', @@ -31,7 +35,7 @@ export type ReferenceKey = keyof typeof zh declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** The unified `@` reference menu's copy. */ + /** Reference menu and per-file navigation feedback. */ reference: ReferenceKey } } @@ -43,6 +47,10 @@ export const en = { 'section.sessions': 'Sessions', 'candidate.noCwd': '(no cwd)', 'crumb.root': 'Workspace', + 'fileOpen.title': 'Couldn’t open file', + 'fileOpen.failed': 'Couldn’t open “{path}”. Try again or close this message.', + 'fileOpen.close': 'Close', + 'fileOpen.retry': 'Retry', 'time.now': 'now', 'time.minutes': '{n}min', 'time.hours': '{n}h', diff --git a/packages/client/ui-reference/src/client/user-text-rules.ts b/packages/client/ui-reference/src/client/user-text-rules.ts new file mode 100644 index 0000000000..b227660c86 --- /dev/null +++ b/packages/client/ui-reference/src/client/user-text-rules.ts @@ -0,0 +1,49 @@ +/** Forward file and folder recognition in original text, without Facts or discovery. */ +import type { UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client' + +interface PathReferenceData { + readonly path: string + readonly label: string +} + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface UserTextDataMap { + file: PathReferenceData + folder: PathReferenceData + } +} + +function nextPath(text: string, from: number, folder: boolean) { + const tokens = /(? = { + id: 'file', + order: 30, + match(text, from) { + const candidate = nextPath(text, from, false) + return candidate === undefined ? undefined : { id: 'file', ...candidate } + }, +} + +/** Return the nearest trailing-slash directory using the file-reference grammar. */ +export const folderReferenceRule: UserTextRule<'folder'> = { + id: 'folder', + order: 30, + match(text, from) { + const candidate = nextPath(text, from, true) + return candidate === undefined ? undefined : { id: 'folder', ...candidate } + }, +} diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index b0cb5ebeef..1ebd8d0f72 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -6,6 +6,10 @@ import { Context, Service } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/assembly.ts' +import { FileReference, FolderReference, type FileReferenceInjected } from '../src/client/FileReference.tsx' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { RemoteError, TestSessions } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionFixture } from '@deepseek-ai/dsh-client-test-runtime' @@ -56,6 +60,19 @@ function request( } } +function TextSlotDeclaration(_props: PropsRenderSlots<'conversation.user.text.node'>): null { + return null +} + +async function installTextSlots(ctx: Context, sessions: TestSessions): Promise { + new UiConversation(ctx, sessions) + await ctx.plugin(SlotRegistry).await() + ctx.slots.register({ + name: 'root', + children: { 'conversation.user.text.node': { kind: 'keyed', scope: 'session' } }, + }, TextSlotDeclaration) +} + async function bench( files: RemoteLookup = vi.fn(() => Promise.resolve({ ok: true as const, @@ -114,6 +131,7 @@ async function bench( for (const [id, summary] of Object.entries(listed)) { if (id !== session.sessionId) await sessionStore.add({ id, summary }) } + await installTextSlots(ctx, sessionStore) const owner = sessionStore.retainFor(ctx, session.sessionId) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -125,11 +143,12 @@ describe('apply', () => { it('declares its services and releases the @ reference registration on disposal', async () => { expect(inject).toEqual([ 'inputTriggers', 'locale', 'sessions', 'remote', 'remote.fileReferences', - 'remote.sessionReferenceResolver', 'sidebarRight', + 'remote.sessionReferenceResolver', 'sidebarRight', 'slots', 'uiConversation', ]) const { fiber } = await bench() let registered: InputTriggerSource | undefined const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) ctx.provide('sidebarRight', { openResource: vi.fn() }) ctx.provide('inputTriggers', { registerSource(source: InputTriggerSource) { @@ -148,12 +167,23 @@ describe('apply', () => { ctx.provide('remote.fileReferences', { list: () => Promise.resolve({ ok: true, value: [] }) }) ctx.provide('remote.sessionReferenceResolver', { candidates: () => Promise.resolve({ ok: true, value: [] }) }) ctx.provide('locale', new LocaleRuntime(ctx)) - ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: {} }) } }) + const sessions = new TestSessions(async (action) => { await action() }, ctx) + onTestFinished(() => sessions.disposeScopes()) + ctx.provide('sessions', sessions) + await installTextSlots(ctx, sessions) const ownFiber = ctx.plugin({ inject: [...inject], apply }) await ownFiber.await() expect(registered).toMatchObject({ trigger: '@', name: 'reference', showGroupTitle: false }) + expect(ctx.uiConversation.userText.entries().map(rule => rule.id)).toEqual(['file', 'folder']) + expect(ctx.slots.entries('conversation.user.text.node')).toMatchObject([ + { options: { key: 'file' }, component: FileReference }, + { options: { key: 'folder' }, component: FolderReference }, + ]) + expect(ctx.slots.entries('conversation.user.text.node')[0]?.inject).toBeTypeOf('function') await ownFiber.dispose() expect(registered).toBeUndefined() + expect(ctx.uiConversation.userText.entries()).toHaveLength(0) + expect(ctx.slots.entries('conversation.user.text.node')).toHaveLength(0) await fiber.dispose() }) @@ -648,6 +678,19 @@ describe('pick and codec', () => { }) describe('reference preview', () => { + it('binds each inline file action to its originating Session', async () => { + const { ctx } = await bench() + const entry = ctx.slots.entries('conversation.user.text.node').find(candidate => candidate.options.key === 'file')! + const factory = entry.inject as NonNullable & ((id: SessionId) => FileReferenceInjected) + const openResource = vi.spyOn(ctx.sidebarRight, 'openResource') + factory(session.sessionId).openFile('docs/a b.md') + factory(sid('other')).openFile('notes.md') + expect(openResource.mock.calls).toEqual([ + ['dsh-resource://file/session/target/docs/a%20b.md'], + ['dsh-resource://file/session/other/notes.md'], + ]) + }) + it('opens plain and quoted file references without treating folders or sessions as files', async () => { const { ctx, source, fiber } = await bench() const openResource = vi.spyOn(ctx.sidebarRight, 'openResource') diff --git a/packages/client/ui-reference/tests/file-reference.client.spec.tsx b/packages/client/ui-reference/tests/file-reference.client.spec.tsx new file mode 100644 index 0000000000..b3e6eba6f3 --- /dev/null +++ b/packages/client/ui-reference/tests/file-reference.client.spec.tsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render } from '@testing-library/react' +import type { ComponentProps } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { FileReference, FolderReference } from '../src/client/FileReference.tsx' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +type Props = ComponentProps + +function props(openFile: Props['openFile']): Props { + return { + node: { id: 'file', start: 0, end: 14, data: { path: 'docs/a b.md', label: 'a b.md' } }, + raw: '@"docs/a b.md"', interactive: true, openFile, t: makeTranslate(en), + } as Props +} + +describe('FileReference', () => { + it('opens the decoded path and leaves summaries and folders non-interactive', () => { + const openFile = vi.fn() + const input = props(openFile) + const view = render() + fireEvent.click(view.getByRole('button', { name: 'a b.md' })) + expect(openFile).toHaveBeenCalledWith('docs/a b.md') + expect(view.queryByRole('dialog')).toBeNull() + view.rerender() + expect(view.getByText('a b.md').getAttribute('title')).toBe('@"docs/a b.md"') + expect(view.queryByRole('button')).toBeNull() + view.rerender() + expect(view.getByText('docs').getAttribute('title')).toBe('@docs/') + expect(view.queryByRole('button')).toBeNull() + }) + + it('keeps a navigation failure local and retries or closes without losing the reference', () => { + const openFile = vi.fn() + .mockImplementationOnce(() => { throw new Error('unavailable') }) + .mockImplementationOnce(() => { throw new Error('still unavailable') }) + .mockImplementationOnce(() => {}) + .mockImplementationOnce(() => { throw new Error('unavailable again') }) + const view = render() + fireEvent.click(view.getByRole('button', { name: 'a b.md' })) + expect(view.getByRole('dialog', { name: 'Couldn’t open file' }).textContent).toContain('Couldn’t open “docs/a b.md”') + fireEvent.click(view.getByRole('button', { name: 'Retry' })) + expect(view.getByRole('dialog')).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: 'Retry' })) + expect(view.queryByRole('dialog')).toBeNull() + expect(openFile.mock.calls).toEqual([['docs/a b.md'], ['docs/a b.md'], ['docs/a b.md']]) + fireEvent.click(view.getByRole('button', { name: 'a b.md' })) + fireEvent.click(view.getAllByRole('button', { name: 'Close' }).at(-1)!) + expect(view.queryByRole('dialog')).toBeNull() + expect(view.getByRole('button', { name: 'a b.md' })).toBeTruthy() + }) +}) diff --git a/packages/client/ui-reference/tests/user-text-rules.client.spec.ts b/packages/client/ui-reference/tests/user-text-rules.client.spec.ts new file mode 100644 index 0000000000..49c0af04ab --- /dev/null +++ b/packages/client/ui-reference/tests/user-text-rules.client.spec.ts @@ -0,0 +1,76 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { UserTextRegistry } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/user-text-registry.ts' +import { fileReferenceRule, folderReferenceRule } from '../src/client/user-text-rules.ts' + +function setup(rules: readonly UserTextRule[] = [folderReferenceRule, fileReferenceRule]) { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + const registry = new UserTextRegistry(ctx) + for (const rule of rules) registry.register(rule) + return registry.createEvaluator() +} + +describe('fileReferenceRule and folderReferenceRule', () => { + it('reads no facts and reuses the entire result across fact updates', () => { + const read = vi.fn() + const file = vi.fn['match']>((text, from, context) => fileReferenceRule.match(text, from, { + read: (key) => { read(key); return context.read(key) }, + })) + const folder = vi.fn['match']>((text, from, context) => folderReferenceRule.match(text, from, { + read: (key) => { read(key); return context.read(key) }, + })) + const evaluate = setup([{ ...fileReferenceRule, match: file }, { ...folderReferenceRule, match: folder }]) + const text = '@src/a.ts @docs/' + const before = evaluate({ text, facts: {} }) + expect(before.filter(node => node.id !== 'text').map(node => node.id)).toEqual(['file', 'folder']) + expect(evaluate({ text, facts: { sessionLabels: ['new label'] } })).toBe(before) + expect(read).not.toHaveBeenCalled() + expect(file.mock.calls.map(([, from]) => from)).toEqual([0, text.indexOf(' ')]) + expect(folder.mock.calls.map(([, from]) => from)).toEqual([0, text.length]) + }) + + it('separates ordinary files from directories and decodes quoted paths', () => { + const evaluate = setup() + const text = '@src/deep/file.txt 与 @dir/ 与 @"a b.md" 与 @"dir a/" 与 @C:\\src\\a.ts' + const input = Object.freeze({ text, facts: Object.freeze({}) }) + const nodes = evaluate(input) + expect(nodes.filter(node => node.id === 'file').map(node => ({ raw: text.slice(node.start, node.end), data: node.data }))).toEqual([ + { raw: '@src/deep/file.txt', data: { path: 'src/deep/file.txt', label: 'file.txt' } }, + { raw: '@"a b.md"', data: { path: 'a b.md', label: 'a b.md' } }, + { raw: '@C:\\src\\a.ts', data: { path: 'C:\\src\\a.ts', label: 'a.ts' } }, + ]) + expect(nodes.filter(node => node.id === 'folder').map(node => ({ raw: text.slice(node.start, node.end), data: node.data }))).toEqual([ + { raw: '@dir/', data: { path: 'dir/', label: 'dir' } }, + { raw: '@"dir a/"', data: { path: 'dir a/', label: 'dir a' } }, + ]) + expect(JSON.parse(JSON.stringify(nodes))).toEqual(nodes) + }) + + it('leaves trailing sentence punctuation outside ranges without stripping quoted filename punctuation', () => { + const evaluate = setup() + const text = '@a.ts,。;:!? @b.md.,;:!? @"a!.md", @dir/。' + const nodes = evaluate({ text, facts: {} }) + expect(nodes.filter(node => node.id === 'file').map(node => ({ raw: text.slice(node.start, node.end), data: node.data }))) + .toEqual([ + { raw: '@a.ts', data: { path: 'a.ts', label: 'a.ts' } }, + { raw: '@b.md', data: { path: 'b.md', label: 'b.md' } }, + { raw: '@"a!.md"', data: { path: 'a!.md', label: 'a!.md' } }, + ]) + expect(nodes.filter(node => node.id === 'folder').map(node => text.slice(node.start, node.end))).toEqual(['@dir/']) + }) + + it('keeps root-directory display fallback and requires a token boundary', () => { + const evaluate = setup() + const text = '@"/" @/ mail@host prefix@a.ts @。 @' + const nodes = evaluate({ text, facts: {} }) + expect(nodes.filter(node => node.id === 'folder').map(node => node.data)).toEqual([ + { path: '/', label: '"/"' }, { path: '/', label: '/' }, + ]) + expect(nodes.filter(node => node.id === 'file')).toEqual([]) + expect(evaluate({ text: '@name', facts: {} })).toEqual([ + { id: 'file', start: 0, end: 5, data: { path: 'name', label: 'name' } }, + ]) + }) +}) diff --git a/packages/client/ui-reference/tsconfig.json b/packages/client/ui-reference/tsconfig.json index 88de84671c..5c126676ae 100644 --- a/packages/client/ui-reference/tsconfig.json +++ b/packages/client/ui-reference/tsconfig.json @@ -41,6 +41,12 @@ { "path": "../ui-primitives" }, + { + "path": "../ui-conversation/tsconfig.client.json" + }, + { + "path": "../ui-renderer" + }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-reference/tsdown.config.ts b/packages/client/ui-reference/tsdown.config.ts index f306d4db44..7aa03cebc1 100644 --- a/packages/client/ui-reference/tsdown.config.ts +++ b/packages/client/ui-reference/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-reference', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-renderer/locale/en.json b/packages/client/ui-renderer/locale/en.json new file mode 100644 index 0000000000..9b397064bb --- /dev/null +++ b/packages/client/ui-renderer/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-renderer" + } +} diff --git a/packages/client/ui-renderer/locale/zh.json b/packages/client/ui-renderer/locale/zh.json new file mode 100644 index 0000000000..8616d95117 --- /dev/null +++ b/packages/client/ui-renderer/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "界面渲染", + "description": "将各插件提供的界面组件组装到应用中。" + } +} diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 347920cc81..12ce753757 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -43,14 +44,13 @@ "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "@types/use-sync-external-store": "^1.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", "use-sync-external-store": "1.6.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-renderer/src/client/scoped-slots.tsx b/packages/client/ui-renderer/src/client/scoped-slots.tsx index 24bb7f6bd3..89ab67fb19 100644 --- a/packages/client/ui-renderer/src/client/scoped-slots.tsx +++ b/packages/client/ui-renderer/src/client/scoped-slots.tsx @@ -10,7 +10,7 @@ import { SlotOwnershipError, StaleAuthorizationError, standardHookPropName, type ChainRenderOpts, type HostObservable, type KeyedStandardSource, type LocaleFace, type RenderFactorySlot, type RenderOpts, type ScopedStandardSourceBinding, type SessionAreaProps, type SessionProviderComponent, type SlotRenderer, - type SlotRendererHost, type SlotScope, type SlotScopeAdapter, type StandardSourceBinding, + type SlotEntryDef, type SlotRendererHost, type SlotScope, type SlotScopeAdapter, type SlotSpec, type StandardSourceBinding, type StoredEntry, type StoredFactory, type Translate, } from '@deepseek-ai/dsh-client-ui-slots' import { @@ -21,6 +21,7 @@ import { import { SlotAssemblyError } from './errors.ts' type InjectedProps = Record +type SlotAnchorTag = 'div' | 'span' type SlotHookFactory = (standard: InjectedProps, hookContext: unknown) => unknown type SlotHookFactories = Readonly> @@ -79,7 +80,7 @@ function boundRenderSlot(host: SlotRendererHost, entry: StoredEntry): RenderSlot if (declared.kind === 'chain') { throw new SlotOwnershipError(`slot '${key}' is declared 'chain' — use renderSlotChain`) } - return + return } renderSlotCache.set(entry, binding) } @@ -108,7 +109,7 @@ function boundRenderSlotChain(host: SlotRendererHost, entry: StoredEntry): Rende if (declared.kind !== 'chain') { throw new SlotOwnershipError(`slot '${key}' is declared '${declared.kind}', not 'chain' — use renderSlot`) } - return + return } renderSlotChainCache.set(entry, binding) } @@ -128,7 +129,7 @@ function boundFactoryRenderSlot(host: SlotRendererHost, definition: StoredFactor const declared = definition.children?.[key] if (declared === undefined) throw new SlotOwnershipError(`slot '${key}' is not declared by this Factory`) if (declared.kind === 'chain') throw new SlotOwnershipError(`slot '${key}' is declared 'chain' — use renderSlotChain`) - return + return } factoryRenderSlotCache.set(definition, binding) return binding @@ -146,7 +147,7 @@ function boundFactoryRenderSlotChain(host: SlotRendererHost, definition: StoredF if (declared.kind !== 'chain') { throw new SlotOwnershipError(`slot '${key}' is declared '${declared.kind}', not 'chain' — use renderSlot`) } - return + return } factoryRenderSlotChainCache.set(definition, binding) return binding @@ -402,7 +403,7 @@ function sessionGenerationKeyOf(binding: ScopedStandardSourceBinding): number { * the outlet then owns the crash face). */ class SlotErrorBoundary extends Component< - { slotKey: string; onEntryError: (error: unknown) => void; children: ReactNode }, { failed: boolean } + { slotKey: string; anchor?: SlotAnchorTag; onEntryError: (error: unknown) => void; children: ReactNode }, { failed: boolean } > { override state = { failed: false } static getDerivedStateFromError(error: unknown): { failed: boolean } { @@ -414,7 +415,8 @@ class SlotErrorBoundary extends Component< this.props.onEntryError(error) } override render(): ReactNode { - if (this.state.failed) return
+ const Anchor = this.props.anchor ?? 'div' + if (this.state.failed) return return this.props.children } } @@ -1024,8 +1026,9 @@ function FactoryOutlet({ name, inputProps, slots: selected = EMPTY_FACTORY_SELEC ) } -function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookContext, hasHookContext, onEntryError }: { +function StrictSessionEntry({ slotKey, anchor, entry, ownerProps, slotInjected, hookContext, hasHookContext, onEntryError }: { slotKey: string + anchor: SlotAnchorTag entry: StoredEntry ownerProps: object slotInjected: BoundSlotInject @@ -1041,7 +1044,7 @@ function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookCont // Per-session remount rides this key; per-entry remount rides the outer // element's entry-identity key (the outlet's guarded() call). return ( - + - {renderOutletContent(host, slotKey, ownerProps, opts, scopeBinding)} -
+ + {spec === undefined ? null : renderOutletContent(host, slotKey, ownerProps, opts, scopeBinding, spec)} + ) } @@ -1098,14 +1106,11 @@ function renderOutletContent( ownerProps: object, opts: (RenderOpts & ChainRenderOpts) | undefined, scopeBinding: StandardSourceBinding, + spec: SlotSpec, ): ReactNode { - const spec = host.specOf(slotKey) - // Undeclared (or no-longer-declared) keys render empty: a declaring entry's - // unload returns the slot to the undeclared state while retained elements - // may still be mounted — natural empty, not an ownership failure. - if (!spec) return null + const Anchor = spec.inline === true ? 'span' : 'div' if (spec.kind === 'chain' && opts?.fallbackOnly === true) { - return renderChainResult(slotKey, null, opts) + return renderChainResult(slotKey, null, opts, Anchor) } if (spec.scope === 'session' && scopeBinding.key === undefined) { throw new SlotAssemblyError(`strict session slot '${slotKey}' rendered without a scope binding`) @@ -1130,6 +1135,7 @@ function renderOutletContent( ? ( ) : ( - + {spec.scope === 'session-maybe' ? (
+ const deadCell = () => if (spec.kind === 'single') { const entry = host.entriesOfSlot(slotKey)[0] @@ -1211,7 +1217,7 @@ function renderOutletContent( break } } - return renderChainResult(slotKey, elected, opts) + return renderChainResult(slotKey, elected, opts, Anchor) } // list: one row per id cell — the cell's shadowing winner, or the crash // face once every entry of the cell abdicated (a dry cell must not @@ -1239,7 +1245,7 @@ function renderOutletContent( <> {list.map((item, i) => item.entry !== undefined ? guarded(item.entry, `e${entryKeyOf(item.entry)}`) - :
)} + : )} ) } @@ -1249,16 +1255,17 @@ function renderChainResult( slotKey: string, elected: ReactNode, opts: (RenderOpts & ChainRenderOpts) | undefined, + Anchor: SlotAnchorTag, ): ReactNode { if (!opts?.overlay) return elected ?? <>{opts?.fallback ?? null} return ( <> -
{opts.fallback ?? null} -
+
{elected} ) diff --git a/packages/client/ui-renderer/tests/inline-slots.client.spec.tsx b/packages/client/ui-renderer/tests/inline-slots.client.spec.tsx new file mode 100644 index 0000000000..15fc5056bd --- /dev/null +++ b/packages/client/ui-renderer/tests/inline-slots.client.spec.tsx @@ -0,0 +1,123 @@ +// @vitest-environment jsdom +import { useState, type ReactNode } from 'react' +import { act, cleanup, fireEvent } from '@testing-library/react' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import type { PropsRenderFactories, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + 'spec.inline.single': { kind: 'single'; scope: 'root'; inline: true } + 'spec.inline.keyed': { kind: 'keyed'; scope: 'root'; inline: true } + 'spec.inline.list': { kind: 'list'; scope: 'root'; inline: true } + 'spec.inline.chain': { kind: 'chain'; scope: 'root'; inline: true; owner: { enabled: boolean } } + 'spec.inline.child': { kind: 'single'; scope: 'root'; inline: true } + 'spec.inline.block': { kind: 'single'; scope: 'root' } + } + interface SlotFactoryMap { + 'spec.inline.factory': { + scope: 'root' + children: { 'spec.inline.child': { kind: 'single'; scope: 'root'; inline: true } } + } + } +} + +afterEach(cleanup) + +async function setup() { + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + return runtime +} + +function Crashed(): never { + throw new Error('inline entry failed') +} + +describe('inline Slot anchors', () => { + it('keeps inline anchors and exhausted cells as spans while ordinary Slots remain divs', async () => { + const runtime = await setup() + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) + await runtime.root.declare({ + 'spec.inline.single': { kind: 'single', scope: 'root', inline: true }, + 'spec.inline.keyed': { kind: 'keyed', scope: 'root', inline: true }, + 'spec.inline.list': { kind: 'list', scope: 'root', inline: true }, + 'spec.inline.block': { kind: 'single', scope: 'root' }, + }, ({ renderSlot }) => <> + {renderSlot('spec.inline.single', {})} + {renderSlot('spec.inline.keyed', {}, { entryKey: 'failed' })} + {renderSlot('spec.inline.list', {})} + {renderSlot('spec.inline.block', {}, { fallback: Ordinary fallback })} + ) + runtime.slots.register({ name: 'spec.inline.single' }, Crashed) + runtime.slots.register({ name: 'spec.inline.keyed', key: 'failed' }, Crashed) + runtime.slots.register({ name: 'spec.inline.list', id: 'failed' }, Crashed) + const view = runtime.renderRoot() + await act(async () => {}) + for (const name of ['spec.inline.single', 'spec.inline.keyed', 'spec.inline.list'] as const) { + const anchor = view.container.querySelector(`[data-slot="${name}"]`) + expect(anchor?.tagName).toBe('SPAN') + expect(anchor?.querySelector('[data-slot-error]')?.tagName).toBe('SPAN') + expect(runtime.slots.entries(name)).toHaveLength(1) + } + expect(view.container.querySelector('[data-slot="spec.inline.block"]')?.tagName).toBe('DIV') + expect(view.getByText('Ordinary fallback')).toBeTruthy() + }) + + it('uses span overlay fallbacks and span error boundaries for inline chains', async () => { + const runtime = await setup() + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) + await runtime.root.declare({ + 'spec.inline.chain': { kind: 'chain', scope: 'root', inline: true }, + }, ({ renderSlotChain }) => { + const [enabled, setEnabled] = useState(false) + return <> + + {renderSlotChain('spec.inline.chain', { enabled }, { + fallback: Fallback, overlay: true, fallbackOnly: !enabled, + })} + + }) + runtime.slots.register({ name: 'spec.inline.chain', select: ({ enabled }) => enabled ? {} : null }, Crashed) + const view = runtime.renderRoot() + const fallback = view.container.querySelector('[data-chain-overlay-fallback]')! + expect(fallback.tagName).toBe('SPAN') + expect(fallback.style.display).toBe('contents') + fireEvent.click(view.getByRole('button', { name: 'Enable' })) + expect(view.container.querySelector('[data-chain-overlay-fallback]')).toBe(fallback) + expect(fallback.style.display).toBe('none') + expect(view.container.querySelector('[data-slot-error="spec.inline.chain"]')?.tagName).toBe('SPAN') + }) + + it('retains the inline child anchor after its Factory declaration is disposed', async () => { + const runtime = await setup() + let retained: ReactNode = null + const dispose = runtime.slots.registerFactory({ + name: 'spec.inline.factory', scope: 'root', + children: { 'spec.inline.child': { kind: 'single', scope: 'root', inline: true } }, + }, ({ renderSlot }) => { + retained = renderSlot('spec.inline.child', {}, { fallback: Child fallback }) + return retained + }) + runtime.slots.register({ name: 'spec.inline.child' }, () => Child) + await runtime.root.declare({}, ({ renderFactorySlot }: PropsRuntime<'root'> & PropsRenderFactories) => { + const [hold, setHold] = useState(false) + return <> + + {hold ? retained : renderFactorySlot('spec.inline.factory', {})} + + }) + const view = runtime.renderRoot() + expect(view.getByText('Child')).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: 'Retain child' })) + const anchor = view.container.querySelector('[data-slot="spec.inline.child"]')! + expect(anchor.tagName).toBe('SPAN') + await act(async () => { dispose() }) + expect(view.container.querySelector('[data-slot="spec.inline.child"]')).toBe(anchor) + expect(anchor.textContent).toBe('') + expect(view.queryByText('Child fallback')).toBeNull() + expect(runtime.slots.entries('spec.inline.child')).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-renderer/tsdown.config.ts b/packages/client/ui-renderer/tsdown.config.ts index 2aa78ec480..7aa03cebc1 100644 --- a/packages/client/ui-renderer/tsdown.config.ts +++ b/packages/client/ui-renderer/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-renderer', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 21244ecf66..205e4c6b84 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -15,14 +15,14 @@ en: d152484eb41ac6b4 zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: c668e3d223102e47 - zh: efedf3996679fb17 + en: edba0e1573f3ce42 + zh: 77875b469d94cc62 /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: en: 6bd6882dbbbefcb0 zh: 03aa15f1a65d13dc /deepseek-ai-dsh-client-ui-schedule/understand-the-implementation: - en: a03a551e0ed96c6b - zh: a85efe7db56b7df3 + en: 959145f9a08c3913 + zh: caae642a82d2acd3 /deepseek-ai-dsh-client-ui-schedule/further-exploration: en: 06e265120b05dbf5 zh: 08ab1ef979b8ea40 diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 9f60a11b1c..220af1e6c7 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -27,6 +27,8 @@ Manage active and inactive tasks from the Automation tasks page and the right Si The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. +The page header keeps the title at its natural width and moves New to another line when the two no longer fit together. The action label stays on one line. + Selecting a task from the list opens Rules; refreshing that task preserves the selected tab. Rules edits an active task's name, instruction, and run time; the edits stay in a local draft until saved, and inactive tasks remain read-only. Unmodified Left/Right arrows cycle the tabs, Home/End select the first/last tab, and keyboard focus follows the selection. The tabs do not consume these keys with Alt, Ctrl, Meta, or Shift. The linked-Session control in the detail tab strip stays available on the Rules tab, and confirmed deletion is available from either view. Rules shows the rule in a Run time card whose rows edit a local draft: Repeat is a menu of Weekly, Monday to Friday, Every day, Every N hours, Every N minutes, Every N seconds, Once, and Custom (cron), a weekly rule adds a Weekday row, a fixed-rate choice edits only the interval quantity in the unit it names, a one-shot rule edits separate Date and Time rows, and Custom (cron) edits one Cron expression row holding a five-field expression, interpreted in the selected time zone. The card parses that expression in the browser and, under the row, shows a localized sentence describing it; an expression the Host would reject shows the localized validation message in place of that sentence and blocks Save. A stored weekly rule whose weekday set is exactly Monday through Friday selects the Monday to Friday choice. The Date row is a read-only trigger showing the staged date as `YYYY/MM/DD` and opening a month calendar with localized weekday headings, month navigation, and a marked today; the draft and the submitted text keep the ISO form; the Time row is a read-only trigger showing one 24-hour clock and opening three scrolling columns of hours, minutes, and seconds with the staged value marked and scrolled into view. A calendar or clock pick stages the draft under the same staged save as every other row, arrow keys walk the open column or grid, Enter stages the marked value, Escape closes the panel and returns focus to its row, and a disabled row cannot open one. Clock rows show whole seconds, an untouched row keeps a stored millisecond value for its submit, and editing a time submits whole-second precision. Time zone is a searchable, height-bounded menu. An unlabeled top block keeps the five most recently selected exact zones in browser-local storage; first use seeds the actual system zone and UTC rather than inferring a location from UI language. The system zone is marked, and the remaining inventory is ordered by current UTC offset and canonical IANA id. Each visible label puts `UTC±HH:MM` before the localized zone name; canonical ids stay internal but remain searchable. Zones that ICU renders with the same offset and localized name share one visible row; every grouped id remains searchable, and an already stored id remains selected instead of being rewritten. The inventory comes from `Intl.supportedValuesOf('timeZone')`, and names come from `Intl.DateTimeFormat(..., { timeZoneName: 'longGeneric' })`: both are browser ICU/CLDR data, not a scraped or product-maintained translation table. There is therefore no crawler or generated zone-name resource to refresh. A third-party locale supplies its ordinary `time.locale` value and automatically receives the corresponding ICU names; only the selector's surrounding UI copy needs translation. When the runtime cannot enumerate zones, a short identifier fallback is offered, and a stored zone outside the offered set is kept. Daily, Weekly, and Cron keep their stored rule and IANA zone, and each frequency line names the zone only when it differs from the host's system zone. A one-shot After/At seeds the date and time from the record's stored zone, or this device's zone when the record stores none, so the seeded clock names the stored instant; changing that zone keeps the entered clock values and changes the instant. A kind the card seeds on selection uses the committed occurrence's clock and the choice's zone, which is the record's stored zone or this device's zone, a seeded weekly rule starts from that occurrence's weekday in that zone, and a seeded cron rule states that occurrence's clock as a daily expression. Every accepts at least 60 whole seconds, independent of time zones. One Save submits the complete expected record with the staged change and preserves the id, original Session, latest receipt, and saved history; [Schedule](../../schedule/schedule/README.md#use-this-package) defines future-target selection, reanchoring, and unchanged-rule no-ops. @@ -67,6 +69,8 @@ A restored task tab carries no navigation parameters, because the Sidebar persis Subscriptions exist only while the framework observes a source. The final unsubscribe releases event listeners and invalidates pending responses. Components receive framework-bound `useCatalog` hooks and explicit action callbacks. The mounted Records view reads `schedule.history` with the selected task's original Session binding; catalog and list responses omit full history. Components own disposable search, filtering, selection, confirmation, and history-page state. Reminder records remain Host-owned and do not come from historical Session projections or browser-local demo data. +Task menus use the [standard menu geometry](../../../docs/ui-radius.md#nested-hover), including keyboard-highlighted rows. Created-task cards follow the [file-card family](../../../docs/ui-radius.md#radius-scale) for their radius and icon-tile material. + diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index 29c127bad3..88575c4405 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 +页头按标题的自然宽度排布;标题和“新建”无法并排容纳时,将按钮移到下一行。操作文案保持单行。 + 从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。 “规则”在“运行时间”卡片中以行编辑本地草稿:“重复”是含每周、周一至周五、每天、每 N 小时、每 N 分钟、每 N 秒、仅一次和自定义(cron)的菜单,每周规则增加“星期”行,固定速率选项只修改它所属单位的间隔数量,一次性规则用独立的“日期”和“时间”两行编辑目标,自定义(cron)用一行“Cron 表达式”编辑按所选时区解释的 5 字段表达式。卡片在浏览器中解析该表达式,并在该行下方显示描述它的本地化语句;Host 会拒绝的表达式改为显示本地化校验信息,并在解析通过前阻止保存。已存储的每周规则若星期集合恰好为周一至周五,则选中“周一至周五”选项。“日期”行是只读触发器,以 `YYYY/MM/DD` 显示暂存的日期并展开带本地化星期表头、月份切换和今天标记的月历;草稿与提交文本保持 ISO 形态;“时间”行是只读触发器,显示统一的 24 小时时钟,并展开时、分、秒三列可滚动列表,当前值高亮并在打开时滚动到可见。在月历或时钟上点选即按与其他行相同的分阶段保存写入草稿;方向键在打开的列或网格内移动,Enter 选定当前值,Escape 关闭面板并把焦点还给所在行,被禁用的行无法打开面板。时钟行显示到秒,未改动的行在提交时保留已存储的毫秒值,编辑时间时按整秒精度提交。“时区”使用高度受限的可搜索菜单。顶部无标题区域保留浏览器本地最近选过的 5 个准确时区;首次使用按真实系统时区和 UTC 初始化,而不根据界面语言猜测所在地。系统时区带有标记,其余清单按当前 UTC 偏移、再按规范 IANA 标识排序。可见文案把 `UTC±HH:MM` 放在本地化时区名称之前;IANA 标识仅在内部使用,但可用于搜索。ICU 显示成相同偏移和本地化名称的多个时区只占一行;所有被合并的标识仍可用于搜索,任务已保存的标识也会继续作为选中值,不会被改写。时区清单来自 `Intl.supportedValuesOf('timeZone')`,名称来自 `Intl.DateTimeFormat(..., { timeZoneName: 'longGeneric' })`:两者都是浏览器内置的 ICU/CLDR 数据,不是抓取或产品自维护的翻译表。因此没有需要刷新的爬虫或生成时区资源;第三方语言包只需提供常规的 `time.locale` 值,即可自动获得对应的 ICU 名称,仅需翻译选择器周边的 UI 文案。运行时无法枚举时改用简短的标识候选列表,已存储但不在其中的时区会保留。每天、每周和 cron 保留各自已存储的规则与时区,其频率行仅在时区与宿主系统时区不同时标出时区。一次性 After/At 以记录已存储的时区初始化日期和时间;记录未保存时区时取本设备时区,初始钟表值在该时区下仍表示同一瞬时;更改时区会保留输入的钟表值并改变时点。因选择种类而重新初始化的种类使用已提交发生时点的钟表值和该选项的时区,该时区取记录已存储的时区或本设备时区:每周的星期取自该发生时点在该时区下的星期,cron 则把该发生时点的钟表值写成每天执行的表达式。固定间隔至少为 60 整秒,不受时区影响。一次保存以分阶段的变更提交完整预期记录,并保留 id、原会话、最近一次回执及已保存历史;未来目标选择、重新确定起算时间和未变更规则的无写入语义见 [Schedule](../../schedule/schedule/README.zh.md#use-this-package)。 @@ -67,6 +69,8 @@ kind: "package-reference" 仅在框架观察数据源期间订阅。最后一个订阅取消时释放事件监听,并使尚未完成的响应失效。组件接收框架绑定的 `useCatalog` hook 和明确的操作回调。已挂载的任务运行记录视图使用所选任务的原会话绑定读取 `schedule.history`;目录和列表响应不包含完整历史。组件拥有可丢弃的搜索、筛选、选择、确认和历史分页状态。提醒记录由 Host 拥有,不来自历史会话投影或浏览器本地演示数据。 +任务菜单使用[标准菜单几何](../../../docs/ui-radius.zh.md#nested-hover),包含键盘高亮行。已创建任务卡片的圆角和图标底框材质遵循[文件卡片家族](../../../docs/ui-radius.zh.md#radius-scale)。 + diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index 0d65fc8977..b8a228585b 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-alpha.1", + "version": "0.2.1-alpha.2", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -74,8 +74,6 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "clsx": "^2.1.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", diff --git a/packages/client/ui-schedule/src/client/ScheduleCreateCard.module.css b/packages/client/ui-schedule/src/client/ScheduleCreateCard.module.css index 551dee0b93..a56588c1d4 100644 --- a/packages/client/ui-schedule/src/client/ScheduleCreateCard.module.css +++ b/packages/client/ui-schedule/src/client/ScheduleCreateCard.module.css @@ -1,7 +1,4 @@ -/* schedule_create transcript card: clock, task title, frequency, open action. - The box is the delivery card's (ui-deliverables Deliverables.module.css - `.file`): the same fill/hover pair, 60px row, 8/10 padding, and 18px corner, - with one inset overlay button making the whole row open the task. */ +/* Created-task cards follow the file-card treatment in docs/ui-radius.md. */ .card { --card-fill: var(--dsw-static-neutral-50); @@ -15,7 +12,7 @@ padding: 8px 10px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l1); - border-radius: 18px; + border-radius: var(--dsw-radius-lg); background: var(--card-fill); color: var(--dsw-alias-label-primary); transition: background-color 120ms ease; @@ -59,8 +56,6 @@ pointer-events: none; } -/* The clock glyph sits in the delivery card's icon frame (`.fileIcon`): a - 40px bordered 10px-corner square on the card's own fill. */ .leading { display: grid; flex: none; @@ -68,12 +63,16 @@ box-sizing: border-box; width: 40px; height: 40px; - border: 0.5px solid var(--dsw-alias-border-l2); + border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; - background: var(--card-fill); + background: color-mix(in srgb, var(--dsw-static-neutral-00) 50%, transparent); color: var(--dsw-alias-label-secondary); } +:global(body[data-ds-dark-theme]) .leading { + background: color-mix(in srgb, var(--dsw-static-neutral-00) 5%, transparent); +} + .leading svg { flex: none; } @@ -118,7 +117,7 @@ white-space: nowrap; } -/* The open control keeps the primitive's outlined capsule geometry and stays +/* The open control keeps the primitive's compact outlined geometry and stays its own target over the row-wide overlay. */ .openButton { flex: none; diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css index 25b573fcd4..ba3ee2a030 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css @@ -45,9 +45,10 @@ padding: 0 clamp(24px, 4vw, 48px) 48px; } -/* The page name and the creation actions on one line. */ +/* Page title and creation actions. */ .pageHeading { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; @@ -68,8 +69,9 @@ their titles share one size. The theme carries no size token for this step, which is why every first-level page states the same literal. */ .pageHeading h1 { - flex: 1; + flex: 0 1 auto; min-width: 0; + max-width: 100%; margin: 0; font-size: 20px; line-height: 28px; @@ -82,6 +84,7 @@ flex: none; align-items: center; gap: 16px; + white-space: nowrap; } /* The Plugins toolbar's Add-plugin step: a 32px capsule with the 13px label. */ diff --git a/packages/client/ui-schedule/src/client/TaskMenu.module.css b/packages/client/ui-schedule/src/client/TaskMenu.module.css index 91fa09e354..5ddd158c37 100644 --- a/packages/client/ui-schedule/src/client/TaskMenu.module.css +++ b/packages/client/ui-schedule/src/client/TaskMenu.module.css @@ -10,12 +10,12 @@ .list { /* min-widths below are the design's outer card widths — include the pad. */ box-sizing: border-box; - padding: 3px; + padding: 4px; display: flex; flex-direction: column; gap: 0; border: 0; - border-radius: 16px; + border-radius: var(--dsw-radius-lg); background: var(--dsw-specific-menu); backdrop-filter: var(--dsw-menu-backdrop-filter); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); @@ -88,7 +88,7 @@ min-height: 34px; padding: 6px 8px; border: none; - border-radius: 8px; + border-radius: var(--dsw-radius-md); background: transparent; cursor: pointer; font-size: 13px; diff --git a/packages/client/ui-schedule/tests/schedule-create-card.client.spec.tsx b/packages/client/ui-schedule/tests/schedule-create-card.client.spec.tsx index 9a295266ba..58a5f134a6 100644 --- a/packages/client/ui-schedule/tests/schedule-create-card.client.spec.tsx +++ b/packages/client/ui-schedule/tests/schedule-create-card.client.spec.tsx @@ -284,11 +284,9 @@ describe('ScheduleCreateCard', () => { expect(frequency).toMatch(/color:\s*var\(--dsw-alias-label-tertiary\);/) expect(frequency).toMatch(/font-size:\s*10px;/) expect(frequency).toMatch(/line-height:\s*16px;/) - // The box is the delivery card's (ui-deliverables `.file`): fill/hover pair, - // 8/10 padding, 18px corner, and a 60px row (8px insets around 44px). const card = /\.card\s*\{([^}]*)\}/.exec(stylesheet)?.[1] ?? '' expect(card).toMatch(/padding:\s*8px 10px;/) - expect(card).toMatch(/border-radius:\s*18px;/) + expect(card).toMatch(/border-radius:\s*var\(--dsw-radius-lg\);/) expect(card).toMatch(/background:\s*var\(--card-fill\);/) expect(stylesheet).toMatch(/\.card:has\(\.cardOpen\):hover\s*\{[^}]*background:\s*var\(--card-hover\);/) expect(stylesheet).toMatch(/\.row\s*\{[^}]*min-height:\s*44px;/) diff --git a/packages/client/ui-schedule/tsdown.config.ts b/packages/client/ui-schedule/tsdown.config.ts index 5607f49cd6..7aa03cebc1 100644 --- a/packages/client/ui-schedule/tsdown.config.ts +++ b/packages/client/ui-schedule/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-schedule', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-secret-mask/README.i18n.yaml b/packages/client/ui-secret-mask/README.i18n.yaml new file mode 100644 index 0000000000..acb11ab860 --- /dev/null +++ b/packages/client/ui-secret-mask/README.i18n.yaml @@ -0,0 +1,40 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-secret-mask/README.md +/: + en: 1dc6c25906eed342 + zh: 6d2502e37189d576 +/deepseek-ai-dsh-client-ui-secret-mask: + en: f26ae7ca26826032 + zh: b74b0e6322c0b56c +/deepseek-ai-dsh-client-ui-secret-mask/summary: + en: 2a509a8ec34675b3 + zh: e0afbb185bde0c57 +/deepseek-ai-dsh-client-ui-secret-mask/table-of-contents: + en: d152484eb41ac6b4 + zh: 09388d293f9be9cb +/deepseek-ai-dsh-client-ui-secret-mask/use-this-package: + en: b0381650c2b15970 + zh: a30982f630ea71f1 +/deepseek-ai-dsh-client-ui-secret-mask/use-this-package/what-is-masked: + en: 445a9c788955600d + zh: b36ebee0735f5c48 +/deepseek-ai-dsh-client-ui-secret-mask/understand-the-implementation: + en: 6d51c32ed0187492 + zh: 43cf79bbe45ee90b +/deepseek-ai-dsh-client-ui-secret-mask/further-exploration: + en: 7549b5b4c03132f4 + zh: 50516120cf5d0c25 +/deepseek-ai-dsh-client-ui-secret-mask/model-experience: + en: 9a362b8fa90e0d5a + zh: 65c22dcf8f352782 +/deepseek-ai-dsh-client-ui-secret-mask/model-experience/kv-cache-effect: + en: ca75c51c89c2c9b0 + zh: c2241315dc0c89c9 +/deepseek-ai-dsh-client-ui-secret-mask/known-limitations-and-deferred-work: + en: 14c6e5817a106972 + zh: 13082b68474b5a6f +/deepseek-ai-dsh-client-ui-secret-mask/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: e88aeb617ffb74ea diff --git a/packages/client/ui-secret-mask/README.md b/packages/client/ui-secret-mask/README.md new file mode 100644 index 0000000000..bdd123220d --- /dev/null +++ b/packages/client/ui-secret-mask/README.md @@ -0,0 +1,99 @@ +--- +description: "Web display masking for recognized credentials in sent user text, with a per-value reveal control in interactive messages." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-secret-mask + +English | [中文](README.zh.md) + +## Summary + +Use `dsh-client-ui-secret-mask` when Web users paste API keys, tokens, or passwords into messages that others may see on a shared screen or in a screenshot. Sent user text draws each recognized credential masked: a key or token keeps its first eight characters, and each remaining character is drawn as `*`. Masking changes only what is drawn; the message text, the copy action, and the model input keep the original characters. + +## 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 + +Mount the package beside `dsh-client-ui-conversation`. The Web app bundle mounts it by default; removing its row draws credentials as written. Every caller of the [UserText Factory](../ui-conversation/README.md#sent-user-text) masks: message bubbles, steering messages, pending echoes, queued messages, and `/goal` input bubbles. + +Each masked value is drawn in a monospace font, so revealing it does not move the surrounding text. Interactive text, which is a sent message or an admitted steering message, adds an eye control at the end of each run. The control reveals or hides that one value, and every mount starts masked. Other callers draw the masked value without a control. + + +### What is masked + +| Value | Examples | Display | +| --- | --- | --- | +| Model-service and Google API keys | `sk-…` with at least one digit, `AIza…` | First eight characters kept | +| Code-hosting and package-registry tokens | `ghp_…`, `github_pat_…`, `glpat-…`, `npm_…`, `hf_…` | First eight characters kept | +| Cloud access key IDs | `AKIA…`, `ASIA…`, `LTAI…`, `AKID…` | First eight characters kept | +| Collaboration and payment tokens | `xoxb-…`, `sk_live_…`, `rk_test_…`, `whsec_…` | First eight characters kept | +| JSON Web Tokens | `eyJ….eyJ….…` | First eight characters kept | +| Private key blocks | `-----BEGIN … PRIVATE KEY-----` through its `END` line | `BEGIN` and `END` lines and line breaks kept | +| Address passwords | `postgres://user:password@host/db` | Password masked completely, including a token used as one | + +Named assignments such as `password=…` or `API_KEY=…`, passwords written in a sentence, verification codes, and personal data have no published format and stay as written. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The plugin registers one UserText matcher, `secret`, and its keyed `conversation.user.text.node` renderer; the `/client` export is the plugin body (`apply`/`inject`) only. The matcher reads no Facts, so Facts updates never query it again. Each query returns the nearest credential at or after the requested offset and allocates nothing before it returns a node. A literal-only alternation finds where a format can begin; where a literal's suffix can begin another literal, the scan resumes at that suffix. Format checks are bounded character comparisons and sticky `[class]*` patterns, which stop at the first character outside the class and leave nothing to backtrack into. The matcher instance that `apply` creates memoizes run ends, the next digit, and the next private-key `END` line per text across queries, so all queries of one text cost linear time together; the instance keeps the last queried text until a query for another text. At an equal start, the private-key, address-password, and prefix formats win in that order, and every overlapping candidate joins the earliest under its mask. When an address's `://` precedes the requested offset, the matcher walks back over the user name only if `:`, a password, and `@` follow the offset, so a query from anywhere inside an address finds the same password. The [user-text benchmark](../../../benchmarks/user-text/user-text.bench.client.ts) bounds evaluation time for text with and without credentials, linear scaling, and the matcher's allocation ([rationale](../../../.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md)). + +The node payload carries only the masked display text. The renderer draws each run of `*` as centered marks while keeping the characters for copying and assistive technology, because a font's own asterisk sits near the cap height. It switches between that text and the node's original text, and it remembers the revealed original value instead of a flag, so a different credential at the same position starts masked. + +
+ +----- + + +## Further Exploration + +- [ui-conversation sent user text](../ui-conversation/README.md#sent-user-text) — the matcher registry, the scheduler, and the node renderer Slot. +- [User text candidate scheduling](../../../.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.md) — the forward-query and cache rules this matcher follows. +- [Credential matching performance](../../../.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md) — the linear matcher, its per-part complexity and trade-offs, and the time and allocation gates. + +----- + + +## Model Experience + +None, as masking changes only what the browser draws; the message text and the model input keep the original characters. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +These limits define when a credential is drawn as written; they are current package constraints. + +- **Recognition requires a published format** — a credential without one of the [recognized formats](#what-is-masked) is drawn as written. Named assignments stay plain because the same names also label ordinary values, such as token usage. +- **Only UserText callers mask** — session titles, turn previews, search excerpts, the goal bar, tool cards, assistant replies, and the unsent composer draw credentials as written. +- **An earlier reference draws its own text** — a credential inside a reference that starts before it, such as the file mention `@config/.txt`, is drawn by that reference. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/ui-secret-mask/README.zh.md b/packages/client/ui-secret-mask/README.zh.md new file mode 100644 index 0000000000..3157142215 --- /dev/null +++ b/packages/client/ui-secret-mask/README.zh.md @@ -0,0 +1,99 @@ +--- +description: "Web 端已发送用户文本中可识别凭据的显示遮挡,交互消息中每个值带显示控件。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-secret-mask + +[English](README.md) | 中文 + +## 概述 + +当 Web 用户把 API 密钥、令牌或密码粘贴进消息,而消息可能在共享屏幕或截图中被他人看到时,使用 `dsh-client-ui-secret-mask`。已发送的用户文本会遮挡每个可识别的凭据:密钥或令牌保留前八个字符,其余每个字符显示为 `*`。遮挡只改变显示内容;消息原文、复制操作和模型输入都保留原始字符。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +把本包与 `dsh-client-ui-conversation` 一起挂载。Web 应用 bundle 默认挂载本包;移除该行后凭据按原样显示。[UserText Factory](../ui-conversation/README.zh.md#sent-user-text) 的每个调用方都会遮挡:消息气泡、插话消息、发送中回显、排队消息以及 `/goal` 输入气泡。 + +每个被遮挡的值以等宽字体显示,因此展开时不会挪动周围文字。交互文本(即已发送消息或已接纳的插话消息)会在每段末尾加一个眼睛控件。该控件显示或隐藏这一个值,每次挂载都从遮挡状态开始。其他调用方只显示遮挡后的值,不带控件。 + + +### 遮挡范围 + +| 值 | 示例 | 显示 | +| --- | --- | --- | +| 模型服务与 Google API 密钥 | 至少含一个数字的 `sk-…`、`AIza…` | 保留前八个字符 | +| 代码托管与包仓库令牌 | `ghp_…`、`github_pat_…`、`glpat-…`、`npm_…`、`hf_…` | 保留前八个字符 | +| 云访问密钥 ID | `AKIA…`、`ASIA…`、`LTAI…`、`AKID…` | 保留前八个字符 | +| 协作与支付令牌 | `xoxb-…`、`sk_live_…`、`rk_test_…`、`whsec_…` | 保留前八个字符 | +| JSON Web Token | `eyJ….eyJ….…` | 保留前八个字符 | +| 私钥块 | 从 `-----BEGIN … PRIVATE KEY-----` 到对应 `END` 行 | 保留 `BEGIN`、`END` 两行及换行 | +| 地址中的密码 | `postgres://user:password@host/db` | 密码全部遮挡,用作密码的令牌也一样 | + +`password=…`、`API_KEY=…` 这类带名字的赋值、写在句子里的密码、验证码以及个人信息都没有公开格式,保持原样。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +本插件注册一个 UserText 匹配器 `secret` 及其 keyed `conversation.user.text.node` renderer;`/client` 导出只有插件主体(`apply`/`inject`)。匹配器不读取 Facts,因此 Facts 更新不会再次查询它。每次查询返回请求偏移处或之后最近的凭据,返回节点之前不分配内存。一个只含字面量的交替正则找到格式可能开始的位置;字面量的后缀可能是另一个字面量的开头时,扫描从该后缀处继续。格式检查是有界的字符比较和粘性 `[字符类]*` 模式,这类模式在第一个不属于该类的字符处停止,没有可回溯的部分。`apply` 创建的匹配器实例按文本跨查询记忆字符段结尾、下一个数字和下一行私钥 `END`,因此一段文本的全部查询合计线性;实例保留最近一次查询的文本,直到查询另一段文本。同一起点上,私钥、地址密码和前缀格式依次优先;所有重叠的候选都并入最早的候选,并使用其遮挡方式。地址的 `://` 在请求偏移之前时,只有偏移后紧跟 `:`、密码和 `@` 才回看用户名,因此从地址内任何位置查询都得到同一个密码。[user-text benchmark](../../../benchmarks/user-text/user-text.bench.client.ts) 约束无凭据与含凭据文本的求值耗时、线性增长和匹配器的内存分配([理由](../../../.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md))。 + +节点 payload 只携带遮挡后的显示文本。renderer 把每段 `*` 绘制为垂直居中的标记,同时保留字符本身供复制与辅助技术读取,因为字体自带的星号靠近大写字母顶部。renderer 在该文本与节点原文之间切换,并记住已显示的原文值而不是一个开关,因此同一位置换成另一个凭据时会从遮挡状态开始。 + +
+ +----- + + +## 进一步探索 + +- [ui-conversation 已发送用户文本](../ui-conversation/README.zh.md#sent-user-text)——匹配器注册表、调度器与节点 renderer Slot。 +- [用户文本候选调度](../../../.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.zh.md)——本匹配器遵循的向前查询与缓存规则。 +- [凭据识别的性能](../../../.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md)——线性匹配器各部分的复杂度与取舍,以及耗时与内存分配门禁。 + +----- + + +## 模型体验 + +无。遮挡只改变浏览器的显示内容;消息原文与模型输入都保留原始字符。 + +#### KV Cache 影响 + +无;本包从不组装或发送提供方请求。 + +## 已知限制与延期工作 + + + +以下限制说明凭据何时按原样显示;它们是本包当前的约束。 + +- **识别依赖公开格式**——不具备[可识别格式](#what-is-masked)的凭据按原样显示。带名字的赋值保持原样,因为同样的名字也用于 token 用量等普通值。 +- **只有 UserText 调用方会遮挡**——会话标题、轮次预览、搜索摘要、目标栏、工具卡片、助手回复以及未发送的输入框都按原样显示凭据。 +- **更早开始的引用显示自身文本**——位于更早开始的引用之内的凭据,例如文件提及 `@config/.txt`,由该引用显示。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/client/ui-secret-mask/locale/en.json b/packages/client/ui-secret-mask/locale/en.json new file mode 100644 index 0000000000..07a7beb439 --- /dev/null +++ b/packages/client/ui-secret-mask/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ui-secret-mask", + "description": "Masks recognized keys, tokens, and passwords in sent messages." + } +} diff --git a/packages/client/ui-secret-mask/locale/zh.json b/packages/client/ui-secret-mask/locale/zh.json new file mode 100644 index 0000000000..daee3f10d8 --- /dev/null +++ b/packages/client/ui-secret-mask/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "凭据遮挡", + "description": "遮挡已发送消息中可识别的密钥、令牌与密码。" + } +} diff --git a/packages/client/ui-secret-mask/package.json b/packages/client/ui-secret-mask/package.json new file mode 100644 index 0000000000..3101e5ed7d --- /dev/null +++ b/packages/client/ui-secret-mask/package.json @@ -0,0 +1,62 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-secret-mask", + "description": "Web display masking for recognized credentials in sent user text", + "version": "0.2.1-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-secret-mask" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "react": "^18.2.0" + }, + "files": [ + "locale/*.json", + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-secret-mask/src/client/SecretText.module.css b/packages/client/ui-secret-mask/src/client/SecretText.module.css new file mode 100644 index 0000000000..10d529c107 --- /dev/null +++ b/packages/client/ui-secret-mask/src/client/SecretText.module.css @@ -0,0 +1,62 @@ +/* A monospace box: the value and the control are flex items centered on one + axis, and the box takes the surrounding text's baseline from the value. + Each mask character keeps its original character's advance, so revealing + never reflows the line. */ +.secret { + display: inline-flex; + align-items: center; + box-sizing: border-box; + max-width: 100%; + margin: 0 2px; + font-family: var(--ds-font-family-code); + font-size: 0.875em; +} + +.value { + min-width: 0; + overflow-wrap: anywhere; +} + +/* The characters stay for copying but are not painted; an asterisk tile per + character advance replaces them, because a font's own asterisk sits near + the cap height. */ +.mask { + -webkit-text-fill-color: transparent; + background-color: currentColor; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 20'%3E%3Cpath d='M6 5.6v8.8M2.19 7.8l7.62 4.4M2.19 12.2l7.62-4.4' stroke='%23000' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E") left center / 1ch 100% repeat-x; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 20'%3E%3Cpath d='M6 5.6v8.8M2.19 7.8l7.62 4.4M2.19 12.2l7.62-4.4' stroke='%23000' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E") left center / 1ch 100% repeat-x; +} + +/* The line-box center sits below the middle of the surrounding CJK and Latin + glyphs; both marks and the control rise together without moving layout. */ +.mask, +.toggle { + position: relative; + top: -0.125em; +} + +.toggle { + display: inline-flex; + flex: none; + align-items: center; + padding: 0; + border: none; + background: none; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.toggle:hover { + color: var(--dsw-alias-label-primary); +} + +.toggle:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 1px; + border-radius: var(--dsw-radius-xs); +} + +.icon { + width: 1em; + height: 1em; +} diff --git a/packages/client/ui-secret-mask/src/client/SecretText.tsx b/packages/client/ui-secret-mask/src/client/SecretText.tsx new file mode 100644 index 0000000000..2a238d7bf7 --- /dev/null +++ b/packages/client/ui-secret-mask/src/client/SecretText.tsx @@ -0,0 +1,68 @@ +/** Masked credential presentation with a per-value reveal control in interactive text. */ +import { useState } from 'react' +import type { UserTextNodeProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { NS } from './locales.ts' +import { MASK } from './credential-masks.ts' +import css from './SecretText.module.css' + +const EYE_CONTOUR = 'M1.5 8C2.9 5.25 5.25 3.5 8 3.5C10.75 3.5 13.1 5.25 14.5 8C13.1 10.75 10.75 12.5 8 12.5C5.25 12.5 2.9 10.75 1.5 8Z' +const EYE_PATH = `${EYE_CONTOUR}M10 8C10 9.1 9.1 10 8 10C6.9 10 6 9.1 6 8C6 6.9 6.9 6 8 6C9.1 6 10 6.9 10 8Z` +/** The struck-through glyph drops the pupil so it stays distinct at text size. */ +const EYE_OFF_PATH = `${EYE_CONTOUR}M2.5 2.5L13.5 13.5` +const MASK_RUNS = new RegExp(`(\\${MASK}+)`, 'u') + +function EyeGlyph({ path }: { readonly path: string }) { + return ( + + ) +} + +/** + * Split the masked form into kept text and mask runs. Mask runs keep their + * characters for copying and assistive technology but draw centered marks, + * because a font's own asterisk sits near the cap height. + */ +function MaskedText({ masked }: { readonly masked: string }) { + let offset = 0 + return masked.split(MASK_RUNS).map((part) => { + const key = offset + offset += part.length + if (part === '') return null + return part.startsWith(MASK) + ? {part} + : {part} + }) +} + +type SecretTextProps = UserTextNodeProps<'secret'> & PropsLocale + +/** + * Draw one credential masked; interactive text adds a control that reveals or hides this value. + * Every mount, and every different original value at the same position, starts masked. + * @param props - masked node, original spelling, interaction mode, and locale seat. + * @returns an inline masked run with an optional reveal control. + */ +export function SecretText({ node, raw, interactive, t }: SecretTextProps) { + // Holding the revealed value instead of a flag re-masks a different credential that reuses this element. + const [revealedRaw, setRevealedRaw] = useState() + const revealed = interactive && revealedRaw === raw + return ( + + {revealed ? raw : } + {interactive && ( + + )} + + ) +} diff --git a/packages/client/ui-secret-mask/src/client/credential-formats.ts b/packages/client/ui-secret-mask/src/client/credential-formats.ts new file mode 100644 index 0000000000..fc8b27c950 --- /dev/null +++ b/packages/client/ui-secret-mask/src/client/credential-formats.ts @@ -0,0 +1,100 @@ +/** + * The credential formats the matcher recognizes, as data: the literal each + * format begins with, how its candidate is validated, and the ASCII character + * classes those checks use. [format-tables.ts](./format-tables.ts) derives + * lookup tables from this list on first use. + */ + +// Character classes of one ASCII code unit, as bits. +/** `[A-Za-z0-9_-]` */ +export const KEY = 1 +/** `[A-Za-z0-9]` */ +export const ALNUM = 2 +/** `\w` */ +export const WORD = 4 +/** `[A-Za-z0-9-]` */ +export const ALNUM_DASH = 8 +/** `[A-Z0-9]` */ +export const UPPER_ALNUM = 16 +/** `[A-Z0-9 ]` */ +export const LABEL = 32 +/** `[a-z0-9+.-]` */ +export const SCHEME = 64 +/** `[a-z]` */ +export const LOWER = 128 +/** `[^\s/:@?#]` */ +export const USER = 256 +/** `[^\s/@?#]` */ +export const PASSWORD = 512 + +// Literal kinds. Same-start candidates resolve by ascending kind, so a token used as an address password is masked completely. +/** A private key block from its `-----BEGIN` line. */ +export const KEY_BLOCK = 0 +/** The password of an address that carries credentials. */ +export const ADDRESS = 1 +/** A provider key or token after its fixed prefix. */ +export const PREFIXED = 2 + +// Shapes of a prefixed format's character run after its literal. +/** The whole run, at least `min`. */ +export const RUN = 0 +/** `min` to `max`, then `\b`. */ +export const BOUNDED = 1 +/** At least `min`, with a digit among the first 256. */ +export const SK = 2 +/** Exactly `min`. */ +export const GOOGLE = 3 +/** Three runs joined by `.eyJ` and `.`. */ +export const JWT = 4 + +/** One recognized format. */ +export interface CredentialFormat { + /** ASCII literal the candidate begins with; no literal occurs inside another. */ + readonly literal: string + readonly kind: typeof KEY_BLOCK | typeof ADDRESS | typeof PREFIXED + /** Prefixed formats only: how the run after the literal is validated. */ + readonly shape: number + /** Prefixed formats only: the class bit of the run after the literal. */ + readonly run: number + readonly min: number + readonly max: number +} + +const prefixed = (literal: string, shape: number, run: number, min: number, max = 0): CredentialFormat => + ({ literal, kind: PREFIXED, shape, run, min, max }) + +/** Every prefixed format matches more than the 8 characters a mask leaves readable. */ +export const CREDENTIAL_FORMATS: readonly CredentialFormat[] = [ + { literal: '-----BEGIN ', kind: KEY_BLOCK, shape: RUN, run: 0, min: 0, max: 0 }, + { literal: '://', kind: ADDRESS, shape: RUN, run: 0, min: 0, max: 0 }, + // Model services: `sk-` keys carry a digit, which hyphenated identifiers rarely do. + prefixed('sk-', SK, KEY, 20), + prefixed('AIza', GOOGLE, KEY, 35), + // Code hosting and package registries. + prefixed('ghp_', RUN, ALNUM, 36), + prefixed('gho_', RUN, ALNUM, 36), + prefixed('ghu_', RUN, ALNUM, 36), + prefixed('ghs_', RUN, ALNUM, 36), + prefixed('ghr_', RUN, ALNUM, 36), + prefixed('github_pat_', RUN, WORD, 22), + prefixed('glpat-', RUN, KEY, 20), + prefixed('npm_', RUN, ALNUM, 36), + prefixed('hf_', RUN, ALNUM, 30), + // Cloud access key IDs. + prefixed('AKIA', BOUNDED, UPPER_ALNUM, 16, 16), + prefixed('ASIA', BOUNDED, UPPER_ALNUM, 16, 16), + prefixed('LTAI', BOUNDED, ALNUM, 12, 20), + prefixed('AKID', BOUNDED, ALNUM, 28, 36), + // Collaboration and payment services. + prefixed('xoxa-', RUN, ALNUM_DASH, 10), + prefixed('xoxb-', RUN, ALNUM_DASH, 10), + prefixed('xoxp-', RUN, ALNUM_DASH, 10), + prefixed('xoxr-', RUN, ALNUM_DASH, 10), + prefixed('xoxs-', RUN, ALNUM_DASH, 10), + prefixed('sk_live_', RUN, ALNUM, 16), + prefixed('sk_test_', RUN, ALNUM, 16), + prefixed('rk_live_', RUN, ALNUM, 16), + prefixed('rk_test_', RUN, ALNUM, 16), + prefixed('whsec_', RUN, ALNUM, 24), + prefixed('eyJ', JWT, KEY, 8, 2048), +] diff --git a/packages/client/ui-secret-mask/src/client/credential-masks.ts b/packages/client/ui-secret-mask/src/client/credential-masks.ts new file mode 100644 index 0000000000..eabccb0376 --- /dev/null +++ b/packages/client/ui-secret-mask/src/client/credential-masks.ts @@ -0,0 +1,127 @@ +/** + * Display forms of credential ranges. Every mark comes from a slice of a shared + * string, so a one-line mask is one slice and a multi-line mask one slice per line. + */ +import { ADDRESS, PREFIXED } from './credential-formats.ts' + +/** Character that replaces each hidden credential character in `masked`. */ +export const MASK = '*' +/** Leading characters of a prefixed key left readable: the provider prefix and little else. */ +const VISIBLE_PREFIX = 8 +const SHARED_MARKS = 4096 + +/** Builds masks; owns the shared mark strings and its sticky run pattern. */ +export class CredentialMasks { + private readonly marksOnly = MASK.repeat(SHARED_MARKS) + private readonly marksLf = `${this.marksOnly}\n` + private readonly marksCr = `${this.marksOnly}\r` + /** Code units that `lines` counts without inspection: neither line breaks nor surrogates. Set `lastIndex` before each test. */ + private readonly ordinary = /[^\r\n\ud800-\udfff]*/y + + /** + * The display form of `text[start, end)`, whose earliest candidate has `kind`. + * @param text - the queried text. + * @param kind - the literal kind of the range's earliest candidate. + * @param plain - the range holds only prefixed-format characters: ASCII without line breaks. + * @param start - first offset of the range. + * @param end - offset after the range. + * @returns the mask; line breaks stay in place, one mark per code point. + */ + masked(text: string, kind: number, plain: boolean, start: number, end: number): string { + if (kind === ADDRESS) return this.range(text, start, end) + if (kind === PREFIXED) { + const prefix = text.slice(start, start + VISIBLE_PREFIX) + return prefix + (plain ? this.marks(end - start - VISIBLE_PREFIX) : this.range(text, start + VISIBLE_PREFIX, end)) + } + return this.keyBlock(text, start, end) + } + + /** `count` marks: up to the shared string's length, one slice of it. */ + private marks(count: number): string { + return count <= SHARED_MARKS ? this.marksOnly.slice(0, count) : MASK.repeat(count) + } + + /** `count` marks and then the line break `code`, 10 or 13: one slice where `marks` would be one. */ + private marksThen(count: number, code: number): string { + return count <= SHARED_MARKS + ? (code === 10 ? this.marksLf : this.marksCr).slice(SHARED_MARKS - count) + : MASK.repeat(count) + (code === 10 ? '\n' : '\r') + } + + /** + * Keep the `BEGIN` line, the `END` line when present, and every line break; a block written on + * one line is masked whole. Both searches stay inside the range, so they cost what the mask costs. + */ + private keyBlock(text: string, start: number, end: number): string { + let bodyStart = start + for (let index = start; index < end; index++) { + if (text.charCodeAt(index) === 10) { + bodyStart = index + 1 + break + } + } + let bodyEnd = end + for (let index = end - '\n-----END'.length; index >= bodyStart; index--) { + if (text.charCodeAt(index) === 10 && text.startsWith('-----END', index + 1)) { + bodyEnd = index + break + } + } + // Native runs stop at a line break or the end of the text, so they stay inside a body that ends at one. + const bounded = bodyEnd === text.length || text.charCodeAt(bodyEnd) === 10 || text.charCodeAt(bodyEnd) === 13 + const body = bounded ? this.lines(text, bodyStart, bodyEnd) : this.range(text, bodyStart, bodyEnd) + return text.slice(start, bodyStart) + body + text.slice(bodyEnd, end) + } + + /** + * `text[start, end)` with every character except line breaks replaced; a surrogate pair is one + * character, as `[^\r\n]` with the `u` flag counts it. A character loop: a native run could read far past `end`. + */ + private range(text: string, start: number, end: number): string { + let masked = '' + let count = 0 + for (let index = start; index < end; index++) { + const code = text.charCodeAt(index) + if (code === 10 || code === 13) { + masked += this.marksThen(count, code) + count = 0 + continue + } + if (code >= 0xd800 && code <= 0xdbff && index + 1 < end) { + const next = text.charCodeAt(index + 1) + if (next >= 0xdc00 && next <= 0xdfff) index++ + } + count++ + } + return masked + this.marks(count) + } + + /** `range` for a body that ends at a line break or the end of the text, where native runs stop. */ + private lines(text: string, start: number, end: number): string { + const ordinary = this.ordinary + let masked = '' + let count = 0 + let index = start + while (index < end) { + ordinary.lastIndex = index + ordinary.test(text) + const stop = Math.min(ordinary.lastIndex, end) + count += stop - index + if (stop === end) break + const code = text.charCodeAt(stop) + index = stop + 1 + if (code === 10 || code === 13) { + masked += this.marksThen(count, code) + count = 0 + continue + } + // A high surrogate followed by a low one is one character. + if (code <= 0xdbff && index < end) { + const next = text.charCodeAt(index) + if (next >= 0xdc00 && next <= 0xdfff) index++ + } + count++ + } + return masked + this.marks(count) + } +} diff --git a/packages/client/ui-secret-mask/src/client/credential-scanner.ts b/packages/client/ui-secret-mask/src/client/credential-scanner.ts new file mode 100644 index 0000000000..14bf645708 --- /dev/null +++ b/packages/client/ui-secret-mask/src/client/credential-scanner.ts @@ -0,0 +1,358 @@ +/** + * Prefix-pruned, linear credential scanning that allocates nothing until it + * returns a node. A literal-only alternation finds each place a format can + * begin and resumes inside a literal that can overlap the next one. Format + * checks are bounded character comparisons and sticky `[class]*` runs, which + * end at the first character outside the class and leave nothing to backtrack + * into. Run ends, the next digit, and the next key-block `END` line are + * memoized per text across queries, so each is measured once per text. + */ +import type { UserTextNode } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { + ADDRESS, ALNUM, ALNUM_DASH, BOUNDED, GOOGLE, KEY_BLOCK, LABEL, LOWER, PREFIXED, RUN, SCHEME, SK, UPPER_ALNUM, USER, WORD, +} from './credential-formats.ts' +import { CredentialMasks } from './credential-masks.ts' +import type { FormatTables } from './format-tables.ts' + +// Memo layout: [start, end] pairs. A lookup at `offset` hits when start <= offset <= end; -1 pairs hold nothing. +const KEY_SLOTS = 0 // four pairs: a JSON Web Token measures three runs +const KEY_NEXT = 8 +const ALNUM_SLOT = 9 +const WORD_SLOT = 11 +const ALNUM_DASH_SLOT = 13 +const UPPER_ALNUM_SLOT = 15 +const USER_SLOT = 17 +const PASSWORD_SLOT = 19 +const DIGIT_FROM = 21 +const DIGIT_AT = 22 +const BLOCK_FROM = 23 +const BLOCK_END_START = 24 +const BLOCK_END = 25 +/** Start of the password that the last address check accepted. */ +const CANDIDATE_START = 26 +const MEMO_SIZE = 27 + +/** `\s` above ASCII. */ +function isWideSpace(code: number): boolean { + return code === 0xa0 || code === 0x1680 || (code >= 0x2000 && code <= 0x200a) || code === 0x2028 || code === 0x2029 + || code === 0x202f || code === 0x205f || code === 0x3000 || code === 0xfeff +} + +/** Offset after the run that sticky `pattern` matches at `start`. */ +function runEnd(pattern: RegExp, text: string, start: number): number { + pattern.lastIndex = start + pattern.test(text) + return pattern.lastIndex +} + +/** + * One matcher's scanning state: the tables, its own patterns, and a memo of the + * last queried text, which it keeps until a query for another text. Queries are + * synchronous and never re-enter, so the patterns' cursors are never shared. + */ +export class CredentialScanner { + private readonly classes: Uint16Array + private readonly literals: readonly string[] + private readonly literalLength: Uint8Array + private readonly literalKind: Uint8Array + private readonly literalShape: Uint8Array + private readonly literalMin: Int32Array + private readonly literalMax: Int32Array + private readonly literalOverlap: Uint8Array + private readonly ending: Int8Array + private readonly sharedEndings: readonly (readonly number[] | undefined)[] + /** Memo slot of each prefixed format's run class; 0 for `[A-Za-z0-9_-]`, which has four. */ + private readonly literalSlot: Uint8Array + private readonly literalPattern: readonly RegExp[] + private readonly prefixes: RegExp + // Sticky run patterns: nothing follows the starred class, so nothing backtracks, and `test` allocates nothing. + private readonly keyRun = /[A-Za-z0-9_-]*/y + private readonly userRun = /[^\s/:@?#]*/y + private readonly passwordRun = /[^\s/@?#]*/y + private readonly nonDigits = /\D*/y + private readonly masks = new CredentialMasks() + private readonly memo = new Int32Array(MEMO_SIZE).fill(-1) + private text = '' + + constructor(tables: FormatTables) { + this.classes = tables.classes + this.literals = tables.literals + this.literalLength = tables.literalLength + this.literalKind = tables.literalKind + this.literalShape = tables.literalShape + this.literalMin = tables.literalMin + this.literalMax = tables.literalMax + this.literalOverlap = tables.literalOverlap + this.ending = tables.ending + this.sharedEndings = tables.sharedEndings + this.prefixes = new RegExp(tables.prefixSource, 'g') + const patterns = new Map([ + [ALNUM, [ALNUM_SLOT, /[A-Za-z0-9]*/y]], + [WORD, [WORD_SLOT, /\w*/y]], + [ALNUM_DASH, [ALNUM_DASH_SLOT, /[A-Za-z0-9-]*/y]], + [UPPER_ALNUM, [UPPER_ALNUM_SLOT, /[A-Z0-9]*/y]], + ]) + this.literalSlot = Uint8Array.from(tables.literalRun, run => patterns.get(run)?.[0] ?? 0) + this.literalPattern = Array.from(tables.literalRun, run => patterns.get(run)?.[1] ?? this.keyRun) + } + + /** + * The nearest credential at or after `from`. Overlapping candidates of any format join the earliest + * one and take its mask, so no credential is drawn half masked. Candidates arrive in start order; + * an address password waits until the scan passes its start, and no other `://` can occur before + * then, because a user name and a password exclude `/`. A password that starts inside a range also + * ends inside it: the range holds the `:` before the password, so it is a key block, whose `END` + * line has spaces, or one that runs to the end of the text. + * @param text - the complete original text. + * @param from - the query offset. + * @returns the `secret` node, or undefined when no credential starts at or after `from`. + */ + match(text: string, from: number): UserTextNode<'secret'> | undefined { + if (text !== this.text) { + this.memo.fill(-1) + this.text = text + } + const prefixes = this.prefixes + const length = text.length + let start = -1 + let end = -1 + let kind = PREFIXED + let plain = true + let deferredEnd = this.addressBefore(from) + let deferredStart = deferredEnd < 0 ? -1 : this.slot(CANDIDATE_START) + prefixes.lastIndex = from + while (prefixes.test(text)) { + const literalEnd = prefixes.lastIndex + const literal = this.literalEndingAt(literalEnd) + const at = literalEnd - (this.literalLength[literal] as number) + prefixes.lastIndex = literalEnd - (this.literalOverlap[literal] as number) + if (deferredStart >= 0 && deferredStart <= at) { + if (start < 0) { + start = deferredStart + end = deferredEnd + kind = ADDRESS + } else if (deferredStart >= end) { + break + } + deferredStart = -1 + } + // A candidate starts at or after its literal, so none past the range reaches it; nothing extends a range to the end. + if (start >= 0 && (at >= end || end === length)) break + const literalKind = this.literalKind[literal] as number + if (literalKind === ADDRESS) { + deferredEnd = this.addressPassword(at) + deferredStart = deferredEnd < 0 ? -1 : this.slot(CANDIDATE_START) + continue + } + const candidateEnd = literalKind === KEY_BLOCK ? this.keyBlockEnd(at) : this.prefixedEnd(literal, at) + if (candidateEnd < 0) continue + if (start < 0) { + start = at + end = candidateEnd + kind = literalKind + plain = literalKind === PREFIXED + } else if (candidateEnd > end) { + end = candidateEnd + if (literalKind !== PREFIXED) plain = false + } + } + if (start < 0 && deferredStart >= 0) { + start = deferredStart + end = deferredEnd + kind = ADDRESS + } + if (start < 0) return undefined + return { id: 'secret', start, end, data: { masked: this.masks.masked(text, kind, plain, start, end) } } + } + + /** Memo entry at `index`; the layout keeps every index inside the array. */ + private slot(index: number): number { + return this.memo[index] as number + } + + /** `\w` at `index`; out of range is not a word character. */ + private isWordAt(index: number): boolean { + const code = this.text.charCodeAt(index) + return code < 128 && ((this.classes[code] as number) & WORD) !== 0 + } + + /** `[^\s/:@?#]` for one UTF-16 code unit; NaN, read out of range, is not one. */ + private isUserCode(code: number): boolean { + return code < 128 ? ((this.classes[code] as number) & USER) !== 0 : code === code && !isWideSpace(code) + } + + /** Index of the literal the prefix scan matched ending at `end`; one literal sharing the ending matches, so the last needs no check. */ + private literalEndingAt(end: number): number { + const text = this.text + const key = text.charCodeAt(end - 2) * 128 + text.charCodeAt(end - 1) + const literal = this.ending[key] as number + if (literal > 0) return literal - 1 + const shared = this.sharedEndings[key] as readonly number[] + let index = 0 + for (; index < shared.length - 1; index++) { + const candidate = shared[index] as number + if (text.startsWith(this.literals[candidate] as string, end - (this.literalLength[candidate] as number))) break + } + return shared[index] as number + } + + /** End of the `[A-Za-z0-9_-]*` run at `start`, through four memo pairs replaced in turn. */ + private keyRunEnd(start: number): number { + for (let slot = KEY_SLOTS; slot < KEY_NEXT; slot += 2) { + if (this.slot(slot) <= start && start <= this.slot(slot + 1)) return this.slot(slot + 1) + } + const end = runEnd(this.keyRun, this.text, start) + const next = (this.slot(KEY_NEXT) + 1) & 3 + this.memo[KEY_NEXT] = next + this.memo[KEY_SLOTS + 2 * next] = start + this.memo[KEY_SLOTS + 2 * next + 1] = end + return end + } + + /** End of the run `pattern` matches at `start`, through the memo pair at `slot`. */ + private memoRunEnd(pattern: RegExp, start: number, slot: number): number { + if (this.slot(slot) <= start && start <= this.slot(slot + 1)) return this.slot(slot + 1) + const end = runEnd(pattern, this.text, start) + this.memo[slot] = start + this.memo[slot + 1] = end + return end + } + + /** Offset of the first digit at or after `start`, or the text length. */ + private nextDigit(start: number): number { + if (this.slot(DIGIT_FROM) <= start && start <= this.slot(DIGIT_AT)) return this.slot(DIGIT_AT) + this.memo[DIGIT_FROM] = start + this.memo[DIGIT_AT] = runEnd(this.nonDigits, this.text, start) + return this.slot(DIGIT_AT) + } + + /** Offset after `[A-Z0-9 ]{0,40}PRIVATE KEY-----` at `start`, or -1; reads at most 52 characters. */ + private labelEnd(start: number): number { + const text = this.text + const limit = Math.min(text.length, start + 52) + let index = start + while (index < limit) { + const code = text.charCodeAt(index) + if (code > 127 || ((this.classes[code] as number) & LABEL) === 0) break + index++ + } + // "PRIVATE KEY" is label characters and the `-` after it is not, so the run ends right after it. + const tail = index - 'PRIVATE KEY'.length + return index < start + 52 && tail >= start && text.startsWith('PRIVATE KEY-----', tail) ? tail + 'PRIVATE KEY-----'.length : -1 + } + + /** + * End of the `-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----` block at `at`: after its first complete + * `END` line, or the end of the text; -1 for an invalid header. + */ + private keyBlockEnd(at: number): number { + const header = this.labelEnd(at + '-----BEGIN '.length) + if (header < 0) return -1 + if (this.slot(BLOCK_FROM) <= header && header <= this.slot(BLOCK_END_START)) return this.slot(BLOCK_END) + const text = this.text + this.memo[BLOCK_FROM] = header + this.memo[BLOCK_END_START] = text.length + this.memo[BLOCK_END] = text.length + for (let line = text.indexOf('-----END ', header); line >= 0; line = text.indexOf('-----END ', line + 1)) { + const end = this.labelEnd(line + '-----END '.length) + if (end < 0) continue + this.memo[BLOCK_END_START] = line + this.memo[BLOCK_END] = end + break + } + return this.slot(BLOCK_END) + } + + /** Whether `\b[a-z][a-z0-9+.-]{1,15}` ends at `at`; reads at most 17 characters back. */ + private schemeBefore(at: number): boolean { + const text = this.text + for (let start = at - 1; start >= 0 && start >= at - 16; start--) { + const code = text.charCodeAt(start) + const bits = code < 128 ? (this.classes[code] as number) : 0 + if ((bits & SCHEME) === 0) return false + if ((bits & LOWER) !== 0 && at - start >= 2 && !this.isWordAt(start - 1)) return true + } + return false + } + + /** + * The password in `\b[a-z][a-z0-9+.-]{1,15}://[^\s/:@?#]{0,256}:[^\s/@?#]+@` for the `://` at + * `at`; neither part crosses `?` or `#`, where the authority ends. Callers pass a `://` at or after + * the query offset, or one whose user name reaches it, so the password never starts before it. + * @returns the password end with its start in the memo, or -1. + */ + private addressPassword(at: number): number { + if (!this.schemeBefore(at)) return -1 + const text = this.text + const userStart = at + '://'.length + const userEnd = this.memoRunEnd(this.userRun, userStart, USER_SLOT) + if (userEnd - userStart > 256 || text.charCodeAt(userEnd) !== 58) return -1 + const start = userEnd + 1 + const end = this.memoRunEnd(this.passwordRun, start, PASSWORD_SLOT) + if (end === start || text.charCodeAt(end) !== 64) return -1 + this.memo[CANDIDATE_START] = start + return end + } + + /** + * The address password at or after `from` whose `://` starts before `from`, as `addressPassword` + * returns it. The walk back over the user name runs only when a `:`, a password, and `@` follow. + */ + private addressBefore(from: number): number { + const text = this.text + if (from < 3) return -1 + // `from` inside the `://`. + if (text.startsWith('://', from - 1)) return this.addressPassword(from - 1) + if (text.startsWith('://', from - 2)) return this.addressPassword(from - 2) + let index = from - 1 + const before = text.charCodeAt(index) + let passwordStart: number + if (before === 58) { + // The password starts at `from`, right after the user name's `:`. + passwordStart = from + index-- + } else if (this.isUserCode(before)) { + const userEnd = this.memoRunEnd(this.userRun, from, USER_SLOT) + if (text.charCodeAt(userEnd) !== 58) return -1 + passwordStart = userEnd + 1 + } else { + return text.startsWith('://', from - 3) ? this.addressPassword(from - 3) : -1 + } + const passwordEnd = this.memoRunEnd(this.passwordRun, passwordStart, PASSWORD_SLOT) + if (passwordEnd === passwordStart || text.charCodeAt(passwordEnd) !== 64) return -1 + // A user name is at most 256 characters, followed by `:`. + const lowest = Math.max(0, passwordStart - 258) + while (index >= lowest && this.isUserCode(text.charCodeAt(index))) index-- + return index >= 2 && text.startsWith('://', index - 2) ? this.addressPassword(index - 2) : -1 + } + + /** End of the prefixed credential whose literal starts at `at`, or -1. */ + private prefixedEnd(literal: number, at: number): number { + if (this.isWordAt(at - 1)) return -1 + const text = this.text + const start = at + (this.literalLength[literal] as number) + const shape = this.literalShape[literal] as number + const min = this.literalMin[literal] as number + if (shape === SK) { + // `(?=[A-Za-z0-9_-]{0,255}\d)[A-Za-z0-9_-]{20,}`: the memoized next digit rejects a digit-free `sk-` before any run is measured. + const digit = this.nextDigit(start) + if (digit - start > 255) return -1 + const end = this.keyRunEnd(start) + return end - start >= min && digit < end ? end : -1 + } + const slot = this.literalSlot[literal] as number + const end = slot === 0 ? this.keyRunEnd(start) : this.memoRunEnd(this.literalPattern[literal] as RegExp, start, slot) + const length = end - start + if (length < min) return -1 + if (shape === RUN) return end + if (shape === GOOGLE) return start + min + if (shape === BOUNDED) return length <= (this.literalMax[literal] as number) && !this.isWordAt(end) ? end : -1 + // `[A-Za-z0-9_-]{8,2048}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}` after `eyJ`. + if (length > (this.literalMax[literal] as number) || !text.startsWith('.eyJ', end)) return -1 + const payloadStart = end + '.eyJ'.length + const payloadEnd = this.keyRunEnd(payloadStart) + if (payloadEnd - payloadStart < min || text.charCodeAt(payloadEnd) !== 46) return -1 + const signatureEnd = this.keyRunEnd(payloadEnd + 1) + return signatureEnd - (payloadEnd + 1) >= min ? signatureEnd : -1 + } +} diff --git a/packages/client/ui-secret-mask/src/client/format-tables.ts b/packages/client/ui-secret-mask/src/client/format-tables.ts new file mode 100644 index 0000000000..07bf976e82 --- /dev/null +++ b/packages/client/ui-secret-mask/src/client/format-tables.ts @@ -0,0 +1,113 @@ +/** Lookup tables derived from the credential formats, built once on first use. */ +import { + ALNUM, ALNUM_DASH, CREDENTIAL_FORMATS, KEY, LABEL, LOWER, PASSWORD, SCHEME, UPPER_ALNUM, USER, WORD, +} from './credential-formats.ts' + +/** Tables indexed by format position in `CREDENTIAL_FORMATS`, by ASCII code unit, or by a literal's last two code units. */ +export interface FormatTables { + /** Class bits per ASCII code unit. */ + readonly classes: Uint16Array + readonly literals: readonly string[] + readonly literalLength: Uint8Array + readonly literalKind: Uint8Array + readonly literalShape: Uint8Array + readonly literalRun: Uint16Array + readonly literalMin: Int32Array + readonly literalMax: Int32Array + /** + * Length of the longest proper suffix of each literal that begins a literal. The + * scan resumes there, so a key block whose first dash ends `sk-` is still seen. + */ + readonly literalOverlap: Uint8Array + /** Literal-only alternation of every literal, grouped by shared leading characters; no quantifier. */ + readonly prefixSource: string + /** Per key `code[-2] * 128 + code[-1]`: the literal with that ending plus one, or -1 where several share it. */ + readonly ending: Int8Array + /** Literals sharing an ending, by the same key. */ + readonly sharedEndings: readonly (readonly number[] | undefined)[] +} + +let tables: FormatTables | undefined + +/** + * The tables for `CREDENTIAL_FORMATS`; the first call builds them. + * @returns the shared, read-only tables. + */ +export function formatTables(): FormatTables { + tables ??= buildTables() + return tables +} + +function buildTables(): FormatTables { + const formats = CREDENTIAL_FORMATS + const literals = formats.map(f => f.literal) + const { ending, sharedEndings } = endingTables(literals) + return { + classes: charClasses(), + literals, + literalLength: Uint8Array.from(literals, literal => literal.length), + literalKind: Uint8Array.from(formats, f => f.kind), + literalShape: Uint8Array.from(formats, f => f.shape), + literalRun: Uint16Array.from(formats, f => f.run), + literalMin: Int32Array.from(formats, f => f.min), + literalMax: Int32Array.from(formats, f => f.max), + literalOverlap: Uint8Array.from(literals, literal => overlap(literal, literals)), + prefixSource: alternation(literals), + ending, + sharedEndings, + } +} + +function charClasses(): Uint16Array { + const classes = new Uint16Array(128) + for (let code = 0; code < 128; code++) { + const upper = code >= 65 && code <= 90 + const lower = code >= 97 && code <= 122 + const digit = code >= 48 && code <= 57 + const alnum = upper || lower || digit + const authority = !((code >= 9 && code <= 13) || code === 32 || code === 47 || code === 64 || code === 63 || code === 35) + classes[code] = (alnum || code === 95 || code === 45 ? KEY : 0) | (alnum ? ALNUM : 0) | (alnum || code === 95 ? WORD : 0) + | (alnum || code === 45 ? ALNUM_DASH : 0) | (upper || digit ? UPPER_ALNUM : 0) | (upper || digit || code === 32 ? LABEL : 0) + | (lower || digit || code === 43 || code === 46 || code === 45 ? SCHEME : 0) | (lower ? LOWER : 0) + | (authority && code !== 58 ? USER : 0) | (authority ? PASSWORD : 0) + } + return classes +} + +function overlap(literal: string, literals: readonly string[]): number { + for (let size = literal.length - 1; size > 0; size--) { + const suffix = literal.slice(-size) + if (literals.some(other => other.startsWith(suffix))) return size + } + return 0 +} + +function endingTables(literals: readonly string[]): Pick { + const ending = new Int8Array(128 * 128) + const sharedEndings: number[][] = [] + literals.forEach((literal, index) => { + const key = literal.charCodeAt(literal.length - 2) * 128 + literal.charCodeAt(literal.length - 1) + const existing = ending[key] as number + if (existing === 0) { + ending[key] = index + 1 + return + } + if (existing > 0) { + sharedEndings[key] = [existing - 1] + ending[key] = -1 + } + (sharedEndings[key] as number[]).push(index) + }) + return { ending, sharedEndings } +} + +const escape = (literal: string): string => literal.replace(/[.*+?^${}()|[\]\\/]/gu, '\\$&') + +/** Literal alternatives grouped by shared leading characters; groups only factor literals and add no quantifier. */ +function alternation(literals: readonly string[]): string { + const groups = new Map() + for (const literal of literals) groups.set(literal.charAt(0), [...groups.get(literal.charAt(0)) ?? [], literal.slice(1)]) + return [...groups].map(([head, tails]) => tails.length === 1 + ? escape(head + tails.join('')) + : `${escape(head)}(?:${alternation(tails)})`).join('|') +} diff --git a/packages/client/ui-secret-mask/src/client/index.ts b/packages/client/ui-secret-mask/src/client/index.ts new file mode 100644 index 0000000000..0250d4a569 --- /dev/null +++ b/packages/client/ui-secret-mask/src/client/index.ts @@ -0,0 +1,33 @@ +/** + * Credential display masking for sent Web user text: one UserText matcher and + * its keyed `secret` node renderer. Masking changes only the drawn text; the + * message text, copy actions, and model input keep the original characters. + * + * @module @deepseek-ai/dsh-client-ui-secret-mask/client + */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: pulls the UiConversation service merge (ctx.uiConversation). +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { SecretText } from './SecretText.tsx' +import { createSecretRule } from './secret-rule.ts' +export type {} from './secret-rule.ts' +import { en, NS, zh } from './locales.ts' + +/** Required services: the copy, the node renderer registry, and the UserText rule registry. */ +export const inject = ['locale', 'slots', 'uiConversation'] + +/** + * Register the dictionaries, the credential matcher, and its node renderer. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-secret-mask: dictionaries') + ctx.uiConversation.userText.register(createSecretRule()) + ctx.slots.inject('conversation.user.text.node', () => ctx.slots.register({ + name: 'conversation.user.text.node', key: 'secret', priority: 100, locale: NS, + }, SecretText)) +} diff --git a/packages/client/ui-secret-mask/src/client/locales.ts b/packages/client/ui-secret-mask/src/client/locales.ts new file mode 100644 index 0000000000..5a42d5a8e2 --- /dev/null +++ b/packages/client/ui-secret-mask/src/client/locales.ts @@ -0,0 +1,28 @@ +/** `secretMask` dictionaries for the per-credential reveal control. */ + +import type {} from '@deepseek-ai/dsh-client-ui-slots' + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'secretMask' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'secret.reveal': '显示密钥', + 'secret.hide': '隐藏密钥', +} satisfies Record + +/** The secretMask namespace key union. */ +export type SecretMaskKey = keyof typeof zh + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Accessible names of the control that reveals one masked credential. */ + secretMask: SecretMaskKey + } +} + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'secret.reveal': 'Show secret', + 'secret.hide': 'Hide secret', +} satisfies Record diff --git a/packages/client/ui-secret-mask/src/client/secret-rule.ts b/packages/client/ui-secret-mask/src/client/secret-rule.ts new file mode 100644 index 0000000000..ff6ead7eab --- /dev/null +++ b/packages/client/ui-secret-mask/src/client/secret-rule.ts @@ -0,0 +1,36 @@ +/** + * The `secret` UserText rule. A value is recognized by its published format + * alone: provider key and token prefixes, JSON Web Tokens, private key blocks, + * and the password of an address that carries credentials. Free-form passwords + * and `name=value` assignments have no such format and stay plain. The formats + * live in [credential-formats.ts](./credential-formats.ts); scanning lives in + * [credential-scanner.ts](./credential-scanner.ts). + */ +import type { UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { CredentialScanner } from './credential-scanner.ts' +import { formatTables } from './format-tables.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface UserTextDataMap { + /** One recognized credential range. */ + secret: { + /** Display form of the original range; line breaks stay in place. */ + readonly masked: string + } + } +} + +/** + * Create the credential matcher. Each instance owns a scanner whose memo holds + * the last queried text until a query for another text. + * @returns the `secret` rule; it reads no Facts, so Facts changes never invalidate its queries. + */ +export function createSecretRule(): UserTextRule<'secret'> { + const scanner = new CredentialScanner(formatTables()) + return { + id: 'secret', + // Same-start ties with other rules draw the credential masked. + order: 0, + match: (text, from) => scanner.match(text, from), + } +} diff --git a/packages/client/ui-secret-mask/src/css-modules.d.ts b/packages/client/ui-secret-mask/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-secret-mask/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-secret-mask/src/index.ts b/packages/client/ui-secret-mask/src/index.ts new file mode 100644 index 0000000000..bb2ed8f04e --- /dev/null +++ b/packages/client/ui-secret-mask/src/index.ts @@ -0,0 +1,9 @@ +/** + * Credential display masking plugin, node half. Pure UI plugin: the empty + * apply exists so the plugin appears in the host cordis.yml / Loader; the + * browser half ships via exports["./client"], discovered through the + * package.json `dsh.client` declaration. + */ + +/** Host plugin body — no host-side behavior for this display plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-secret-mask/tests/browser-plugin.client.spec.ts b/packages/client/ui-secret-mask/tests/browser-plugin.client.spec.ts new file mode 100644 index 0000000000..f6bbe35e62 --- /dev/null +++ b/packages/client/ui-secret-mask/tests/browser-plugin.client.spec.ts @@ -0,0 +1,50 @@ +/** Plugin registration: the credential matcher and its keyed node renderer leave with the plugin fiber. */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, onTestFinished } from 'vitest' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { TestSessions } from '@deepseek-ai/dsh-client-test-runtime' +import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/assembly.ts' +import { SecretText } from '../src/client/SecretText.tsx' +import { NS } from '../src/client/locales.ts' +import { apply, inject } from '../src/client/index.ts' +import { apply as nodeApply } from '../src/index.ts' + +function TextSlotDeclaration(_props: PropsRenderSlots<'conversation.user.text.node'>): null { + return null +} + +describe('apply', () => { + it('declares its services and releases the matcher and renderer on disposal', async () => { + expect(inject).toEqual(['locale', 'slots', 'uiConversation']) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + ctx.provide('locale', new LocaleRuntime(ctx)) + const sessions = new TestSessions(async (action) => { await action() }, ctx) + onTestFinished(() => sessions.disposeScopes()) + ctx.provide('sessions', sessions) + new UiConversation(ctx, sessions) + await ctx.plugin(SlotRegistry).await() + ctx.slots.register({ + name: 'root', + children: { 'conversation.user.text.node': { kind: 'keyed', scope: 'session' } }, + }, TextSlotDeclaration) + + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(ctx.uiConversation.userText.entries().map(rule => rule.id)).toEqual(['secret']) + expect(ctx.slots.entries('conversation.user.text.node')).toMatchObject([ + { options: { key: 'secret' }, component: SecretText }, + ]) + expect(ctx.locale.bind(NS)('secret.reveal')).toBe('Show secret') + + await fiber.dispose() + expect(ctx.uiConversation.userText.entries()).toHaveLength(0) + expect(ctx.slots.entries('conversation.user.text.node')).toHaveLength(0) + }) + + it('the node half applies without host-side behavior', () => { + expect(() => { nodeApply() }).not.toThrow() + }) +}) diff --git a/packages/client/ui-secret-mask/tests/regex-oracle.client.ts b/packages/client/ui-secret-mask/tests/regex-oracle.client.ts new file mode 100644 index 0000000000..8a68bc6e0b --- /dev/null +++ b/packages/client/ui-secret-mask/tests/regex-oracle.client.ts @@ -0,0 +1,119 @@ +/** + * The backtracking regular-expression formats this package replaced, kept as + * an equivalence oracle for the prefix-pruned matcher. Not product code. It + * joins every overlapping match, including later matches of the same format. + */ +import type { UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '../src/client/secret-rule.ts' + +const MASK = '*' +/** Leading characters of a prefixed key left readable: the provider prefix and little else. */ +const VISIBLE_PREFIX = 8 + +const maskRun = (value: string): string => value.replace(/[^\r\n]/gu, MASK) +const maskAfterPrefix = (value: string): string => + value.slice(0, VISIBLE_PREFIX) + maskRun(value.slice(VISIBLE_PREFIX)) +/** + * Keeps the `BEGIN` line, the `END` line when present, and every line break. + * A block written on one line has no line to keep and is masked whole. + */ +const maskKeyBlock = (value: string): string => { + const bodyStart = value.indexOf('\n') + 1 + const end = value.lastIndexOf('\n-----END') + const bodyEnd = end < bodyStart ? value.length : end + return value.slice(0, bodyStart) + maskRun(value.slice(bodyStart, bodyEnd)) + value.slice(bodyEnd) +} + +interface Format { + /** Global pattern whose whole match is the credential. */ + readonly pattern: RegExp + readonly mask: (value: string) => string +} + +/** + * Credentials whose published format starts with a fixed prefix. Every pattern + * matches more than `VISIBLE_PREFIX` characters, which `maskAfterPrefix` relies on. + */ +const PREFIXED: readonly RegExp[] = [ + // Model services: `sk-` keys carry at least one digit, which hyphenated identifiers rarely do. + // The bounded lookahead keeps a long run of digit-free `sk-` repeats linear. + /\bsk-(?=[A-Za-z0-9_-]{0,255}\d)[A-Za-z0-9_-]{20,}/gu, + /\bAIza[A-Za-z0-9_-]{35}/gu, + // Code hosting and package registries. + /\bgh[pousr]_[A-Za-z0-9]{36,}/gu, + /\bgithub_pat_[A-Za-z0-9_]{22,}/gu, + /\bglpat-[A-Za-z0-9_-]{20,}/gu, + /\bnpm_[A-Za-z0-9]{36,}/gu, + /\bhf_[A-Za-z0-9]{30,}/gu, + // Cloud access key IDs. + /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/gu, + /\bLTAI[A-Za-z0-9]{12,20}\b/gu, + /\bAKID[A-Za-z0-9]{28,36}\b/gu, + // Collaboration and payment services. + /\bxox[abprs]-[A-Za-z0-9-]{10,}/gu, + /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/gu, + /\bwhsec_[A-Za-z0-9]{24,}/gu, + // JSON Web Tokens. The header bound keeps a long run of `eyJ` repeats linear. + /\beyJ[A-Za-z0-9_-]{8,2048}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/gu, +] + +/** Same-start matches resolve in this order, so a token used as an address password is masked completely. */ +const FORMATS: readonly Format[] = [ + { + pattern: /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]{0,40}PRIVATE KEY-----|$)/gu, + mask: maskKeyBlock, + }, + // The lookbehind starts the match at the password, so a query from inside the address finds the same candidate. + // The user name may be empty, and neither part crosses `?` or `#`, where the authority ends. + { pattern: /(?<=\b[a-z][a-z0-9+.-]{1,15}:\/\/[^\s/:@?#]{0,256}:)[^\s/@?#]+(?=@)/gu, mask: maskRun }, + ...PREFIXED.map(pattern => ({ pattern, mask: maskAfterPrefix })), +] + +interface Hit { + readonly start: number + readonly end: number +} + +function find(regex: RegExp, text: string, from: number): Hit | undefined { + regex.lastIndex = from + const match = regex.exec(text) + return match === null ? undefined : { start: match.index, end: match.index + match[0].length } +} + +/** + * Return the nearest credential at or after `from`. Overlapping matches of any + * format join the earliest one and take its mask, so no credential is drawn half masked. + * Reads no Facts, so Facts changes never invalidate its queries. + */ +export const regexSecretRule: UserTextRule<'secret'> = { + id: 'secret', + order: 0, + match(text, from) { + const scans = FORMATS.map((format) => { + const regex = new RegExp(format.pattern) + return { format, regex, hit: find(regex, text, from) } + }) + let first: { readonly format: Format; readonly hit: Hit } | undefined + for (const { format, hit } of scans) { + // The strict comparison keeps the earlier format at an equal start. + if (hit !== undefined && (first === undefined || hit.start < first.hit.start)) first = { format, hit } + } + if (first === undefined) return undefined + const { start } = first.hit + let end = first.hit.end + for (let grown = true; grown;) { + grown = false + for (const scan of scans) { + while (scan.hit !== undefined && scan.hit.start < end) { + if (scan.hit.end > end) { + end = scan.hit.end + grown = true + } + // Overlapping matches of one format join as well, so a key that starts inside another is masked whole. + scan.hit = find(scan.regex, text, scan.hit.start + 1) + } + } + } + return { id: 'secret', start, end, data: { masked: first.format.mask(text.slice(start, end)) } } + }, +} diff --git a/packages/client/ui-secret-mask/tests/secret-rule-equivalence.client.spec.ts b/packages/client/ui-secret-mask/tests/secret-rule-equivalence.client.spec.ts new file mode 100644 index 0000000000..f3e51ab6ca --- /dev/null +++ b/packages/client/ui-secret-mask/tests/secret-rule-equivalence.client.spec.ts @@ -0,0 +1,102 @@ +/** + * The prefix-pruned matcher produces the same nodes as the regular-expression + * matcher it replaced, through the production evaluator, on fixed and generated inputs. + */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, onTestFinished } from 'vitest' +import type { UserTextRule, UserTextTransformContext } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { UserTextRegistry } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/user-text-registry.ts' +import { collapseWhitespaceRule, trimWhitespaceRule } from '@deepseek-ai/dsh-client-ui-conversation/src/client/user-text/whitespace-rules.ts' +import { createSecretRule } from '../src/client/secret-rule.ts' +import { regexSecretRule } from './regex-oracle.client.ts' + +function evaluator(rule: UserTextRule) { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + const registry = new UserTextRegistry(ctx) + for (const each of [rule, collapseWhitespaceRule, trimWhitespaceRule]) registry.register(each) + return registry.createEvaluator() +} + +const join = (...parts: string[]): string => parts.join('') +/** Fragments that begin, continue, break, or end each recognized format. */ +const FRAGMENTS = [ + join('s', 'k-'), join('s', 'k_live_'), join('r', 'k_test_'), join('AI', 'za'), join('gh', 'p_'), join('gh', 's_'), + join('github', '_pat_'), join('gl', 'pat-'), join('np', 'm_'), join('h', 'f_'), join('AK', 'IA'), join('AS', 'IA'), + join('LT', 'AI'), join('AK', 'ID'), join('xo', 'xb-'), join('wh', 'sec_'), join('ey', 'J'), '.', join('.ey', 'J'), + join('-----BEGIN', ' '), join('-----END', ' '), ' PRIVATE KEY-----', 'PRIVATE KEY-----', 'RSA ', + '://', 'https', 'postgres', ':', '@', '/', '?', '#', ' ', '\n', '\r\n', '\t', '-', '_', '+', + 'a', 'Z', '0', '7', 'abcdefgh', 'ABCDEFGH', '01234567', 'a1B2c3D4e5F6g7H8i9J0', '用', ':', '🙂', +] as const + +/** Deterministic generator so every run checks the same inputs. */ +function generator(seed: number): () => number { + let state = seed + return () => { + state = (state + 0x6d2b79f5) | 0 + let mixed = Math.imul(state ^ (state >>> 15), 1 | state) + mixed = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed + return ((mixed ^ (mixed >>> 14)) >>> 0) / 4_294_967_296 + } +} + +function generated(count: number, seed: number, maxFragments = 60): string[] { + const random = generator(seed) + return Array.from({ length: count }, () => { + const length = 1 + Math.floor(random() * maxFragments) + return Array.from({ length }, () => { + const fragment = FRAGMENTS[Math.floor(random() * FRAGMENTS.length)]! + // Repeat short fragments so length thresholds are crossed on both sides. + return fragment.length <= 2 ? fragment.repeat(1 + Math.floor(random() * 40)) : fragment + }).join('') + }) +} + +const fill = (unit: string, characters: number): string => unit.repeat(Math.ceil(characters / unit.length)).slice(0, characters) +const FIXED = [ + '', + join('用 ', 'sk', '-', '0123456789abcdef0123456789abcdef', ' 试试'), + join('postgres://admin:', 'p4ss-W0rd', '@db.internal:5432/app'), + join('git clone https://x-access-token:', 'gh', 'p_', fill('a1B2c3', 36), '@github.com/o/r.git'), + join('redis://:p4ss@cache https://example.com:8080/a@b https://example.com:8443?e=a@b'), + join('https://', 'sk', '-', '0123456789abcdef0123456789abcdef', ':p4ss-W0rd@api.example.com/v1'), + [join('-----BEGIN', ' OPENSSH PRIVATE KEY-----'), 'b3BlbnNzaC1rZXk', join('-----END', ' OPENSSH PRIVATE KEY-----')].join('\n'), + [join('-----BEGIN', ' PRIVATE KEY-----'), 'MIIEvQIBADANBgkq', join('-----END', ' PRIVATE KEY-----')].join('\r\n'), + join('-----BEGIN', ' PRIVATE KEY-----', 'MIIE', '-----END', ' PRIVATE KEY-----'), + join('ey', 'J', fill('a1B2', 17), '.', 'ey', 'J', fill('c3D4', 30), '.', fill('e5F6', 43)), + join('xo', 'xb-1234567890-', 'sk', '-abc_', fill('a1B2', 20)), + join('AK', 'IA', 'ABCDEFGHIJ234567 ', 'AK', 'IA', 'ABCDEFGHIJ2345678 ', 'LT', 'AI', fill('a1', 20), ' ', 'AK', 'ID', fill('a1', 40)), + join('x', 's', 'k-----BEGIN', ' RSA PRIVATE KEY-----\nabc\n'), + join('gl', 'pat-----BEGIN', ' RSA PRIVATE KEY-----\nabc\n-----END', ' RSA PRIVATE KEY-----'), + join('LT', 'AIza', 'a1B2c3D4e5F6 '), + fill(join('-', 's', 'k-', 'gh', 'p_', 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJ'), 2_000), + fill(join('s', 'k-'), 2_000), + fill(join('ey', 'Jabcdefgh-'), 2_000), + fill(join('-----BEGIN', ' RSA PRIVATE KEY-----\nabc\n'), 2_000), + fill('a://u:', 2_000), + fill(`abcdefghijklmnop://${'u'.repeat(250)}:`, 2_000), + fill(join('gh', 'p_', fill('a1B2c3', 35), ' '), 2_000), +] + +const context: UserTextTransformContext = { read: () => undefined } + +describe('prefix-pruned credential matching', () => { + it.each([['fixed', FIXED], ['generated', generated(5_000, 5824)]] as const)('matches the regular-expression nodes on %s inputs', (_name, texts) => { + const pruned = evaluator(createSecretRule()) + const oracle = evaluator(regexSecretRule) + for (const text of texts) { + for (const facts of [{}, { collapseWhitespace: true, trimWhitespace: true }]) { + expect(pruned({ text, facts }), JSON.stringify(text)).toEqual(oracle({ text, facts })) + } + } + }) + + it('matches the regular-expression candidate from every query offset', () => { + const rule = createSecretRule() + for (const text of [...FIXED.filter(text => text.length <= 600), ...generated(400, 77, 25)]) { + for (let from = 0; from <= text.length; from++) { + expect(rule.match(text, from, context), `${JSON.stringify(text)} from ${String(from)}`).toEqual(regexSecretRule.match(text, from, context)) + } + } + }) +}) diff --git a/packages/client/ui-secret-mask/tests/secret-rule.client.spec.ts b/packages/client/ui-secret-mask/tests/secret-rule.client.spec.ts new file mode 100644 index 0000000000..8bc69500bb --- /dev/null +++ b/packages/client/ui-secret-mask/tests/secret-rule.client.spec.ts @@ -0,0 +1,245 @@ +/** + * Credential recognition, masking forms, and forward-query behavior through the + * production UserText evaluator. + */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { UserTextNode, UserTextRule, UserTextTransformContext } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { UserTextRegistry } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/user-text-registry.ts' +import { collapseWhitespaceRule, trimWhitespaceRule } from '@deepseek-ai/dsh-client-ui-conversation/src/client/user-text/whitespace-rules.ts' +import { formatTables } from '../src/client/format-tables.ts' +import { createSecretRule } from '../src/client/secret-rule.ts' + +// Samples are assembled at run time so the repository holds no literal in a credential format. +const hex = (length: number): string => '0123456789abcdef'.repeat(8).slice(0, length) +const mixed = (length: number): string => 'a1B2c3D4e5F6g7H8i9J0'.repeat(8).slice(0, length) +const stars = (length: number): string => '*'.repeat(length) +const join = (...parts: string[]): string => parts.join('') + +function setup(rules: readonly UserTextRule[] = [createSecretRule()]) { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + const registry = new UserTextRegistry(ctx) + for (const rule of rules) registry.register(rule) + return registry.createEvaluator() +} + +const context: UserTextTransformContext = { read: () => undefined } +const isSecret = (node: UserTextNode): node is UserTextNode<'secret'> => node.id === 'secret' +const secrets = (text: string): UserTextNode<'secret'>[] => setup()({ text, facts: {} }).filter(isSecret) +const found = (text: string): string[] => secrets(text).map(node => text.slice(node.start, node.end)) +const masks = (text: string): string[] => secrets(text).map(node => node.data.masked) + +describe('secretRule recognition', () => { + it.each([ + ['a model-service key', join('sk', '-', hex(32))], + ['a project-scoped model-service key', join('sk', '-proj-', mixed(48))], + ['a Google API key', join('AI', 'za', mixed(35))], + ['a GitHub classic token', join('gh', 'p_', mixed(36))], + ['a GitHub fine-grained token', join('github', '_pat_', mixed(22), '_', mixed(59))], + ['a GitLab token', join('gl', 'pat-', mixed(20))], + ['an npm token', join('np', 'm_', mixed(36))], + ['a Hugging Face token', join('h', 'f_', mixed(34))], + ['an AWS access key ID', join('AK', 'IA', 'ABCDEFGHIJ234567')], + ['a temporary AWS access key ID', join('AS', 'IA', 'ABCDEFGHIJ234567')], + ['an Alibaba Cloud AccessKey ID', join('LT', 'AI', mixed(20))], + ['a Tencent Cloud SecretId', join('AK', 'ID', mixed(32))], + ['a Slack token', join('xo', 'xb-', '1234567890-', mixed(24))], + ['a Stripe secret key', join('sk', '_live_', mixed(24))], + ['a Stripe restricted key', join('rk', '_test_', mixed(24))], + ['a Stripe webhook secret', join('wh', 'sec_', mixed(32))], + ['a JSON Web Token', join('ey', 'J', mixed(17), '.', 'ey', 'J', mixed(30), '.', mixed(43))], + ])('recognizes %s inside a sentence', (_name, sample) => { + expect(found(`用 ${sample} 试试`)).toEqual([sample]) + }) + + it.each([ + ['ordinary prose', '把密码改一下,token 用量也看看,password=hunter2'], + ['a hyphenated identifier without digits', 'sk-button-primary-large-rounded'], + ['a key prefix that is too short', 'sk-12345'], + ['a key prefix inside a longer word', join('task', '-', hex(32))], + ['a commit hash', hex(40)], + ['a UUID', '123e4567-e89b-12d3-a456-426614174000'], + ])('leaves %s alone', (_name, text) => { + expect(found(text)).toEqual([]) + }) + + it('leaves addresses without a password alone', () => { + expect(found('https://example.com:8080/a@b https://user@example.com/x git@github.com:o/r.git')).toEqual([]) + expect(found('https://example.com:8443?email=alice@example.org https://example.com:8443#alice@example.org')).toEqual([]) + }) +}) + +describe('secretRule masking', () => { + it('keeps the first eight characters of a key and masks every other character', () => { + const key = join('sk', '-', hex(32)) + expect(masks(`${key}用这个`)).toEqual([key.slice(0, 8) + stars(27)]) + }) + + it('masks only the password of an address that carries credentials', () => { + const text = join('postgres://admin:', 'p4ss-W0rd', '@db.internal:5432/app') + expect(found(text)).toEqual(['p4ss-W0rd']) + expect(masks(text)).toEqual([stars(9)]) + }) + + it('masks a key used as an address user name and the password after it separately', () => { + const key = join('sk', '-', hex(32)) + const text = `https://${key}:p4ss-W0rd@api.example.com/v1` + expect(found(text)).toEqual([key, 'p4ss-W0rd']) + expect(masks(text)).toEqual([key.slice(0, 8) + stars(27), stars(9)]) + }) + + it('masks one character per code point, so an emoji or a lone surrogate in a password is one mark', () => { + expect(masks('postgres://u:p\u{1F642}\ud83dx@db')).toEqual([stars(4)]) + }) + + it('ends a key in an address user name before the password, which a later query finds', () => { + const key = join('sk', '-', hex(32)) + const token = join('gh', 'p_', mixed(36)) + const text = `https://${key}:p4ss-W0rd@api.example.com ${token}` + expect(found(text)).toEqual([key, 'p4ss-W0rd', token]) + }) + + it('masks an address password that has no user name', () => { + expect(found('redis://:p4ss-W0rd@cache:6379/0')).toEqual(['p4ss-W0rd']) + }) + + it('masks a token used as an address password completely', () => { + const token = join('gh', 'p_', mixed(36)) + const text = `git clone https://x-access-token:${token}@github.com/o/r.git` + expect(found(text)).toEqual([token]) + expect(masks(text)).toEqual([stars(token.length)]) + }) + + it('joins an overlapping later match to the earliest one and keeps its mask', () => { + const slack = join('xo', 'xb-', '1234567890-') + const key = join('sk', '-abc_', mixed(20)) + const text = `${slack}${key} 完` + expect(found(text)).toEqual([slack + key]) + expect(masks(text)).toEqual([(slack + key).slice(0, 8) + stars(slack.length + key.length - 8)]) + }) + + it('masks the body of a private key block and keeps its first and last lines', () => { + const begin = join('-----BEGIN', ' OPENSSH PRIVATE KEY-----') + const end = join('-----END', ' OPENSSH PRIVATE KEY-----') + const block = [begin, 'b3BlbnNzaC1rZXk', 'AAAAB3NzaC1yc2E=', end].join('\n') + const text = `私钥在这\n${block}\n帮我配一下` + expect(found(text)).toEqual([block]) + expect(masks(text)).toEqual([[begin, stars(15), stars(16), end].join('\n')]) + }) + + it('keeps carriage returns of a private key block with CRLF line endings', () => { + const begin = join('-----BEGIN', ' PRIVATE KEY-----') + const end = join('-----END', ' PRIVATE KEY-----') + const text = [begin, 'MIIEvQIBADANBgkq', end].join('\r\n') + expect(masks(text)).toEqual([[begin, stars(16), end].join('\r\n')]) + }) + + it('masks a private key block that is cut off before its last line', () => { + const begin = join('-----BEGIN', ' RSA PRIVATE KEY-----') + const text = `${begin}\nMIIEowIBAAKCAQEA` + expect(found(text)).toEqual([text]) + expect(masks(text)).toEqual([`${begin}\n${stars(16)}`]) + }) + + it.each([join('s', 'k-'), join('gl', 'pat-'), join('xo', 'xb-')])('finds a private key block whose first dash ends the key prefix %s', (prefix) => { + const block = [join('-----BEGIN', ' RSA PRIVATE KEY-----'), 'MIIEowIBAAKCAQEA', join('-----END', ' RSA PRIVATE KEY-----')].join('\n') + const text = `x${prefix.slice(0, -1)}${block}` + expect(found(text)).toEqual([block]) + }) + + it('masks a key longer than four thousand characters one mark per character', () => { + const key = join('sk', '-', hex(16), 'a'.repeat(5_000)) + expect(masks(`${key} 完`)).toEqual([key.slice(0, 8) + stars(key.length - 8)]) + }) + + it('joins a private key block that starts inside a key and keeps the line breaks of both', () => { + const key = join('sk', '-', hex(20)) + const block = [join('-----BEGIN', ' RSA PRIVATE KEY-----'), 'MIIE', join('-----END', ' RSA PRIVATE KEY-----')].join('\r\n') + const text = `${key}${block}` + expect(found(text)).toEqual([text]) + const tail = text.slice(8).split('\r\n').map(line => stars(line.length)).join('\r\n') + expect(masks(text)).toEqual([text.slice(0, 8) + tail]) + }) + + it('masks a private key body one mark per code point, including lone and cut surrogates', () => { + const begin = join('-----BEGIN', ' PRIVATE KEY-----') + const end = join('-----END', ' PRIVATE KEY-----') + const text = [begin, 'a\u{1F642}b\udc00c\ud83dx\ud83d', end].join('\n') + expect(masks(text)).toEqual([[begin, stars(8), end].join('\n')]) + }) + + it('masks private key body lines longer than four thousand characters, before either line break', () => { + const begin = join('-----BEGIN', ' PRIVATE KEY-----') + const end = join('-----END', ' PRIVATE KEY-----') + const text = `${begin}\n${'A'.repeat(5_000)}\r\n${'B'.repeat(5_000)}\nC\n${end}` + expect(masks(text)).toEqual([`${begin}\n${stars(5_000)}\r\n${stars(5_000)}\n${stars(1)}\n${end}`]) + }) + + it('masks an address inside a private key block as part of the block', () => { + const begin = join('-----BEGIN', ' PRIVATE KEY-----') + const end = join('-----END', ' PRIVATE KEY-----') + const key = join('sk', '-', hex(32)) + const block = [begin, 'https://u:p4ss@host', key, end].join('\n') + expect(found(`${block}\n用这个`)).toEqual([block]) + }) + + it('masks each of many one-line private key blocks whole, with text after the last', () => { + const block = join('-----BEGIN', ' PRIVATE KEY-----', 'abcd', '-----END', ' PRIVATE KEY-----') + expect(masks(`${block} ${block} 完`)).toEqual([stars(block.length), stars(block.length)]) + }) + + it('masks a private key block written on one line whole', () => { + const text = join('-----BEGIN', ' PRIVATE KEY-----', 'MIIEvQIBADANBgkq', '-----END', ' PRIVATE KEY-----') + expect(masks(text)).toEqual([stars(text.length)]) + }) +}) + +describe('secretRule scheduling', () => { + it('finds format starts through grouped literals only, so scanning cannot backtrack', () => { + const literals = formatTables().prefixSource.replace(/\\./gu, '').replaceAll('(?:', '').replaceAll(')', '') + expect(literals.split('|').length).toBeGreaterThan(20) + expect(literals).not.toMatch(/[*+?{}()[\].^$]/u) + }) + + it('reads no Facts, queries forward once per credential, and reuses the result across Facts updates', () => { + const read = vi.fn() + const rule = createSecretRule() + const match = vi.fn['match']>((text, from, context) => rule.match(text, from, { + read: (key) => { read(key); return context.read(key) }, + })) + const evaluate = setup([{ ...rule, match }]) + const first = join('gh', 'p_', mixed(36)) + const second = join('sk', '-', hex(32)) + const text = `${first} 和 ${second}` + const before = evaluate({ text, facts: {} }) + expect(before.filter(isSecret).map(node => text.slice(node.start, node.end))).toEqual([first, second]) + expect(before.map(node => [node.start, node.end])).toEqual([ + [0, first.length], [first.length, first.length + 3], [first.length + 3, text.length], + ]) + expect(match.mock.calls.map(([, from]) => from)).toEqual([0, first.length, text.length]) + expect(evaluate({ text, facts: { collapseWhitespace: true } })).toBe(before) + expect(match).toHaveBeenCalledTimes(3) + expect(read).not.toHaveBeenCalled() + }) + + it.each(['postgres://admin:p4ss@db', 'redis://:p4ss@cache'])('finds the same address password in %s from any cursor before it', (text) => { + const start = text.indexOf('p4ss') + for (let from = 0; from <= start; from++) { + // A fresh instance per cursor: no query earlier in the text warms its memo. + expect(createSecretRule().match(text, from, context), `from ${String(from)}`).toEqual({ id: 'secret', start, end: start + 4, data: { masked: stars(4) } }) + } + expect(createSecretRule().match(text, start + 1, context)).toBeUndefined() + }) + + it('keeps line breaks of a private key block when queue whitespace transforms run beside it', () => { + const begin = join('-----BEGIN', ' PRIVATE KEY-----') + const end = join('-----END', ' PRIVATE KEY-----') + const block = [begin, 'MIIEvQIBADANBgkq', end].join('\n') + const evaluate = setup([createSecretRule(), collapseWhitespaceRule, trimWhitespaceRule]) + const nodes = evaluate({ text: ` 私钥:\n${block}\n `, facts: { collapseWhitespace: true, trimWhitespace: true } }) + const display = (node: UserTextNode): string => + isSecret(node) ? node.data.masked : node.id === 'text' ? node.data.display ?? node.data.text : '' + expect(nodes.map(display).join('')).toBe(`私钥: ${[begin, stars(16), end].join('\n')}`) + }) +}) diff --git a/packages/client/ui-secret-mask/tests/secret-text.client.spec.tsx b/packages/client/ui-secret-mask/tests/secret-text.client.spec.tsx new file mode 100644 index 0000000000..c075d9de30 --- /dev/null +++ b/packages/client/ui-secret-mask/tests/secret-text.client.spec.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +/** The masked credential renderer: reveal control, accessible names, and reset to masked. */ +import { cleanup, fireEvent, render } from '@testing-library/react' +import type { ComponentProps } from 'react' +import { afterEach, describe, expect, it } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { SecretText } from '../src/client/SecretText.tsx' +import { en, zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +type Props = ComponentProps + +// Assembled at run time so the repository holds no literal in a credential format. +const KEY = ['sk', '-', '0123456789abcdef0123456789abcdef'].join('') +const OTHER = ['sk', '-', 'fedcba9876543210fedcba9876543210'].join('') +const masked = (value: string): string => value.slice(0, 8) + '*'.repeat(value.length - 8) + +function props(raw: string, interactive: boolean, dictionary: Record = zh): Props { + // The component reads only these owner and locale seats. + return { + node: { id: 'secret', start: 0, end: raw.length, data: { masked: masked(raw) } }, + raw, interactive, t: makeTranslate(dictionary), + } as Props +} + +describe('SecretText', () => { + it('draws only the masked form without a control in non-interactive text', () => { + const view = render(
) + const run = view.container.querySelector('[data-secret]')! + expect(run.textContent).toBe(masked(KEY)) + expect(view.container.querySelector('button')).toBeNull() + }) + + it('reveals and hides the value from its own control', () => { + const view = render() + const run = view.container.querySelector('[data-secret]')! + const reveal = view.getByRole('button', { name: '显示密钥' }) + expect(run.textContent).toBe(masked(KEY)) + expect(reveal.getAttribute('aria-pressed')).toBe('false') + expect(reveal.querySelector('svg')).not.toBeNull() + + fireEvent.click(reveal) + expect(run.textContent).toBe(KEY) + const hide = view.getByRole('button', { name: '隐藏密钥' }) + expect(hide.getAttribute('aria-pressed')).toBe('true') + + fireEvent.click(hide) + expect(run.textContent).toBe(masked(KEY)) + }) + + it('starts masked again when a different credential reuses the same element', () => { + const view = render() + fireEvent.click(view.getByRole('button', { name: 'Show secret' })) + view.rerender() + expect(view.container.querySelector('[data-secret]')!.textContent).toBe(masked(OTHER)) + expect(view.getByRole('button', { name: 'Show secret' }).getAttribute('aria-pressed')).toBe('false') + }) +}) diff --git a/packages/client/ui-secret-mask/tsconfig.json b/packages/client/ui-secret-mask/tsconfig.json new file mode 100644 index 0000000000..a783ae90ea --- /dev/null +++ b/packages/client/ui-secret-mask/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../locale/tsconfig.client.json" + }, + { + "path": "../ui-conversation/tsconfig.client.json" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-slots" + } + ] +} diff --git a/packages/client/ui-secret-mask/tsdown.config.ts b/packages/client/ui-secret-mask/tsdown.config.ts new file mode 100644 index 0000000000..7aa03cebc1 --- /dev/null +++ b/packages/client/ui-secret-mask/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-session/locale/en.json b/packages/client/ui-session/locale/en.json new file mode 100644 index 0000000000..03c3a8e2cc --- /dev/null +++ b/packages/client/ui-session/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-session" + } +} diff --git a/packages/client/ui-session/locale/zh.json b/packages/client/ui-session/locale/zh.json new file mode 100644 index 0000000000..8dee9f572e --- /dev/null +++ b/packages/client/ui-session/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "会话界面连接", + "description": "将会话控制和状态连接到 React 界面组件。" + } +} diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index e0d1b55faa..db6caa564f 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -54,10 +55,10 @@ "@deepseek-ai/dsh-remote-mock": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-session/tsdown.config.ts b/packages/client/ui-session/tsdown.config.ts index ca879a1232..7aa03cebc1 100644 --- a/packages/client/ui-session/tsdown.config.ts +++ b/packages/client/ui-session/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-session', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index 65b2cc2812..a0ed274b05 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md /: - en: 1fcba8f273b661fb - zh: 14e1236f84ae39d7 + en: 13d891a226092817 + zh: 90bf6ff56dd3c096 /deepseek-ai-dsh-client-ui-settings-account: en: 9ff7ac2d70f70b59 zh: ded522c4cfd6e040 @@ -15,17 +15,17 @@ en: c26f81e8adf0128b zh: e11ab371f51f66bc /deepseek-ai-dsh-client-ui-settings-account/use-this-package: - en: fe1db216d397749f - zh: f823c2cd43152e66 + en: f73e8f3448239b51 + zh: 88bee1c1f465b906 /deepseek-ai-dsh-client-ui-settings-account/use-this-package/desktop-onboarding: en: c1c4db71fd09ca13 zh: a40c0a01e9951945 /deepseek-ai-dsh-client-ui-settings-account/understand-the-implementation: - en: 93e9874de6ed3a4d - zh: d98ba48e88078eee + en: 149d25b949a24036 + zh: a432400e245bf48b /deepseek-ai-dsh-client-ui-settings-account/further-exploration: - en: a28acdef21b1643a - zh: 5e2e357a6b716274 + en: f5ca95916ef486dc + zh: 1e238d8299e0d838 /deepseek-ai-dsh-client-ui-settings-account/model-experience: en: ee1df7ca3467ee3f zh: 84904a5148da3862 @@ -33,8 +33,8 @@ en: ca3015e252db0114 zh: ebca9253cf15e8e9 /deepseek-ai-dsh-client-ui-settings-account/known-limitations-and-deferred-work: - en: 161a4d95bfdd5181 - zh: b94c3e165e69d23b + en: 7afbd3f7afc20bd8 + zh: f54c57e3d6738c7c /deepseek-ai-dsh-client-ui-settings-account/known-limitations-and-deferred-work/dev-note: en: 2facb0fc2e6c1a9c zh: bd04803f5c9e132c diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index c5a3c88397..913eddfa4b 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -1,5 +1,5 @@ --- -description: "Desktop Account settings display DeepSeek login state and offer browser authorization and cancellation; the sidebar account menu provides Platform sign-out." +description: "Account settings display DeepSeek login state and offer browser authorization and cancellation; the sidebar account menu provides Platform sign-out." kind: "package-reference" --- @@ -28,7 +28,7 @@ A new account-model sign-in-required event displays “Model unavailable. Please ## Use this package -The client activates only inside Desktop, identified by its preload bridge. Plain Web clients keep the standard Settings launcher and API-key onboarding without account login, account settings, or an account-state subscription. +Desktop and plain Web clients both mount the sidebar account launcher, Account settings, the sign-in step of model onboarding, and the account-state subscription. Desktop main opens the authorization page in the system browser. A plain Web client, identified by the absent Desktop preload bridge, sends `login_source` web with its own origin as the callback origin, preopens a tab during the sign-in click, navigates it to the authorization URL with the same `theme` parameter once its attempt receives one, and closes it on failure, expiry, or cancellation; a blocked popup leaves the copy-link route. Embedded Platform pages, Desktop onboarding, and the native device reader stay Desktop-only, so Web Platform links open new tabs. The sidebar and Account settings display circular profile images with diameters of 24px and 32px respectively, with the account icon as fallback when the URL is absent or the image fails to load. The collapsed sidebar centers the avatar in a 36 × 36 px button. Before sign-in the sidebar launcher shows a More row instead, leading with the ellipsis glyph. @@ -42,7 +42,7 @@ Desktop usage, top-up, and onboarding recharge actions open one isolated native Chat offers each live quota failure from a bound Session to the frame-wide `shell.quota-notice` chain from its host in `shell.overlay`, so the notice outlives the Chat panel that reported it. The account entry claims `ACCOUNT_QUOTA`: while an account credential is stored and the shared host entry exists, the Modal offers Cancel and Top up. While a page requested through the shared request channel is showing, the native view covers this document, so the entry paints neither the Modal nor the Toast. A quota failure that arrives while a Settings-opened page is already showing is not retained: it stays subject to newest-notice replacement, and the Modal appears after the viewer returns while that notice is still current. That Modal reports the failure of the original request, not the account's current balance: the top-up re-read is a separate read, and returning neither proves a successful payment nor dismisses the notice on its own. The notice's own Top up is the retained case: it calls `keepOpen()` and requests the `top-up` page through the shared request channel, keeping that notice and its page mounted through later quota failures. With the shared host entry the entry waits for the first account snapshot instead of rendering a Toast that its own timer could discard; a snapshot that reports no stored credential, or a failed account stream with no snapshot, then renders the same neutral warning Toast itself, and without that host entry the entry renders that Toast immediately, instead of relying on the chat host fallback. The entry owns the hold and its page request and releases both when it unmounts, so a later quota failure publishes again without replaying the dropped ones; dismissal or sign-out clears the hold and the page request. Close, Cancel, a sign-out, or leaving the notice's own embedded page takes the notice down without retrying the request. -The account menu's Feedback entry opens the Feishu questionnaire in the system browser. Every click samples its context then: the account UID when the profile is ready and carries an id, the build version as harness_version, and device_info. The device_info value includes the physical screen resolution as screen_resolution=widthxheight, after rounding the screen dimensions multiplied by devicePixelRatio; unavailable screen dimensions omit this entry. Each context field also carries hide_*=1, stale prefill values for these fields are dropped from the configured form URL first, including uid, device_info and harness_version regardless of the configured URL. The URL carries no os_version, device_brand, app_locale or separate screen_resolution parameters; configured prefill and hide parameters for these fields are removed. Tokens and masked contact details stay out, and a signed-out, still-loading, or failed profile leaves prefill_uid out. When the Desktop bridge has no deviceInfo reader, the form opens with navigator.userAgent. Desktop exposes the optional dshDesktop.deviceInfo reader, whose field format the [Desktop README](../../../apps/desktop/README.md) owns; its answer replaces the user agent, a failed read still opens the form with any available screen resolution in device_info, and device_info describes the reported environment rather than a hardware model. Configure contactFormUrl on the ui-settings-account plugin to select another HTTPS form; contactSource defaults to empty until the questionnaire supports a Harness source option. +The account menu's Feedback entry opens the Feishu questionnaire in the system browser. Every click samples its context then: the account UID when the profile is ready and carries an id, the build version as harness_version, and device_info. The device_info value includes the physical screen resolution as screen_resolution=widthxheight, after rounding the screen dimensions multiplied by devicePixelRatio; unavailable screen dimensions omit this entry. Each context field also carries hide_*=1, stale prefill values for these fields are dropped from the configured form URL first, including uid, device_info and harness_version regardless of the configured URL. The URL carries no os_version, device_brand, app_locale or separate screen_resolution parameters; configured prefill and hide parameters for these fields are removed. Tokens and masked contact details stay out, and a signed-out, still-loading, or failed profile leaves prefill_uid out. In a plain browser, or when the Desktop bridge has no deviceInfo reader, the form opens with navigator.userAgent. Desktop exposes the optional dshDesktop.deviceInfo reader, whose field format the [Desktop README](../../../apps/desktop/README.md) owns; its answer replaces the user agent, a failed read still opens the form with any available screen resolution in device_info, and device_info describes the reported environment rather than a hardware model. Configure contactFormUrl on the ui-settings-account plugin to select another HTTPS form; contactSource defaults to empty until the questionnaire supports a Harness source option. Account profile and balance cards share the [settings card material and radius](../../../docs/web-styling.md#corner-radii-and-settings-cards). Usage and top-up links match standard Button geometry; authorization actions use the shared Button. @@ -68,14 +68,14 @@ The welcome page requires Get started. The credit page always appears. A positiv The Account section appears first in Settings only while signed in; it is hidden before account state loads and after sign-out. The plugin owns one Host snapshot stream shared through framework hooks by settings.section and settings.launcher. The launcher opens Settings and offers Sign out only while an account credential is stored. Platform failures leave the menu available for retry. It maintains no independent credential state. -The desktop controller writes progress to `ui-settings-account` through Host settings. Completion first applies `ui-chat.transcriptView` and `ui-chat.performanceUsage` together, then calls the shared Coding Tools preference to persist `ui-settings.enabled`, and finally saves the done marker; a rejected write keeps the flow available for retry. Successful completion reveals the workspace through a 180ms fade; choices and ordinary navigation preview immediately while writes run in order. A failed final queued write restores saved progress and allows retry. Completion waits for queued choices and retains the page until preferences persist; reduced motion skips the fade. Onboarding choices compact, standard, and detailed apply directly to the corresponding Chat work-detail modes. API-key entry and explicit skip select standard; onboarding does not select verbose. Complete Figma illustration layers are bundled as transparent, palette-compressed 3× PNG assets; welcome layers are merged with local sidebar blur; recharge uses a 70% opaque foreground window with local blur of the covered illustration, and headings use the bundled Montserrat brand font; English headings and descriptions use Light (300), except the DeepSeek Harness brand within headings uses Medium (500); card titles retain Regular (400) when selected. English footer navigation uses Light (300). Confirmed onboarding sets a 960px minimum window width and enlarges narrower windows; completion releases the minimum without restoring the previous size. Loading or already-completed onboarding does not resize the window. Empty footer navigation space passes pointer events through to the primary action. Cards exclude native window dragging and own the keyboard focus outline for their checkbox. The account package owns its onboarding overlay and control geometry. Each step and the confirmation dialog has a separate component; the flow coordinates navigation, transitions, and recharge. The overlay hides the workspace while initial account and progress state loads. Question headings and cards follow the Figma positions at 1440 × 920 and 960 × 600, independently of their action buttons. On macOS, content clears the body portal’s inherited no-drag region and a first-child drag band leaves an 8px native resize margin; interactive controls and later dialogs exclude their regions, and a native Platform page disables the underlying onboarding band. The upper workspace popup illustration has an opaque background in both languages and themes; the underlying sidebar omits its selected-row highlight. Welcome keeps its illustration at its original size, repositions and clips the artwork in narrow windows, and reveals the complete illustration on wide windows. The [historical onboarding decision](../../../.agents/notes/archived/feature/2026-09-16-desktop-onboarding.md) owns persistence and presentation trade-offs. +The desktop controller writes progress to `ui-settings-account` through Host settings. Completion first applies `ui-chat.transcriptView` and `ui-chat.performanceUsage` together, then calls the shared Coding Tools preference to persist `ui-settings.enabled`, and finally saves the done marker; a rejected write keeps the flow available for retry. Successful completion reveals the workspace through a 180ms fade; choices and ordinary navigation preview immediately while writes run in order. A failed final queued write restores saved progress and allows retry. Completion waits for queued choices and retains the page until preferences persist; reduced motion skips the fade. Onboarding choices compact, standard, and detailed apply directly to the corresponding Chat work-detail modes. API-key entry and explicit skip select standard; onboarding does not select verbose. Complete Figma illustration layers are bundled as transparent, lossless 3× WebP assets; welcome layers are merged with local sidebar blur; recharge uses a 70% opaque foreground window with local blur of the covered illustration, and headings use the bundled Montserrat brand font; English headings and descriptions use Light (300), except the DeepSeek Harness brand within headings uses Medium (500); card titles retain Regular (400) when selected. English footer navigation uses Light (300). Confirmed onboarding sets a 960px minimum window width and enlarges narrower windows; completion releases the minimum without restoring the previous size. Loading or already-completed onboarding does not resize the window. Empty footer navigation space passes pointer events through to the primary action. Cards exclude native window dragging and own the keyboard focus outline for their checkbox. The account package owns its onboarding overlay and control geometry. Each step and the confirmation dialog has a separate component; the flow coordinates navigation, transitions, and recharge. The overlay hides the workspace while initial account and progress state loads. Question headings and cards follow the Figma positions at 1440 × 920 and 960 × 600, independently of their action buttons. On macOS, content clears the body portal’s inherited no-drag region and a first-child drag band leaves an 8px native resize margin; interactive controls and later dialogs exclude their regions, and a native Platform page disables the underlying onboarding band. The upper workspace popup illustration has an opaque background in both languages and themes; the underlying sidebar omits its selected-row highlight. Welcome keeps its illustration at its original size, repositions and clips the artwork in narrow windows, and reveals the complete illustration on wide windows. The [historical onboarding decision](../../../.agents/notes/archived/feature/2026-09-16-desktop-onboarding.md) owns persistence and presentation trade-offs. ## Further Exploration The [credentials subsystem](../../../docs/subsystems/credentials.md) owns storage APIs; the [architecture](../../../docs/architecture.md) explains application composition. -Account login uses a dismissible dialog before the model onboarding credential editor. The dialog uses a compact, right-aligned action row with the primary action last. The sidebar displays the profile name, falling back to the server-masked phone number or email when the name is absent; it stays blank while the profile is loading. Dialog copy and the sidebar account label are not text-selectable; the copy-link button remains available and restores its label two seconds after each successful copy. Clipboard failures show Copy failed on the link for two seconds without interrupting sign-in. Waiting shows a copyable authorization link and a loading indicator; timeout and failure require an explicit retry. The dialog's Copy sign-in link and the Settings Open link append the resolved Desktop palette as `theme=light` or `theme=dark` from the live theme snapshot, so both routes reach the Platform page in the current application theme without changing the theme preference Platform has saved. Sign-in failures render in that dialog; a failed sign-out keeps the menu open so the user can try again, and the launcher shows no failure of its own. Closing a waiting dialog cancels its Host attempt. The sidebar can reopen the same API-key editor through the settings coordinator. +Account login uses a dismissible dialog before the model onboarding credential editor. The dialog uses a compact, right-aligned action row with the primary action last. The sidebar displays the profile name, falling back to the server-masked phone number or email when the name is absent; it stays blank while the profile is loading. Dialog copy and the sidebar account label are not text-selectable; the copy-link button remains available and restores its label two seconds after each successful copy. Clipboard failures show Copy failed on the link for two seconds without interrupting sign-in. Waiting shows a copyable authorization link and a loading indicator; timeout and failure require an explicit retry. The dialog's Copy sign-in link and the Settings Open link append the resolved application palette as `theme=light` or `theme=dark` from the live theme snapshot, so both routes reach the Platform page in the current application theme without changing the theme preference Platform has saved. Sign-in failures render in that dialog; a failed sign-out keeps the menu open so the user can try again, and the launcher shows no failure of its own. Closing a waiting dialog cancels its Host attempt. The sidebar can reopen the same API-key editor through the settings coordinator. A terminal account-state stream failure appears in the sign-in dialog or Account settings. Plugin unload suppresses late failure reports. @@ -99,10 +99,12 @@ No model request prefix changes. - Profile and recharge-wallet balances use the existing Platform Web endpoints through Host getProfile / getBalance. The page refreshes when opened and after login or reconnect, preserves server-masked contact data, and shows query failures independently without manufacturing a zero balance. Usage and top-up use Host-provided links derived from platformOrigin and the browser’s own login; the links never carry a DSH token. - Completed installations do not reapply defaults or overwrite later user preferences. Completion does not synchronize across devices or create a per-account history. -- Onboarding follows the interface locale and theme, with four Figma PNG illustration sets covering Chinese and English in light and dark appearance. Export canvases include the full illustration geometry; Finder artwork uses the same vertical fade in both themes. +- Onboarding follows the interface locale and theme, with four Figma illustration sets covering Chinese and English in light and dark appearance. Export canvases include the full illustration geometry; Finder artwork uses the same vertical fade in both themes. - The notice copy is server-localized for the UI language in effect when the client reads it. Switching language does not itself re-read the unnotified bonus, so a notice already on screen keeps the copy the server sent for the earlier language until the user refreshes or the account lifecycle restarts. +- Web sign-in needs a loopback HTTP page origin (`localhost`, `127.0.0.1`, or `[::1]`), because the Host accepts only those callback origins. A Web UI reached through `--trusted-host` or an HTTPS reverse proxy still shows the entry, but starting sign-in fails with the generic retry state; such deployments add an API key instead. + - The Platform view’s dialog marks every other document child inert, including the Desktop-owned caption menu host, so the Application and Edit menu stays visible but cannot be operated until the view closes. Exempting that host needs an inertness contract owned by the Desktop. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 09bc1997bb..0015b1ffe5 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Desktop 设置中的账号页面显示 DeepSeek 登录状态,并提供浏览器授权和取消;侧边栏账号菜单提供 Platform 退出登录。" +description: "设置中的账号页面显示 DeepSeek 登录状态,并提供浏览器授权和取消;侧边栏账号菜单提供 Platform 退出登录。" kind: "package-reference" --- @@ -28,7 +28,7 @@ kind: "package-reference" ## 使用此包 -客户端仅在具有 preload 桥接的 Desktop 内启用。普通 Web 客户端保留标准设置入口和 API Key 引导,不显示账号登录和账号设置,也不订阅账号状态。 +Desktop 与普通 Web 客户端都挂载侧边栏账号菜单、账号设置、模型引导中的登录步骤,并订阅账号状态。Desktop 主进程在系统浏览器中打开授权页面。普通 Web 客户端(以缺少 Desktop preload 桥接识别)以 `login_source` web 发起登录,并以自身 origin 作为回调 origin;它在点击登录时预先打开一个标签页,本次尝试获得授权 URL 后带上相同的 `theme` 参数导航该标签页,并在失败、过期或取消时关闭它;弹窗被拦截时仍可使用复制链接。内嵌 Platform 页面、Desktop 引导和原生设备信息读取器仅在 Desktop 中提供,因此 Web 中的 Platform 链接在新标签页打开。 侧边栏和账号设置以正圆图片显示账号头像,直径分别为 24px 和 32px;头像 URL 缺失或图片加载失败时使用账号图标兜底。折叠侧边栏将头像居中放置在 36 × 36 px 的按钮内。登录前侧边栏入口改为「更多」行,以三点图标开头。 @@ -42,7 +42,7 @@ Desktop 的用量、充值与引导充值操作在 48px 返回栏下方打开同 Chat 通过 `shell.overlay` 中的宿主把已绑定 Session 的实时欠费交给全局 `shell.quota-notice` 链,提示的生命周期因此长于上报它的 Chat 面板。账号条目认领 `ACCOUNT_QUOTA`:已存储账号凭证且存在共享宿主条目时,Modal 提供取消和去充值。只要通过该共享请求通道请求的页面正在显示,原生视图就会盖住本页,因此该条目既不绘制 Modal 也不绘制 Toast。在 Account 设置页已打开页面的情况下新到达的欠费不会被保持:它仍受最新提示替换规则约束,返回后若该提示仍是最新一条就显示 Modal。该 Modal 报告的是原始请求的失败,而不是账号当前余额:返回时的重读是一次独立读取,返回本身既不证明支付成功,也不会自动撤下该提示。提示自身的去充值属于被保持的情形:它调用 `keepOpen()` 并通过共享请求通道申请 `top-up` 页面,使该提示与其页面在后续欠费期间保持挂载。存在共享宿主条目时,该条目会等待首个账号快照,而不是渲染一个可能被自身计时器丢弃的 Toast;快照报告未存储凭证,或账号流失败且无快照时,才由该条目自行渲染同一段中立警告 Toast,缺少该宿主条目时则立即渲染该 Toast,而不依赖 Chat 宿主回退。该条目持有保持与页面请求,并在自身卸载时释放二者,因此后续欠费会重新发布而不重放已丢弃的提示;关闭与退出登录也会清除保持和页面请求。关闭、取消、退出登录或离开该提示自己的内嵌页面都会撤下提示,且不会重试请求。 -账号菜单的“意见反馈”入口在系统浏览器中打开飞书问卷。每次点击都按当时的上下文取值:profile ready 且带 id 时的账号 UID、以 harness_version 表示的构建版本,以及 device_info。device_info 以 screen_resolution=宽x高 包含屏幕物理分辨率,尺寸乘以 devicePixelRatio 后四舍五入;屏幕尺寸不可用时省略此项。所有上下文字段同时设置 hide_*=1,配置的表单 URL 中这些字段的 stale prefill 参数先被清除,uid、device_info 与 harness_version 的隐藏参数不依赖配置 URL 的取值。URL 不携带 os_version、device_brand、app_locale 或独立的 screen_resolution 参数;配置 URL 中这些字段的 prefill 与 hide 参数会被移除。不传 token 或脱敏联系方式;未登录、profile 仍在加载或读取失败时都不填写 prefill_uid。Desktop 桥接没有 deviceInfo 读取器时,打开的表单以 navigator.userAgent 作为 device_info。Desktop 端暴露可选的 dshDesktop.deviceInfo 读取器,其字段格式由 [Desktop README](../../../apps/desktop/README.zh.md) 负责;该读取器的结果取代 user-agent,读取失败时仍打开表单,device_info 保留可获取的屏幕分辨率,而 device_info 描述的是上报的环境而非硬件型号。可在 ui-settings-account 插件配置 contactFormUrl,切换到另一个 HTTPS 问卷;问卷支持 Harness 来源选项前,contactSource 默认为空。 +账号菜单的“意见反馈”入口在系统浏览器中打开飞书问卷。每次点击都按当时的上下文取值:profile ready 且带 id 时的账号 UID、以 harness_version 表示的构建版本,以及 device_info。device_info 以 screen_resolution=宽x高 包含屏幕物理分辨率,尺寸乘以 devicePixelRatio 后四舍五入;屏幕尺寸不可用时省略此项。所有上下文字段同时设置 hide_*=1,配置的表单 URL 中这些字段的 stale prefill 参数先被清除,uid、device_info 与 harness_version 的隐藏参数不依赖配置 URL 的取值。URL 不携带 os_version、device_brand、app_locale 或独立的 screen_resolution 参数;配置 URL 中这些字段的 prefill 与 hide 参数会被移除。不传 token 或脱敏联系方式;未登录、profile 仍在加载或读取失败时都不填写 prefill_uid。普通浏览器中,或 Desktop 桥接没有 deviceInfo 读取器时,打开的表单以 navigator.userAgent 作为 device_info。Desktop 端暴露可选的 dshDesktop.deviceInfo 读取器,其字段格式由 [Desktop README](../../../apps/desktop/README.zh.md) 负责;该读取器的结果取代 user-agent,读取失败时仍打开表单,device_info 保留可获取的屏幕分辨率,而 device_info 描述的是上报的环境而非硬件型号。可在 ui-settings-account 插件配置 contactFormUrl,切换到另一个 HTTPS 问卷;问卷支持 Harness 来源选项前,contactSource 默认为空。 账号资料与余额卡片共用[设置卡片材质与圆角](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards)。用量与充值链接与标准 Button 尺寸一致;授权操作使用公共 Button。 @@ -68,14 +68,14 @@ Desktop preload 标记在账号凭证已存储后启用引导。进度属于本 仅登录后在设置导航首位显示账号页;账号状态加载完成前以及退出登录后隐藏该页。 插件持有一条 Host 快照流,通过框架 hook 供 settings.section 和 settings.launcher 共享。入口菜单可打开设置,并仅在已存储账号凭证时提供退出登录。Platform 请求失败时保留菜单,以供重试。插件不维护独立凭证状态。 -桌面控制器通过 Host 设置将进度写入 `ui-settings-account`。完成时先一并应用 `ui-chat.transcriptView` 与 `ui-chat.performanceUsage`,再通过共享代码工作工具偏好保存 `ui-settings.enabled`,最后保存完成标记;写入被拒绝时保留引导以供重试。成功完成后通过 180ms 淡出显露工作区;选择和普通翻页立即预览,写入在后台按顺序执行。队列最后一次写入失败时恢复已保存进度,并允许重试。完成操作会等待选择队列及偏好保存成功,再关闭页面;减少动态效果偏好会跳过淡出。onboarding 选项 compact、standard、detailed 直接应用到对应的 Chat 工作过程模式。API Key 进入与显式跳过均采用 standard;onboarding 不选择 verbose。完整 Figma 示意图层以透明、调色板压缩的 3× PNG 资源打包;欢迎页图层合并并烘焙侧栏局部模糊;充值页前景窗口不透明度为 70%,仅烘焙其覆盖插图区域的模糊,标题使用已打包的 Montserrat 品牌字体,英文标题和描述使用 Light(300),标题中的 DeepSeek Harness 使用 Medium(500);卡片标题选中前后均使用 Regular(400)。英文底部导航使用 Light(300)。确认进入引导后,窗口最小宽度设为 960px,较窄窗口自动放大;完成后解除宽度限制,但不恢复原尺寸。加载中或已完成引导的启动不改变窗口尺寸。底部导航空白区域透过鼠标事件,避免遮挡主操作。卡片区域不参与原生窗口拖动,复选框的键盘焦点仅在卡片外框展示。账号包负责引导覆盖层和控件尺寸样式。每个步骤和确认弹窗各有独立组件,流程组件协调导航、切换动画和充值。初始账号与进度加载期间,覆盖层隐藏工作区。答题标题和卡片独立于操作按钮,位置对齐 Figma 的 1440 × 920 和 960 × 600 断点。macOS 的内容层清除 body 浮层继承的 no-drag,位于内容之前的拖拽层保留 8px 原生缩放边缘;交互控件和后续弹窗排除自身区域;原生平台页面打开时禁用底层引导拖拽层。上方工作区弹窗插画在两种语言和主题下均使用不透明背景,下层侧边栏不绘制选中行高亮。欢迎页保持示意图的原始尺寸,在窄窗口内调整插图位置并裁切,在宽窗口内展示完整插图。[历史引导决策](../../../.agents/notes/archived/feature/2026-09-16-desktop-onboarding.md)记录持久化与展示方面的取舍。 +桌面控制器通过 Host 设置将进度写入 `ui-settings-account`。完成时先一并应用 `ui-chat.transcriptView` 与 `ui-chat.performanceUsage`,再通过共享代码工作工具偏好保存 `ui-settings.enabled`,最后保存完成标记;写入被拒绝时保留引导以供重试。成功完成后通过 180ms 淡出显露工作区;选择和普通翻页立即预览,写入在后台按顺序执行。队列最后一次写入失败时恢复已保存进度,并允许重试。完成操作会等待选择队列及偏好保存成功,再关闭页面;减少动态效果偏好会跳过淡出。onboarding 选项 compact、standard、detailed 直接应用到对应的 Chat 工作过程模式。API Key 进入与显式跳过均采用 standard;onboarding 不选择 verbose。完整 Figma 示意图层以透明、无损编码的 3× WebP 资源打包;欢迎页图层合并并烘焙侧栏局部模糊;充值页前景窗口不透明度为 70%,仅烘焙其覆盖插图区域的模糊,标题使用已打包的 Montserrat 品牌字体,英文标题和描述使用 Light(300),标题中的 DeepSeek Harness 使用 Medium(500);卡片标题选中前后均使用 Regular(400)。英文底部导航使用 Light(300)。确认进入引导后,窗口最小宽度设为 960px,较窄窗口自动放大;完成后解除宽度限制,但不恢复原尺寸。加载中或已完成引导的启动不改变窗口尺寸。底部导航空白区域透过鼠标事件,避免遮挡主操作。卡片区域不参与原生窗口拖动,复选框的键盘焦点仅在卡片外框展示。账号包负责引导覆盖层和控件尺寸样式。每个步骤和确认弹窗各有独立组件,流程组件协调导航、切换动画和充值。初始账号与进度加载期间,覆盖层隐藏工作区。答题标题和卡片独立于操作按钮,位置对齐 Figma 的 1440 × 920 和 960 × 600 断点。macOS 的内容层清除 body 浮层继承的 no-drag,位于内容之前的拖拽层保留 8px 原生缩放边缘;交互控件和后续弹窗排除自身区域;原生平台页面打开时禁用底层引导拖拽层。上方工作区弹窗插画在两种语言和主题下均使用不透明背景,下层侧边栏不绘制选中行高亮。欢迎页保持示意图的原始尺寸,在窄窗口内调整插图位置并裁切,在宽窗口内展示完整插图。[历史引导决策](../../../.agents/notes/archived/feature/2026-09-16-desktop-onboarding.md)记录持久化与展示方面的取舍。 ## 深入探索 [凭证子系统](../../../docs/subsystems/credentials.zh.md)定义存储接口;[架构](../../../docs/architecture.zh.md)说明应用组合。 -账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。侧边栏优先显示昵称,缺少昵称时显示服务端返回的脱敏手机号或邮箱;资料加载期间保持留空。 弹窗文字和侧边栏账号状态文字不可选中,复制登录链接按钮始终可用,并在每次复制成功两秒后恢复原文案。剪贴板失败时,链接原位显示“复制失败”两秒,不中断登录。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。弹窗的“复制登录链接”和设置页的“打开”链接会从当前主题快照读取 Desktop 生效配色,追加 `theme=light` 或 `theme=dark`,使两条路径都以当前应用主题打开 Platform 页面,且不改变 Platform 已保存的主题偏好。登录失败在该弹窗内呈现;退出登录失败时菜单保持展开以便重试,入口本身不展示失败提示。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。 +账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。侧边栏优先显示昵称,缺少昵称时显示服务端返回的脱敏手机号或邮箱;资料加载期间保持留空。 弹窗文字和侧边栏账号状态文字不可选中,复制登录链接按钮始终可用,并在每次复制成功两秒后恢复原文案。剪贴板失败时,链接原位显示“复制失败”两秒,不中断登录。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。弹窗的“复制登录链接”和设置页的“打开”链接会从当前主题快照读取应用生效配色,追加 `theme=light` 或 `theme=dark`,使两条路径都以当前应用主题打开 Platform 页面,且不改变 Platform 已保存的主题偏好。登录失败在该弹窗内呈现;退出登录失败时菜单保持展开以便重试,入口本身不展示失败提示。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。 账户状态流的终止错误会显示在登录对话框或账户设置中。插件卸载后不再报告延迟到达的错误。 @@ -99,10 +99,12 @@ Desktop preload 标记在账号凭证已存储后启用引导。进度属于本 - 资料和充值钱包余额通过 Host getProfile / getBalance 复用已有 Platform Web 接口。页面在打开、登录及重连后刷新,原样显示服务端脱敏后的联系方式,并分别处理查询失败,不将失败显示为零余额。用量和充值使用 Host 按 platformOrigin 派生的链接,并沿用浏览器自身登录态,不携带 DSH token。 - 已完成引导的安装不会重新应用默认值或覆盖用户后续调整的偏好。完成状态不跨设备同步,也不创建逐账号历史。 -- 引导跟随界面语言和主题,使用中文、英文分别搭配浅色、深色外观的四套 Figma PNG 插图。导出画布包含完整插图范围,Finder 插图在两种主题下使用一致的纵向渐变。 +- 引导跟随界面语言和主题,使用中文、英文分别搭配浅色、深色外观的四套 Figma 插图。导出画布包含完整插图范围,Finder 插图在两种主题下使用一致的纵向渐变。 - 通知文案按客户端读取时的界面语言由服务端本地化。切换语言本身不会重新读取未通知赠金,因此已在屏幕上的通知会沿用服务端先前语言下发的文案,直到用户手动刷新或登录生命周期重新开始。 +- Web 登录要求页面 origin 为回环 HTTP(`localhost`、`127.0.0.1` 或 `[::1]`),因为 Host 只接受这些回调 origin。经 `--trusted-host` 或 HTTPS 反向代理访问的 Web UI 仍显示登录入口,但发起登录会进入通用的失败重试状态;这类部署应改为添加 API Key。 + - Platform 视图的弹窗会把文档内其它子节点设为 inert,其中包括 Desktop 拥有的顶栏菜单 host,因此 Application 和 Edit 菜单保持可见,但在视图关闭前无法操作。放行该 host 需要 Desktop 侧提供 inert 契约。 diff --git a/packages/client/ui-settings-account/locale/en.json b/packages/client/ui-settings-account/locale/en.json new file mode 100644 index 0000000000..ce3988b946 --- /dev/null +++ b/packages/client/ui-settings-account/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-settings-account" + } +} diff --git a/packages/client/ui-settings-account/locale/zh.json b/packages/client/ui-settings-account/locale/zh.json new file mode 100644 index 0000000000..505a15ac87 --- /dev/null +++ b/packages/client/ui-settings-account/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "账号设置界面", + "description": "管理 DeepSeek 登录,并打开平台账单页面。" + } +} diff --git a/packages/client/ui-settings-account/package.json b/packages/client/ui-settings-account/package.json index ed21b99671..6f9784dfbe 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -63,16 +64,15 @@ "@deepseek-ai/dsh-settings": "workspace:*", "@testing-library/react": "^16.3.3", "@types/big.js": "^6.2.2", - "@types/react": "~18.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", "@deepseek-ai/dsh-client-ui-settings-models": "workspace:*", - "@types/react-dom": "~18.3.0", "@deepseek-ai/dsh-host-webserver": "workspace:*", "big.js": "^7.0.1", "@deepseek-ai/dsh-client-product-analytics": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-settings-account/src/client/OnboardingCreditStep.tsx b/packages/client/ui-settings-account/src/client/OnboardingCreditStep.tsx index 7187696217..e0b73f69b8 100644 --- a/packages/client/ui-settings-account/src/client/OnboardingCreditStep.tsx +++ b/packages/client/ui-settings-account/src/client/OnboardingCreditStep.tsx @@ -3,10 +3,10 @@ import type { RefObject } from 'react' import { Button } from '@deepseek-ai/dsh-client-ui-primitives' import type { DesktopOnboardingProps } from './onboarding-contract.ts' import { OnboardingIllustration } from './OnboardingIllustration.tsx' -import art from './assets/onboarding-recharge.png' -import artDark from './assets/onboarding-recharge-dark.png' -import artZh from './assets/onboarding-recharge-zh.png' -import artZhDark from './assets/onboarding-recharge-zh-dark.png' +import art from './assets/onboarding-recharge.webp' +import artDark from './assets/onboarding-recharge-dark.webp' +import artZh from './assets/onboarding-recharge-zh.webp' +import artZhDark from './assets/onboarding-recharge-zh-dark.webp' import css from './DesktopOnboarding.module.css' /** @param props - credit facts, localized content and recharge/navigation actions. @returns the credit step. */ diff --git a/packages/client/ui-settings-account/src/client/OnboardingWelcomeStep.tsx b/packages/client/ui-settings-account/src/client/OnboardingWelcomeStep.tsx index f7c0aabc2f..66629a9f7f 100644 --- a/packages/client/ui-settings-account/src/client/OnboardingWelcomeStep.tsx +++ b/packages/client/ui-settings-account/src/client/OnboardingWelcomeStep.tsx @@ -3,10 +3,10 @@ import type { RefObject } from 'react' import { Button } from '@deepseek-ai/dsh-client-ui-primitives' import type { DesktopOnboardingProps } from './onboarding-contract.ts' import { OnboardingIllustration } from './OnboardingIllustration.tsx' -import art from './assets/onboarding-welcome.png' -import artDark from './assets/onboarding-welcome-dark.png' -import artZh from './assets/onboarding-welcome-zh.png' -import artZhDark from './assets/onboarding-welcome-zh-dark.png' +import art from './assets/onboarding-welcome.webp' +import artDark from './assets/onboarding-welcome-dark.webp' +import artZh from './assets/onboarding-welcome-zh.webp' +import artZhDark from './assets/onboarding-welcome-zh-dark.webp' import css from './DesktopOnboarding.module.css' /** @param props - localized content, focus target and navigation. @returns the welcome step. */ diff --git a/packages/client/ui-settings-account/src/client/SignInDialog.tsx b/packages/client/ui-settings-account/src/client/SignInDialog.tsx index 6fae239b3c..77441cae58 100644 --- a/packages/client/ui-settings-account/src/client/SignInDialog.tsx +++ b/packages/client/ui-settings-account/src/client/SignInDialog.tsx @@ -10,7 +10,7 @@ import css from './SignInDialog.module.css' /** @param props - safe account state, localized copy, and user actions. @returns login dialog. */ export function SignInDialog({ account, colorScheme, start, cancel, close, useApiKey, t }: { account: AccountSnapshot - /** Resolved scheme of the active Desktop theme; the copied link carries it. */ + /** Resolved scheme of the active application theme; the copied link carries it. */ colorScheme: 'light' | 'dark' start: () => Promise cancel: (id: SignInAttemptId) => Promise diff --git a/packages/client/ui-settings-account/src/client/assets.d.ts b/packages/client/ui-settings-account/src/client/assets.d.ts index da6a4dd8ea..db768fc288 100644 --- a/packages/client/ui-settings-account/src/client/assets.d.ts +++ b/packages/client/ui-settings-account/src/client/assets.d.ts @@ -1,7 +1,7 @@ /** Illustration imports are embedded in the account client bundle. */ -declare module '*.png' { - const svg: string - export default svg +declare module '*.webp' { + const url: string + export default url } declare module '*.svg' { const url: string diff --git a/packages/client/ui-settings-account/src/client/assets/onboarding-recharge-dark.png b/packages/client/ui-settings-account/src/client/assets/onboarding-recharge-dark.png deleted file mode 100644 index 8f484ee1a1..0000000000 Binary files a/packages/client/ui-settings-account/src/client/assets/onboarding-recharge-dark.png and /dev/null differ diff --git a/packages/client/ui-settings-account/src/client/assets/onboarding-recharge-dark.webp b/packages/client/ui-settings-account/src/client/assets/onboarding-recharge-dark.webp new file mode 100644 index 0000000000..f2c5290058 Binary files /dev/null and b/packages/client/ui-settings-account/src/client/assets/onboarding-recharge-dark.webp differ diff --git a/packages/client/ui-settings-account/src/client/assets/onboarding-recharge-zh-dark.png b/packages/client/ui-settings-account/src/client/assets/onboarding-recharge-zh-dark.png deleted file mode 100644 index 51c579edc7..0000000000 Binary files a/packages/client/ui-settings-account/src/client/assets/onboarding-recharge-zh-dark.png and /dev/null differ diff --git a/packages/client/ui-settings-account/src/client/assets/onboarding-recharge-zh-dark.webp b/packages/client/ui-settings-account/src/client/assets/onboarding-recharge-zh-dark.webp new file mode 100644 index 0000000000..99fb9996b2 Binary files /dev/null and b/packages/client/ui-settings-account/src/client/assets/onboarding-recharge-zh-dark.webp differ diff --git a/packages/client/ui-settings-account/src/client/assets/onboarding-recharge-zh.png b/packages/client/ui-settings-account/src/client/assets/onboarding-recharge-zh.png deleted file mode 100644 index 0fa9ba310c..0000000000 Binary files a/packages/client/ui-settings-account/src/client/assets/onboarding-recharge-zh.png and /dev/null differ diff --git a/packages/client/ui-settings-account/src/client/assets/onboarding-recharge-zh.webp b/packages/client/ui-settings-account/src/client/assets/onboarding-recharge-zh.webp new file mode 100644 index 0000000000..5b09bd55f7 Binary files /dev/null and b/packages/client/ui-settings-account/src/client/assets/onboarding-recharge-zh.webp differ diff --git a/packages/client/ui-settings-account/src/client/assets/onboarding-recharge.png b/packages/client/ui-settings-account/src/client/assets/onboarding-recharge.png deleted file mode 100644 index 2a5fc4f41c..0000000000 Binary files a/packages/client/ui-settings-account/src/client/assets/onboarding-recharge.png and /dev/null differ diff --git a/packages/client/ui-settings-account/src/client/assets/onboarding-recharge.webp b/packages/client/ui-settings-account/src/client/assets/onboarding-recharge.webp new file mode 100644 index 0000000000..772f4c9ded Binary files /dev/null and b/packages/client/ui-settings-account/src/client/assets/onboarding-recharge.webp differ diff --git a/packages/client/ui-settings-account/src/client/assets/onboarding-welcome-dark.png b/packages/client/ui-settings-account/src/client/assets/onboarding-welcome-dark.png deleted file mode 100644 index 17c53b32b5..0000000000 Binary files a/packages/client/ui-settings-account/src/client/assets/onboarding-welcome-dark.png and /dev/null differ diff --git a/packages/client/ui-settings-account/src/client/assets/onboarding-welcome-dark.webp b/packages/client/ui-settings-account/src/client/assets/onboarding-welcome-dark.webp new file mode 100644 index 0000000000..88d103a5b1 Binary files /dev/null and b/packages/client/ui-settings-account/src/client/assets/onboarding-welcome-dark.webp differ diff --git a/packages/client/ui-settings-account/src/client/assets/onboarding-welcome-zh-dark.png b/packages/client/ui-settings-account/src/client/assets/onboarding-welcome-zh-dark.png deleted file mode 100644 index 1ea7058e90..0000000000 Binary files a/packages/client/ui-settings-account/src/client/assets/onboarding-welcome-zh-dark.png and /dev/null differ diff --git a/packages/client/ui-settings-account/src/client/assets/onboarding-welcome-zh-dark.webp b/packages/client/ui-settings-account/src/client/assets/onboarding-welcome-zh-dark.webp new file mode 100644 index 0000000000..29a579e5bc Binary files /dev/null and b/packages/client/ui-settings-account/src/client/assets/onboarding-welcome-zh-dark.webp differ diff --git a/packages/client/ui-settings-account/src/client/assets/onboarding-welcome-zh.png b/packages/client/ui-settings-account/src/client/assets/onboarding-welcome-zh.png deleted file mode 100644 index 0672b15aac..0000000000 Binary files a/packages/client/ui-settings-account/src/client/assets/onboarding-welcome-zh.png and /dev/null differ diff --git a/packages/client/ui-settings-account/src/client/assets/onboarding-welcome-zh.webp b/packages/client/ui-settings-account/src/client/assets/onboarding-welcome-zh.webp new file mode 100644 index 0000000000..971351cc62 Binary files /dev/null and b/packages/client/ui-settings-account/src/client/assets/onboarding-welcome-zh.webp differ diff --git a/packages/client/ui-settings-account/src/client/assets/onboarding-welcome.png b/packages/client/ui-settings-account/src/client/assets/onboarding-welcome.png deleted file mode 100644 index 0f157e7cbd..0000000000 Binary files a/packages/client/ui-settings-account/src/client/assets/onboarding-welcome.png and /dev/null differ diff --git a/packages/client/ui-settings-account/src/client/assets/onboarding-welcome.webp b/packages/client/ui-settings-account/src/client/assets/onboarding-welcome.webp new file mode 100644 index 0000000000..a77f7de6e0 Binary files /dev/null and b/packages/client/ui-settings-account/src/client/assets/onboarding-welcome.webp differ diff --git a/packages/client/ui-settings-account/src/client/authorize-url.ts b/packages/client/ui-settings-account/src/client/authorize-url.ts index eba9e19094..3e701b9de9 100644 --- a/packages/client/ui-settings-account/src/client/authorize-url.ts +++ b/packages/client/ui-settings-account/src/client/authorize-url.ts @@ -1,10 +1,10 @@ -/** Platform authorization links that carry the Desktop palette into the login page. */ +/** Platform authorization links that carry the active application palette into the login page. */ /** - * Add the resolved Desktop palette to a Platform authorization link without + * Add the resolved application palette to a Platform authorization link without * dropping any parameter the Host already put on it. * @param authorizeUrl - authorization URL of the current account attempt. - * @param colorScheme - resolved scheme of the active Desktop theme. + * @param colorScheme - resolved scheme of the active application theme. * @returns the authorization URL carrying `theme=light` or `theme=dark`. */ export function authorizeUrlWithTheme(authorizeUrl: string, colorScheme: 'light' | 'dark'): string { diff --git a/packages/client/ui-settings-account/src/client/browser-login.ts b/packages/client/ui-settings-account/src/client/browser-login.ts new file mode 100644 index 0000000000..3d03d1a9a8 --- /dev/null +++ b/packages/client/ui-settings-account/src/client/browser-login.ts @@ -0,0 +1,76 @@ +/** Browser tab ownership for one UI-initiated authorization attempt. */ +import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' + +/** Keeps a preopened browser tab separate from Host authorization state. */ +export class BrowserLogin { + private popup: Window | null = null + private navigated = false + private attempt: SignInAttemptId | undefined + + /** @param destination - maps the Host authorization URL to the page the tab opens, read when the tab navigates. */ + constructor(private readonly destination: (authorizeUrl: string) => string) {} + + /** + * Preopen a tab and bind it to the attempt returned by the Host. + * @param request - authenticated Host start request. + * @param latest - most recent streamed state, including frames received before the response. + * @returns after this UI is following the new attempt. + */ + async start(request: () => Promise, latest: () => AccountView | undefined): Promise { + this.begin() + try { + this.follow(await request()) + this.update(latest()) + } catch (error) { + this.dispose() + throw error + } + } + + /** Preopen a tab during the user's click; blocked popups need the copy-link route. */ + begin(): void { + this.dispose() + this.popup = window.open('about:blank', '_blank') + if (this.popup) this.popup.opener = null + } + + /** + * Bind the preopened tab to its Host attempt. + * @param view - initial RPC response identifying this UI's attempt. + */ + follow(view: AccountView): void { + this.attempt = view.attempt?.id + this.update(view) + } + + /** + * Navigate or close the pending tab when its attempt changes. + * @param view - latest Host state. + */ + update(view: AccountView | undefined): void { + const attempt = view?.attempt + if (attempt?.id !== this.attempt) return + if (attempt && ['failed', 'expired', 'cancelled'].includes(attempt.phase)) { + this.dispose() + return + } + if (attempt?.phase === 'succeeded') { + // A tab that never reached the authorization page stays blank; the Host finished without it. + if (!this.navigated) this.popup?.close() + this.popup = null + this.attempt = undefined + return + } + if (!attempt?.authorizeUrl || this.navigated) return + this.navigated = true + if (this.popup && !this.popup.closed) this.popup.location.replace(this.destination(attempt.authorizeUrl)) + } + + /** Close the owned authorization tab and stop following its attempt. */ + dispose(): void { + this.popup?.close() + this.popup = null + this.attempt = undefined + this.navigated = false + } +} diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index 974626fa04..b28b3e7747 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -1,4 +1,4 @@ -/** Desktop account settings registration and reconnecting Remote subscription. */ +/** Account settings registration and reconnecting Remote subscription. */ import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type { TranscriptViewMode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { Context } from '@deepseek-ai/cordis' @@ -14,6 +14,8 @@ import type { OnboardingChange } from './onboarding-contract.ts' import type { PlatformBridge } from './PlatformOverlay.tsx' import { ContactConfig, CONTACT_CONFIG_GLOBAL } from '../contact-config.ts' import { contactUrl } from './contact-url.ts' +import { authorizeUrlWithTheme } from './authorize-url.ts' +import { BrowserLogin } from './browser-login.ts' import { AccountOnboarding } from './AccountOnboarding.tsx' import { AccountPlatformHost, type AccountPlatformHostInjected } from './AccountPlatformHost.tsx' import { AccountMenu } from './AccountMenu.tsx' @@ -39,14 +41,17 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Services required by account settings. */ export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session', 'theme', 'configForms'] -/** Register account UI only in the Desktop renderer. @param ctx - client plugin context. */ +/** Register account UI in Desktop and plain Web clients. @param ctx - client plugin context. */ export function apply(ctx: Context): void { - if (!('dshDesktop' in globalThis)) return + const desktop = 'dshDesktop' in globalThis ctx.effect(() => ctx.locale.register('settings.account', { en, zh }), 'account: dictionaries') const t = ctx.locale.bind('settings.account') const page = globalThis as Partial> const config = ContactConfig(page[CONTACT_CONFIG_GLOBAL] ?? {}) let snapshot: AccountSnapshot = { view: undefined, details: undefined, failed: false, loginVisible: false } + // Desktop main opens the authorization page natively; a plain browser opens it in a tab it owns. + const browser = new BrowserLogin(url => authorizeUrlWithTheme(url, ctx.theme.getTheme().active.colorScheme)) + ctx.effect(() => () => { browser.dispose() }, 'account: pending browser window') const listeners = new Set<() => void>() const publish = (value: AccountSnapshot) => { snapshot = value; for (const listener of listeners) listener() } /** @returns the client identity for one account call, read at call time so it carries the language and zone in effect then. */ @@ -119,6 +124,7 @@ export function apply(ctx: Context): void { const { notice, ...previous } = snapshot publish({ ...previous, view: frame.value, details: undefined, failed: false, ...(frame.value.status === 'credential-stored' && notice ? { notice } : {}) }) + browser.update(frame.value) frame.accept() if (frame.value.status === 'credential-stored') notices.begin() else notices.end() @@ -176,7 +182,7 @@ export function apply(ctx: Context): void { dshDesktop?: { deviceInfo?: () => Promise } }).dshDesktop?.deviceInfo if (readDeviceInfo === undefined) { - // A Desktop bridge without the optional reader reports the renderer user agent. + // A plain browser, or a Desktop bridge without the optional reader, reports the renderer user agent. openForm(navigator.userAgent) return } @@ -208,11 +214,16 @@ export function apply(ctx: Context): void { const transport = (globalThis as typeof globalThis & { __DSH_TRANSPORT__?: { streamBaseUrl?: string } }).__DSH_TRANSPORT__ - try { + const request = async () => { const result = await ctx.remote.account.startSignIn(client(), - transport?.streamBaseUrl !== undefined ? new URL(transport.streamBaseUrl).origin : window.location.origin, - 'desktop') + desktop && transport?.streamBaseUrl !== undefined ? new URL(transport.streamBaseUrl).origin : window.location.origin, + desktop ? 'desktop' : 'web') if (!result.ok) throw new Error('account start failed') + return result.value + } + try { + if (desktop) await request() + else await browser.start(request, () => snapshot.view) } catch (error) { publish({ ...snapshot, loginFailed: true }) throw error @@ -230,7 +241,7 @@ export function apply(ctx: Context): void { throw result.error }, } - if ('dshDesktop' in globalThis) { + if (desktop) { const controller = new DesktopOnboardingController( ctx.configForms.get(DESKTOP_ONBOARDING_NAMESPACE), ctx.configForms.get<{ transcriptView?: TranscriptViewMode | null; performanceUsage: 'compact' | 'detailed' }>('ui-chat'), diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index a1c4b9960f..9ed6bac4fc 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -505,7 +505,7 @@ it.each(['initializing', 'waiting-browser', 'exchanging'] as const)('shows %s an expect(operations.cancel).toHaveBeenCalledExactlyOnceWith('attempt') }) -it('opens the authorization link with the active Desktop palette and follows later switches', () => { +it('opens the authorization link with the active application palette and follows later switches', () => { const authorizeUrl = 'https://platform.deepseek.com/dsh/authorize?state=example' const operations = operationsOf({ status: 'signed-out', attempt: { id: 'attempt' as SignInAttemptId, phase: 'waiting-browser', authorizeUrl } }) const element = (theme: ThemeSnapshot) => { }) afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.restoreAllMocks() }) -it('keeps account UI and account RPC inactive in a plain browser, including after reload', async ({ start, mock }) => { +it('registers account sign-in in a plain browser without the Desktop-only surfaces, including after reload', async ({ start }) => { const c = await start() for (const reload of [false, true]) { if (reload) await c.reload(SELF) - await c.flush() - expect(c.ctx.slots.entries('settings.launcher')).toHaveLength(0) - expect(c.ctx.slots.entries('settings.models.sign-in')).toHaveLength(0) - expect(c.ctx.slots.entries('settings.section').some(entry => entry.options.id === 'account')).toBe(false) - expect(quotaNoticeEntry(c)).toBeUndefined() + await c.mock.streams.opened('account/watch', reload ? 2 : 1) + expect(c.ctx.slots.entries('settings.launcher')).toHaveLength(1) + expect(c.ctx.slots.entries('settings.models.sign-in')).toHaveLength(1) + expect(quotaNoticeEntry(c)).toBeDefined() expect(platformHostEntry(c)).toBeUndefined() - expect(mock.log.calls().filter(call => call.endpoint.startsWith('account/'))).toEqual([]) - expect(mock.log.streams().filter(stream => stream.endpoint.startsWith('account/'))).toEqual([]) + expect(c.ctx.slots.entries('shell.overlay').some(entry => entry.options.id === 'desktop-onboarding')).toBe(false) } }, 60_000) @@ -241,15 +239,17 @@ it('coalesces refreshes, publishes independent failures, and rejects stale respo expect(actions.hooks.account.getSnapshot().details?.profile).toEqual({ status: 'failed' }) }, 60_000) -it('uses the Desktop login carrier and exposes operation errors', async ({ start, mock }) => { - vi.spyOn(window, 'open').mockReturnValue(null) - vi.stubGlobal('dshDesktop', {}) +for (const desktop of [false, true]) it(`uses the browser or Desktop login carrier (Desktop: ${desktop}) and exposes operation errors`, async ({ start, mock }) => { + const open = vi.spyOn(window, 'open').mockReturnValue(null) + if (desktop) vi.stubGlobal('dshDesktop', {}) const c = await start() const actions = operations(c) mock.remote.account.startSignIn.mockResolvedValue(ok(view)) await actions.start() expect(mock.remote.account.startSignIn).toHaveBeenCalledWith(expect.objectContaining({ locale: 'en', version: '0.0.0-test' }), - window.location.origin, 'desktop') + window.location.origin, desktop ? 'desktop' : 'web') + // Only a plain browser preopens the authorization tab during the click. + expect(open).toHaveBeenCalledTimes(desktop ? 0 : 1) const failure = { ok: false as const, error: new RemoteError('gateway/internal', 'offline', {}) } mock.remote.account.startSignIn.mockResolvedValueOnce(failure) await expect(actions.start()).rejects.toThrow('account start failed') @@ -264,6 +264,21 @@ it('uses the Desktop login carrier and exposes operation errors', async ({ start await expect(actions.signOut()).rejects.toBe(failure.error) }, 60_000) +it('navigates the preopened browser tab to the themed authorization page', async ({ start, mock }) => { + const replace = vi.fn() + const popup: Pick & { location: Pick } = { closed: false, close: vi.fn(), opener: window, location: { replace } } + // jsdom opens no real tab; the UI only touches these members of the preopened window. + vi.spyOn(window, 'open').mockReturnValue(popup as Window) + const c = await start() + const actions = operations(c) + const attempt = { id: 'attempt' as SignInAttemptId, phase: 'waiting-browser' as const, authorizeUrl: 'https://platform.deepseek.com/dsh/authorize' } + mock.remote.account.startSignIn.mockResolvedValue(ok({ ...view, attempt: { id: attempt.id, phase: 'initializing' } })) + await actions.start() + c.mock.streams.push('account/watch', { ...view, attempt }) + const theme = (c.ctx.get('theme') as ThemeRuntime).getTheme().active.colorScheme + await vi.waitFor(() => { expect(replace).toHaveBeenCalledExactlyOnceWith(`${attempt.authorizeUrl}?theme=${theme}`) }) +}, 60_000) + it('uses the Desktop stream origin and exposes the native platform bridge', async ({ start, mock }) => { vi.stubGlobal('dshDesktop', {}) const c = await start() diff --git a/packages/client/ui-settings-account/tests/browser-login.client.spec.ts b/packages/client/ui-settings-account/tests/browser-login.client.spec.ts new file mode 100644 index 0000000000..7d27c3d33c --- /dev/null +++ b/packages/client/ui-settings-account/tests/browser-login.client.spec.ts @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +/** Browser handoff tracks the initiating attempt and cleans up unused tabs. */ +import { afterEach, expect, it, vi } from 'vitest' +import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' +import { BrowserLogin } from '../src/client/browser-login.ts' + +afterEach(() => { vi.restoreAllMocks() }) +function view(phase: NonNullable['phase'], authorizeUrl?: string): AccountView { + return { status: 'signed-out', links: { usageUrl: '', topUpUrl: '' }, + attempt: { id: 'attempt' as SignInAttemptId, phase, ...(authorizeUrl ? { authorizeUrl } : {}) } } +} +function setup() { + const close = vi.fn() + const replace = vi.fn() + const popup: { closed: boolean; opener: unknown; close: typeof close; location: { replace: typeof replace } } = { + closed: false, opener: window, close, location: { replace }, + } + vi.spyOn(window, 'open').mockReturnValue(new Proxy(window, { + get(target, property, receiver): unknown { + if (property === 'closed') return popup.closed + if (property === 'opener') return popup.opener + if (property === 'close') return popup.close + if (property === 'location') return popup.location + return Reflect.get(target, property, receiver) + }, + set(target, property, value: unknown, receiver) { + if (property === 'opener') { popup.opener = value; return true } + return Reflect.set(target, property, value, receiver) + }, + })) + const browser = new BrowserLogin(url => url) + browser.begin() + return { browser, popup, close, replace } +} +it('navigates once when waiting-browser arrives after the initial RPC response', () => { + const { browser, popup, replace, close } = setup() + expect(popup.opener).toBeNull() + browser.follow(view('initializing')) + expect(replace).not.toHaveBeenCalled() + browser.update(view('waiting-browser', 'https://platform.deepseek.com/dsh/authorize')) + browser.update(view('waiting-browser', 'https://platform.deepseek.com/dsh/authorize')) + expect(replace).toHaveBeenCalledExactlyOnceWith('https://platform.deepseek.com/dsh/authorize') + browser.dispose() + expect(close).toHaveBeenCalledOnce() +}) +it('closes the blank tab on request failure without later navigating it', () => { + const { browser, close, replace } = setup() + browser.dispose() + browser.update(view('waiting-browser', 'https://platform.deepseek.com/dsh/authorize')) + expect(close).toHaveBeenCalledOnce() + expect(replace).not.toHaveBeenCalled() +}) +it('retains the manual link route when popup opening is blocked', () => { + vi.spyOn(window, 'open').mockReturnValue(null) + const browser = new BrowserLogin(url => url) + browser.begin() + expect(() => { browser.follow(view('waiting-browser', 'https://platform.deepseek.com/dsh/authorize')) }).not.toThrow() +}) + +it('closes the pending tab when the authenticated start request rejects', async () => { + const { browser, close, replace } = setup() + close.mockClear() + await expect(browser.start(() => Promise.reject(new Error('disconnected')), () => undefined)).rejects.toThrow('disconnected') + expect(close).toHaveBeenCalledTimes(2) + expect(replace).not.toHaveBeenCalled() +}) +it('uses a waiting frame received before the initial response', async () => { + const { browser, replace } = setup() + await browser.start(async () => view('initializing'), () => view('waiting-browser', 'https://platform.deepseek.com/dsh/authorize')) + expect(replace).toHaveBeenCalledExactlyOnceWith('https://platform.deepseek.com/dsh/authorize') +}) + +it.each(['failed', 'expired', 'cancelled'] as const)('closes an already navigated tab on %s', (phase) => { + const { browser, close } = setup() + browser.follow(view('waiting-browser', 'https://platform.deepseek.com/dsh/authorize')) + browser.update(view(phase)) + expect(close).toHaveBeenCalledOnce() +}) +it('leaves success completion to the Platform page', () => { + const { browser, close } = setup() + browser.follow(view('waiting-browser', 'https://platform.deepseek.com/dsh/authorize')) + browser.update(view('succeeded')) + browser.dispose() + expect(close).not.toHaveBeenCalled() +}) +it('closes a blank tab when the Host finishes before it navigates', () => { + const { browser, close } = setup() + browser.follow(view('exchanging')) + browser.update(view('succeeded')) + browser.dispose() + expect(close).toHaveBeenCalledOnce() +}) +it('opens the destination mapped from the authorization URL', () => { + const { replace } = setup() + const browser = new BrowserLogin(url => `${url}?theme=dark`) + browser.begin() + browser.follow(view('waiting-browser', 'https://platform.deepseek.com/dsh/authorize')) + expect(replace).toHaveBeenCalledExactlyOnceWith('https://platform.deepseek.com/dsh/authorize?theme=dark') +}) diff --git a/packages/client/ui-settings-account/tests/onboarding-state.client.spec.ts b/packages/client/ui-settings-account/tests/onboarding-state.client.spec.ts index 55e6b8ef91..480ab21ec7 100644 --- a/packages/client/ui-settings-account/tests/onboarding-state.client.spec.ts +++ b/packages/client/ui-settings-account/tests/onboarding-state.client.spec.ts @@ -64,13 +64,13 @@ async function harness(options: { const progress = new ConfigFormController(ctx, { namespace: DESKTOP_ONBOARDING_NAMESPACE, decode: value => OnboardingSettingsSchema(value as OnboardingSettings), - }, mirror, 'host', schema) + }, mirror, schema) const chat = new ConfigFormController<{ transcriptView: TranscriptViewMode; performanceUsage: 'compact' | 'detailed' }>(ctx, { namespace: 'ui-chat', decode: value => value as { transcriptView: TranscriptViewMode; performanceUsage: 'compact' | 'detailed' }, - }, mirror, 'host', schema) + }, mirror, schema) const developerScope = new ConfigFormController<{ enabled: boolean }>(ctx, { namespace: 'ui-settings', decode: value => value as { enabled: boolean }, - }, mirror, 'host', schema) + }, mirror, schema) const developerTools = new DeveloperToolsPreference(developerScope) const setDeveloperTools = (enabled: boolean) => developerTools.setEnabled(enabled) cleanup.push(() => developerScope.dispose()) diff --git a/packages/client/ui-settings-account/tsdown.config.ts b/packages/client/ui-settings-account/tsdown.config.ts index 247d3a8ea8..45af1505d1 100644 --- a/packages/client/ui-settings-account/tsdown.config.ts +++ b/packages/client/ui-settings-account/tsdown.config.ts @@ -3,24 +3,24 @@ import { readFile } from 'node:fs/promises' import { basename } from 'node:path' import { fileURLToPath } from 'node:url' -const bundle = clientBundle('@deepseek-ai/dsh-client-ui-settings-account', ['lib/types/index.js']) +const bundle = clientBundle(import.meta.dirname, ['lib/types/index.js']) export default ((options) => bundle(options).map(config => ({ ...config, plugins: [...(config.plugins ?? []), { name: 'account-onboarding-assets', resolveId(source: string) { - if (!/^\.\/assets\/[^/]+\.(?:svg|png)(\?raw)?$/.test(source)) return null + if (!/^\.\/assets\/[^/]+\.(?:svg|webp)(\?raw)?$/.test(source)) return null const url = new URL(`./src/client/assets/${basename(source)}`, import.meta.url) return fileURLToPath(url) + url.search }, async load(id: string) { - if (!/\/ui-settings-account\/src\/client\/assets\/[^/]+\.(?:svg|png)(\?raw)?$/.test(id.replaceAll('\\', '/'))) return null + if (!/\/ui-settings-account\/src\/client\/assets\/[^/]+\.(?:svg|webp)(\?raw)?$/.test(id.replaceAll('\\', '/'))) return null const path = id.replace(/\?raw$/, '') this.addWatchFile(path) const data = await readFile(path) if (id.endsWith('?raw')) return `export default ${JSON.stringify(data.toString('utf8'))}` - return `export default ${JSON.stringify(`data:${path.endsWith('.png') ? 'image/png' : 'image/svg+xml'};base64,${data.toString('base64')}`)}` + return `export default ${JSON.stringify(`data:${path.endsWith('.webp') ? 'image/webp' : 'image/svg+xml'};base64,${data.toString('base64')}`)}` }, }], }))) satisfies typeof bundle diff --git a/packages/client/ui-settings-agent-loop/locale/en.json b/packages/client/ui-settings-agent-loop/locale/en.json new file mode 100644 index 0000000000..5c9a7a32f1 --- /dev/null +++ b/packages/client/ui-settings-agent-loop/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-settings-agent-loop" + } +} diff --git a/packages/client/ui-settings-agent-loop/locale/zh.json b/packages/client/ui-settings-agent-loop/locale/zh.json new file mode 100644 index 0000000000..c6a1dff952 --- /dev/null +++ b/packages/client/ui-settings-agent-loop/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "智能体运行设置", + "description": "配置同时执行的工具调用数量上限。" + } +} diff --git a/packages/client/ui-settings-agent-loop/package.json b/packages/client/ui-settings-agent-loop/package.json index 4ab203ecbf..16e111f91f 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -54,10 +55,10 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-settings-agent-loop/tsdown.config.ts b/packages/client/ui-settings-agent-loop/tsdown.config.ts index 7ae5a8134a..7aa03cebc1 100644 --- a/packages/client/ui-settings-agent-loop/tsdown.config.ts +++ b/packages/client/ui-settings-agent-loop/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-agent-loop', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 85937c4fe6..3a8b6aa02c 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -21,8 +21,8 @@ en: 8b8a7c6287bb303b zh: 19535112531aedd5 /deepseek-ai-dsh-client-ui-settings-general/use-this-package/opening-the-configuration-file: - en: ca2b4f5ccfbe2705 - zh: bd1dc1d9879d0f0e + en: 597df68b5913e8d7 + zh: bc9114f3e877679a /deepseek-ai-dsh-client-ui-settings-general/use-this-package/onboarding-steps: en: 27f0f6ec55f00228 zh: 69b47ff1efd81c17 @@ -36,8 +36,8 @@ en: e37fb7df883ea356 zh: c972524e56c6d6c2 /deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation/document-availability: - en: 474168eef3339de0 - zh: 254ea21415082dae + en: ff1aec9eb1a87a79 + zh: eb91ab8bef59742d /deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation/host-half: en: 5d722d055516f905 zh: e7a236f0ff84f7ff diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 6e246e990e..c66352ac1c 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -47,7 +47,7 @@ The General section holds the built-in Coding Tools and Current version rows alo ### Opening the configuration file -On a loopback browser, the shell renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action opens that document in the native text editor (bypassing the browser file association on macOS). Remote browsers never register the action and never issue the privileged settings read. +On a loopback browser, the shell renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action opens that document in the native text editor (bypassing the browser file association on macOS). Remote browsers never register this native-open action, including on configured public deployments; their preferences persist through the Host settings service. ### Onboarding steps @@ -75,7 +75,7 @@ The shell is an explicit recovery consumer, so it injects Connection directly ra ### Document availability -On a loopback page, the Client loads the provider's `hasDocument` capability through `settings/describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action calls the pathless, browser-authenticated `settings/openSettingsDocument` Remote; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Non-loopback pages retain the Client policy that withholds this native action and its settings read. +On a loopback page, the Client loads the provider's `hasDocument` capability through `settings/describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action calls the pathless, browser-authenticated `settings/openSettingsDocument` Remote; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Non-loopback pages withhold this native action through the Client policy. ### Host half diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 8bf6cc82b6..9d095efb46 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -47,7 +47,7 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注 ### 打开配置文件 -在回环浏览器上,只有当宿主确认可准备好一份由提供方持有的本地文档时,外壳才渲染**打开配置文件**。该操作会在原生文本编辑器中打开该文档(macOS 上绕过浏览器文件关联)。远程浏览器从不注册该操作,也从不发起这项特权设置读取。 +在回环浏览器上,只有当宿主确认可准备好一份由提供方持有的本地文档时,外壳才渲染**打开配置文件**。该操作会在原生文本编辑器中打开该文档(macOS 上绕过浏览器文件关联)。远程浏览器从不注册这项原生打开操作,配置了公共 URL 的部署也不例外;它们的偏好通过 Host settings 服务持久化。 ### 引导步骤 @@ -75,7 +75,7 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注 ### 文档可用性 -在 loopback 页面上,Client 通过 `settings/describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**操作。该操作调用无路径参数且经浏览器认证的 `settings/openSettingsDocument` Remote;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。非 loopback 页面保留 Client 策略,不提供该原生操作及其 settings 读取。 +在 loopback 页面上,Client 通过 `settings/describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**操作。该操作调用无路径参数且经浏览器认证的 `settings/openSettingsDocument` Remote;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。非 loopback 页面通过 Client 策略拦下该原生操作。 ### 宿主端 diff --git a/packages/client/ui-settings-general/locale/en.json b/packages/client/ui-settings-general/locale/en.json new file mode 100644 index 0000000000..f6258cc9cc --- /dev/null +++ b/packages/client/ui-settings-general/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-settings-general" + } +} diff --git a/packages/client/ui-settings-general/locale/zh.json b/packages/client/ui-settings-general/locale/zh.json new file mode 100644 index 0000000000..ed8dea60c4 --- /dev/null +++ b/packages/client/ui-settings-general/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "通用设置与欢迎提示", + "description": "提供通用设置、设置窗口文案和首次使用提示。" + } +} diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index ddba8ede7b..ba5dcebee3 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -26,7 +26,8 @@ "types": "./lib/types/types.d.ts" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -66,8 +67,6 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-remote-mock": "workspace:*", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "clsx": "^2.0.0", "react": "^18.2.0", "@deepseek-ai/dsh-settings": "workspace:*", @@ -75,6 +74,7 @@ "@deepseek-ai/dsh-client-shortcuts": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index 6a5d909196..a1df5a7399 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -188,21 +188,59 @@ describe('ui-settings-general apply', () => { await vi.waitFor(() => { expect(c.mock.log.calls('settings/describe')).toHaveLength(3) }) }) - it('withholds the Host document action off-loopback', async ({ mock, start }) => { + it('keeps Host preferences off-loopback and withholds only the native document action', async ({ mock, start }) => { const loopbackUrl = location.href setPageUrl('http://198.51.100.7:3000/') onTestFinished(() => { setPageUrl(loopbackUrl) }) - const { c } = await client(mock, start) + const { c, settings } = await client(mock, start) expect(c.connection.isLoopback).toBe(false) + // The stored Host locale applies off-loopback; only the native document + // action stays loopback-only. + expect(c.ctx.locale.getSnapshot().active).toBe('zh') expect(ownEntries(c, 'settings.action')).toEqual([]) - // Off-loopback settings stay process-local: no describe read, so the browser language stands. - expect(c.mock.log.calls('settings/describe')).toEqual([]) - expect(c.ctx.locale.getSnapshot().active).toBe('en') + const english = localeView('en', 1) + settings.mutate.mockResolvedValueOnce(ok(english)) + c.ctx.locale.setLocale('en') + await vi.waitFor(() => { + expect(settings.mutate.mock.calls).toEqual([ + [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'en' }], 0], + ]) + }) await c.unload(SELF) await c.flush() for (const [name] of SEATS) expect(ownEntries(c, name)).toEqual([]) }) + it('updates remote locale consumers after Host commits and reconnects', async ({ mock, start }) => { + const originalUrl = location.href + setPageUrl('http://198.51.100.7:3000/') + onTestFinished(() => { setPageUrl(originalUrl) }) + const { c, settings } = await client(mock, start) + expect(c.connection.isLoopback).toBe(false) + expect(c.ctx.locale.getSnapshot().active).toBe('zh') + settings.describe.mockResolvedValue(ok({ + writable: true, hasDocument: false, namespaces: [localeView('en', 1)], + })) + await c.mock.streams.opened('$events', 1) + c.mock.streams.push('$events', { + type: 'emit', event: 'settings/document-updated', args: [LOCALE_SETTINGS_NAMESPACE, 1], + }) + await vi.waitFor(() => { + expect(c.ctx.locale.getSnapshot().active).toBe('en') + expect(generalLabel(c)).toBe('General') + }) + settings.describe.mockResolvedValue(ok({ + writable: true, hasDocument: false, namespaces: [localeView('zh', 2)], + })) + c.connection.reconnect() + await c.mock.streams.opened('$events', 2) + await vi.waitFor(() => { + expect(c.ctx.locale.getSnapshot().active).toBe('zh') + expect(generalLabel(c)).toBe('通用设置') + }) + expect(settings.mutate).not.toHaveBeenCalled() + }) + it('re-registers after a Loader rebuild of the declaring chain (stale disposers must not block)', async ({ mock, start }) => { const { c, settings } = await client(mock, start) const before = SEATS.map(([name]) => ownEntries(c, name)[0]) diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index ee90396ccd..1305c915d1 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -109,9 +109,7 @@ describe('ui-settings-general shell', () => { off() }) - it('shows Account first in Desktop while signed in and removes it on sign-out', async ({ start }) => { - vi.stubGlobal('dshDesktop', {}) - onTestFinished(() => { vi.unstubAllGlobals() }) + it('shows Account first only while signed in and removes it on sign-out', async ({ start }) => { const c = await start() const { sections } = injectedOf(c).hooks await c.mock.streams.opened('account/watch', 1) diff --git a/packages/client/ui-settings-general/tsdown.config.ts b/packages/client/ui-settings-general/tsdown.config.ts index 45665e4054..7aa03cebc1 100644 --- a/packages/client/ui-settings-general/tsdown.config.ts +++ b/packages/client/ui-settings-general/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-general', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index a5c0917a25..cef103488f 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -18,11 +18,11 @@ en: db5aaf7f702e1f0c zh: 409a78c14fb2b6e3 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/api-keys: - en: c2f2a5c06ef20d5b - zh: 49a7eb0b66b4ed53 + en: b39c74c9a874f5d4 + zh: db0dfcc2de9cab53 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider: - en: ec0ba62a4e7185b4 - zh: 37d1e1e7a3817d12 + en: 0786af5e4ed58c8a + zh: af94474af0749101 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: en: 9d533c65c82cd4a4 zh: 0a5da334e127c035 @@ -30,8 +30,8 @@ en: 589397a35f82244e zh: 342fbceee2c173a2 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/extension-slots: - en: 65e9d8822b67b6c2 - zh: 7e83be9defda28ee + en: 79f8f80bdaba73cd + zh: f4d7496b293fbf81 /deepseek-ai-dsh-client-ui-settings-models/understand-the-implementation: en: a87afbec5e339b36 zh: 6348c81af7e650e1 @@ -42,8 +42,8 @@ en: 6e04c00818f014cd zh: 2d57f8d8944e67ed /deepseek-ai-dsh-client-ui-settings-models/understand-the-implementation/onboarding-coordinator: - en: 46dea666dffe8813 - zh: 6f56fdc365fa3642 + en: dc515a31212e477a + zh: 770ba77cd9a69f73 /deepseek-ai-dsh-client-ui-settings-models/further-exploration: en: 20abd8b98b6c20e2 zh: 6dfda7955ce5f33f diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index def38dbba2..6c9548829a 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -43,11 +43,13 @@ Host configuration `credentialOnboarding` defaults to `true`. The Electron prelo API-key inputs start empty and use `autocomplete="new-password"` to ask browsers not to autofill saved login passwords. -The primary field on an editor card is a single **API key** input — the page never asks for an environment-variable name. A typed key stores write-only through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `cordis.patch.yml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and preserves provider-native authentication (for example the Bedrock credential chain or Vertex ADC). A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing. A successful Apply emits a local accessible status message without echoing secret material. +The primary field on an editor card is a single **API key** input — the page never asks for an environment-variable name. A typed key stores write-only through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `cordis.patch.yml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and preserves provider-native authentication (for example the Bedrock credential chain or Vertex ADC). The field's placeholder and help tooltip use the profile's `apiKeyEnv` when it has one; otherwise they use the environment variables of the route's `nativeAuth`, in lookup order, as the configurable-provider directory reports them. These display hints omit references containing `oauth` (case-insensitively), and describe saved credentials followed by the displayed environment fallbacks. This presentation filter does not change the [credential resolution order](../../../docs/subsystems/credentials.md#resolution), the adapter's complete environment list, or an explicit reference. A filtered explicit reference still has a usable key input with the plain placeholder and no tooltip. A native route that has no key authentication omits the key field and authentication help entirely; an explicit reference still enables the field. A profile without a reference on a route without `nativeAuth`, such as a hand-declared route, shows the plain key placeholder and no tooltip. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing. A successful Apply emits a local accessible status message without echoing secret material. + +For a profile with a named credential reference, the editor also shows **API key source** from the page's existing redacted `CredentialInfo` snapshot: saved credentials (`file`), launch environment (`env`), `$cwd/.env` (`project-env`), or `$DSH_HOME/.env` (`user-env`). Refreshing that snapshot updates an open editor without a separate editor probe. Unconfigured references, unknown source ids, and reference-free native authentication show no source label: a derived reference cannot identify the native provider's active credential. The label never receives a credential value or changes field writability. ### Editing a provider -The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `cordis.patch.yml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, and input types; unrelated model fields survive edits. +The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the placeholder shows the endpoint the profile resolves to, the public endpoint for deepseek, else the `defaultBaseURL` the configurable-provider directory reports for a pi-ai catalog route), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `cordis.patch.yml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, and input types; unrelated model fields survive edits. The DeepSeek card edits the `llm-deepseek` endpoint, credentials, and model catalog. It uses Messages, with the default endpoint placeholder `https://api.deepseek.com/anthropic`. @@ -65,7 +67,7 @@ After the versioned notice step completes, the DeepSeek step projects first-run The section declares two seats for plugins distributed outside this repository, typed in [`src/client/slot-contract.ts`](src/client/slot-contract.ts) and exported from `./client`. `settings.models.provider-card` (keyed) renders inside every card that shows a directory row — a saved row's card, its first-run setup posture, and the add-provider draft — dispatched with `entryKey = settingsNs` and owner props carrying the row's `ConfigurableProviderView`, its configured state, and its confirmed api-key credential state, so one registration under an adapter family's namespace receives every card of that family, hand-declared routes included; the hand-declared draft card has no directory row yet and dispatches nothing until saved. `settings.models.footer` (list) renders after the rows and the add controls. A registrant activates through `ctx.slots.inject` with a type-only import of this package's `/client` entry; without registrants both seats render nothing. -The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). Its editor exposes the shared DeepSeek model catalog without API-key or base-URL inputs, and saves that catalog under the account route's own settings section (`llm-deepseek-account` by default), so an account edit never rewrites the `llm-deepseek` section the official route reads. The account row is hidden when its available model catalog is empty, including before sign-in and after sign-out; it returns when account models become available. +The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). The row shows a **Signed in** status and offers no editor: the account listing supplies its models, and [model overrides](../../llm/llm-deepseek-account/README.md#use-this-package) live in `cordis.patch.yml`. The account row is hidden when its available model catalog is empty, including before sign-in and after sign-out; it returns when account models become available. ----- @@ -89,7 +91,7 @@ Concurrent `ModelsSettingsStore.load()` callers resolve after the current refres ### Onboarding coordinator -The notice step owns its exact copy in `src/client/locales.ts` and its acknowledgement version in `src/onboarding-copy.ts`; on loopback it compares and writes `ui-settings-general.welcomeNoticeVersion` through the shared configuration form, and only an explicit Continue records the current version. Users who acknowledged an earlier version see the current notice again. A non-loopback browser cannot use that Host-only namespace, so acknowledgement is process-local and the notice returns after reload. The DeepSeek step targets `deepseek-official` in `llm-deepseek` and renders the existing `ProviderEditor` in credential-only mode inside the shared onboarding modal; `credentials.set` stays the only secret write, and no provider settings are changed. +The notice step owns its exact copy in `src/client/locales.ts` and its acknowledgement version in `src/onboarding-copy.ts`. Authenticated browsers compare and write `ui-settings-general.welcomeNoticeVersion` through the shared configuration form; only an explicit Continue records the current version, and an unavailable namespace or a refused write leaves the notice unacknowledged. Users who acknowledged an earlier version see the current notice again. The DeepSeek step targets `deepseek-official` in `llm-deepseek` and renders the existing `ProviderEditor` in credential-only mode inside the shared onboarding modal; `credentials.set` stays the only secret write, and no provider settings are changed. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 0af8ee6abb..c5e80da5c5 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -43,11 +43,13 @@ Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会 API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求浏览器不要自动填入已保存的登录密码。 -编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `cordis.patch.yml` 从不携带密钥值。为新的 pi-ai 提供商留空密钥会保存一个不带引用的 profile,从而保留提供商原生认证(例如 Bedrock 凭据链或 Vertex ADC)。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。 +编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `cordis.patch.yml` 从不携带密钥值。为新的 pi-ai 提供商留空密钥会保存一个不带引用的 profile,从而保留提供商原生认证(例如 Bedrock 凭据链或 Vertex ADC)。profile 有 `apiKeyEnv` 时,字段的占位文字与帮助提示采用该引用;否则按查找顺序采用可配置提供商目录为该路由报告的 `nativeAuth` 环境变量。这些显示提示会忽略包含 `oauth` 的引用(不区分大小写),并说明优先使用已保存凭据,其后为显示的环境后备。这项显示过滤不改变[凭据解析顺序](../../../docs/subsystems/credentials.zh.md#resolution)、适配器的完整环境变量列表或显式引用。被过滤的显式引用仍有可用的密钥输入框,使用普通占位文字且不显示帮助提示。没有密钥认证的原生路由不显示密钥字段及认证帮助;显式引用仍会启用该字段。路由没有 `nativeAuth`(例如手动声明的路由)且 profile 没有引用时,显示普通的密钥占位文字,且不显示帮助提示。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。 + +profile 有具名凭据引用时,编辑器还会根据页面已有的脱敏 `CredentialInfo` 快照显示 **API 密钥来源**:已保存凭据(`file`)、启动环境(`env`)、`$cwd/.env`(`project-env`)或 `$DSH_HOME/.env`(`user-env`)。快照刷新会更新打开的编辑器,无需单独探测。未配置的引用、未知来源 id 和无引用的原生认证不显示来源标签:派生引用不能说明原生提供商当前采用的凭据。标签从不接收凭据值,也不改变字段的可写性。 ### 编辑提供商 -收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `cordis.patch.yml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供商级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和输入类型;无关的模型字段在编辑后仍会保留。 +收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(占位符显示 profile 解析出的端点,deepseek 即公共端点;没有时显示可配置提供商目录为 pi-ai 内置路由报告的 `defaultBaseURL`)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `cordis.patch.yml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供商级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和输入类型;无关的模型字段在编辑后仍会保留。 `llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。 @@ -65,7 +67,7 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求 本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供商」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。 -Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。其编辑器展示共享的 DeepSeek 模型目录,不提供 API Key 或 Base URL 输入框;目录保存到账号路由自己的设置段(默认为 `llm-deepseek-account`),因此账号侧的编辑不会改写 official 路由读取的 `llm-deepseek` 段。账号可用模型目录为空时隐藏账号行,包括登录前和退登后;账号模型恢复可用时重新显示。 +Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。该行显示 **已登录** 状态,不提供编辑器:模型来自账号的模型列表,[模型覆盖](../../llm/llm-deepseek-account/README.zh.md#use-this-package)写在 `cordis.patch.yml` 中。账号可用模型目录为空时隐藏账号行,包括登录前和退登后;账号模型恢复可用时重新显示。 ----- @@ -89,7 +91,7 @@ Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepS ### 引导协调器 -预览版说明在 `src/client/locales.ts` 中持有精确文案,并在 `src/onboarding-copy.ts` 中持有确认版本;回环时它通过既有 settings API 比较并写入 `ui-settings-general.welcomeNoticeVersion`,且只有显式点击「继续」才会记录当前版本。已确认旧版的用户会再次看到当前说明。非回环浏览器无法使用这个仅限宿主的 namespace,因此确认只保留在进程内,刷新后说明会再次出现。DeepSeek 步骤面向 `llm-deepseek` 中的 `deepseek-official`,在共享引导模态框内以仅凭据模式渲染既有 `ProviderEditor`;`credentials.set` 仍是唯一的机密写入,且不改变任何提供商设置。 +预览版说明在 `src/client/locales.ts` 中持有精确文案,并在 `src/onboarding-copy.ts` 中持有确认版本。认证浏览器通过共享配置表单比较并写入 `ui-settings-general.welcomeNoticeVersion`;只有显式点击「继续」才会记录当前版本;命名空间不可用或写入被拒时,声明保持未确认状态。已确认旧版的用户会再次看到当前说明。DeepSeek 步骤面向 `llm-deepseek` 中的 `deepseek-official`,在共享引导模态框内以仅凭据模式渲染既有 `ProviderEditor`;`credentials.set` 仍是唯一的机密写入,且不改变任何提供商设置。 diff --git a/packages/client/ui-settings-models/locale/en.json b/packages/client/ui-settings-models/locale/en.json new file mode 100644 index 0000000000..100cf6fbe5 --- /dev/null +++ b/packages/client/ui-settings-models/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-settings-models" + } +} diff --git a/packages/client/ui-settings-models/locale/zh.json b/packages/client/ui-settings-models/locale/zh.json new file mode 100644 index 0000000000..6ce8aece28 --- /dev/null +++ b/packages/client/ui-settings-models/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "模型设置界面", + "description": "管理模型服务及其接入配置,并提供相关引导。" + } +} diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 0f4aa17a27..4884bc67a0 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -52,7 +53,6 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", @@ -64,6 +64,7 @@ "@deepseek-ai/schemastery": "workspace:~" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css index 2074fc6a07..91f5c2e9c0 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -113,6 +113,16 @@ background: var(--dsw-alias-state-error-primary); } +.accountStatus { + display: inline-flex; + flex: none; + align-items: center; + gap: 6px; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-state-success-primary); +} + .rowActions { display: inline-flex; align-items: center; @@ -265,6 +275,28 @@ color: var(--dsw-alias-label-secondary); } +.helpButton { + width: 16px; + height: 16px; + padding: 0; + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: 50%; + corner-shape: round; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font: inherit; + font-size: 11px; + line-height: 15px; + text-align: center; + cursor: help; +} + +.helpButton:hover, +.helpButton:focus-visible { + color: var(--dsw-alias-label-primary); + border-color: var(--dsw-alias-label-tertiary); +} + .linkButton { box-sizing: border-box; display: inline-flex; diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index cb5dc86b08..87727ffff1 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -25,7 +25,7 @@ import { useId, useState } from 'react' import type { ReactNode } from 'react' import { Button, IconPlusOutlineRegular, Modal, SegmentedControl } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { LlmNativeAuth, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls this package's SlotMap merge (the two Models child slots). import type {} from './slot-contract.ts' import { CustomProviderCard } from './CustomProviderCard.tsx' @@ -92,6 +92,10 @@ interface EditorTarget extends ProviderIdentity { credentialRef?: string /** The adapter reports this route as one it does not ship (see {@link ProviderEditorProps.declared}). */ declared?: boolean + /** The route's catalog endpoint (see {@link ProviderEditorProps.defaultBaseURL}). */ + defaultBaseURL?: string + /** The route's native authentication (see {@link ProviderEditorProps.nativeAuth}). */ + nativeAuth?: LlmNativeAuth } /** A dormant directory row the add card can adopt, with its registered namespace. */ @@ -109,7 +113,7 @@ interface CatalogDraft { /** Values that vary around the shared provider-editor rendering. */ interface ProviderEditorRenderProps extends Pick< ProviderEditorProps, - 'namespace' | 'schema' | 'operations' | 't' | 'readOnly' | 'onClose' + 'namespace' | 'schema' | 'operations' | 't' | 'readOnly' | 'onClose' | 'credential' > { target: EditorTarget } @@ -122,6 +126,8 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): displayName={target.displayName} settingsPath={target.settingsPath} {...target.declared === true ? { declared: true } : {}} + {...target.defaultBaseURL === undefined ? {} : { defaultBaseURL: target.defaultBaseURL }} + {...target.nativeAuth === undefined ? {} : { nativeAuth: target.nativeAuth }} {...props} /> ) @@ -200,6 +206,8 @@ function targetOf(row: ProviderRow): EditorTarget { ...credentialRef === undefined ? {} : { credentialRef }, // Only declared routes may expose route-owned fields. ...row.entry.declared === true ? { declared: true } : {}, + ...row.entry.defaultBaseURL === undefined ? {} : { defaultBaseURL: row.entry.defaultBaseURL }, + ...row.entry.nativeAuth === undefined ? {} : { nativeAuth: row.entry.nativeAuth }, } } @@ -424,6 +432,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS {renderProviderEditor({ target, namespace, + ...row.credential === undefined ? {} : { credential: row.credential }, schema, operations, t, @@ -454,6 +463,15 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS {row.entry.declared === true ? {t('customTag')} : null} + {/* The store keeps the account row only while the account lists models. */} + {row.entry.provider === 'deepseek-account' + ? ( + + + ) + : null} {credentialConfigured ? ( - + {/* The account catalog comes from the account listing; its model overrides live in cordis.patch.yml. */} + {row.entry.provider === 'deepseek-account' + ? null + : ( + + )} {row.removable ? ( + + ) + // A provider with no native key authentication has no key for this field to store. + const keylessNativeAuth = nativeAuth?.env.length === 0 && nativeAuth.oauth + const keyPlaceholder = keyHint === undefined + ? t('keyPlaceholder') + : t('keyPlaceholderFallback').replace('{hint}', () => keyHint) /** What both family editors take: the rows, whose layer owns them, and the two writes. */ const catalogProps = { models, @@ -373,28 +386,31 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { }, onReset: () => { setDraft(current => schema.deletePath(current, ['models'])) }, } - if (accountProvider) return return ( <> -
- {t('keyInput')} - { setKeyDraft(event.target.value) }} - /> - {shownKeyFailure === undefined ? null :

{t(shownKeyFailure)}

} -
+ {keylessNativeAuth + ? null + :
+ + {t('keyInput')} + {authHelp} + + { setKeyDraft(event.target.value) }} + /> + {keySource === undefined ? null : {keySource}} + {shownKeyFailure === undefined ? null :

{t(shownKeyFailure)}

} +
} {props.credentialOnly === true ? null :
{t('customized')}
@@ -433,7 +449,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { value={stringAt(draft, 'baseURL') ?? ''} placeholder={family === 'deepseek' ? t('deepSeekBaseUrl') - : stringAt(fallback, 'baseURL') ?? t('baseUrlDefault')} + : (() => { + const baseURL = stringAt(fallback, 'baseURL') ?? props.defaultBaseURL + return baseURL === undefined ? t('baseUrlDefault') : t('baseUrlPlaceholder').replace('{url}', () => baseURL) + })()} aria-describedby={family === 'deepseek' ? `${props.provider}-endpoint-hint` : undefined} aria-label={t('baseUrl')} disabled={disabled} diff --git a/packages/client/ui-settings-models/src/client/WelcomeNotice.tsx b/packages/client/ui-settings-models/src/client/WelcomeNotice.tsx index 51685beb44..798854ab32 100644 --- a/packages/client/ui-settings-models/src/client/WelcomeNotice.tsx +++ b/packages/client/ui-settings-models/src/client/WelcomeNotice.tsx @@ -13,7 +13,7 @@ import css from './WelcomeNotice.module.css' /** Registration-side dependencies of {@link WelcomeNotice}. */ export interface WelcomeNoticeInjected { hooks: { - /** Durable or process-local acknowledgement state. */ + /** Durable acknowledgement state. */ welcome: SnapshotStore } /** Welcome acknowledgement controller. */ diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index bb60baabe8..bf3c7e13e0 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -106,8 +106,6 @@ export function apply(ctx: ClientContext): void { schema, t, }) - // The scope's own memory mode is what keeps a remote browser process-local, - // so the store needs no isLoopback branch of its own. const welcomeController = new WelcomeNoticeStore(ctx.configForms.get>(WELCOME_NOTICE_SETTINGS_NAMESPACE)) const welcomeInjected = (): WelcomeNoticeInjected => ({ controller: welcomeController, diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 0ece7ada5e..034c1e7b94 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -31,19 +31,25 @@ export const en = { savedProvider: 'Saved {provider}.', credentialConfigured: 'API key configured', credentialMissing: 'API key missing', + accountSignedIn: 'Signed in', readOnly: 'The settings document is read-only in this deployment.', loadFailed: 'Loading the provider directory failed', conflict: 'Someone else changed these settings while this card was open. Close it and reopen to edit the current values.', retry: 'Retry', keyInput: 'API key', keyPlaceholder: 'Enter your API key', - keyPlaceholderNative: 'Enter an API key, or leave blank to use environment authentication', - keyStored: 'Configured — enter a new value to replace', - keyEnvLocked: 'Provided by the launch environment (read-only)', + keyPlaceholderFallback: 'Leave blank to keep existing authentication ({hint})', + keyHelp: 'Saved credentials are used first. Environment fallbacks shown here, in order: {env}. Sources are launch environment, $cwd/.env, then $DSH_HOME/.env.', + authHelp: 'Authentication details', + keySourceFile: 'API key source: saved credentials', + keySourceEnv: 'API key source: launch environment', + keySourceProjectEnv: 'API key source: $cwd/.env', + keySourceUserEnv: 'API key source: $DSH_HOME/.env', customized: 'Customized settings', baseUrl: 'Base URL', - baseUrlDefault: 'Provider default', - deepSeekBaseUrl: 'https://api.deepseek.com/anthropic', + baseUrlDefault: 'No default endpoint', + baseUrlPlaceholder: '{url}', + deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (Default to use DEEPSEEK_BASE_URL)', deepSeekEndpointHint: 'Use an API endpoint compatible with Anthropic Messages.', models: 'Models', modelsInherited: 'Using the adapter defaults', @@ -151,19 +157,25 @@ export const zh: { [Key in keyof typeof en]: string } = { savedProvider: '已保存 {provider}。', credentialConfigured: 'API 密钥已配置', credentialMissing: 'API 密钥缺失', + accountSignedIn: '已登录', readOnly: '当前部署的设置文档为只读。', loadFailed: '加载提供商目录失败', conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。', retry: '重试', keyInput: 'API 密钥', keyPlaceholder: '输入 API 密钥', - keyPlaceholderNative: '输入 API 密钥,或留空使用环境认证', - keyStored: '已配置——输入新值可替换', - keyEnvLocked: '由启动环境提供(只读)', + keyPlaceholderFallback: '留空时保留现有认证({hint})', + keyHelp: '优先使用已保存的凭据。此处显示的环境后备按以下顺序排列:{env}。来源依次为启动环境、$cwd/.env、$DSH_HOME/.env。', + authHelp: '认证说明', + keySourceFile: 'API 密钥来源:已保存凭据', + keySourceEnv: 'API 密钥来源:启动环境', + keySourceProjectEnv: 'API 密钥来源:$cwd/.env', + keySourceUserEnv: 'API 密钥来源:$DSH_HOME/.env', customized: '自定义设置', baseUrl: 'API 地址', - baseUrlDefault: '提供商默认', - deepSeekBaseUrl: 'https://api.deepseek.com/anthropic', + baseUrlDefault: '没有默认地址', + baseUrlPlaceholder: '{url}', + deepSeekBaseUrl: 'https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL)', deepSeekEndpointHint: '请填写兼容 Anthropic Messages 协议的 API 地址。', models: '模型目录', modelsInherited: '正在使用适配器默认模型', diff --git a/packages/client/ui-settings-models/src/client/operations.ts b/packages/client/ui-settings-models/src/client/operations.ts index 8ca6beb798..d8c5d0ac99 100644 --- a/packages/client/ui-settings-models/src/client/operations.ts +++ b/packages/client/ui-settings-models/src/client/operations.ts @@ -7,7 +7,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { - CredentialInfo, LlmDiscoveredModel, LlmModelDiscoveryRequest, + LlmDiscoveredModel, LlmModelDiscoveryRequest, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-api-remotes/client' @@ -32,12 +32,6 @@ export type ModelDiscoveryOutcome = /** The Host operations the Models page and its cards invoke. */ export interface ModelsOperations { - /** - * Read one credential reference's state. - * @param ref - credential reference name. - * @returns the state, or undefined when the reference is unknown or the read was refused. - */ - describeCredential(ref: string): Promise /** * Store one credential literal under its reference. * @param ref - credential reference name. @@ -81,10 +75,6 @@ export interface ModelsOperations { */ export function createModelsOperations(ctx: ClientContext): ModelsOperations { return { - describeCredential: async (ref) => { - const response = await ctx.remote.credentials.describe([ref]) - return response.ok ? response.value[ref] : undefined - }, storeCredential: async (ref, value) => { const response = await ctx.remote.credentials.set(ref, value) return response.ok ? undefined : response.error.message diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 52e00bd7eb..4283890eac 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -9,7 +9,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { - CredentialInfo, LlmConfigurableProvider, LlmProviderInfo, SettingsNamespaceView, + CredentialInfo, LlmConfigurableProvider, LlmNativeAuth, LlmProviderInfo, SettingsNamespaceView, } from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -30,6 +30,8 @@ export interface ProviderDirectoryEntry { readonly settingsPath: readonly string[] readonly active: boolean readonly declared?: boolean + readonly defaultBaseURL?: string + readonly nativeAuth?: LlmNativeAuth readonly error?: string } @@ -52,6 +54,8 @@ export function joinProviderDirectory( settingsPath: [...entry.settingsPath], active: active.has(entry.provider), ...entry.declared === undefined ? {} : { declared: entry.declared }, + ...entry.defaultBaseURL === undefined ? {} : { defaultBaseURL: entry.defaultBaseURL }, + ...entry.nativeAuth === undefined ? {} : { nativeAuth: entry.nativeAuth }, ...entry.error === undefined ? {} : { error: entry.error }, })) for (const provider of registered) { @@ -209,7 +213,9 @@ export class ModelsSettingsStore { if (!declared.ok) { this.failLoad(declared.error.message); return } const mirrored = this.describeFace.getSnapshot() if (mirrored.view === undefined) { - this.failLoad(mirrored.error ?? 'settings are unavailable in this browser') + /* v8 ignore next -- SettingsDescribeFace.ensure settles with a view or the read's error */ + if (mirrored.error === null) throw new Error('settings describe settled with neither a view nor an error') + this.failLoad(mirrored.error) return } const providers = joinProviderDirectory(registered.value, declared.value) diff --git a/packages/client/ui-settings-models/src/client/welcome-store.ts b/packages/client/ui-settings-models/src/client/welcome-store.ts index 1de2066ada..49cec92fc1 100644 --- a/packages/client/ui-settings-models/src/client/welcome-store.ts +++ b/packages/client/ui-settings-models/src/client/welcome-store.ts @@ -1,8 +1,6 @@ /** - * Welcome-notice state derived from the welcome settings scope. The scope is - * the transport: a loopback browser follows the durable Host section, while a - * remote browser's memory-mode scope never answers and the acknowledgement - * stays process-local here. + * Welcome-notice state derived from the welcome settings scope: the notice + * follows the Host section and records its acknowledgement through it. */ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -39,21 +37,16 @@ function assertNever(_value: never): never { throw new Error('unexpected welcome settings status') } -/** Coordinates durable Host acknowledgement or a process-local remote fallback. */ +/** Coordinates the durable Host acknowledgement. */ export class WelcomeNoticeStore { /** uSES-safe state source shared by the registered welcome step. */ readonly store: SnapshotStore = createSnapshotStore({ status: 'idle', acknowledged: false, error: null, }) - private localAcknowledged = false private saving = false private following: (() => void) | undefined - /** - * @param scope - the welcome settings namespace scope; its memory mode is - * what keeps a remote browser process-local. - */ constructor(private readonly scope: ConfigForm) {} /** @@ -67,17 +60,12 @@ export class WelcomeNoticeStore { } /** - * Persist this copy version, or advance only this process for a remote - * browser. Success is judged against the state the write left behind, so a - * refused or failed write reports false after its recovery read settles. - * @returns true when the selected persistence mode holds the acknowledgement. + * Persist this copy version through the scope. Success is judged against the + * state the write left behind, so a refused or failed write reports false + * after its recovery read settles. + * @returns true when the Host holds the acknowledgement. */ async acknowledge(): Promise { - if (this.scope.getSnapshot().mode === 'memory') { - this.localAcknowledged = true - this.derive() - return true - } this.saving = true this.store.update((state) => { state.status = 'saving'; state.error = null }) try { @@ -105,14 +93,6 @@ export class WelcomeNoticeStore { private derive(): void { if (this.saving) return const scope = this.scope.getSnapshot() - if (scope.mode === 'memory') { - this.store.update((state) => { - state.status = 'ready' - state.acknowledged = this.localAcknowledged - state.error = null - }) - return - } switch (scope.status) { case 'loading': this.store.update((state) => { state.status = 'loading'; state.error = null }) diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 2372690ba2..f9dac62ceb 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -27,7 +27,7 @@ afterEach(() => { vi.unstubAllGlobals() }) // so browser-language detection never runs and a fresh LocaleRuntime opens on // FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead. -async function bench(isLoopback = true, mock = RemoteMock.create().load(remoteDefaultResponses), services: object = {}) { +async function bench(mock = RemoteMock.create().load(remoteDefaultResponses), services: object = {}) { onTestFinished(() => { mock.assertNoUnmatched() }) const ctx = new Context() await ctx.plugin(SlotRegistry).await() @@ -49,8 +49,6 @@ async function bench(isLoopback = true, mock = RemoteMock.create().load(remoteDe settings: mock.remote.settings, session: { initializeDefaultModel: vi.fn(async () => ({ ok: true, value: undefined })) }, }) - // The fixed Host facts the settings provider reads its persistence from. - remote.$host = { home: undefined, isLoopback } await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, remote } } @@ -242,22 +240,6 @@ describe('ui-settings-models apply', () => { expect(() => b.locale.register('settings.models', 'zh', {})).not.toThrow() expect(() => b.locale.register('settings.models', 'en', {})).not.toThrow() }) - - it('keeps remote-browser acknowledgement in process memory', async () => { - const b = await bench(false) - declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - const entry = b.slots.entries('settings.onboarding') - .find(candidate => candidate.options.id === 'welcome-notice')! - const injected = ( - entry.inject as unknown as () => import('../src/client/WelcomeNotice.tsx').WelcomeNoticeInjected - )() - - await injected.controller.load() - expect(injected.controller.store.getSnapshot()).toEqual({ - status: 'ready', acknowledged: false, error: null, - }) - }) }) describe('pushed invalidations', () => { @@ -318,7 +300,7 @@ describe('pushed invalidations', () => { } const document = { writable: true, hasDocument: false, namespaces: [namespace] } mock.remote.settings.describe.mockResolvedValue(ok(document)) - const b = await bench(true, mock) + const b = await bench(mock) declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const entry = b.slots.entries('settings.onboarding') @@ -348,7 +330,7 @@ describe('pushed invalidations', () => { const describe = mock.remote.settings.describe describe.mockResolvedValue(ok(document)) const listProviders = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) - const b = await bench(true, mock, { listProviders }) + const b = await bench(mock, { listProviders }) declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const entry = b.slots.entries('settings.section') diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 588d0eda2d..0b12855e64 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -14,6 +14,7 @@ import { } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { ProviderEditor, pathOps } from '../src/client/ProviderEditor.tsx' +import type { ProviderEditorProps } from '../src/client/ProviderEditor.tsx' import { DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels, } from '../src/client/DeepSeekModelsEditor.tsx' @@ -23,7 +24,7 @@ import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' import { createModelsOperations } from '../src/client/operations.ts' import type { ModelsOperations } from '../src/client/operations.ts' import type { ProviderRow } from '../src/client/store.ts' -import { en, zh } from '../src/client/locales.ts' +import { en } from '../src/client/locales.ts' import { settingsSchema } from './settings-schema.client.ts' afterEach(cleanup) @@ -155,11 +156,6 @@ function wireNamespaces(): SettingsNamespaceView[] { ] } -/** The account provider's settings namespace, under a composition's entry id. */ -function accountNamespace(ns = 'llm-deepseek-account'): SettingsNamespaceView { - return { ...wireNamespaces().find(view => view.ns === 'llm-deepseek-account')!, ns } -} - /** Credentials answers over the Remote carrier, which has no envelope. */ function remoteOk(value: T) { return { ok: true as const, value } @@ -198,8 +194,15 @@ function scriptedFace(overrides: { ]))), listConfigurableProviders: vi.fn(() => Promise.resolve(remoteOk([ { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, - { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true }, - { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false }, + { + provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true, + defaultBaseURL: 'https://api.openai.com/v1', nativeAuth: { env: ['OPENAI_API_KEY'], oauth: false }, + }, + { + provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false, + defaultBaseURL: 'https://api.anthropic.com', + nativeAuth: { env: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'], oauth: true }, + }, { provider: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false }, { provider: 'broken', displayName: 'broken', settingsNs: 'llm-pi-ai', settingsPath: ['nope', 'x'], active: false }, { provider: 'plain', displayName: 'plain', settingsNs: 'llm-plain', settingsPath: ['profiles', 'plain'], active: false }, @@ -261,6 +264,32 @@ function operationsWith(face: object): ModelsOperations { return bound } +/** The pi-ai namespace with one more route profile stored in the user layer. */ +function piAiNamespaceWith(provider: string, profile: { [key: string]: JsonValue }): SettingsNamespaceView { + const namespace = wireNamespaces().find(view => view.ns === 'llm-pi-ai')! + const providers = { ...(namespace.value as { providers: object }).providers, [provider]: profile } + return { ...namespace, value: { providers }, user: { providers } } +} + +/** Render one pi-ai route's editor with the directory and credential facts the page already holds. */ +function renderPiAiEditor( + provider: string, + namespace: SettingsNamespaceView, + facts: Pick = {}, +): void { + render() +} + +/** Hover the key label's help button and read its tooltip; null when the label has no button. */ +function authHelpText(): string | null { + const button = screen.queryByRole('button', { name: en.authHelp }) + if (button === null) return null + fireEvent.mouseEnter(button) + return screen.getByRole('tooltip').textContent +} + /** One recorded child-slot dispatch: seat name, owner share, kind options. */ type RenderSlotCall = [name: string, owner: Record, opts?: { entryKey?: string }] @@ -871,7 +900,7 @@ describe('ModelsSection', () => { const baseURL = screen.getByLabelText(en.baseUrl) // The deepseek placeholder is pinned to the public endpoint, not the // effective value (which may reflect a launch-environment override). - expect(baseURL.placeholder).toBe('https://api.deepseek.com/anthropic') + expect(baseURL.placeholder).toBe('https://api.deepseek.com/anthropic (Default to use DEEPSEEK_BASE_URL)') fireEvent.change(baseURL, { target: { value: 'https://next2' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) @@ -943,7 +972,7 @@ describe('ModelsSection', () => { />) fireEvent.click(screen.getByText(en.customized)) expect(screen.getByLabelText(en.baseUrl).placeholder) - .toBe('https://api.deepseek.com/anthropic') + .toBe('https://api.deepseek.com/anthropic (Default to use DEEPSEEK_BASE_URL)') expect(screen.queryByLabelText(en.customApi)).toBeNull() expect(screen.getByText(en.deepSeekEndpointHint)).toBeTruthy() fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-messages-test' } }) @@ -1355,7 +1384,7 @@ describe('ModelsSection', () => { />) fireEvent.click(screen.getByText(en.customized)) const baseURL = screen.getByLabelText(en.baseUrl) - expect(baseURL.placeholder).toBe('https://api.deepseek.com/anthropic') + expect(baseURL.placeholder).toBe('https://api.deepseek.com/anthropic (Default to use DEEPSEEK_BASE_URL)') fireEvent.change(baseURL, { target: { value: 'https://x' } }) expect(baseURL.value).toBe('https://x') fireEvent.change(baseURL, { target: { value: '' } }) @@ -1384,14 +1413,15 @@ describe('ModelsSection', () => { it('edits a pi-ai profile with the curated fields only', async () => { const { mutate } = await mountSection() fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) - // The configured credential shows as the stored placeholder. + // The field names the profile's reference; a stored value stays hidden. const editorKey = await screen.findByLabelText(en.keyInput) - await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyStored) }) + expect(editorKey.placeholder).toBe(en.keyPlaceholderFallback.replace('{hint}', 'OPENAI_API_KEY')) // pi-ai carries Base URL too: the stored override shows as the value and - // the effective profile endpoint as its placeholder source. + // the effective profile endpoint, ahead of the catalog's, as its placeholder. fireEvent.click(screen.getByText(en.customized)) const url = screen.getByLabelText(en.baseUrl) expect(url.value).toBe('https://proxy') + expect(url.placeholder).toBe('https://proxy') fireEvent.change(url, { target: { value: 'https://proxy/v2' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) @@ -1410,12 +1440,12 @@ describe('ModelsSection', () => { const pick = await screen.findByLabelText(en.provider) expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain']) expect(pick.value).toBe('anthropic') - // A dormant profile has no endpoint anywhere: the pi-ai placeholder - // falls back to the provider-default wording. + // A dormant route shows its catalog endpoint and the first variable its + // provider reads; a typed key still stores under the derived reference. fireEvent.click(screen.getByText(en.customized)) - expect(screen.getByLabelText(en.baseUrl).placeholder).toBe(en.baseUrlDefault) + expect(screen.getByLabelText(en.baseUrl).placeholder).toBe('https://api.anthropic.com') const addKey = screen.getByLabelText(en.keyInput) - expect(addKey.placeholder).toBe(en.keyPlaceholderNative) + expect(addKey.placeholder).toBe(en.keyPlaceholderFallback.replace('{hint}', 'ANTHROPIC_AUTH_TOKEN')) fireEvent.change(addKey, { target: { value: 'sk-ant' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) @@ -1525,7 +1555,7 @@ describe('ModelsSection', () => { renderSlot={() => null} />) const key = await screen.findByLabelText(en.keyInput) - expect(key.placeholder).toBe(en.keyPlaceholder) + expect(key.placeholder).toBe(en.keyPlaceholderFallback.replace('{hint}', 'DEEPSEEK_API_KEY')) }) it('tells the user to reopen when another writer moved the namespace first', async () => { @@ -1553,36 +1583,25 @@ describe('ModelsSection', () => { expect(screen.getByText(en.apply)).toBeTruthy() }) - it('surfaces a shadowed credential write on the card', async () => { + it('surfaces a refused credential write on the card', async () => { await mountFirstRun({ - set: vi.fn(() => Promise.resolve(remoteFail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment'))), + set: vi.fn(() => Promise.resolve(remoteFail( + 'credentials-local: invalid document at /home/user/.dsh/.credentials.yaml: BAD_INDENT at line 3, column 1', + ))), }) const key = screen.getByLabelText(en.keyInput) fireEvent.change(key, { target: { value: 'sk-live' } }) fireEvent.click(screen.getByText(en.apply)) - await screen.findByText(/shadowed by the read-only environment/) + await screen.findByText(/invalid document at \/home\/user\/\.dsh\/\.credentials\.yaml/) expect(screen.queryByRole('status')).toBeNull() }) - it('locks the key input when the launch environment provides the credential', async () => { - const { face } = await mountSection() - face.credentials.describe.mockImplementation((refs: string[]) => Promise.resolve(remoteOk( - Object.fromEntries(refs.map(ref => [ref, { - configured: ref === 'OPENAI_API_KEY', source: 'env', writable: false, - }])), - ))) - fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) - const editorKey = await screen.findByLabelText(en.keyInput) - await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) }) - expect(editorKey.disabled).toBe(true) - }) - it('keeps a failed credential describe silent and the input usable', async () => { const { face, set } = await mountSection() face.credentials.describe.mockImplementation(() => Promise.resolve(remoteFail('down', 'gateway/internal'))) fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) const editorKey = await screen.findByLabelText(en.keyInput) - expect(editorKey.placeholder).toBe(en.keyPlaceholderNative) + expect(editorKey.placeholder).toBe(en.keyPlaceholderFallback.replace('{hint}', 'OPENAI_API_KEY')) fireEvent.change(editorKey, { target: { value: 'sk-live' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) }) @@ -2131,118 +2150,131 @@ describe('apiKeyFailure', () => { }) }) -it.each([en, zh])('edits the account model catalog without credential or endpoint fields', async (copy) => { - const mutate = vi.fn(() => Promise.resolve(remoteOk(accountNamespace()))) - const scripted = scriptedFace({ mutate }) - const ops = operationsWith(scripted.face) - const describe = vi.spyOn(ops, 'describeCredential') - const namespace = accountNamespace() - const onClose = vi.fn() - render( copy[key]} readOnly={false} onClose={onClose} />) - expect(screen.queryByLabelText(copy.keyInput)).toBeNull() - expect(screen.queryByLabelText(copy.baseUrl)).toBeNull() - expect(describe).not.toHaveBeenCalled() - expect(screen.getByDisplayValue('deepseek-v4-flash')).toBeTruthy() - expect(screen.queryByText(content => content.includes(copy.advancedHint))).toBeNull() - await expect(`${document.body.textContent}\n`) - .toMatchFileSnapshot(`./expected/deepseek-account-${copy === en ? 'en' : 'zh'}.txt`) - const set = vi.spyOn(ops, 'storeCredential') - fireEvent.change(screen.getAllByLabelText(new RegExp(copy.modelId))[0]!, { target: { value: 'deepseek-v4-mini' } }) - fireEvent.click(screen.getByText(copy.apply)) - await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) - expect(mutate.mock.calls[0]).toEqual([ - 'llm-deepseek-account', - [{ - op: 'set', - path: ['models'], - value: [ - { - id: 'deepseek-v4-mini', - name: 'DeepSeek-V4-Flash', - description: 'Preserved hidden detail', - contextWindow: 1_000_000, - }, - { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 1_000_000 }, - ], - }], - 0, - ]) - expect(set).not.toHaveBeenCalled() +it.each([ + ['file', 'keySourceFile'], ['env', 'keySourceEnv'], + ['project-env', 'keySourceProjectEnv'], ['user-env', 'keySourceUserEnv'], +] as const)('shows the named credential source %s without reading its value', (source, key) => { + renderPiAiEditor('openai', piAiNamespaceWith('openai', { apiKeyEnv: 'OPENAI_API_KEY' }), { + credential: { configured: true, source, writable: true }, + }) + expect(screen.getByText(en[key])).toBeTruthy() + expect(screen.getByLabelText(en.keyInput).value).toBe('') }) -it('keeps the DeepSeek editor for an account route under a renamed settings entry', async () => { - const namespace = accountNamespace('team-account-entry') - const mutate = vi.fn(() => Promise.resolve(remoteOk(namespace))) - const ops = operationsWith(scriptedFace({ mutate }).face) - const set = vi.spyOn(ops, 'storeCredential') - const onClose = vi.fn() - render() - expect(screen.queryByText(content => content.includes(en.advancedHint))).toBeNull() - expect(screen.getByDisplayValue('deepseek-v4-flash')).toBeTruthy() - fireEvent.change(screen.getAllByLabelText(new RegExp(en.modelId))[0]!, { target: { value: 'deepseek-v4-mini' } }) - fireEvent.click(screen.getByText(en.apply)) - await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) - expect(mutate).toHaveBeenCalledWith('team-account-entry', expect.any(Array), 0) - expect(set).not.toHaveBeenCalled() +it.each([ + ['unconfigured', { apiKeyEnv: 'OPENAI_API_KEY' }, { configured: false, source: 'env', writable: true }], + ['unknown source', { apiKeyEnv: 'OPENAI_API_KEY' }, { configured: true, source: 'oauth', writable: true }], + ['native authentication', {}, { configured: true, source: 'file', writable: true }], +] as const)('does not guess a source for %s', (_label, profile, credential) => { + renderPiAiEditor('openai', piAiNamespaceWith('openai', profile), { credential }) + expect(screen.queryByText(/^API key source:/)).toBeNull() + expect(document.body.textContent).not.toMatch(/oauth/i) }) -it('opens the account row from the section and saves to its own namespace', async () => { - const namespace = accountNamespace() - const mutate = vi.fn(() => Promise.resolve(remoteOk(namespace))) - const { controller, set } = await mountSection({ mutate }) - const row = controller.store.getSnapshot().rows[0]! - await act(async () => { controller.store.update((state) => { - state.rows = [{ - ...row, - accountAvailable: true, - entry: { - provider: 'deepseek-account', displayName: en.deepSeekAccount, settingsNs: namespace.ns, settingsPath: [], active: true, - }, - }, ...state.rows] - }) }) - fireEvent.click(screen.getByRole('button', { - name: providerCopy(en.editProvider, { provider: 'deepseek-account', displayName: en.deepSeekAccount }), - })) - expect(screen.getByDisplayValue('deepseek-v4-flash')).toBeTruthy() - fireEvent.change(screen.getAllByLabelText(new RegExp(en.modelId))[0]!, { target: { value: 'deepseek-v4-mini' } }) - fireEvent.click(screen.getByText(en.apply)) - await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) - expect(mutate.mock.calls[0]).toEqual([ - 'llm-deepseek-account', - [{ - op: 'set', - path: ['models'], - value: [ - { - id: 'deepseek-v4-mini', - name: 'DeepSeek-V4-Flash', - description: 'Preserved hidden detail', - contextWindow: 1_000_000, - }, - { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 1_000_000 }, - ], - }], - 0, - ]) - expect(set).not.toHaveBeenCalled() +it('refreshes the open editor source from the page snapshot without an editor probe', async () => { + const { face, controller } = await mountSection() + const reads = face.credentials.describe.mock.calls.length + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) + expect(screen.getByText(en.keySourceFile)).toBeTruthy() + expect(face.credentials.describe).toHaveBeenCalledTimes(reads) + face.credentials.describe.mockImplementation(refs => Promise.resolve(remoteOk( + Object.fromEntries(refs.map(ref => [ref, { configured: true, source: 'env', writable: true }])), + ))) + await act(() => controller.load()) + expect(screen.getByText(en.keySourceEnv)).toBeTruthy() + expect(screen.queryByText(en.keySourceFile)).toBeNull() + expect(face.credentials.describe).toHaveBeenCalledTimes(reads + 1) }) -it('renders the localized account row and supports catalogs without capacity defaults', async () => { +it('keeps an OAuth-only provider on its native authentication path', () => { + renderPiAiEditor('openai-codex', wireNamespaces().find(view => view.ns === 'llm-pi-ai')!, { + nativeAuth: { env: [], oauth: true }, + }) + expect(screen.queryByLabelText(en.keyInput)).toBeNull() + expect(authHelpText()).toBeNull() + expect(document.body.textContent).not.toMatch(/oauth/i) +}) + +it.each([ + ['anthropic', ['ANTHROPIC_API_KEY'], en.keyHelp.replace('{env}', 'ANTHROPIC_API_KEY')], + ['openai', ['OPENAI_API_KEY'], en.keyHelp.replace('{env}', 'OPENAI_API_KEY')], +] as const)('opens authentication help for %s on click and dismisses it with Escape', (provider, env, text) => { + renderPiAiEditor(provider, piAiNamespaceWith(provider, {}), { + nativeAuth: { env: [...env], oauth: true }, + }) + const help = screen.getByLabelText(en.authHelp) + fireEvent.click(help) + const tooltip = screen.getByRole('tooltip') + expect(tooltip.textContent).toBe(text) + expect(help.getAttribute('aria-describedby')).toBe(tooltip.id) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('tooltip')).toBeNull() +}) + +it.each([ + ['an OAuth-only provider', 'openai-codex', 'CODEX_TOKEN', { env: [], oauth: true }], + [ + 'a provider with catalog variables and an OAuth sign-in', 'anthropic', 'TEAM_ANTHROPIC_KEY', + { env: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'], oauth: true }, + ], +] as const)('names the profile reference of %s instead of its native authentication', (_label, provider, ref, nativeAuth) => { + renderPiAiEditor(provider, piAiNamespaceWith(provider, { apiKeyEnv: ref }), { nativeAuth }) + expect(screen.getByLabelText(en.keyInput).placeholder) + .toBe(en.keyPlaceholderFallback.replace('{hint}', ref)) + expect(authHelpText()).toBe(en.keyHelp.replace('{env}', ref)) + expect(document.body.textContent).not.toMatch(/oauth/i) +}) + +it.each([ + [ + 'with an OAuth sign-in', 'anthropic', + { env: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'], oauth: true }, + 'ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY', + ], + ['without an OAuth sign-in', 'minimax-cn', { env: ['MINIMAX_CN_API_KEY'], oauth: false }, 'MINIMAX_CN_API_KEY'], +] as const)('lists method-neutral fallbacks for a catalog route %s', (_label, provider, nativeAuth, refs) => { + renderPiAiEditor(provider, piAiNamespaceWith(provider, {}), { nativeAuth }) + expect(screen.getByLabelText(en.keyInput).placeholder) + .toBe(en.keyPlaceholderFallback.replace('{hint}', nativeAuth.env[0])) + expect(authHelpText()).toBe(en.keyHelp.replace('{env}', refs)) + expect(document.body.textContent).not.toMatch(/oauth/i) +}) + +it('shows the next method-neutral fallback without modifying native authentication metadata', () => { + const nativeAuth = { env: ['ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'], oauth: true } + renderPiAiEditor('anthropic', piAiNamespaceWith('anthropic', {}), { nativeAuth }) + expect(screen.getByLabelText(en.keyInput).placeholder) + .toBe(en.keyPlaceholderFallback.replace('{hint}', 'ANTHROPIC_API_KEY')) + expect(authHelpText()).toBe(en.keyHelp.replace('{env}', 'ANTHROPIC_API_KEY')) + expect(nativeAuth.env).toEqual(['ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']) + expect(document.body.textContent).not.toMatch(/oauth/i) +}) + +it('keeps a method-specific profile reference usable without displaying its name', () => { + renderPiAiEditor('anthropic', piAiNamespaceWith('anthropic', { apiKeyEnv: 'TEAM_OAUTH_TOKEN' }), { + nativeAuth: { env: ['ANTHROPIC_API_KEY'], oauth: true }, + }) + expect(screen.getByLabelText(en.keyInput).placeholder).toBe(en.keyPlaceholder) + expect(authHelpText()).toBeNull() + expect(document.body.textContent).not.toMatch(/oauth/i) +}) + +it('offers a plain key field to a route the adapter reports no authentication or endpoint for', () => { + renderPiAiEditor('zombie', wireNamespaces().find(view => view.ns === 'llm-pi-ai')!) + expect(screen.getByLabelText(en.keyInput).placeholder).toBe(en.keyPlaceholder) + expect(authHelpText()).toBeNull() + fireEvent.click(screen.getByText(en.customized)) + expect(screen.getByLabelText(en.baseUrl).placeholder).toBe(en.baseUrlDefault) +}) + +it('renders the localized account row as signed in without an edit action', async () => { const scripted = scriptedFace({}) - const { controller, view } = await mountFace(scripted) + const { controller } = await mountFace(scripted) const row = controller.store.getSnapshot().rows[0]! await act(async () => { controller.store.update((state) => { state.rows = [{ ...row, accountAvailable: true, entry: { ...row.entry, provider: 'deepseek-account' } }] }) }) - expect(screen.getByText(en.deepSeekAccount)).toBeTruthy() - view.unmount() - const namespace = accountNamespace() - render( {}} />) - expect(screen.queryByLabelText(en.keyInput)).toBeNull() + const item = screen.getByText(en.deepSeekAccount).closest('li')! + expect(within(item).getByText(en.accountSignedIn)).toBeTruthy() + expect(within(item).queryByText(en.edit)).toBeNull() }) diff --git a/packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt b/packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt deleted file mode 100644 index c9841889a6..0000000000 --- a/packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt +++ /dev/null @@ -1 +0,0 @@ -DeepSeek Accountdeepseek-accountModelsUsing the adapter defaultsAdd modelCancelApply diff --git a/packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt b/packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt deleted file mode 100644 index e11c2e1969..0000000000 --- a/packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt +++ /dev/null @@ -1 +0,0 @@ -DeepSeek 账号deepseek-account模型目录正在使用适配器默认模型添加模型取消保存 diff --git a/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx b/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx index a0034f2f7c..1e40a70f1e 100644 --- a/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx @@ -11,7 +11,6 @@ afterEach(cleanup) function operations(discoverModels: ModelsOperations['discoverModels']): ModelsOperations { return { discoverModels, - describeCredential: vi.fn(), storeCredential: vi.fn(), removeCredential: vi.fn(), writeSettings: vi.fn(), diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index bade6e9d63..0c327d45fe 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -4,7 +4,28 @@ import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { settingsSchema } from './settings-schema.client.ts' -import { joinProviderDirectory, ModelsSettingsStore, providerUsable } from '../src/client/store.ts' +import { deriveKeyRef, joinProviderDirectory, ModelsSettingsStore, providerUsable } from '../src/client/store.ts' + +it.each([ + ['anthropic', 'ANTHROPIC_API_KEY'], + ['moonshotai', 'MOONSHOTAI_API_KEY'], + ['moonshotai-cn', 'MOONSHOTAI_CN_API_KEY'], + ['google-vertex', 'GOOGLE_VERTEX_API_KEY'], + ['constructor', 'CONSTRUCTOR_API_KEY'], +])('stores a typed key for route %s under a reference derived from the route id', (provider, ref) => { + expect(deriveKeyRef(provider)).toBe(ref) +}) + +it('carries the endpoint and native authentication the adapter reports onto the row', () => { + const nativeAuth = { env: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'], oauth: true } + expect(joinProviderDirectory([], [{ + provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], + defaultBaseURL: 'https://api.anthropic.com', nativeAuth, + }])).toEqual([{ + provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], + active: false, defaultBaseURL: 'https://api.anthropic.com', nativeAuth, + }]) +}) it.each([false, true])('retains configuration diagnostics when the route is active: %s', (active) => { expect(joinProviderDirectory(active ? [{ id: 'openai', name: 'openai' }] : [], [{ @@ -439,24 +460,23 @@ describe('edge joins', () => { expect(state.rows[0]?.derivedCredential).toMatchObject({ configured: true }) }) - it('surfaces a settings describe failure', async () => { - const { ctx, mirror } = api({ describeSettings: () => Promise.resolve(remoteFail('settings down')) }) + it('recovers the provider directory when an initial settings failure is retried', async () => { + let settingsCall = 0 + const { ctx, mirror } = api({ + describeSettings: () => Promise.resolve(++settingsCall === 1 + ? remoteFail('settings down') + : remoteOk({ writable: true, hasDocument: false, namespaces: NAMESPACES })), + }) const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' }) - }) - - it('reports a terminally unavailable settings mirror precisely', async () => { - const { ctx } = api() - const store = new ModelsSettingsStore( - ctx, - settingsSchema, - new SettingsDescribeMirror(ctx, 'memory'), - ) await store.load() - expect(store.store.getSnapshot()).toMatchObject({ - status: 'error', - error: 'settings are unavailable in this browser', + const state = store.store.getSnapshot() + expect(state).toMatchObject({ status: 'ready', error: null, writable: true }) + expect(state.rows.find(row => row.entry.provider === 'openai')).toMatchObject({ + configured: true, + apiKeyEnv: 'OPENAI_API_KEY', + credential: { configured: true, writable: true }, }) }) diff --git a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx index 82794a042c..09cb3bb49d 100644 --- a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx @@ -81,7 +81,6 @@ function mount( ctx, { namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, decode: decodeWelcomeSection }, mirror, - 'host', schemaService, ) const controller = new WelcomeNoticeStore(scope) diff --git a/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts b/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts index ba0e2d16ca..f03d23b912 100644 --- a/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts @@ -3,8 +3,9 @@ import { Context } from '@deepseek-ai/cordis' import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { ConfigFormController } from '@deepseek-ai/dsh-client-ui-settings/src/client/config-form.ts' -import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' -import { decodeWelcomeSection, WelcomeNoticeStore } from '../src/client/welcome-store.ts' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { apply as applySettings, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' +import { decodeWelcomeSection, WelcomeNoticeStore, type WelcomeSection } from '../src/client/welcome-store.ts' import { WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, } from '../src/onboarding-copy.ts' @@ -26,7 +27,7 @@ function rejected(message: string) { function namespace(value: unknown = {}, revision = 0) { return { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, - schema: {}, + schema: { type: 'object', dict: { [WELCOME_NOTICE_ACK_FIELD]: { type: 'string', meta: {} } } }, value, autoGenerate: true, applies: 'live' as const, secrets: [], @@ -41,36 +42,19 @@ function acknowledgedNamespace(version: string, revision = 1) { /** The welcome store over a real mirror-derived scope and a fake wire. */ function buildWelcome( api: { describe?: ReturnType; mutate?: ReturnType }, - persistence: 'host' | 'memory' = 'host', ) { const ctx = { remote: { settings: api } } as never - const mirror = new SettingsDescribeMirror(ctx, persistence) + const mirror = new SettingsDescribeMirror(ctx) const scope = new ConfigFormController( ctx, { namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, decode: decodeWelcomeSection }, mirror, - persistence, schemaService, ) return { mirror, controller: new WelcomeNoticeStore(scope) } } describe('WelcomeNoticeStore', () => { - it('acknowledges in memory while Host settings persistence is disabled', async () => { - const describeCall = vi.fn() - const mutate = vi.fn() - const { controller } = buildWelcome({ describe: describeCall, mutate }, 'memory') - - await controller.load() - expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: false, error: null }) - await expect(controller.acknowledge()).resolves.toBe(true) - expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null }) - await controller.load() - expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null }) - expect(describeCall).not.toHaveBeenCalled() - expect(mutate).not.toHaveBeenCalled() - }) - it('acknowledges only the exact current copy version', async () => { for (const [version, acknowledged] of [ [undefined, false], @@ -108,6 +92,42 @@ describe('WelcomeNoticeStore', () => { expect(describeCall).toHaveBeenCalledTimes(1) }) + it('retains acknowledgement when the settings plugin is recreated', async () => { + let stored = namespace({}, 3) + const ctx = new Context() + new TestRemote(ctx, { + settings: { + describe: vi.fn(async () => ok({ writable: true, hasDocument: false, namespaces: [stored] })), + mutate: vi.fn(async () => { + stored = acknowledgedNamespace(WELCOME_NOTICE_VERSION, 4) + return ok(stored) + }), + }, + }) + const plugin = { inject: [...settingsInject], apply: applySettings } + let controller: WelcomeNoticeStore | undefined + try { + const first = ctx.plugin(plugin) + await first.await() + await ctx.configForms.describe().ensure() + controller = new WelcomeNoticeStore( + ctx.configForms.get(WELCOME_NOTICE_SETTINGS_NAMESPACE)) + await controller.load() + await expect(controller.acknowledge()).resolves.toBe(true) + controller.dispose() + await first.dispose() + await ctx.plugin(plugin).await() + await ctx.configForms.describe().ensure() + controller = new WelcomeNoticeStore( + ctx.configForms.get(WELCOME_NOTICE_SETTINGS_NAMESPACE)) + await controller.load() + expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) + } finally { + controller?.dispose() + await ctx.fiber.dispose() + } + }) + it('keeps the notice pending while the settings read has not answered', async () => { const describeCall = vi.fn(() => Promise.reject(new Error('offline'))) const { mirror, controller } = buildWelcome({ describe: describeCall }) diff --git a/packages/client/ui-settings-models/tsdown.config.ts b/packages/client/ui-settings-models/tsdown.config.ts index 758f6681f8..7aa03cebc1 100644 --- a/packages/client/ui-settings-models/tsdown.config.ts +++ b/packages/client/ui-settings-models/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-models', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml index d4b459bb3e..6aca3b6682 100644 --- a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml +++ b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml @@ -18,8 +18,8 @@ en: 67637808e7940157 zh: d4d0716ea4d33e4a /deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/reading-a-card: - en: 141d6a73e99fee51 - zh: 2aa28b174867c967 + en: bb000df9983be741 + zh: d54ee50bafd136fb /deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/the-preset-switcher: en: 84e5938329199a22 zh: 6f5cfaac56433e8b diff --git a/packages/client/ui-settings-plugin-inventory/README.md b/packages/client/ui-settings-plugin-inventory/README.md index 2107090327..bf5ad3d53e 100644 --- a/packages/client/ui-settings-plugin-inventory/README.md +++ b/packages/client/ui-settings-plugin-inventory/README.md @@ -33,7 +33,7 @@ A failed installation with pending pnpm build permissions offers **Allow these s Each collapsed card uses the plugin's localized title and description when available; a description shows at most two lines until its card is expanded. Settings shortens literal package-name or module-name fallbacks by removing the npm scope and the `cordis:`, `cordis-plugin-`, or `dsh-`/`dsh-host-`/`dsh-client-` prefix; locale titles remain unchanged. The Host supplies locale and package-field fallbacks in `meta`; the Client selects the current language. Full module specifiers remain available in details and search. Missing descriptions are omitted, and metadata diagnostics appear with the card. This compact naming applies only to Settings, not the sidebar's Plugins page. -A small enablement tag marks only the states that differ from plainly enabled: disabled, conditional, provided by presets, and failed; a plainly enabled row carries no tag. A colored root-fiber status dot marks the live phases no tag states: `pending` maps to idle, while `loading` and `unloading` map to ongoing; an `active` or `failed` fiber shows no dot. The stable entry id appears in a code chip at the card's bottom-left, in line with the title, only when it differs from the title: a composition-generated id drops its leading `include:` marker there, and an id that then repeats the title is omitted. Whenever the chip shows, its hover text and the card's accessible name carry the complete id; search and expanded details always retain it. Long entry ids truncate in the chip and remain available on hover. Expanding one card reveals its full description, the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-preset-registry/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset declarations' Chinese names. Search filters both groups by localized title and description, module name, and entry id. +A small enablement tag marks only the states that differ from plainly enabled: disabled, conditional, provided by presets, and failed; a plainly enabled row carries no tag. A colored root-fiber status dot marks the live phases no tag states: `pending` maps to idle, while `loading` and `unloading` map to ongoing; an `active` or `failed` fiber shows no dot. The stable entry id appears in a code chip at the card's bottom-left, in line with the title, only when it differs from the title: a composition-generated id drops its leading `include:` marker there, and an id that then repeats the title is omitted. Whenever the chip shows, its hover text and the card's accessible name carry the complete id; search and expanded details always retain it. Long entry ids truncate in the chip and remain available on hover. Expanding one card reveals its full description, the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-preset-registry/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset declarations' Chinese names. Search filters both groups by localized title and description, module name, and entry id. ### The preset switcher diff --git a/packages/client/ui-settings-plugin-inventory/README.zh.md b/packages/client/ui-settings-plugin-inventory/README.zh.md index 737ff343de..6a992313c1 100644 --- a/packages/client/ui-settings-plugin-inventory/README.zh.md +++ b/packages/client/ui-settings-plugin-inventory/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" 每张收起的卡片使用插件可用的本地化标题与描述;描述最多显示两行,展开卡片后显示全文。设置页会缩短字面的包名或模块名回退值,移除 npm scope 以及 `cordis:`、`cordis-plugin-` 或 `dsh-`/`dsh-host-`/`dsh-client-` 前缀;locale 标题保持原样。Host 在 `meta` 中提供 locale 与包字段的回退结果,Client 选择当前语言。详情与搜索仍保留完整模块标识。缺失的描述不显示,元信息诊断随卡片展示。这种短名显示仅用于设置页,不影响侧栏的插件页。 -表示启停状态的小标签只标记与普通启用不同的状态:已停用、条件启用、由预设提供与启动失败;普通启用的行不带标签。彩色根 fiber 状态圆点标记没有标签表达的存活阶段:`pending` 映射为 idle,`loading` 与 `unloading` 映射为 ongoing;`active` 或 `failed` fiber 不显示圆点。稳定的条目 id 仅在与标题不同时显示,位于卡片左下角的代码标签中,与标题左对齐:组合生成的 id 在此省略开头的 `include:` 标记,省略后与标题相同的 id 不再重复显示。显示代码标签时,其悬停提示与卡片的无障碍名称带完整 id;搜索与展开详情始终保留完整 id。长条目 id 会在代码标签内截断,悬停时仍可查看完整值。展开卡片后会显示完整描述、声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-preset-registry/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设声明里的中文名。搜索按本地化标题与描述、模块名称及条目 id 过滤两组。 +表示启停状态的小标签只标记与普通启用不同的状态:已停用、条件启用、由预设提供与启动失败;普通启用的行不带标签。彩色根 fiber 状态圆点标记没有标签表达的存活阶段:`pending` 映射为 idle,`loading` 与 `unloading` 映射为 ongoing;`active` 或 `failed` fiber 不显示圆点。稳定的条目 id 仅在与标题不同时显示,位于卡片左下角的代码标签中,与标题左对齐:组合生成的 id 在此省略开头的 `include:` 标记,省略后与标题相同的 id 不再重复显示。显示代码标签时,其悬停提示与卡片的无障碍名称带完整 id;搜索与展开详情始终保留完整 id。长条目 id 会在代码标签内截断,悬停时仍可查看完整值。展开卡片后会显示完整描述、声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-preset-registry/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设声明里的中文名。搜索按本地化标题与描述、模块名称及条目 id 过滤两组。 ### 预设切换器 diff --git a/packages/client/ui-settings-plugin-inventory/locale/en.json b/packages/client/ui-settings-plugin-inventory/locale/en.json new file mode 100644 index 0000000000..38e92db659 --- /dev/null +++ b/packages/client/ui-settings-plugin-inventory/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-settings-plugin-inventory" + } +} diff --git a/packages/client/ui-settings-plugin-inventory/locale/zh.json b/packages/client/ui-settings-plugin-inventory/locale/zh.json new file mode 100644 index 0000000000..df4f5ab36f --- /dev/null +++ b/packages/client/ui-settings-plugin-inventory/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "内置插件列表界面", + "description": "在设置中查看全局插件和各 Agent 预设使用的插件。" + } +} diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index b4f868e424..260faa8169 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -55,7 +56,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-package-manifest": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -65,6 +65,7 @@ "@deepseek-ai/dsh-client-store": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index be3aa42773..5836c2b56a 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -300,17 +300,6 @@ font-variant-numeric: tabular-nums; } -/* A middle dot with equal space on both sides separates the subtitle's parts. */ -.groupSub > * + *::before { - content: '·'; - padding: 0 6px; - color: var(--dsw-alias-label-tertiary); -} - -.failedCount { - color: var(--dsw-alias-state-error-primary); -} - /* The preset switcher trigger mirrors the General-settings selector pill. */ .switcher { display: inline-flex; @@ -415,13 +404,6 @@ height: 12px; } -.enabledIn { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 4px 10px; -} - .card[data-failed='true'] { border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent); } diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx index 57810b993a..8138b0d397 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -413,18 +413,7 @@ export function PluginInventorySettingsTab( facts={providers !== undefined ? [ [t('configuration'), t('presetProvidedDetail')], - [t('enabledIn'), ( - - {providers.map(preset => presetName(preset)).join(' · ')} - - - )], + [t('enabledIn'), providers.map(preset => presetName(preset)).join(' · ')], ] : [ [t('configuration'), t(entry.enabled ? 'enabledTag' : 'disabledTag')], @@ -527,12 +516,7 @@ export function PluginInventorySettingsTab( />
-

- {t('presetSubtitle')} - - {`${String(selectedRows.length)} ${t('countUnit')}`} - -

+

{t('presetSubtitle')}

{presetEffectiveOpen ? (
{selected.broken !== undefined ? ( @@ -577,13 +561,7 @@ export function PluginInventorySettingsTab( {t('globalTitle')}
-

- {t('globalSubtitle')} - {`${String(globalCount)} ${t('countUnit')}`} - {filteredFailed.length > 0 ? ( - {filteredFailed.length} {t('failedCountLabel')} - ) : null} -

+

{t('globalSubtitle')}

{globalEffectiveOpen && globalCount > 0 ? (
    {filteredFailed.map(entry => globalRowCard(entry))} diff --git a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts index 3fb7662a86..d535c07a9a 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts @@ -15,7 +15,6 @@ export const zh = { emptySearch: '没有匹配的插件。', presetTitle: '会话插件', presetSubtitle: '由 Agent 预设按会话组成', - countUnit: '个', switcherLabel: '选择要查看的 Agent 预设', presetOptionDefault: '{name}(默认)', presetOptionBroken: '{name}(加载失败)', @@ -23,9 +22,7 @@ export const zh = { globalSubtitle: '系统与所有会话共用', presetProvidedDetail: '全局已停用,由 Agent 预设按会话提供', enabledIn: '启用于', - viewInPreset: '去预设分组查看', matchesInOtherPresets: '其他预设中还有 {count} 个匹配:', - failedCountLabel: '个失败', enabledTag: '已启用', disabledTag: '已停用', conditionalTag: '条件启用', @@ -62,7 +59,6 @@ export const en = { emptySearch: 'No matching plugins.', presetTitle: 'Session plugins', presetSubtitle: 'Composed per session by agent presets', - countUnit: 'plugins', switcherLabel: 'Choose the agent preset to inspect', presetOptionDefault: '{name} (default)', presetOptionBroken: '{name} (failed to load)', @@ -70,9 +66,7 @@ export const en = { globalSubtitle: 'Shared by the system and every session', presetProvidedDetail: 'Disabled globally; agent presets provide it per session', enabledIn: 'Enabled in', - viewInPreset: 'View in the preset group', matchesInOtherPresets: '{count} more matches in other presets: ', - failedCountLabel: 'failed', enabledTag: 'Enabled', disabledTag: 'Disabled', conditionalTag: 'Conditional', diff --git a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx index 9a87e8d6d8..cd4e5acd14 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx @@ -193,6 +193,8 @@ describe('PluginInventorySettingsTab', () => { const { locale, pageProps } = localizedProps(list) const view = render() await screen.findByRole('searchbox', { name: en.search }) + expect(screen.getByText(en.presetSubtitle).textContent).toBe(en.presetSubtitle) + expect(screen.getByText(en.globalSubtitle).textContent).toBe(en.globalSubtitle) fireEvent.click(globalToggle()) const global = screen.getByRole('button', { name: 'Navigation, include:global-navigation, Enabled' }) const preset = screen.getByRole('button', { name: 'Session runner, include:preset-runner, Enabled' }) @@ -209,6 +211,8 @@ describe('PluginInventorySettingsTab', () => { locale.setLocale('zh') view.rerender() + expect(screen.getByText(zh.presetSubtitle).textContent).toBe(zh.presetSubtitle) + expect(screen.getByText(zh.globalSubtitle).textContent).toBe(zh.globalSubtitle) expect(screen.getByRole('button', { name: '导航, include:global-navigation, 已启用' })).toBeTruthy() expect(screen.getByRole('button', { name: '会话执行器, include:preset-runner, 已启用' })).toBeTruthy() expect(screen.getByText('全局导航控件')).toBeTruthy() @@ -321,8 +325,7 @@ describe('PluginInventorySettingsTab', () => { ]) fireEvent.keyDown(document, { key: 'Escape' }) expect(screen.queryAllByRole('menuitem')).toHaveLength(0) - expect(screen.getByText(en.presetSubtitle)).toBeTruthy() - expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('6') + expect(screen.getByText(en.presetSubtitle).textContent).toBe(en.presetSubtitle) // The preset group starts open with its rows while the larger global plane stays folded. expect(presetToggle().getAttribute('aria-expanded')).toBe('true') @@ -338,8 +341,7 @@ describe('PluginInventorySettingsTab', () => { expect(screen.queryByRole('img', { name: en.unobserved })).toBeNull() expect(globalToggle().getAttribute('aria-expanded')).toBe('false') - expect(view.container.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('7') - expect(screen.getByText(`1 ${en.failedCountLabel}`)).toBeTruthy() + expect(screen.getByText(en.globalSubtitle).textContent).toBe(en.globalSubtitle) // A preset row expands into its source facts. fireEvent.click(screen.getByRole('button', { name: 'pwsh, Conditional' })) @@ -484,14 +486,14 @@ describe('PluginInventorySettingsTab', () => { } pickPreset('ptc') - expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('3') + expect(view.container.querySelectorAll('[data-plugin-scope="preset"] li')).toHaveLength(3) fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash, Enabled' })) // An unnamed preset labels its source by id. expect(screen.getByText(en.fromPreset).nextElementSibling?.textContent).toBe('ptc') pickPreset('坏预设 (failed to load)') expect(screen.getByRole('alert').textContent).toBe('the composition file is missing') - expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('0') + expect(view.container.querySelectorAll('[data-plugin-scope="preset"] li')).toHaveLength(0) }) it('folds the preset group on demand and reopens it for a search', async () => { @@ -500,10 +502,10 @@ describe('PluginInventorySettingsTab', () => { expect(toggle.getAttribute('aria-expanded')).toBe('true') expect(view.container.querySelectorAll('[data-plugin-scope="preset"] li')).toHaveLength(6) - // The header keeps its count while the rows are folded away. + // The explanatory subtitle remains visible while the rows are folded away. fireEvent.click(toggle) expect(toggle.getAttribute('aria-expanded')).toBe('false') - expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('6') + expect(screen.getByText(en.presetSubtitle).textContent).toBe(en.presetSubtitle) expect(view.container.querySelectorAll('[data-plugin-scope="preset"] li')).toHaveLength(0) fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: 'pwsh' } }) @@ -542,28 +544,15 @@ describe('PluginInventorySettingsTab', () => { expect(screen.getByText('Localized standard · Localized ptc')).toBeTruthy() }) - it('jumps from a preset-provided row to the preset that enables it', async () => { - await renderReady() - fireEvent.click(screen.getByRole('button', { name: en.switcherLabel })) - fireEvent.click(screen.getByRole('menuitem', { name: 'ptc' })) - - fireEvent.click(globalToggle()) - fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash-host, Via presets' })) - fireEvent.click(screen.getByRole('button', { name: en.viewInPreset })) - expect(screen.getByRole('button', { name: en.switcherLabel }).textContent) - .toBe('标准模式 (default)') - }) - it('searches across scopes and points at matches in other presets', async () => { const view = await renderReady() const search = screen.getByRole('searchbox', { name: en.search }) fireEvent.change(search, { target: { value: 'tool-bash' } }) // Searching forces the collapsed global plane and drawer open. - expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('1') - expect(view.container.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('1') + expect(view.container.querySelectorAll('[data-plugin-scope="preset"] li')).toHaveLength(1) + expect(view.container.querySelectorAll('[data-plugin-scope="global"] li')).toHaveLength(1) expect(screen.getByText(en.presetEnabledTag)).toBeTruthy() - expect(screen.queryByText(`1 ${en.failedCountLabel}`)).toBeNull() const hint = screen.getByText((text: string) => text.startsWith('2 more matches')) expect(hint).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'ptc' })) @@ -571,13 +560,13 @@ describe('PluginInventorySettingsTab', () => { // A match visible only in another preset keeps the pointer without rows. fireEvent.change(search, { target: { value: 'crashy' } }) - expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('0') + expect(view.container.querySelectorAll('[data-plugin-scope="preset"] li')).toHaveLength(0) expect(screen.getByText((text: string) => text.startsWith('1 more matches'))).toBeTruthy() expect(screen.queryByText(en.emptySearch)).toBeNull() // A match on a Loader entry id only reaches the global plane. fireEvent.change(search, { target: { value: '8a1b2c3d' } }) - expect(view.container.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('1') + expect(view.container.querySelectorAll('[data-plugin-scope="global"] li')).toHaveLength(1) expect(screen.queryByText((text: string) => text.includes('more matches'))).toBeNull() fireEvent.change(search, { target: { value: 'not-a-plugin' } }) diff --git a/packages/client/ui-settings-plugin-inventory/tsdown.config.ts b/packages/client/ui-settings-plugin-inventory/tsdown.config.ts index 205aaf47d4..7aa03cebc1 100644 --- a/packages/client/ui-settings-plugin-inventory/tsdown.config.ts +++ b/packages/client/ui-settings-plugin-inventory/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-plugin-inventory', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-plugins/locale/en.json b/packages/client/ui-settings-plugins/locale/en.json new file mode 100644 index 0000000000..bbe7eea9a3 --- /dev/null +++ b/packages/client/ui-settings-plugins/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-settings-plugins" + } +} diff --git a/packages/client/ui-settings-plugins/locale/zh.json b/packages/client/ui-settings-plugins/locale/zh.json new file mode 100644 index 0000000000..71ab797452 --- /dev/null +++ b/packages/client/ui-settings-plugins/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "内置插件设置入口", + "description": "提供设置中的内置插件入口及其功能标签页。" + } +} diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 0f32affb06..6a3275581a 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -50,10 +51,10 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-settings-plugins/tsdown.config.ts b/packages/client/ui-settings-plugins/tsdown.config.ts index f9bc4e134b..7aa03cebc1 100644 --- a/packages/client/ui-settings-plugins/tsdown.config.ts +++ b/packages/client/ui-settings-plugins/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-plugins', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-session-log/locale/en.json b/packages/client/ui-settings-session-log/locale/en.json new file mode 100644 index 0000000000..3f4017f7d2 --- /dev/null +++ b/packages/client/ui-settings-session-log/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-settings-session-log" + } +} diff --git a/packages/client/ui-settings-session-log/locale/zh.json b/packages/client/ui-settings-session-log/locale/zh.json new file mode 100644 index 0000000000..fc41c18589 --- /dev/null +++ b/packages/client/ui-settings-session-log/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "会话日志上传设置", + "description": "设置是否随 DeepSeek 官方模型 API 请求上传会话日志。" + } +} diff --git a/packages/client/ui-settings-session-log/package.json b/packages/client/ui-settings-session-log/package.json index 32fb45425d..d1f0a7a55e 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -53,13 +54,12 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-layout": "workspace:*", - "react-dom": "^18.2.0", - "@types/react-dom": "~18.3.0" + "react-dom": "^18.2.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-settings-session-log/tests/upload.client.spec.tsx b/packages/client/ui-settings-session-log/tests/upload.client.spec.tsx index 955d767892..4ddc52d85e 100644 --- a/packages/client/ui-settings-session-log/tests/upload.client.spec.tsx +++ b/packages/client/ui-settings-session-log/tests/upload.client.spec.tsx @@ -14,7 +14,7 @@ afterEach(cleanup) function fixture(writable = true) { const formState = createSnapshotStore>({ - status: 'ready', value: { enabled: true }, base: {}, user: {}, revision: 0, writable, mode: 'host', + status: 'ready', value: { enabled: true }, base: {}, user: {}, revision: 0, writable, }) const set = vi.fn(async (_field: string, enabled: unknown) => { formState.update((state) => { state.value = { enabled: enabled === true } }) diff --git a/packages/client/ui-settings-session-log/tsdown.config.ts b/packages/client/ui-settings-session-log/tsdown.config.ts index 701ce5e140..7aa03cebc1 100644 --- a/packages/client/ui-settings-session-log/tsdown.config.ts +++ b/packages/client/ui-settings-session-log/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-session-log', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-shell/locale/en.json b/packages/client/ui-settings-shell/locale/en.json new file mode 100644 index 0000000000..f43265d805 --- /dev/null +++ b/packages/client/ui-settings-shell/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-settings-shell" + } +} diff --git a/packages/client/ui-settings-shell/locale/zh.json b/packages/client/ui-settings-shell/locale/zh.json new file mode 100644 index 0000000000..aed59177c8 --- /dev/null +++ b/packages/client/ui-settings-shell/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "命令执行设置", + "description": "配置命令执行超时和输出长度上限。" + } +} diff --git a/packages/client/ui-settings-shell/package.json b/packages/client/ui-settings-shell/package.json index 017b51260c..2afa80d649 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -54,10 +55,10 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-settings-shell/tsdown.config.ts b/packages/client/ui-settings-shell/tsdown.config.ts index 4e48f8dff3..7aa03cebc1 100644 --- a/packages/client/ui-settings-shell/tsdown.config.ts +++ b/packages/client/ui-settings-shell/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-shell', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-subagent/locale/en.json b/packages/client/ui-settings-subagent/locale/en.json new file mode 100644 index 0000000000..08e121d1c3 --- /dev/null +++ b/packages/client/ui-settings-subagent/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-settings-subagent" + } +} diff --git a/packages/client/ui-settings-subagent/locale/zh.json b/packages/client/ui-settings-subagent/locale/zh.json new file mode 100644 index 0000000000..1498349fa6 --- /dev/null +++ b/packages/client/ui-settings-subagent/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "子智能体设置", + "description": "配置委派深度、并发数量和可选模型。" + } +} diff --git a/packages/client/ui-settings-subagent/package.json b/packages/client/ui-settings-subagent/package.json index 0899660cd8..a07ea3707e 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -55,10 +56,10 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-settings-subagent/tsdown.config.ts b/packages/client/ui-settings-subagent/tsdown.config.ts index f97175aba2..7aa03cebc1 100644 --- a/packages/client/ui-settings-subagent/tsdown.config.ts +++ b/packages/client/ui-settings-subagent/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-subagent', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-web-search/locale/en.json b/packages/client/ui-settings-web-search/locale/en.json new file mode 100644 index 0000000000..5c0a85691f --- /dev/null +++ b/packages/client/ui-settings-web-search/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-settings-web-search" + } +} diff --git a/packages/client/ui-settings-web-search/locale/zh.json b/packages/client/ui-settings-web-search/locale/zh.json new file mode 100644 index 0000000000..e450e1004c --- /dev/null +++ b/packages/client/ui-settings-web-search/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "网页搜索设置", + "description": "配置 DeepSeek 网页搜索的密钥、服务地址和请求预算。" + } +} diff --git a/packages/client/ui-settings-web-search/package.json b/packages/client/ui-settings-web-search/package.json index 5cdf2fdfe7..9604d1c6cf 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -55,10 +56,10 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-settings-web-search/tsdown.config.ts b/packages/client/ui-settings-web-search/tsdown.config.ts index 112d2e7f96..7aa03cebc1 100644 --- a/packages/client/ui-settings-web-search/tsdown.config.ts +++ b/packages/client/ui-settings-web-search/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-web-search', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index b5488bea3e..2b1ae7c2f5 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/README.i18n.yaml @@ -18,8 +18,8 @@ en: 2417d8409aad6ca7 zh: 449eee623a2f334b /deepseek-ai-dsh-client-ui-settings/use-this-package/configuration-forms: - en: 5bda0401a6a4f158 - zh: 6778a64d3e2a7453 + en: c2b31046779bab9e + zh: ce183f67e63114b3 /deepseek-ai-dsh-client-ui-settings/use-this-package/following-served-namespaces: en: 2edd420502bebce1 zh: 6624abe01110c012 @@ -33,8 +33,8 @@ en: d756fe51d9456fb4 zh: 41de582aace7332f /deepseek-ai-dsh-client-ui-settings/understand-the-implementation/the-describe-mirror: - en: ea274a9480884a7f - zh: bbaf650e04646c04 + en: 0cd270ddcac46776 + zh: 4e03bf1d153b3f04 /deepseek-ai-dsh-client-ui-settings/understand-the-implementation/shared-entry-writes: en: 11a73ab8e9280b81 zh: f0e72a05959e3f32 @@ -51,8 +51,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-settings/known-limitations-and-deferred-work: - en: d8ef5c4f3a48e05f - zh: 7c335dc8543249c6 + en: f74ad1a2cc849b42 + zh: 1320e40dcdb0b208 /deepseek-ai-dsh-client-ui-settings/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 0a7113c921..b43b91a829 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -29,9 +29,11 @@ Feature plugins use this package to store and edit their preferences without re- ### Configuration forms -`ctx.configForms.developerTools` owns the Coding Tools switch and the shared Web and desktop preference `ui-settings.enabled`, defaulting to `true`. Its `enabled` observable publishes accepted choices and `setEnabled` uses the same ordered settings writes. Desktop and loopback Web persist to the Host document; remote Web keeps this choice in one browser-local observable until reload without issuing Host writes. This controls presentation and HTML preview permissions, not Host authorization or Session recording. Host-backed clients keep developer features disabled until the first accepted schema-resolved value arrives; missing or failed initial responses do not enable them. Later refreshes retain the last accepted choice. +`ctx.configForms.developerTools` owns the Coding Tools switch and the shared Web and desktop preference `ui-settings.enabled`, defaulting to `true`. Its `enabled` observable publishes accepted choices and `setEnabled` uses the same ordered settings writes. Desktop and every authenticated Web browser persist to the Host document. This controls presentation and HTML preview permissions, not Host authorization or Session recording. Host-backed clients keep developer features disabled until the first accepted schema-resolved value arrives; missing or failed initial responses do not enable them. Later refreshes retain the last accepted choice. -Feature adapters use `ctx.configForms.get(entryId)` to obtain accepted values and a write queue shared by every editor of that Host entry. Snapshots contain resolved `value`, inherited `base`, raw `user`, revision, writability, and persistence mode. `set` and `unset` submit one operation; `mutate` submits one atomic operation list. Staged editors pass the revision read before editing; conflicts preserve their drafts. Unsetting removes the override and restores inheritance. +Feature adapters use `ctx.configForms.get(entryId)` to obtain accepted values and a write queue shared by every editor of that Host entry. Snapshots contain resolved `value`, inherited `base`, raw `user`, revision, and writability. `set` and `unset` submit one operation; `mutate` submits one atomic operation list. Staged editors pass the revision read before editing; conflicts preserve their drafts. Unsetting removes the override and restores inheritance. + +A configuration form persists through the Host settings service for every authenticated browser; the [authenticated-browser Host preferences note](../../../.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.md) owns that policy. `settings.describe.writable` is the authority on whether a write is accepted. ### Following served namespaces @@ -59,7 +61,7 @@ The package realizes one ownership rule: the browser keeps one shared mirror of ### The describe mirror -The Host Config of the `ui-settings` entry declares the default-on `enabled` preference. The Client plugin injects `remote` with its `settings` namespace, resolves Host persistence once from the fixed `remote.$host` facts, and owns the one `settings.describe` reader in the browser: a shared mirror refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included, closing the window where a commit lands between the eager read and the SSE subscription). Cross-namespace surfaces read it through `ctx.configForms.describe()`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in). +The Host Config of the `ui-settings` entry declares the default-on `enabled` preference. The Client plugin injects `remote` with its `settings` namespace and owns the one `settings.describe` reader in the browser: a shared mirror refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included, closing the window where a commit lands between the eager read and the SSE subscription). Cross-namespace surfaces read it through `ctx.configForms.describe()`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in). ### Shared entry writes @@ -101,9 +103,9 @@ None; this package neither assembles nor sends a provider request. -These limits define where the settings transport cannot reach; they are current package constraints. +These limits define the settings transport's reach and who shares a settings document; they are current package constraints. -- **Non-loopback pages get no durable settings** — this Client keeps Host persistence disabled there, so a form starts `unavailable` and never crosses the wire; form writes are inert even though Connection authentication covers the API. The shared Coding Tools preference instead provides browser-local changes. +- **Host preferences are shared across authenticated entries** — browsers accessing the same settings document share its persisted preferences, rather than retaining per-browser choices. ### Dev Note diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index 193d764637..463ec9fd15 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -29,9 +29,11 @@ kind: "package-reference" ### 配置表单 -`ctx.configForms.developerTools` 管理代码工作工具开关与 Web 和桌面端共享的偏好 `ui-settings.enabled`,默认为 `true`。其 `enabled` 可观察值发布已接受的选择,`setEnabled` 使用相同的有序设置写入。桌面端和回环 Web 将设置持久化到 Host 文档;远程 Web 将此选择保存在单个浏览器本地可观察值中,刷新后重置,不发送 Host 写入。此设置控制界面展示和 HTML 预览权限,不控制 Host 授权或 Session 记录。 使用 Host 偏好的客户端在首个经过 schema 解析并接受的值到达前保持开发者功能关闭;首次响应缺失或失败不会启用它们。后续刷新保留已接受的选择。 +`ctx.configForms.developerTools` 管理代码工作工具开关与 Web 和桌面端共享的偏好 `ui-settings.enabled`,默认为 `true`。其 `enabled` 可观察值发布已接受的选择,`setEnabled` 使用相同的有序设置写入。桌面端和每个已认证的 Web 浏览器都将设置持久化到 Host 文档。此设置控制界面展示和 HTML 预览权限,不控制 Host 授权或 Session 记录。 使用 Host 偏好的客户端在首个经过 schema 解析并接受的值到达前保持开发者功能关闭;首次响应缺失或失败不会启用它们。后续刷新保留已接受的选择。 -功能适配器使用 `ctx.configForms.get(entryId)` 获取该 Host 条目所有编辑器共享的已接受值和写入队列。快照包含解析后的 `value`、继承 `base`、原始 `user`、修订号、可写性和持久化模式。`set` 与 `unset` 提交单个操作,`mutate` 提交一个原子操作列表。暂存编辑器传入编辑前读取的修订号;冲突时保留草稿。清除操作移除覆盖并恢复继承。 +功能适配器使用 `ctx.configForms.get(entryId)` 获取该 Host 条目所有编辑器共享的已接受值和写入队列。快照包含解析后的 `value`、继承 `base`、原始 `user`、修订号和可写性。`set` 与 `unset` 提交单个操作,`mutate` 提交一个原子操作列表。暂存编辑器传入编辑前读取的修订号;冲突时保留草稿。清除操作移除覆盖并恢复继承。 + +配置表单对每个已认证浏览器都通过 Host settings 服务持久化;该策略由[已认证浏览器 Host 偏好笔记](../../../.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.zh.md)拥有。写入是否被接受由 `settings.describe.writable` 决定。 ### 跟随被服务的命名空间 @@ -59,7 +61,7 @@ kind: "package-reference" ### Describe 镜像 -`ui-settings` 条目的 Host Config 声明默认开启的 `enabled` 偏好。Client 插件注入 `remote` 及其 `settings` 命名空间,从固定的 `remote.$host` 事实一次性解析 Host 持久化模式,并持有浏览器中唯一的 `settings.describe` 读取方:一面共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内,关闭「提交落在急切读取与 SSE 订阅之间」的窗口)。跨命名空间表面通过 `ctx.configForms.describe()` 读它,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。 +`ui-settings` 条目的 Host Config 声明默认开启的 `enabled` 偏好。Client 插件注入 `remote` 及其 `settings` 命名空间,并持有浏览器中唯一的 `settings.describe` 读取方:一面共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内,关闭「提交落在急切读取与 SSE 订阅之间」的窗口)。跨命名空间表面通过 `ctx.configForms.describe()` 读它,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。 ### 共享条目写入 @@ -101,9 +103,9 @@ kind: "package-reference" -这些限制说明设置传输层够不到的地方;它们是当前包约束。 +这些限制说明设置传输层的覆盖范围以及谁共享同一份 settings 文档;它们是当前包约束。 -- **非 loopback 页面没有持久化设置**:本 Client 在那里禁用 Host 持久化,因此 表单以 `unavailable` 起步且从不跨线路;尽管 Connection 认证覆盖 API,表单写入仍在那里无效。共享的代码工作工具偏好单独提供浏览器本地变更。 +- **Host 偏好由各认证入口共享**:访问同一 settings 文档的浏览器共享其持久化偏好,而非各自保存浏览器偏好。 ### 开发备注 diff --git a/packages/client/ui-settings/locale/en.json b/packages/client/ui-settings/locale/en.json new file mode 100644 index 0000000000..71db5ec901 --- /dev/null +++ b/packages/client/ui-settings/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-settings" + } +} diff --git a/packages/client/ui-settings/locale/zh.json b/packages/client/ui-settings/locale/zh.json new file mode 100644 index 0000000000..1373f51e13 --- /dev/null +++ b/packages/client/ui-settings/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "设置界面框架", + "description": "提供设置页面的基础表单和插件扩展位置。" + } +} diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index e1cc80cd17..d01f49a4f2 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -48,11 +49,11 @@ "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0", "@deepseek-ai/dsh-util-values": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-settings/src/client/config-form-types.ts b/packages/client/ui-settings/src/client/config-form-types.ts index 6276618562..41fc3bf738 100644 --- a/packages/client/ui-settings/src/client/config-form-types.ts +++ b/packages/client/ui-settings/src/client/config-form-types.ts @@ -8,8 +8,7 @@ import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' export interface ConfigFormSnapshot { /** * `loading` until the first accepted section, `ready` while one stands, and - * `unavailable` when the namespace is not exposed to this client or the - * connection keeps preferences process-local (memory mode). + * `unavailable` when the namespace is not exposed to this client. */ status: 'loading' | 'ready' | 'unavailable' /** Last accepted schema-resolved section; undefined before the first acceptance. */ @@ -27,10 +26,8 @@ export interface ConfigFormSnapshot { user: unknown /** Namespace revision fencing the next write; undefined before the first Host view. */ revision: number | undefined - /** Whether the Host document accepts writes; memory mode never does. */ + /** Whether the Host document accepts writes. */ writable: boolean - /** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */ - mode: 'host' | 'memory' } /** @@ -47,7 +44,7 @@ export interface ConfigForm { subscribe(listener: () => void): () => void /** * Queue one atomic namespace mutation. All operations share one revision - * fence, Host validation, persistence decision, and recovery read. Supplying + * fence, Host validation, and recovery read. Supplying * `expectedRevision` preserves an earlier read as the fence instead of using * the latest queued or mirrored revision. * @param ops - ordered field operations copied when queued. diff --git a/packages/client/ui-settings/src/client/config-form.ts b/packages/client/ui-settings/src/client/config-form.ts index cf77536a97..7e64f9f990 100644 --- a/packages/client/ui-settings/src/client/config-form.ts +++ b/packages/client/ui-settings/src/client/config-form.ts @@ -52,7 +52,7 @@ export class ConfigFormController implements ConfigForm { private tail: Promise = Promise.resolve() private writeGeneration = 0 private disposed = false - private readonly unsubscribe: (() => void) | undefined + private readonly unsubscribe: () => void /** * Revision answered by a superseded write still ahead of the mirror: the * mirror only folds the LATEST settlement in, so a queued successor takes @@ -65,29 +65,24 @@ export class ConfigFormController implements ConfigForm { * namespace carries this form's writes (reads ride the mirror). * @param spec - namespace identity and optional narrowing decoder. * @param mirror - the shared describe mirror this form derives from. - * @param persistence - client-selected Host persistence; non-loopback pages may remain process-local. * @param schema - settings-owned schema operations. */ constructor( private readonly ctx: Context, private readonly spec: ConfigFormSpec, private readonly mirror: SettingsDescribeMirror, - private readonly persistence: 'host' | 'memory', private readonly schema: SettingsSchemaService, ) { this.store = createSnapshotStore>({ - status: persistence === 'host' ? 'loading' : 'unavailable', + status: 'loading', value: undefined, base: undefined, user: undefined, revision: undefined, writable: false, - mode: persistence, }) - if (persistence === 'host') { - this.unsubscribe = mirror.subscribe(() => { this.derive() }) - this.derive() - } + this.unsubscribe = mirror.subscribe(() => { this.derive() }) + this.derive() } /** @returns the current sync snapshot (stable reference until the next change). */ @@ -167,12 +162,12 @@ export class ConfigFormController implements ConfigForm { async dispose(): Promise { this.disposed = true this.writeGeneration += 1 - this.unsubscribe?.() + this.unsubscribe() await this.tail } private enqueue(operation: () => Promise): Promise { - if (this.persistence === 'memory' || this.disposed) return Promise.resolve(false) + if (this.disposed) return Promise.resolve(false) const task = this.tail.then(async () => { if (this.disposed) return false return await operation() @@ -244,7 +239,6 @@ export class ConfigForms extends Service { readonly developerTools: DeveloperToolsPreference private readonly mirror: SettingsDescribeMirror private readonly schema: SettingsSchemaService - private readonly persistence: 'host' | 'memory' /** * The PROVIDING fiber, kept because a Service reads `ctx` as its *consumer's* * fiber: letting a shared form write through the caller's context would make @@ -254,19 +248,16 @@ export class ConfigForms extends Service { /** * @param ctx - the providing plugin's context. - * @param config - the shared describe mirror every shared form derives from, - * the settings-owned schema operations, and the Host persistence the provider - * resolved from `remote.$host`. + * @param config - the shared describe mirror every shared form derives from + * and the settings-owned schema operations. */ constructor(ctx: Context, config: { mirror: SettingsDescribeMirror schema: SettingsSchemaService - persistence: 'host' | 'memory' }) { super(ctx, 'configForms') this.mirror = config.mirror this.schema = config.schema - this.persistence = config.persistence this.owner = ctx this.developerTools = new DeveloperToolsPreference(this.get(DEVELOPER_TOOLS_NAMESPACE)) ctx.effect(() => async () => { @@ -294,7 +285,7 @@ export class ConfigForms extends Service { const existing = this.forms.get(entryId) if (existing !== undefined) return existing as ConfigFormController const form = new ConfigFormController( - this.owner, { namespace: entryId }, this.mirror, this.persistence, this.schema, + this.owner, { namespace: entryId }, this.mirror, this.schema, ) this.forms.set(entryId, form) void this.mirror.ensure() diff --git a/packages/client/ui-settings/src/client/developer-tools.ts b/packages/client/ui-settings/src/client/developer-tools.ts index d74de75956..52eb1242d0 100644 --- a/packages/client/ui-settings/src/client/developer-tools.ts +++ b/packages/client/ui-settings/src/client/developer-tools.ts @@ -1,5 +1,5 @@ /** One accepted preference drives every developer-tool consumer. */ -import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { DeveloperToolsSettings } from '../developer-tools-settings.ts' import type { ConfigForm } from './config-form-types.ts' @@ -7,13 +7,12 @@ import type { ConfigForm } from './config-form-types.ts' export class DeveloperToolsPreference { /** Accepted enablement, observable through renderer-bound hooks. */ readonly enabled: ObservableSnapshot - private readonly local = createSnapshotStore(true) /** * @param scope - settings-owned namespace controller. */ constructor(private readonly scope: ConfigForm) { - this.enabled = scope.getSnapshot().mode === 'memory' ? this.local : { + this.enabled = { getSnapshot: () => scope.getSnapshot().value?.enabled ?? false, subscribe: (listener) => { let previous = this.enabled.getSnapshot() @@ -28,15 +27,11 @@ export class DeveloperToolsPreference { } /** - * Persist a Host choice with ordered writes, or update the shared browser-local choice. + * Persist the choice on the Host with ordered writes. * @param enabled - requested developer-tool mode. - * @returns settlement after local publication or Host acceptance; rejects after a refused write recovers. + * @returns settlement after Host acceptance; rejects after a refused write recovers. */ async setEnabled(enabled: boolean): Promise { - if (this.scope.getSnapshot().mode === 'memory') { - this.local.set(enabled) - return - } if (!await this.scope.set('enabled', enabled)) throw new Error('Developer tools preference was not saved') } } diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 151a94d3fa..e6182781c8 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -1,7 +1,7 @@ /** Shared configuration forms and their Host describe mirror. */ import type { Context } from '@deepseek-ai/cordis' -// Type-only: the ctx.remote merge, the fixed Host facts, and the carrier's -// `connection/reset` lifecycle event, all through the assembly package. +// Type-only: the ctx.remote merge and the carrier's `connection/reset` +// lifecycle event, all through the assembly package. import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only pair supplying `$on` and its key face without dragging a build // artifact into the Host graph (rationale beside the same pair in @@ -35,9 +35,9 @@ export const inject = ['remote', 'remote.settings'] */ export function apply(ctx: Context): void { const schema = new SettingsSchemaService(ctx) - // Every form uses the persistence mode resolved from the connected Host. - const persistence = ctx.remote.$host.isLoopback ? 'host' : 'memory' - const mirror = new SettingsDescribeMirror(ctx, persistence) + // Persistence policy: every authenticated browser persists through the Host document + // (.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.md). + const mirror = new SettingsDescribeMirror(ctx) ctx.effect(() => { const disposers = [ ctx.remote.$on('settings/document-updated', () => { void mirror.load() }), @@ -50,5 +50,5 @@ export function apply(ctx: Context): void { void mirror.ensure() return () => { for (const dispose of disposers) dispose() } }, 'ui-settings: describe mirror invalidations') - new ConfigForms(ctx, { mirror, schema, persistence }) + new ConfigForms(ctx, { mirror, schema }) } diff --git a/packages/client/ui-settings/src/client/settings-mirror.ts b/packages/client/ui-settings/src/client/settings-mirror.ts index 761a3f141c..8cdf37512d 100644 --- a/packages/client/ui-settings/src/client/settings-mirror.ts +++ b/packages/client/ui-settings/src/client/settings-mirror.ts @@ -26,11 +26,11 @@ export interface SettingsDescribeView { /** Mirror state every derived settings surface renders from. */ export interface SettingsMirrorSnapshot { /** - * `unavailable` is the terminal non-loopback state; `ready` persists across - * later failed refreshes (the held view keeps serving); `idle` means no - * answer is held and no read is running, so `ensure` will start one. + * `ready` persists across later failed refreshes (the held view keeps serving); + * `idle` means no answer is held and no read is running, so `ensure` will + * start one. */ - status: 'idle' | 'loading' | 'ready' | 'unavailable' + status: 'idle' | 'loading' | 'ready' /** The last good answer; undefined until the first success. */ view: SettingsDescribeView | undefined /** The latest refresh failure message, cleared by the next success. */ @@ -52,8 +52,8 @@ export interface SettingsDescribeFace { */ subscribe(listener: () => void): () => void /** - * Resolve once an answer is held (or the mirror is terminally unavailable), - * reading only from `idle`. + * Resolve once an answer is held, reading only from `idle`. A settled mirror + * holds either a view or the read's error. * @returns settlement of the current or newly started read, if any. */ ensure(): Promise @@ -79,14 +79,10 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { /** * @param ctx - the providing plugin's context, whose `remote.settings` * namespace answers the describe read. - * @param persistence - client-selected Host persistence; non-loopback pages may remain process-local. */ - constructor( - private readonly ctx: ClientContext, - private readonly persistence: 'host' | 'memory' = 'host', - ) { + constructor(private readonly ctx: ClientContext) { this.store = createSnapshotStore({ - status: persistence === 'host' ? 'idle' : 'unavailable', + status: 'idle', view: undefined, error: null, }) @@ -112,7 +108,6 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { * @returns settlement after this call's freshness is reflected. */ load(): Promise { - if (this.persistence === 'memory') return Promise.resolve() if (this.inFlight !== undefined) { this.rerun = true return this.inFlight @@ -124,13 +119,10 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { } /** - * Resolve once an answer is held (or the mirror is terminally unavailable), - * reading only from `idle`. The cheap idempotent entry for surfaces that - * render on first use. + * Resolve once an answer is held, reading only from `idle`. * @returns settlement of the current or newly started read, if any. */ ensure(): Promise { - if (this.persistence === 'memory') return Promise.resolve() if (this.inFlight !== undefined) return this.inFlight if (this.getSnapshot().status === 'idle') return this.load() return Promise.resolve() diff --git a/packages/client/ui-settings/tests/config-form.client.spec.ts b/packages/client/ui-settings/tests/config-form.client.spec.ts index 3f185baf4f..e106af17ca 100644 --- a/packages/client/ui-settings/tests/config-form.client.spec.ts +++ b/packages/client/ui-settings/tests/config-form.client.spec.ts @@ -63,14 +63,14 @@ function deferred() { return { promise, resolve, reject } } -/** A host-mode mirror plus a controller derived from it, over one scripted context. */ +/** A mirror plus a controller derived from it, over one scripted context. */ function derivedScope( api: { describe?: ReturnType; mutate?: ReturnType }, spec: { namespace: string; decode?: (section: unknown) => UiTestSettings | undefined } = { namespace: 'ui-test' }, ) { const ctx = ctxWith(api) const mirror = new SettingsDescribeMirror(ctx) - const scope = new ConfigFormController(ctx, spec, mirror, 'host', settingsSchema) + const scope = new ConfigFormController(ctx, spec, mirror, settingsSchema) return { mirror, scope } } @@ -89,11 +89,11 @@ describe('ConfigFormController', () => { const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3)) const { mirror, scope } = derivedScope({ describe: describeCall }) expect(scope.getSnapshot()).toEqual({ - status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host', + status: 'loading', value: undefined, revision: undefined, writable: false, }) await mirror.load() expect(scope.getSnapshot()).toEqual({ - status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host', + status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, }) }) @@ -239,8 +239,8 @@ describe('ConfigFormController', () => { const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 5))) const ctx = ctxWith({ describe: describeCall, mutate }) const mirror = new SettingsDescribeMirror(ctx) - const writer = new ConfigFormController(ctx, { namespace: 'ui-test' }, mirror, 'host', settingsSchema) - const sibling = new ConfigFormController(ctx, { namespace: 'ui-test' }, mirror, 'host', settingsSchema) + const writer = new ConfigFormController(ctx, { namespace: 'ui-test' }, mirror, settingsSchema) + const sibling = new ConfigFormController(ctx, { namespace: 'ui-test' }, mirror, settingsSchema) await mirror.load() await writer.set('preference', 'dark') expect(describeCall).toHaveBeenCalledTimes(1) @@ -424,7 +424,7 @@ describe('ConfigFormController', () => { }, } as never const scope = new ConfigFormController( - ctxWith({}), { namespace: 'ui-test' }, mirror, 'host', settingsSchema) + ctxWith({}), { namespace: 'ui-test' }, mirror, settingsSchema) expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 }) await scope.dispose() @@ -437,23 +437,6 @@ describe('ConfigFormController', () => { expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 }) }) - it('keeps a remote browser in memory mode without Host calls', async () => { - const describeCall = vi.fn() - const mutate = vi.fn() - const ctx = ctxWith({ describe: describeCall, mutate }) - const mirror = new SettingsDescribeMirror(ctx, 'memory') - const scope = new ConfigFormController( - ctx, { namespace: 'ui-test' }, mirror, 'memory', settingsSchema) - expect(scope.getSnapshot()).toEqual({ - status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory', - }) - await mirror.load() - await scope.set('preference', 'dark') - await scope.dispose() - expect(describeCall).not.toHaveBeenCalled() - expect(mutate).not.toHaveBeenCalled() - }) - it('carries the composition base and the user layer into the snapshot', async () => { const layered: SettingsNamespaceView = { ...view({ preference: 'dark' }, 3), @@ -522,7 +505,7 @@ describe('ConfigForms.get', () => { let theme!: ConfigForm let locale!: ConfigForm new TestRemote(ctx, { settings: { describe: describeCall } }) - await ctx.plugin(ConfigForms, { mirror, schema: settingsSchema, persistence: 'host' }).await() + await ctx.plugin(ConfigForms, { mirror, schema: settingsSchema }).await() expect(ctx.configForms.describe()).toBe(mirror) const fiber = ctx.plugin({ inject: ['remote', 'configForms'], @@ -543,22 +526,4 @@ describe('ConfigForms.get', () => { expect(theme.getSnapshot()).toMatchObject({ revision: 1 }) }) - it('binds a remote browser in memory mode without starting a settings read', async () => { - const describeCall = vi.fn() - const mirror = new SettingsDescribeMirror(ctxWith({ describe: describeCall }), 'memory') - const ctx = new Context() - let scope!: ConfigForm - new TestRemote(ctx, { settings: { describe: describeCall } }) - await ctx.plugin(ConfigForms, { mirror, schema: settingsSchema, persistence: 'memory' }).await() - const fiber = ctx.plugin({ - inject: ['remote', 'configForms'], - apply: (plugin: Context) => { - scope = plugin.configForms.get('ui-test') - }, - }) - await fiber.await() - expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', mode: 'memory', writable: false }) - await fiber.dispose() - expect(describeCall).not.toHaveBeenCalled() - }) }) diff --git a/packages/client/ui-settings/tests/developer-tools.client.spec.ts b/packages/client/ui-settings/tests/developer-tools.client.spec.ts index 373c491dfe..77eac74052 100644 --- a/packages/client/ui-settings/tests/developer-tools.client.spec.ts +++ b/packages/client/ui-settings/tests/developer-tools.client.spec.ts @@ -63,20 +63,15 @@ describe('developer tools settings', () => { expect(ctx.configForms.developerTools.enabled.getSnapshot()).toBe(false) }) - it('shares one remote-browser preference across consumers and disposes it with the plugin', async () => { + it('shares one preference instance across consumers and disposes it with the plugin', async () => { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) - const describeCall = vi.fn() - const remote = new TestRemote(ctx, { settings: { describe: describeCall } }) - remote.$host = { home: undefined, isLoopback: false } + const describeCall = vi.fn().mockResolvedValue({ ok: true, value: { writable: true, hasDocument: true, namespaces: [] } }) + new TestRemote(ctx, { settings: { describe: describeCall } }) const fiber = ctx.plugin({ inject, apply: clientApply }) await fiber.await() const preference = ctx.configForms.developerTools - expect(fiber.ctx.configForms.developerTools.enabled).toBe(preference.enabled) - expect(preference.enabled.getSnapshot()).toBe(true) - await preference.setEnabled(true) - expect(fiber.ctx.configForms.developerTools.enabled.getSnapshot()).toBe(true) - expect(describeCall).not.toHaveBeenCalled() + expect(fiber.ctx.configForms.developerTools).toBe(preference) await fiber.dispose() expect(ctx.get('configForms')).toBeUndefined() }) @@ -97,24 +92,6 @@ describe('developer tools settings', () => { }) }) -it('keeps remote browser choices local and publishes only changed values', async () => { - const host = stubConfigForm() - host.publish({ mode: 'memory' }) - const preference = new DeveloperToolsPreference(host.scope) - const notify = vi.fn() - const dispose = preference.enabled.subscribe(notify) - expect(preference.enabled.getSnapshot()).toBe(true) - await preference.setEnabled(false) - expect(preference.enabled.getSnapshot()).toBe(false) - await preference.setEnabled(false) - expect(notify).toHaveBeenCalledOnce() - await preference.setEnabled(true) - expect(preference.enabled.getSnapshot()).toBe(true) - expect(notify).toHaveBeenCalledTimes(2) - expect(host.set).not.toHaveBeenCalled() - dispose() -}) - it('ignores host revisions that do not change enablement', () => { const host = stubConfigForm() const preference = new DeveloperToolsPreference(host.scope) diff --git a/packages/client/ui-settings/tests/plugin.client.spec.ts b/packages/client/ui-settings/tests/plugin.client.spec.ts index 446ba9add8..3c4c0c8933 100644 --- a/packages/client/ui-settings/tests/plugin.client.spec.ts +++ b/packages/client/ui-settings/tests/plugin.client.spec.ts @@ -1,16 +1,33 @@ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import z from '@deepseek-ai/schemastery' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '../src/client/index.ts' import { SettingsSchemaService } from '../src/client/schema.ts' import { ConfigForms } from '../src/client/config-form.ts' +const schema = z.object({ preference: z.string().required() }).toJSON() + +function described(preference: string, revision: number) { + const namespace: SettingsNamespaceView = { + ns: 'ui-test', + schema: JSON.parse(JSON.stringify(schema)) as SettingsNamespaceView['schema'], + value: { preference }, + autoGenerate: true, + applies: 'live', + secrets: [], + revision, + } + return { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [namespace] } } +} + function bench() { - const describeCall = vi.fn().mockResolvedValue({ - ok: true, value: { writable: true, hasDocument: true, namespaces: [] }, - }) + const describeCall = vi.fn().mockResolvedValue(described('light', 0)) const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) const remote = new TestRemote(ctx, { settings: { describe: describeCall } }) + remote.$host = { home: undefined, isLoopback: false } return { ctx, describeCall, remote, fiber: ctx.plugin({ inject: [...inject], apply }) } } @@ -23,14 +40,24 @@ describe('settings domain base plugin', () => { await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) }) - it('refreshes the mirror on document commits and connection resets, once each', async () => { + it('refreshes remote ConfigForm values and revisions on document commits and connection resets', async () => { const { ctx, describeCall, remote, fiber } = bench() await fiber.await() - await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) - remote.emit('settings/document-updated', ['ui-test', 0]) - await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(2) }) + await ctx.configForms.describe().ensure() + const scope = ctx.configForms.get<{ preference: string }>('ui-test') + expect(scope.getSnapshot()).toMatchObject({ + status: 'ready', value: { preference: 'light' }, revision: 0, writable: true, + }) + describeCall.mockResolvedValue(described('dark', 1)) + remote.emit('settings/document-updated', ['ui-test', 1]) + await vi.waitFor(() => { + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 }) + }) + describeCall.mockResolvedValue(described('system', 2)) ctx.emit('connection/reset') - await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) }) + await vi.waitFor(() => { + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 2 }) + }) }) it('fiber disposal retires the service and its invalidation subscriptions', async () => { diff --git a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts index e44c3297dd..4262261399 100644 --- a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts @@ -94,15 +94,6 @@ describe('SettingsDescribeMirror', () => { expect(describeCall).toHaveBeenCalledTimes(1) }) - it('memory persistence is terminally unavailable and never touches the wire', async () => { - const describeCall = vi.fn() - const mirror = new SettingsDescribeMirror(ctxWith(describeCall), 'memory') - await mirror.ensure() - await mirror.load() - expect(mirror.getSnapshot()).toEqual({ status: 'unavailable', view: undefined, error: null }) - expect(describeCall).not.toHaveBeenCalled() - }) - it('acceptView folds one write answer into the held view without a wire read', async () => { const describeCall = vi.fn() .mockResolvedValueOnce(described([view('theme', 1), view('locale', 4)])) diff --git a/packages/client/ui-settings/tests/while-served.client.spec.ts b/packages/client/ui-settings/tests/while-served.client.spec.ts index c61286e741..eff63911f7 100644 --- a/packages/client/ui-settings/tests/while-served.client.spec.ts +++ b/packages/client/ui-settings/tests/while-served.client.spec.ts @@ -25,7 +25,7 @@ async function bench(namespaces: readonly string[]) { const ctx = new Context() new TestRemote(ctx, { settings: { describe: describeCall } }) const mirror = new SettingsDescribeMirror({ remote: { settings: { describe: describeCall } } } as never) - await ctx.plugin(ConfigForms, { mirror, schema: new SettingsSchemaService(ctx), persistence: 'host' }).await() + await ctx.plugin(ConfigForms, { mirror, schema: new SettingsSchemaService(ctx) }).await() return { ctx, mirror, describeCall } } diff --git a/packages/client/ui-settings/tsdown.config.ts b/packages/client/ui-settings/tsdown.config.ts index 53ee2302d2..7aa03cebc1 100644 --- a/packages/client/ui-settings/tsdown.config.ts +++ b/packages/client/ui-settings/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-shortcuts/README.i18n.yaml b/packages/client/ui-shortcuts/README.i18n.yaml index c19532d5f5..48f267d1ad 100644 --- a/packages/client/ui-shortcuts/README.i18n.yaml +++ b/packages/client/ui-shortcuts/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 0cd57b7b8d8c9b59 /deepseek-ai-dsh-client-ui-shortcuts/use-this-package: - en: 045d38d5efdf1ff0 - zh: 86a700339f5abc49 + en: 84a109505e902f0a + zh: 87053c34cf4da672 /deepseek-ai-dsh-client-ui-shortcuts/understand-the-implementation: en: f56f2610e7258825 zh: 249d7cdd55c57166 diff --git a/packages/client/ui-shortcuts/README.md b/packages/client/ui-shortcuts/README.md index 8c874fb2bc..d6bf0cf06b 100644 --- a/packages/client/ui-shortcuts/README.md +++ b/packages/client/ui-shortcuts/README.md @@ -29,7 +29,7 @@ The Web app bundle mounts this package together with `shortcuts`. The reference `Mod+/` opens the reference when no modal is open or Settings is in front, and closes it when the reference is in front. While the reference is in front, the Settings command cannot open or close Settings. Held-key repeats are ignored. Recording, pending writes, and a nested confirmation prevent the reference shortcut from closing the dialog. -Core actions follow a fixed product order, independent of plugin registration, unloading, or remounting. Stopping the current response ends the Message input group and remains read-only. Extension commands outside that order follow the core actions in stable command ID order; other fixed actions retain their groups. +Core actions follow a fixed product order, independent of plugin registration, unloading, or remounting. Desktop's font-size actions follow Open settings, with Increase before Decrease. Stopping the current response ends the Message input group and remains read-only. Extension commands outside that order follow the core actions in stable command ID order; other fixed actions retain their groups. Search matches an ordered subsequence within one label, English alias, or key combination, ignoring case. Each matching command appears once. Search relevance takes precedence; equal matches retain the display order. Application and fixed-action rows remain 42 CSS pixels high during normal display and recording; long command names are truncated. Application rows show command names and binding controls without availability subtitles or reason tooltips, including conflicting and invalid bindings. Commands that cannot execute remain silent. Both platforms show each editable row’s complete key combination in one settings-control grey block with secondary ink. Fixed actions use unboxed tertiary text, cannot enter recording, and do not highlight on hover. Group titles align with command labels. diff --git a/packages/client/ui-shortcuts/README.zh.md b/packages/client/ui-shortcuts/README.zh.md index 4ba2688c9f..a8f61d7466 100644 --- a/packages/client/ui-shortcuts/README.zh.md +++ b/packages/client/ui-shortcuts/README.zh.md @@ -29,7 +29,7 @@ Web 应用 bundle 将此包与 `shortcuts` 一同挂载。速查仅展示已注 没有模态弹窗或设置位于最前方时,`Mod+/` 打开速查;速查位于最前方时,该快捷键关闭速查。速查位于前方时,设置命令不能打开或关闭设置。长按产生的重复按键不执行操作。录键期间、写入尚未完成或嵌套确认框打开时,速查快捷键不会关闭弹窗。 -核心操作采用固定产品顺序,不受插件注册、卸载或重新挂载影响。停止当前回复位于“消息输入”分组末尾,仍为只读项。未列入该顺序的扩展命令在核心操作之后按命令 ID 稳定排列;其余固定操作保留原有分组。 +核心操作采用固定产品顺序,不受插件注册、卸载或重新挂载影响。Desktop 的字号操作位于“打开设置”之后,“增大字号”在“减小字号”之前。停止当前回复位于“消息输入”分组末尾,仍为只读项。未列入该顺序的扩展命令在核心操作之后按命令 ID 稳定排列;其余固定操作保留原有分组。 搜索在单个操作名称、英文别名或按键组合内按字符顺序模糊匹配,忽略大小写。每个命中命令只出现一次。搜索时优先按相关性排序,同分项保留展示顺序。正常显示和录制时,应用操作行和固定操作行均保持 42 CSS 像素高;过长的命令名称会被截断。应用命令行只显示名称和改键控件,不显示可用性副标题或原因悬浮提示,冲突和无效绑定也不例外。无法执行的命令静默结束。两个平台均将可编辑行的完整快捷键放在同一个设置控件灰色色块中,使用次级文字色。固定操作使用无背景的三级文字色,不能进入录键,悬停时不高亮。分组标题与命令名称左对齐。 diff --git a/packages/client/ui-shortcuts/locale/en.json b/packages/client/ui-shortcuts/locale/en.json new file mode 100644 index 0000000000..0602a25dff --- /dev/null +++ b/packages/client/ui-shortcuts/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-shortcuts" + } +} diff --git a/packages/client/ui-shortcuts/locale/zh.json b/packages/client/ui-shortcuts/locale/zh.json new file mode 100644 index 0000000000..ad7f1dab37 --- /dev/null +++ b/packages/client/ui-shortcuts/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "快捷键设置界面", + "description": "查看、录入和修改键盘快捷键。" + } +} diff --git a/packages/client/ui-shortcuts/package.json b/packages/client/ui-shortcuts/package.json index 72204436f3..f50a08ccdf 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -55,12 +56,12 @@ "@deepseek-ai/dsh-client-ui-layout": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", - "@types/react": "~18.3.1", "clsx": "^2.1.1", "react": "^18.2.0", "@deepseek-ai/dsh-client-test-runtime": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-shortcuts/src/client/Reference.tsx b/packages/client/ui-shortcuts/src/client/Reference.tsx index d5dd35efd6..1235b27631 100644 --- a/packages/client/ui-shortcuts/src/client/Reference.tsx +++ b/packages/client/ui-shortcuts/src/client/Reference.tsx @@ -33,6 +33,8 @@ const coreActionOrder = new Map([ 'session.fork', 'session.archive', 'settings.open', + 'fontSize.increase', + 'fontSize.decrease', 'workspace.openLocal', 'sidebar.right.toggle', 'workspace.files', diff --git a/packages/client/ui-shortcuts/tests/reference.client.spec.tsx b/packages/client/ui-shortcuts/tests/reference.client.spec.tsx index d2ffd27699..2e79e2fb88 100644 --- a/packages/client/ui-shortcuts/tests/reference.client.spec.tsx +++ b/packages/client/ui-shortcuts/tests/reference.client.spec.tsx @@ -173,7 +173,7 @@ function referenceFixture({ runtime = 'web', dictionary = en }: { runtime?: 'web } it('keeps core actions in product order across registration, remount and label changes', () => { - const { catalog, fixedCatalog, store } = referenceFixture() + const { catalog, fixedCatalog, store } = referenceFixture({ runtime: 'desktop' }) const template = catalog.getSnapshot()[0]! const core = [ ['shortcuts.open', 'Keyboard shortcuts'], ['session.new', 'New session'], @@ -197,11 +197,14 @@ it('keeps core actions in product order across registration, remount and label c { ...stop, id: 'fixed.select' as ShortcutCommandId, label: 'Select', keys: ['Enter'], bindings: [{ code: 'Enter', modifiers: [] }], group: 'menus' }, { ...stop, id: 'fixed.dismiss' as ShortcutCommandId, label: 'Dismiss', group: 'menus' }, { ...stop, id: 'approval.accept' as ShortcutCommandId, label: 'Approve', keys: ['Enter'], bindings: [{ code: 'Enter', modifiers: [] }], group: 'approval' }, + { ...stop, id: 'fontSize.increase' as ShortcutCommandId, label: 'Increase font size', keys: ['⌘', '='], bindings: [{ code: 'Equal', modifiers: ['meta'] }], group: 'application' }, + { ...stop, id: 'fontSize.decrease' as ShortcutCommandId, label: 'Decrease font size', keys: ['⌘', '-'], bindings: [{ code: 'Minus', modifiers: ['meta'] }], group: 'application' }, stop, ] const labels = (group: string) => within(screen.getByRole('region', { name: group })).getAllByRole('listitem') .map(row => row.querySelector(':scope > span')?.textContent) - const expected = [...core.map(([, label]) => label), 'Zulu extension', 'Alpha extension'] + const expected = [...core.flatMap(([id, label]) => id === 'settings.open' + ? [label, 'Increase font size', 'Decrease font size'] : [label]), 'Zulu extension', 'Alpha extension'] for (const reversed of [true, false]) { act(() => { catalog.set(reversed ? [...extensions, ...commands].reverse() : [...extensions, ...commands]) @@ -218,7 +221,7 @@ it('keeps core actions in product order across registration, remount and label c expect(screen.queryByRole('button', { name: 'Edit shortcut for Stop reply' })).toBeNull() expect(screen.queryByRole('group', { name: 'Stop reply' })).toBeNull() act(() => { catalog.set(extensions); fixedCatalog.set(fixed.filter(row => row.id !== stop.id)) }) - expect(labels(en.application)).toEqual(['Zulu extension', 'Alpha extension']) + expect(labels(en.application)).toEqual(['Increase font size', 'Decrease font size', 'Zulu extension', 'Alpha extension']) act(() => { catalog.set([...extensions, ...[...commands].reverse()]); fixedCatalog.set(fixed) }) expect(labels(en.application)).toEqual(expected) act(() => { diff --git a/packages/client/ui-shortcuts/tsdown.config.ts b/packages/client/ui-shortcuts/tsdown.config.ts index 5d5228cbaf..7aa03cebc1 100644 --- a/packages/client/ui-shortcuts/tsdown.config.ts +++ b/packages/client/ui-shortcuts/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-shortcuts', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-sidebar-browser/locale/en.json b/packages/client/ui-sidebar-browser/locale/en.json new file mode 100644 index 0000000000..6294ad3b9e --- /dev/null +++ b/packages/client/ui-sidebar-browser/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-sidebar-browser" + } +} diff --git a/packages/client/ui-sidebar-browser/locale/zh.json b/packages/client/ui-sidebar-browser/locale/zh.json new file mode 100644 index 0000000000..111b8f6dc6 --- /dev/null +++ b/packages/client/ui-sidebar-browser/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "浏览器面板", + "description": "在右侧面板中打开和浏览网页。" + } +} diff --git a/packages/client/ui-sidebar-browser/package.json b/packages/client/ui-sidebar-browser/package.json index b97e65024b..ecd80c90a2 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -26,7 +26,8 @@ "types": "./lib/types/types.d.ts" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -60,12 +61,11 @@ "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-sidebar-browser/tsdown.config.ts b/packages/client/ui-sidebar-browser/tsdown.config.ts index 229343b169..7aa03cebc1 100644 --- a/packages/client/ui-sidebar-browser/tsdown.config.ts +++ b/packages/client/ui-sidebar-browser/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-browser', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 6a35e6a8d7..67ad419821 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -15,11 +15,11 @@ en: 0dd8c3a22e28aacf zh: c27c11bf4b5cceba /deepseek-ai-dsh-client-ui-sidebar-documentpreview/what-it-registers: - en: a8eaf01a3a3390ee - zh: ddea36dbcc4a0159 + en: 54a50f4a3a6e4d45 + zh: af905ed570812432 /deepseek-ai-dsh-client-ui-sidebar-documentpreview/addresses: - en: 949156aa02ec8b07 - zh: d3675754e2a37b5d + en: 57942270e23d3a8e + zh: 6617124e652b3f67 /deepseek-ai-dsh-client-ui-sidebar-documentpreview/how-it-reads: en: b056bbe0f74fe328 zh: fd9ac88f08825afc diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 588a95a7e0..51e23e2208 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -28,7 +28,7 @@ Preview files in the right Sidebar and choose among registered renderers. Markdo ## What it registers -- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title. +- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, a whole-address file-resource pattern, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title. - **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; the shared [`PathLabel`](../ui-primitives/README.md#component-catalog) subdues directories, preserves a clipped path’s trailing characters with a left-edge fade, and exposes the full value on hover. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. - **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, pending Resource changes, automatic-refresh state, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state. @@ -39,7 +39,7 @@ Document implementations register metadata with `ctx.documentPreviews.register({ ## Addresses -A tab uses the Session address built by `fileAddressFor`, carrying a relative or absolute path. `hostFileOf(address)` takes the Session only from that address, with no external Session argument; neither current nor Tab Session is borrowed. The Host resolves file and related paths through the Session filesystem, whose backend controls read authority. Metadata for the same complete address is shared by every UI, including Global components. The [Workspace Files README](../../api/workspace-files/README.md) owns these rules; renderer selection does not change the navigation address. +A tab uses the Session address built by `fileAddressFor`, carrying a relative or absolute path. Routing preserves `.` and `..` path segments in the address and its content identity; the Host resolves them. `hostFileOf(address)` takes the Session only from that address, with no external Session argument; neither current nor Tab Session is borrowed. The Host resolves file and related paths through the Session filesystem, whose backend controls read authority. Metadata for the same complete address is shared by every UI, including Global components. The [Workspace Files README](../../api/workspace-files/README.md) owns these rules; renderer selection does not change the navigation address. After all text pages load, Markdown images use the authenticated `/api/file` route for absolute file paths and paths relative to the source document's directory. Relative images wait for the Host's absolute document path. URL escapes are decoded once; query and fragment suffixes are excluded from the filename. HTTP(S) images retain their authored URLs, and failed image loads show alt text. Local images require an HTTP(S) application base URL; image files are not added to automatic-refresh dependencies. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 89d90091f5..1c5be3fdc3 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -28,7 +28,7 @@ kind: "package-reference" ## 注册了什么 -- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。 +- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,匹配完整文件资源地址的 pattern,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。 - **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 弱化目录颜色,以左侧渐隐保留被裁剪路径的尾部字符,并在悬停时展示完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 - **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、待处理 Resource 变化、自动刷新状态、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。 @@ -39,7 +39,7 @@ kind: "package-reference" ## 地址 -tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路径。`hostFileOf(address)` 仅从地址取得 Session,不接收外部 Session 参数,也不借用当前或 Tab Session。Host 通过 Session 文件系统解析文件及关联路径,由该后端控制读取权限。任何 UI(包括 Global 组件)都共享同一完整地址的元数据。[Workspace Files README](../../api/workspace-files/README.zh.md) 定义这些规则;渲染器选择不改变导航地址。 +tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路径。路由在地址及其内容身份中保留 `.` 与 `..` 路径段,由 Host 解析。`hostFileOf(address)` 仅从地址取得 Session,不接收外部 Session 参数,也不借用当前或 Tab Session。Host 通过 Session 文件系统解析文件及关联路径,由该后端控制读取权限。任何 UI(包括 Global 组件)都共享同一完整地址的元数据。[Workspace Files README](../../api/workspace-files/README.zh.md) 定义这些规则;渲染器选择不改变导航地址。 全部文本分页加载完成后,Markdown 图片通过已鉴权的 `/api/file` 路由读取绝对文件路径,以及相对于源文档所在目录的路径。相对图片等待 Host 返回文档绝对路径。URL 转义只解码一次;查询参数与片段后缀不计入文件名。HTTP(S) 图片保留原始 URL,加载失败的图片显示 alt 文本。本地图片要求应用基址为 HTTP(S) URL;图片文件不加入自动刷新依赖。 diff --git a/packages/client/ui-sidebar-documentpreview/locale/en.json b/packages/client/ui-sidebar-documentpreview/locale/en.json new file mode 100644 index 0000000000..7d7f557f9a --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-sidebar-documentpreview" + } +} diff --git a/packages/client/ui-sidebar-documentpreview/locale/zh.json b/packages/client/ui-sidebar-documentpreview/locale/zh.json new file mode 100644 index 0000000000..8673bde417 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "文档预览面板", + "description": "在右侧面板预览文档、表格、代码、图片和网页等文件。" + } +} diff --git a/packages/client/ui-sidebar-documentpreview/package.json b/packages/client/ui-sidebar-documentpreview/package.json index de0132b03f..793a88c1b1 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -67,15 +68,12 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-office-to-pdf": "workspace:*", "@deepseek-ai/dsh-host-webserver": "workspace:*", - "@deepseek-ai/dsh-office-to-pdf": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-util-workspace-path": "workspace:*", "@fortune-sheet/core": "1.0.4", "@fortune-sheet/react": "1.0.4", "@testing-library/react": "^16.3.3", "@types/papaparse": "^5.3.16", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "clsx": "^2.0.0", "dompurify": "^3.4.16", "exceljs": "4.4.0", @@ -89,6 +87,7 @@ "yaml": "^2.9.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/client.*.js", diff --git a/packages/client/ui-sidebar-documentpreview/src/client/definition.ts b/packages/client/ui-sidebar-documentpreview/src/client/definition.ts index 7f2892cffd..914616e0fb 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/definition.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/definition.ts @@ -46,7 +46,8 @@ export function textDefinition(): SidebarRightTabDefinition { return { id: TEXTPREVIEW_ID, kind: TEXTPREVIEW_KIND, - patterns: ['dsh-resource://file/**'], + // A character-class repetition includes literal dot segments that globstar excludes. + patterns: ['dsh-resource://file/[\\s\\S]+'], priority: 'fallback', canOpen: address => parseFileAddress(address)?.scope === 'session', title: basenameOf, diff --git a/packages/client/ui-sidebar-documentpreview/tests/definition.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/definition.client.spec.ts index 63984f41ca..a842b76b24 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/definition.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/definition.client.spec.ts @@ -9,7 +9,7 @@ */ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import { parseFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts' import { TEXTPREVIEW_ID, TEXTPREVIEW_KIND, basenameOf, textDefinition } from '../src/client/definition.ts' @@ -32,7 +32,7 @@ describe('textDefinition', () => { const definition = textDefinition() expect(definition.id).toBe(TEXTPREVIEW_ID) expect(definition.kind).toBe(TEXTPREVIEW_KIND) - expect(definition.patterns).toEqual(['dsh-resource://file/**']) + expect(definition.patterns).toEqual(['dsh-resource://file/[\\s\\S]+']) expect(definition.priority).toBe('fallback') expect(definition.title('dsh-resource://file/session/s-1/work/README.md')).toBe('README.md') // Reads require an addressed Session, even when the file path is absolute. @@ -66,6 +66,51 @@ describe('text type in the registry', () => { } }) + it.each([ + '../AGENTS.md', + '../../AGENTS.md', + 'dir/../AGENTS.md', + 'dir/./AGENTS.md', + '/workspace/../AGENTS.md', + 'C:/workspace/../AGENTS.md', + '//host/share/workspace/../AGENTS.md', + '', + ])('claims unresolved Session paths without rewriting their identity: %s', (path) => { + const tabs = registry() + const address = sessionFileAddress('s-1', path) + expect(tabs.claim(address)).toEqual({ kind: TEXTPREVIEW_KIND, contentId: address, title: basenameOf(address) }) + expect(tabs.claim(address).contentId).toBe(address) + expect(parseFileAddress(address)).toEqual({ scope: 'session', sessionId: 's-1', path }) + }) + + it.each([ + ['./AGENTS.md', './AGENTS.md'], + ['%2e%2E/AGENTS.md', '../AGENTS.md'], + ['dir/%2E/AGENTS.md', 'dir/./AGENTS.md'], + ['%252e%252e/AGENTS.md', '%2e%2e/AGENTS.md'], + ['../a%20b%23c%3F.txt?ignored=1#section', '../a b#c?.txt'], + ])('claims an authored address while decoding its path once: %s', (encoded, path) => { + const tabs = registry() + const address = `dsh-resource://file/session/s%2F1/${encoded}` + expect(tabs.claim(address).contentId).toBe(address) + expect(parseFileAddress(address)).toEqual({ scope: 'session', sessionId: 's/1', path }) + }) + + it.each([ + 'dsh-resource://file/session', + 'dsh-resource://file/session//../a.md', + 'dsh-resource://file/session/s-1', + 'dsh-resource://file/session/%/../a.md', + 'dsh-resource://file/session/s-1/../%ZZ.md', + 'DSH-RESOURCE://file/session/s-1/../a.md', + 'dsh-resource://File/session/s-1/../a.md', + ])('keeps the Session parser veto after matching a file prefix: %s', (address) => { + const tabs = registry() + expect(tabs.candidates(address)).toEqual([]) + expect(() => tabs.claim(address)).toThrow('no registered tab type claims') + expect(() => tabs.claim(address, TEXTPREVIEW_KIND)).toThrow(`tab type "${TEXTPREVIEW_KIND}" refuses`) + }) + it.each([ 'dsh-resource://file/shared/team/notes.md', 'dsh-resource://file/absolute/home/me/notes.md', @@ -86,6 +131,38 @@ describe('text type in the registry', () => { expect(tabs.candidates('dsh-resource://file/session/s-1/w/logo.png').map(type => type.kind)).toEqual(['image', TEXTPREVIEW_KIND]) }) + it.each(['builtin', 'extension'] as const)('yields parent paths to the %s band and honors its veto', (priority) => { + const tabs = registry() + const address = sessionFileAddress('s-1', '../AGENTS.md') + const release = tabs.register({ + id: 'test/markdown', kind: 'markdown', patterns: ['*.md'], priority, title: () => 'Markdown', + }) + expect(tabs.candidates(address).map(type => type.kind)).toEqual(['markdown', TEXTPREVIEW_KIND]) + expect(tabs.claim(address)).toEqual({ kind: 'markdown', contentId: address, title: 'Markdown' }) + release() + tabs.register({ + id: 'test/markdown', kind: 'markdown', patterns: ['*.md'], priority, canOpen: () => false, title: () => 'Markdown', + }) + expect(tabs.claim(address).kind).toBe(TEXTPREVIEW_KIND) + }) + + it('ranks the file declaration by its literal length within the fallback band', () => { + const tabs = registry() + tabs.register({ + id: 'test/shorter', kind: 'shorter', patterns: ['dsh-resource://file/**/*.*'], priority: 'fallback', title: () => 'Shorter', + }) + const releaseLonger = tabs.register({ + id: 'test/longer', kind: 'longer', patterns: ['dsh-resource://file/session/**/*.md'], priority: 'fallback', title: () => 'Longer', + }) + tabs.register({ ...textDefinition(), id: 'test/tied', kind: 'tied' }) + const address = sessionFileAddress('s-1', 'AGENTS.md') + // Specificity uses the literal pattern length, not the size of its accepted address set. + expect(tabs.candidates(address).map(type => type.kind)).toEqual(['longer', TEXTPREVIEW_KIND, 'tied', 'shorter']) + expect(tabs.claim(address).kind).toBe('longer') + releaseLonger() + expect(tabs.claim(address).kind).toBe(TEXTPREVIEW_KIND) + }) + it('does not claim addresses of other schemes', () => { const tabs = registry() expect(() => tabs.claim('sidebar://guide')).toThrow('no registered tab type claims') diff --git a/packages/client/ui-sidebar-documentpreview/tsdown.config.ts b/packages/client/ui-sidebar-documentpreview/tsdown.config.ts index b664119dfd..9db8b5b107 100644 --- a/packages/client/ui-sidebar-documentpreview/tsdown.config.ts +++ b/packages/client/ui-sidebar-documentpreview/tsdown.config.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path' import { Rolldown, type UserConfig } from 'tsdown' import { clientBundle } from '../tsdown.client.ts' -const bundle = clientBundle('@deepseek-ai/dsh-client-ui-sidebar-documentpreview', ['lib/types/index.js'], { +const bundle = clientBundle(import.meta.dirname, ['lib/types/index.js'], { clientBanner: fileName => fileName.endsWith('client.pdf.js') ? pdfLicenseBanner() : fileName.endsWith('client.excel.js') ? excelLicenseBanner() : undefined, }) diff --git a/packages/client/ui-sidebar-files/locale/en.json b/packages/client/ui-sidebar-files/locale/en.json new file mode 100644 index 0000000000..6da0972151 --- /dev/null +++ b/packages/client/ui-sidebar-files/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-sidebar-files" + } +} diff --git a/packages/client/ui-sidebar-files/locale/zh.json b/packages/client/ui-sidebar-files/locale/zh.json new file mode 100644 index 0000000000..577a5725c3 --- /dev/null +++ b/packages/client/ui-sidebar-files/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "文件浏览面板", + "description": "浏览工作区目录,并在右侧面板中打开文件。" + } +} diff --git a/packages/client/ui-sidebar-files/package.json b/packages/client/ui-sidebar-files/package.json index 791ed03364..4039019176 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -62,14 +63,13 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-util-workspace-path": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "react": "^18.2.0", "clsx": "^2.0.0", "react-dom": "^18.2.0", "@deepseek-ai/dsh-client-shortcuts": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-sidebar-files/tsdown.config.ts b/packages/client/ui-sidebar-files/tsdown.config.ts index 15e759c557..7aa03cebc1 100644 --- a/packages/client/ui-sidebar-files/tsdown.config.ts +++ b/packages/client/ui-sidebar-files/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-files', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-sidebar-right/locale/en.json b/packages/client/ui-sidebar-right/locale/en.json new file mode 100644 index 0000000000..4187616d33 --- /dev/null +++ b/packages/client/ui-sidebar-right/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-sidebar-right" + } +} diff --git a/packages/client/ui-sidebar-right/locale/zh.json b/packages/client/ui-sidebar-right/locale/zh.json new file mode 100644 index 0000000000..22281c9e82 --- /dev/null +++ b/packages/client/ui-sidebar-right/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "右侧面板", + "description": "管理右侧面板的展开状态、内容和导航。" + } +} diff --git a/packages/client/ui-sidebar-right/package.json b/packages/client/ui-sidebar-right/package.json index 406c3322ac..6367d31c9f 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -63,18 +64,17 @@ "@deepseek-ai/dsh-session": "workspace:*", "@testing-library/react": "^16.3.3", "@types/picomatch": "^4.0.3", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", "picomatch": "^4.0.7", "clsx": "^2.0.0", "@deepseek-ai/dsh-util-crypto": "workspace:*", - "zod": "^4.4.3", + "zod": "4.4.3", "@deepseek-ai/dsh-client-shortcuts": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-sidebar-right/tsdown.config.ts b/packages/client/ui-sidebar-right/tsdown.config.ts index 559865cdcd..7aa03cebc1 100644 --- a/packages/client/ui-sidebar-right/tsdown.config.ts +++ b/packages/client/ui-sidebar-right/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-right', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-sidebar-terminal/locale/en.json b/packages/client/ui-sidebar-terminal/locale/en.json new file mode 100644 index 0000000000..d6ab2b3980 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-sidebar-terminal" + } +} diff --git a/packages/client/ui-sidebar-terminal/locale/zh.json b/packages/client/ui-sidebar-terminal/locale/zh.json new file mode 100644 index 0000000000..ca36d8da54 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "终端面板", + "description": "在右侧面板中使用交互式命令终端。" + } +} diff --git a/packages/client/ui-sidebar-terminal/package.json b/packages/client/ui-sidebar-terminal/package.json index 713c3e9c77..0de48c9ac3 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -59,8 +60,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "react": "^18.2.0", "clsx": "^2.0.0", "react-dom": "^18.2.0", @@ -73,6 +72,7 @@ "@deepseek-ai/dsh-client-shortcuts": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/client.*.js", diff --git a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx index 1c75616db3..28f3895e4b 100644 --- a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx @@ -57,7 +57,7 @@ beforeEach(() => { afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); disconnect.mockClear(); measure = undefined }) const info = { id: 'terminal' as WebTerminalId, title: 'bash', shell: { path: '/bin/bash', name: 'bash', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running' as const, exitCode: null } -const environment = { cwd: '/workspace', maxCols: 200, maxRows: 100, scrollback: 1000, maxInputBytes: 1000 } +const environment = { cwd: '/workspace', maxCols: 200, maxRows: 100, scrollback: 1000, maxInputBytes: 1000, maxBufferedBytes: 100_000 } const idle: TerminalViewState = { phase: 'idle', writable: false, environment } const titleSurfaces = [ { @@ -140,7 +140,7 @@ it('shows only the terminal screen while connected and detaches on unmount', () }) it('keeps one emulator across rename and locale updates, applies snapshots and output once, and releases listeners', () => { - const snapshot: TerminalViewState = { ...idle, info, phase: 'connected', writable: true, render: { revision: 1, frame: { type: 'snapshot', sequence: 0, info, screen: 'restored' } } } + const snapshot: TerminalViewState = { ...idle, info, phase: 'connected', writable: true, render: { revision: 1, frame: { type: 'snapshot', writable: true, sequence: 0, info, screen: 'restored' } } } const h = mount(snapshot) const terminal = fake.terminals[0]! expect(terminal.reset).toHaveBeenCalledOnce() diff --git a/packages/client/ui-sidebar-terminal/tsdown.config.ts b/packages/client/ui-sidebar-terminal/tsdown.config.ts index 016643c5fb..7aa03cebc1 100644 --- a/packages/client/ui-sidebar-terminal/tsdown.config.ts +++ b/packages/client/ui-sidebar-terminal/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-terminal', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-sidebar/locale/en.json b/packages/client/ui-sidebar/locale/en.json new file mode 100644 index 0000000000..dbf02a834f --- /dev/null +++ b/packages/client/ui-sidebar/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-sidebar" + } +} diff --git a/packages/client/ui-sidebar/locale/zh.json b/packages/client/ui-sidebar/locale/zh.json new file mode 100644 index 0000000000..5a3da3ba14 --- /dev/null +++ b/packages/client/ui-sidebar/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "会话侧栏", + "description": "展示会话列表,提供搜索、分组和状态提示。" + } +} diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 092797ec13..e671c81756 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -58,7 +59,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-client-ui-session": "workspace:*", "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "clsx": "^2.0.0", @@ -66,6 +66,7 @@ "@deepseek-ai/dsh-client-product-analytics": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-sidebar/tsdown.config.ts b/packages/client/ui-sidebar/tsdown.config.ts index 15da8a116a..7aa03cebc1 100644 --- a/packages/client/ui-sidebar/tsdown.config.ts +++ b/packages/client/ui-sidebar/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 3f5d54b740..8d82e8e675 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.i18n.yaml @@ -30,8 +30,8 @@ en: 1a02a85859f851c7 zh: 03bd48b83c4a287c /deepseek-ai-dsh-client-ui-skill/understand-the-implementation/registration: - en: 1965ad3169b7fd97 - zh: efc23c44ad1d2765 + en: 4b06a66c56d19ff9 + zh: d9c8d118f73d89bb /deepseek-ai-dsh-client-ui-skill/further-exploration: en: 54198f095d0cc569 zh: 4eba4413e159967b diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 08d2e1137c..4340f3f78e 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -59,6 +59,8 @@ Each catalog fetch requires an existing retained Client Session and waits for it The `/client` exports are the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect. The tool row registers the `skill` wire name in ui-tool's keyed `tool.call.toolview` slot. +For sent text, the plugin registers a UserText matcher that returns one next original-text candidate using caller-confirmed `skillNames` and owns the `skill` entry in `conversation.user.text.node`. The renderer receives `openSkill` from its own Session-bound injection and delegates to this source’s preview operation, not to Chat owner props. Non-interactive previews remain labels. [UserText](../ui-conversation/README.md#sent-user-text) owns evaluation and node dispatch. + ----- diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index e07a59326b..d18c52f30d 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -59,6 +59,8 @@ source 不实现任何裁决钩子,也没有引用 codec:pick 落下字面 `/client` 导出接口只有插件主体(`apply`/`inject`);source 对象是注册 effect 的内部实现。工具行把 `skill` wire 名称注册进 ui-tool 的 keyed `tool.call.toolview` slot。 +对于已发送文本,插件注册使用调用方已确认 `skillNames`、返回原文中下一个候选的 UserText 匹配器,并拥有 `conversation.user.text.node` 中的 `skill` 条目。renderer 从自身绑定 Session 的 Injection 接收 `openSkill`,委托本 source 的预览操作,而非 Chat owner props。不可交互的预览仍为标签。[UserText](../ui-conversation/README.zh.md#sent-user-text) 拥有求值与节点分发。 + ----- diff --git a/packages/client/ui-skill/locale/en.json b/packages/client/ui-skill/locale/en.json new file mode 100644 index 0000000000..d61c3ff256 --- /dev/null +++ b/packages/client/ui-skill/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-skill" + } +} diff --git a/packages/client/ui-skill/locale/zh.json b/packages/client/ui-skill/locale/zh.json new file mode 100644 index 0000000000..a30c0c2fdf --- /dev/null +++ b/packages/client/ui-skill/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "技能引用与展示", + "description": "在会话中提供技能引用和技能调用展示。" + } +} diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index b4e69169b7..da3f022efc 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,13 +23,15 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { "inject": [ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-tool", "@deepseek-ai/dsh-client-ui-input-trigger", @@ -53,6 +55,7 @@ "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", @@ -60,7 +63,6 @@ "@deepseek-ai/dsh-client-ui-tool": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -69,6 +71,7 @@ "@deepseek-ai/dsh-util-workspace-path": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-skill/src/client/SkillReference.tsx b/packages/client/ui-skill/src/client/SkillReference.tsx new file mode 100644 index 0000000000..fef9d71a3e --- /dev/null +++ b/packages/client/ui-skill/src/client/SkillReference.tsx @@ -0,0 +1,28 @@ +/** Skill-reference presentation with activation owned by this keyed entry's Session injection. */ +import type { UserTextNodeProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { ReferenceChip } from '@deepseek-ai/dsh-client-ui-primitives' + +/** Session-bound preview action; accepting a preview is not a completion promise. */ +export interface SkillReferenceInjected { + /** + * Ask the skill source to preview a confirmed name in this entry's Session. + * @param name - skill name without its slash. + */ + readonly openSkill: (name: string) => void +} + +type SkillReferenceProps = UserTextNodeProps<'skill'> & SkillReferenceInjected + +/** + * Render a skill chip; previews omit activation even when the entry has an injected action. + * @param props - resolved node, raw token, interaction mode, and Session-bound preview action. + * @returns an inline skill label or interactive chip. + */ +export function SkillReference({ node, raw, interactive, openSkill }: SkillReferenceProps) { + return { openSkill(node.data.name) } } : {})} + /> +} diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 2b6cd12ea2..ee67b91e4c 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -44,7 +44,11 @@ import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: pulls the SlotRegistry service merge (ctx.slots). import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { SkillRow } from './SkillRow.tsx' +import { SkillReference, type SkillReferenceInjected } from './SkillReference.tsx' +import { skillReferenceRule } from './user-text-rules.ts' +export type {} from './user-text-rules.ts' import { en, NS, zh, type SkillKey } from './locales.ts' declare module '@deepseek-ai/dsh-api-session-controller/client' { @@ -70,7 +74,7 @@ interface CatalogFetch { } /** Required services: reference source faces plus the tool-row and locale registries. */ -export const inject = ['inputTriggers', 'sessions', 'slots', 'locale', 'remote', 'remote.skills', 'sidebarRight'] +export const inject = ['inputTriggers', 'sessions', 'slots', 'locale', 'remote', 'remote.skills', 'sidebarRight', 'uiConversation'] /** * Client plugin body: register the '/' source, dictionaries, and keyed tool row. @@ -78,6 +82,7 @@ export const inject = ['inputTriggers', 'sessions', 'slots', 'locale', 'remote', */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-skill: dictionaries') + ctx.uiConversation.userText.register(skillReferenceRule) ctx.slots.inject('tool.call.toolview', () => ctx.slots.register( { name: 'tool.call.toolview', key: 'skill', locale: NS }, SkillRow, @@ -156,7 +161,7 @@ export function apply(ctx: ClientContext): void { // locale service's own fallback ladder; candidate-time reads stay plain text. const t = ctx.locale.bind(NS) - const source: InputTriggerSource = { + const source = { trigger: '/', name: 'skill', order: 2, @@ -223,7 +228,20 @@ export function apply(ctx: ClientContext): void { // line client-side before it ever becomes a prompt. return { text: `/${candidate.name} ` } }, - } + } satisfies InputTriggerSource + + ctx.slots.inject('conversation.user.text.node', () => ctx.slots.register({ + name: 'conversation.user.text.node', + key: 'skill', + priority: 100, + inject: (sessionId): SkillReferenceInjected => ({ + openSkill: (name) => { + if (sessions.scope(sessionId) === undefined) return + source.openReference({ sessionId }, { ref: `/${name}` }) + }, + }), + }, SkillReference)) + const inputTriggers = ctx.get('inputTriggers') as InputTriggerServiceContract // A preset decides which skill providers an agent reads, so a switched // session's cached catalog belongs to the composition it no longer runs. diff --git a/packages/client/ui-skill/src/client/user-text-rules.ts b/packages/client/ui-skill/src/client/user-text-rules.ts new file mode 100644 index 0000000000..96e07f1369 --- /dev/null +++ b/packages/client/ui-skill/src/client/user-text-rules.ts @@ -0,0 +1,36 @@ +/** Original-text skill candidates restricted to caller-confirmed names. */ +import type { UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface UserTextDataMap { + /** A skill name confirmed for this message, without the leading slash. */ + skill: { readonly name: string } + } + + interface UserTextFacts { + /** Resolved skill names; absence supplies no skill references. */ + readonly skillNames: readonly string[] + } +} + +/** Return one nearest confirmed slash token, retaining its original word boundaries. */ +export const skillReferenceRule: UserTextRule<'skill'> = { + id: 'skill', + order: 20, + match(text, from, context) { + const tokens = /(? null) const capture: PresentationCapture = { slots, @@ -80,11 +88,11 @@ async function bench(list: ListFn, addressed?: SessionId, opening: Pick { await fiber.dispose() }) await fiber.await() - return { ctx, source: captured!, remote, fiber, openResource, sessions } + return { ctx, source: captured!, remote, fiber, openResource, sessions, presentation } } const CATALOG: SkillRow[] = [ @@ -114,11 +122,12 @@ const req = (query: string, signal?: AbortSignal) => describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['inputTriggers', 'sessions', 'slots', 'locale', 'remote', 'remote.skills', 'sidebarRight']) + expect(inject).toEqual(['inputTriggers', 'sessions', 'slots', 'locale', 'remote', 'remote.skills', 'sidebarRight', 'uiConversation']) }) it('registers the dedicated skill row and its locale dictionaries', async () => { const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) ctx.provide('sidebarRight', { openResource: vi.fn() }) ctx.provide('inputTriggers', { registerSource: () => () => {} }) ctx.provide('sessions', { subagentAddress: () => undefined }) @@ -157,6 +166,7 @@ describe('apply', () => { it('registers the "/" skill source; disposal frees the name (HMR safety)', async () => { const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) ctx.provide('sidebarRight', { openResource: vi.fn() }) // InputTriggerService itself injects 'sessions'; the stub unblocks its fiber. ctx.provide('sessions', {}) @@ -460,6 +470,31 @@ describe('reference preview', () => { { name: 'virtual', description: 'Virtual' }, ] + it('routes sent skill previews through the reference entry’s own Session and ignores an absent scope', async () => { + const b = await bench(listOk(rows)) + const entry = b.presentation.slots.entries('conversation.user.text.node').find(row => row.options.key === 'skill') + expect(entry?.component).toBe(SkillReference) + if (entry?.inject === undefined) throw new Error('Skill reference injector was not registered') + const injectReference = entry.inject as ( + ...params: InjectParams<'conversation.user.text.node', undefined> + ) => Pick + const openReference = vi.spyOn(b.source, 'openReference') + const first = injectReference(sid('first')) + const second = injectReference(sid('second')) + first.openSkill('review') + await b.source.candidates(proj('first'), req('')) + expect(openReference).toHaveBeenLastCalledWith(proj('first'), { ref: '/review' }) + expect(b.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/first//skills/review/SKILL.md') + second.openSkill('review') + await b.source.candidates(proj('second'), req('')) + expect(openReference).toHaveBeenLastCalledWith(proj('second'), { ref: '/review' }) + expect(b.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/second//skills/review/SKILL.md') + vi.spyOn(b.sessions, 'scope').mockReturnValueOnce(undefined) + first.openSkill('review') + expect(openReference).toHaveBeenCalledTimes(2) + expect(b.openResource).toHaveBeenCalledTimes(2) + }) + it('finishes the first click after a shared warm fetch and reloads after reconnect', async () => { const gate = Promise.withResolvers() const list = vi.fn().mockReturnValueOnce(gate.promise).mockImplementation(listOk(rows)) diff --git a/packages/client/ui-skill/tests/user-text-rules.client.spec.ts b/packages/client/ui-skill/tests/user-text-rules.client.spec.ts new file mode 100644 index 0000000000..a6958ab233 --- /dev/null +++ b/packages/client/ui-skill/tests/user-text-rules.client.spec.ts @@ -0,0 +1,72 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { UserTextRegistry } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/user-text-registry.ts' +import { skillReferenceRule } from '../src/client/user-text-rules.ts' + +function setup(rule: UserTextRule = skillReferenceRule) { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + const registry = new UserTextRegistry(ctx) + registry.register(rule) + return registry.createEvaluator() +} + +describe('skillReferenceRule', () => { + it('does not read names or rerun when text has no slash-token candidate', () => { + const read = vi.fn() + const match = vi.fn['match']>((text, from, context) => skillReferenceRule.match(text, from, { + read: (key) => { read(key); return context.read(key) }, + })) + const evaluate = setup({ ...skillReferenceRule, match }) + const text = 'plain text /review.md /path/to/file' + const before = evaluate({ text, facts: {} }) + expect(evaluate({ text, facts: { skillNames: ['review', 'path'] } })).toBe(before) + expect(read).not.toHaveBeenCalled() + expect(match).toHaveBeenCalledOnce() + }) + + it('reacts to missing names appearing and retains node identities when unrelated names are added', () => { + const match = vi.fn['match']>((text, from, context) => skillReferenceRule.match(text, from, context)) + const evaluate = setup({ ...skillReferenceRule, match }) + const text = 'use /review now' + expect(evaluate({ text, facts: {} }).every(node => node.id === 'text')).toBe(true) + const before = evaluate({ text, facts: { skillNames: ['review'] } }) + const after = evaluate({ text, facts: { skillNames: ['review', 'unrelated'] } }) + expect(after).toBe(before) + expect(after.find(node => node.id === 'skill')).toBe(before.find(node => node.id === 'skill')) + expect(match.mock.calls.map(([, from]) => from)).toEqual([0, 0, 11, 0, 11]) + }) + + it('recognizes only names explicitly resolved for this message', () => { + const evaluate = setup() + const text = '用 /review 再 /test-skill\n/review' + const input = Object.freeze({ text, facts: Object.freeze({ skillNames: Object.freeze(['review', 'test-skill', 'review']) }) }) + const nodes = evaluate(input) + expect(nodes.filter(node => node.id === 'skill').map(node => ({ raw: text.slice(node.start, node.end), data: node.data }))).toEqual([ + { raw: '/review', data: { name: 'review' } }, { raw: '/test-skill', data: { name: 'test-skill' } }, + { raw: '/review', data: { name: 'review' } }, + ]) + expect(JSON.parse(JSON.stringify(nodes))).toEqual(nodes) + expect(evaluate({ text, facts: {} })).toEqual([{ id: 'text', start: 0, end: text.length, data: { text } }]) + expect(evaluate({ text, facts: { skillNames: [] } }).every(node => node.id === 'text')).toBe(true) + }) + + it('requires whitespace or text boundaries and never strips attached punctuation', () => { + const evaluate = setup() + const text = '/x /x.md /x/path /path /x。 /x, (/x) prefix/x\t/x\n/x' + const nodes = evaluate({ text, facts: { skillNames: ['x'] } }) + expect(nodes.filter(node => node.id === 'skill').map(node => [node.start, node.end, text.slice(node.start, node.end)])).toEqual([ + [0, 2, '/x'], [text.indexOf('\t/x') + 1, text.indexOf('\t/x') + 3, '/x'], + [text.lastIndexOf('/x'), text.length, '/x'], + ]) + for (const input of [ + { text: '/path /x.md /x', facts: {} }, + { text: '/path /x.md /x。', facts: { skillNames: ['x'] } }, + { text: '/nfs-hg/path /root-dir/ /plan.md /plan。', facts: { skillNames: ['nfs-hg', 'root-dir', 'plan'] } }, + { text: '/plan.md /nfs-hg/path /plan。', facts: { skillNames: ['plan.md', 'nfs-hg/path', 'plan。'] } }, + ]) { + expect(evaluate(input).every(node => node.id === 'text')).toBe(true) + } + }) +}) diff --git a/packages/client/ui-skill/tsconfig.json b/packages/client/ui-skill/tsconfig.json index 18b2f4d062..276da77105 100644 --- a/packages/client/ui-skill/tsconfig.json +++ b/packages/client/ui-skill/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../ui-renderer" }, + { + "path": "../ui-conversation/tsconfig.client.json" + }, { "path": "../ui-tool" }, diff --git a/packages/client/ui-skill/tsdown.config.ts b/packages/client/ui-skill/tsdown.config.ts index 4c5539a6b4..7aa03cebc1 100644 --- a/packages/client/ui-skill/tsdown.config.ts +++ b/packages/client/ui-skill/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-skill', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-slots/README.i18n.yaml b/packages/client/ui-slots/README.i18n.yaml index c19da5d570..af812bc333 100644 --- a/packages/client/ui-slots/README.i18n.yaml +++ b/packages/client/ui-slots/README.i18n.yaml @@ -18,8 +18,8 @@ en: 05691d44a0c6c48f zh: 434033d5761297f3 /deepseek-ai-dsh-client-ui-slots/use-this-package/reusable-component-factories: - en: 301de67bf2779ae6 - zh: 6e9d405ee1063d53 + en: 71cbe7aa26f3ff80 + zh: 0e35265d4d95cc36 /deepseek-ai-dsh-client-ui-slots/use-this-package/the-five-framework-props-shares: en: 45e2933a5e084956 zh: 938deedaf489f892 diff --git a/packages/client/ui-slots/README.md b/packages/client/ui-slots/README.md index 599d7c6371..fc4bfaf6ec 100644 --- a/packages/client/ui-slots/README.md +++ b/packages/client/ui-slots/README.md @@ -29,7 +29,7 @@ Compose UI through this package whenever you write a client plugin: register a c ### Reusable Component Factories -Use a Component Factory when one package defines an assembly that unrelated parents render independently. Declare its complete type in `SlotFactoryMap`, install the definition with `ctx.slots.registerFactory()`, render occurrences through the injected `renderFactorySlot()`, and select each declared local Component through the call's `slots` option. The definition reads that choice through `useFactorySlot(name, fallback)`. +A designer may choose a Component Factory for a component or assembly they consider reusable, even with one current caller; unrelated parents, separate packages, and additional injection or local slots are not prerequisites. A one-off component need not use a Factory. Declare its complete type in `SlotFactoryMap`, install the definition with `ctx.slots.registerFactory()`, render occurrences through the injected `renderFactorySlot()`, and select each declared local Component through the call's `slots` option. The definition reads that choice through `useFactorySlot(name, fallback)`. Factory `children` remain ordinary global Slots and must match `SlotMap`, while local `slots` select one Component per occurrence. An occurrence inherits its render-position scope; `renderFactorySlot()` does not accept a Session identity. Shared Store handles use ordinary scope resolution. A Store factory stays lazy until an occurrence first materializes, then creates one handle for that render position and rejects a persistent Store spec whose key would collide across occurrences. diff --git a/packages/client/ui-slots/README.zh.md b/packages/client/ui-slots/README.zh.md index 3bfa4c3aab..b08b278328 100644 --- a/packages/client/ui-slots/README.zh.md +++ b/packages/client/ui-slots/README.zh.md @@ -29,7 +29,7 @@ kind: "package-library" ### 可复用 Component Factory -当一个包定义装配、而互不相关的 parents 需要独立渲染它时,使用 Component Factory。在 `SlotFactoryMap` 中声明完整类型,通过 `ctx.slots.registerFactory()` 安装 definition,通过注入的 `renderFactorySlot()` 渲染 occurrences,并通过调用的 `slots` 选项选择每个已声明的局部 Component。definition 通过 `useFactorySlot(name, fallback)` 读取该选择。 +设计者认为组件或装配可复用时,即使当前只有一个调用方,也可以选择 Component Factory;互不相关的父级、不同的包、额外注入或局部 slot 都不是前提。一次性组件不必使用 Factory。在 `SlotFactoryMap` 中声明完整类型,通过 `ctx.slots.registerFactory()` 安装 definition,通过注入的 `renderFactorySlot()` 渲染 occurrences,并通过调用的 `slots` 选项选择每个已声明的局部 Component。definition 通过 `useFactorySlot(name, fallback)` 读取该选择。 Factory `children` 仍是普通全局 Slots 且必须与 `SlotMap` 匹配,而局部 `slots` 为每个 occurrence 选择一个 Component。occurrence 继承其渲染位置的 scope;`renderFactorySlot()` 不接受 Session identity。共享 Store handle 使用普通 scope 解析。Store factory 保持 lazy,直到 occurrence 首次物化时才为该渲染位置创建一个 handle;若持久化 Store spec 会让 persistence key 在 occurrences 之间冲突,renderer 会拒绝它。 diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 7e7e9c28fb..5155bb9d17 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -24,7 +24,7 @@ "license": "MIT", "devDependencies": { "@deepseek-ai/dsh-client-store": "workspace:*", - "@types/react": "~18.3.1", + "react": "^18.2.0", "@deepseek-ai/cordis": "workspace:~" }, "files": [ diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 6e94643107..85c7d676cc 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -116,6 +116,8 @@ export interface SlotScopeTargetMap {} export interface SlotEntryDef { kind: SlotKind scope: SlotScope + /** Use span anchors and error placeholders for inline content; omitted uses div. */ + inline?: true owner?: object /** * Optional keyed-entry prop table. A keyed registration contributes one @@ -163,6 +165,8 @@ export interface SlotFactoryDef { export type SlotSpec = { kind: E['kind'] scope: E['scope'] + /** Use span anchors and error placeholders for inline content; omitted uses div. */ + inline?: true } & ('inject' extends keyof E ? E extends { inject: infer Injected extends object } ? { inject: Injected } diff --git a/packages/client/ui-slots/tsdown.config.ts b/packages/client/ui-slots/tsdown.config.ts index 09a4a4dc0e..a6264e0b21 100644 --- a/packages/client/ui-slots/tsdown.config.ts +++ b/packages/client/ui-slots/tsdown.config.ts @@ -1,6 +1,6 @@ import { staticLinked } from '../tsdown.client.ts' export default staticLinked( - '@deepseek-ai/dsh-client-ui-slots', + import.meta.dirname, ['lib/types/index.js'], ) diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 5bf8a6dee4..b12b090972 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-subagent/use-this-package: - en: 9a72f6fbd3828d1f - zh: e8b23941cbd16036 + en: 9aafcfbd6a33173c + zh: 6dbd48672f58cd2d /deepseek-ai-dsh-client-ui-subagent/use-this-package/browsing-the-tree: en: 3508631091820123 zh: ec7da9e5bedb22ba @@ -36,8 +36,8 @@ en: b817e9907392f59c zh: e42feb97d572a989 /deepseek-ai-dsh-client-ui-subagent/understand-the-implementation/composer-election: - en: 1c15af4dd7c0b791 - zh: f789737ab4dd8cb2 + en: 13c33af89ba0942e + zh: 00374c7944728ae2 /deepseek-ai-dsh-client-ui-subagent/further-exploration: en: 1766e9012e307178 zh: f3ee637f5941aae5 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 30cea03817..ad56362a7a 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -25,7 +25,7 @@ Browse local subagent conversations and external tasks from the parent header. L ## Use this package -The session header keeps the current session title as the lineage breadcrumb; when the session's direct catalog has entries or a read has failed, the descendant-count trigger renders at the start of the header actions band, with no breadcrumb separator. An absent catalog, an empty loading catalog, or a successfully loaded empty catalog hides the count trigger. The trigger opens that direct catalog, reports its total and running counts, and loads nested catalogs only when their rows expand. Select a local child at any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits. +The session header keeps the current session title as the lineage breadcrumb; when the session's direct catalog has entries or a read has failed, the descendant-count trigger renders after the preset label in the header actions band, with no breadcrumb separator. An absent catalog, an empty loading catalog, or a successfully loaded empty catalog hides the count trigger. The trigger opens that direct catalog, reports its total and running counts, and loads nested catalogs only when their rows expand. Select a local child at any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits. This package registers the `dsh-resource://subagentchat/session/?parent=&mode=` resource and builtin Sidebar tab type. The resource retains the child `SessionReference` directly from its address without refreshing the parent catalog, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls. @@ -71,6 +71,8 @@ One-shot children always elect a read-only composer. A continuable child elects Unknown-mode catalog rows remain visible and clickable, using the child id when no label is available. Their composer is read-only until child history establishes a supported mode; a read failure is reported in the child conversation. +Lineage-menu highlights follow the [standard menu edge geometry](../../../docs/ui-radius.md#nested-hover); tree disclosure controls retain their separate left gutter. + ----- diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 0479608885..1c2048920b 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -会话页头把当前会话标题作为谱系面包屑;当会话的直接目录有子项或读取失败时,后代数量触发器渲染在页头操作区的最前方,不带任何面包屑分隔符。目录缺席、空目录加载中或成功加载为空时,均隐藏数量触发器。触发器打开该直接目录,报告总数与运行数,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。 +会话页头把当前会话标题作为谱系面包屑;当会话的直接目录有子项或读取失败时,后代数量触发器渲染在页头操作区中预设标签之后,不带任何面包屑分隔符。目录缺席、空目录加载中或成功加载为空时,均隐藏数量触发器。触发器打开该直接目录,报告总数与运行数,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。 本包注册 `dsh-resource://subagentchat/session/?parent=&mode=` 资源与 builtin Sidebar tab 类型。资源直接根据地址保留 child 的 `SessionReference`,不刷新 parent 目录,并在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。 @@ -71,6 +71,8 @@ one-shot child 始终选用只读编辑器。可继续 child 仅在其确切 par 模式未知的目录项仍可见、可点击,没有标签时显示子会话 id。读取子历史并确认受支持模式前,输入区保持只读;读取失败在该子会话中报告。 +会话树菜单高亮遵循[标准菜单边缘几何](../../../docs/ui-radius.zh.md#nested-hover);树形展开控件保留独立的左侧空间。 + ----- diff --git a/packages/client/ui-subagent/locale/en.json b/packages/client/ui-subagent/locale/en.json new file mode 100644 index 0000000000..143f7912c3 --- /dev/null +++ b/packages/client/ui-subagent/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-subagent" + } +} diff --git a/packages/client/ui-subagent/locale/zh.json b/packages/client/ui-subagent/locale/zh.json new file mode 100644 index 0000000000..7a018e4d03 --- /dev/null +++ b/packages/client/ui-subagent/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "子智能体会话界面", + "description": "查看和跳转子智能体会话,并支持继续对话和 @ 引用。" + } +} diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 77876b327d..a0d55f7c57 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -60,8 +61,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -74,6 +73,7 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css index 19e1378313..4fc2a6f8f5 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css @@ -97,7 +97,7 @@ width: 336px; max-width: min(400px, calc(100vw - 32px)); max-height: min(560px, calc(100vh - 140px)); - padding: 3px; + padding: 4px; overflow: hidden; border-radius: var(--dsw-radius-lg); --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); @@ -146,7 +146,7 @@ min-height: 44px; padding: 6px 7px 6px 9px; border: 0; - border-radius: var(--dsw-radius-lg); + border-radius: var(--dsw-radius-md); background: transparent; color: var(--dsw-alias-label-primary); font-size: 12px; @@ -171,7 +171,7 @@ min-width: 0; margin: -6px -7px -6px; padding: 6px 7px; - border-radius: var(--dsw-radius-lg); + border-radius: var(--dsw-radius-md); } .rowActivitySlot { diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index 611557c923..fa020060fa 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -751,7 +751,7 @@ export type SubagentCatalogActionProps = /** * Session-header catalog action for root sessions: the descendant count and - * its dropdown at the start of the header actions band. Child sessions render nothing + * its dropdown after the preset label in the header actions band. Child sessions render nothing * here — their breadcrumb switcher in the lineage slot owns the same * navigation. * @param props - Session standard props plus the catalog actions and translator. diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index e24490966d..6d4220ef9c 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -84,10 +84,9 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'subagent-catalog', - // Leads the band, directly after the title crumbs: subagent lineage is - // the title's own continuation, ahead of Team navigation (-20) and the - // preset label (-10). - order: -30, + // Between the preset label (-10) and background tasks (20), after Team + // navigation (-20). + order: 10, locale: NS, inject: catalogActions, }, SubagentCatalogAction), diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index 23aa61b015..d75c9d8639 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -134,10 +134,11 @@ describe('apply', () => { ]) // The root-session catalog seat registers in the actions band with the - // same business face, leading the band directly after the title crumbs. + // same business face, between the preset label (-10) and background + // tasks (20). const actionEntry = ctx.slots.entries('conversation.session.header.actions') .find(entry => entry.options.id === 'subagent-catalog')! - expect(actionEntry.options.order).toBe(-30) + expect(actionEntry.options.order).toBe(10) expect(actionEntry.inject).toBe(catalogEntry.inject) const composerEntry = ctx.slots.entries('conversation.composer') diff --git a/packages/client/ui-subagent/tsdown.config.ts b/packages/client/ui-subagent/tsdown.config.ts index 159de0398f..7aa03cebc1 100644 --- a/packages/client/ui-subagent/tsdown.config.ts +++ b/packages/client/ui-subagent/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-subagent', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index b485bb3eb2..b208594041 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -9,17 +9,17 @@ en: 06c7d7624947a72a zh: ac4ca82984564825 /deepseek-ai-dsh-client-ui-theme/summary: - en: caf6bc13d14dd556 - zh: dc516b595733b697 + en: d9832b31c4cb3ca4 + zh: f2ec42bb5ae66188 /deepseek-ai-dsh-client-ui-theme/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-theme/use-this-package: - en: 219aaf8d2eadee85 - zh: f3bc1a5cfb3bfee5 + en: 5a279269e31713c1 + zh: dcd5c31fbdfd63e2 /deepseek-ai-dsh-client-ui-theme/use-this-package/appearance-and-font-size: - en: 5232d0517dfff80b - zh: 72abb7a9c4fa3ebb + en: cbfc02f8b3b02e0d + zh: 5a8f4d9d688d113c /deepseek-ai-dsh-client-ui-theme/use-this-package/fonts: en: b45d5b48ca799c3a zh: af65514b3759204f @@ -39,11 +39,11 @@ en: bf76e7c34a331595 zh: 5accd0260ef7c61e /deepseek-ai-dsh-client-ui-theme/understand-the-implementation/preference-persistence: - en: 7280ae0a30d26142 - zh: 5f2ba9a479fd4453 + en: b1f0b0bce5e53bae + zh: 26ded298f85834ae /deepseek-ai-dsh-client-ui-theme/further-exploration: - en: 10664fe91f2ca00b - zh: 086f8dffdc223c22 + en: 7d5b953aaeeaf591 + zh: ff4bccb0f196f2b9 /deepseek-ai-dsh-client-ui-theme/model-experience: en: 3120df665299c634 zh: 1d9f365d091d0d2a diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 8f57dd4be2..8904a359b2 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-theme` lets Web GUI users choose `light`, `dark`, or `system`, and set the font and font size of text, code, and the terminal separately in Settings. A loopback client stores these values in the `ui-theme` settings namespace, which the local provider persists in `$DSH_HOME/cordis.patch.yml` by default. The plugin resolves `system` through `prefers-color-scheme` and publishes immutable `ThemeSnapshot`s; ui-layout applies each snapshot to the document. The package also ships the `--dsw-*` token stylesheets and injects a synchronous bootstrap so the selected palette, font size, and fonts apply before the shell loads. Third-party themes can register alias-token overrides through `ctx.theme`. +`dsh-client-ui-theme` lets Web GUI users choose `light`, `dark`, or `system`, and set the font and font size of text, code, and the terminal separately in Settings. Every authenticated browser stores these values in the `ui-theme` settings namespace, which the local provider persists in `$DSH_HOME/cordis.patch.yml` by default. The plugin resolves `system` through `prefers-color-scheme` and publishes immutable `ThemeSnapshot`s; ui-layout applies each snapshot to the document. The package also ships the `--dsw-*` token stylesheets and injects a synchronous bootstrap so the selected palette, font size, and fonts apply before the shell loads. Third-party themes can register alias-token overrides through `ctx.theme`. ## Table of Contents @@ -25,11 +25,13 @@ English | [中文](README.zh.md) ## Use this package -Users switch the color scheme, font sizes, and fonts from rows in Settings (General section); the choices persist across restarts on a loopback browser. Feature plugins consume the current snapshot through `ctx.theme` and read the `--dsw-*` tokens in CSS; they do not manage theme state themselves. +Users switch the color scheme, font sizes, and fonts from rows in Settings (General section); the choices persist across restarts in every authenticated browser. Feature plugins consume the current snapshot through `ctx.theme` and read the `--dsw-*` tokens in CSS; they do not manage theme state themselves. ### Appearance and font size -The plugin registers Appearance preference cubes and the text font-size stepper in the General section. A More font settings button after the stepper expands a region below it, collapsed on every settings open, with a font row for each of text, code, and the terminal, plus the code and terminal font-size steppers. The text stepper accepts integer values from 10 through 22 px and defaults to 14 px. It changes conversation headings and base text by the same increment, including the user bubble and composer draft; flow-row titles, summaries, and tables follow one step under the body size, while small text keeps a fixed size. The code stepper (10–16 px, default 11) sets the code-block size; inline code stays 1 px larger, and the small tool-output variant uses the same size. The terminal stepper (10–20 px, default 13) sets the sidebar terminal cell font size; changing it keeps the shell and output and refits the grid. Each accepted change writes through the Host settings API. Rapid changes serialize in gesture order with namespace revisions, and a rejected latest write reloads the durable values. Non-loopback pages keep these choices process-local. +The plugin registers Appearance preference cubes and the text font-size stepper in the General section. A More font settings button after the stepper expands a region below it, collapsed on every settings open, with a font row for each of text, code, and the terminal, plus the code and terminal font-size steppers. The text stepper accepts integer values from 10 through 22 px and defaults to 14 px. It changes conversation headings and base text by the same increment, including the user bubble and composer draft; flow-row titles, summaries, and tables follow one step under the body size, while small text keeps a fixed size. The code stepper (10–16 px, default 11) sets the code-block size; inline code stays 1 px larger, and the small tool-output variant uses the same size. The terminal stepper (10–20 px, default 13) sets the sidebar terminal cell font size; changing it keeps the shell and output and refits the grid. Each accepted change writes through the Host settings API. Rapid changes serialize in gesture order with namespace revisions, and a rejected latest write reloads the durable values. + +Desktop's fixed `Cmd`/`Ctrl` + `+` (or `=`) and `Cmd`/`Ctrl` + `-` shortcuts change the text, code, and terminal font sizes together by 1 px, each within its own range. Each physical key press saves the changed fields in one settings mutation; holding a key does not repeat the adjustment. The actions appear in the shortcut reference and work in the main document, including dialogs and text inputs, unless a local control consumes the input; terminal input retains its own key handling. Web retains the browser's zoom shortcuts. ### Fonts @@ -91,7 +93,7 @@ The `--dsw-alias-turn-trigger-*` tokens provide separate resting and hover backg ### Preference persistence -The service provides itself immediately with the schema defaults on a loopback browser, then loads the `ui-theme` namespace and writes each accepted theme, font-size, or font-family change through the Host settings API. Pushed settings changes and reconnects refetch the namespace. While its own writes are pending, the service ignores refetched sections, so an echo of an earlier write cannot revert a later change; it adopts the durable section once the writes settle. Non-loopback pages do not create that Host-backed scope. The persistence boundary is owned by the [Host-backed preferences reference](../ui-settings/README.md). +The service provides itself immediately with the schema defaults, then loads the `ui-theme` namespace and writes each accepted theme, font-size, or font-family change through the Host settings API. Pushed settings changes and reconnects refetch the namespace. While its own writes are pending, the service ignores refetched sections, so an echo of an earlier write cannot revert a later change; it adopts the durable section once the writes settle. The persistence boundary is owned by the [Host-backed preferences reference](../ui-settings/README.md). @@ -106,7 +108,7 @@ These pages cover the layout presenter, the token consumers, and the styling rul - [ui-sidebar](../ui-sidebar/README.md) — a consumer of the scrollbar rebinding contract. - [ui-conversation](../ui-conversation/README.md) — a consumer of `--dsh-scrollbar-width` for the composer seat. - [Web styling](../../../docs/web-styling.md) — the authoritative styling rules for web client components. -- [historical Host-backed preferences](../../../.agents/notes/archived/bug-fix/2026-08-06-host-backed-web-preferences.md) — the persistence boundary decision. +- [Authenticated-browser Host preferences](../../../.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.md) — the persistence policy decision. ----- diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index e35467355e..a6c660c0db 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-theme` 让 Web GUI 用户在设置中选择 `light`、`dark` 或 `system`,并分别设置正文、代码与终端的字体和字号。回环客户端把这些值存入 `ui-theme` 设置命名空间,本地提供方默认将其持久化到 `$DSH_HOME/cordis.patch.yml`。插件通过 `prefers-color-scheme` 解析 `system` 并发布不可变的 `ThemeSnapshot`;ui-layout 把每份快照应用到文档。本包还提供 `--dsw-*` token 样式表,并注入同步引导,使所选调色板、字号与字体在外壳加载前生效。第三方主题可通过 `ctx.theme` 注册别名 token 覆盖。 +`dsh-client-ui-theme` 让 Web GUI 用户在设置中选择 `light`、`dark` 或 `system`,并分别设置正文、代码与终端的字体和字号。每个已认证浏览器都把这些值存入 `ui-theme` 设置命名空间,本地提供方默认将其持久化到 `$DSH_HOME/cordis.patch.yml`。插件通过 `prefers-color-scheme` 解析 `system` 并发布不可变的 `ThemeSnapshot`;ui-layout 把每份快照应用到文档。本包还提供 `--dsw-*` token 样式表,并注入同步引导,使所选调色板、字号与字体在外壳加载前生效。第三方主题可通过 `ctx.theme` 注册别名 token 覆盖。 ## 目录 @@ -25,11 +25,13 @@ kind: "package-reference" ## 使用本包 -用户从设置(「通用」分区)的各行中切换配色方案、字号与字体;在回环浏览器上,这些选择都会跨重启持久化。功能插件通过 `ctx.theme` 消费当前快照,并在 CSS 中读取 `--dsw-*` token;它们不自行管理主题状态。 +用户从设置(「通用」分区)的各行中切换配色方案、字号与字体;在每个已认证浏览器中,这些选择都会跨重启持久化。功能插件通过 `ctx.theme` 消费当前快照,并在 CSS 中读取 `--dsw-*` token;它们不自行管理主题状态。 ### 外观与字号 -插件在「通用」分区注册外观偏好方块与正文字号步进器。步进器后的「更多字体设置」按钮在其下方展开一个区域,每次打开设置时均为折叠状态,其中包含正文、代码与终端各自的字体行,以及代码与终端的字号步进器。正文步进器接受 10 至 22 px 的整数,默认值为 14 px。它以相同增量调整会话标题与基础文本,包括用户气泡与 composer 草稿;流内行的标题、摘要与表格跟随比正文低一档的字号,小号文本保持固定字号。代码步进器(10–16 px,默认 11)设置代码块字号;行内代码保持大 1 px,工具输出的小号变体使用相同字号。终端步进器(10–20 px,默认 13)设置侧栏终端的单元格字号;修改后保留 shell 与输出,并重新适配网格。每次通过的变更都经 Host settings API 写入。连续快速变更按操作顺序携带命名空间 revision 串行写入,最新写入被拒时重新加载持久值。非 loopback 页面把这些选择都保留在进程内。 +插件在「通用」分区注册外观偏好方块与正文字号步进器。步进器后的「更多字体设置」按钮在其下方展开一个区域,每次打开设置时均为折叠状态,其中包含正文、代码与终端各自的字体行,以及代码与终端的字号步进器。正文步进器接受 10 至 22 px 的整数,默认值为 14 px。它以相同增量调整会话标题与基础文本,包括用户气泡与 composer 草稿;流内行的标题、摘要与表格跟随比正文低一档的字号,小号文本保持固定字号。代码步进器(10–16 px,默认 11)设置代码块字号;行内代码保持大 1 px,工具输出的小号变体使用相同字号。终端步进器(10–20 px,默认 13)设置侧栏终端的单元格字号;修改后保留 shell 与输出,并重新适配网格。每次通过的变更都经 Host settings API 写入。连续快速变更按操作顺序携带命名空间 revision 串行写入,最新写入被拒时重新加载持久值。 + +Desktop 的固定快捷键 `Cmd`/`Ctrl` + `+`(或 `=`)和 `Cmd`/`Ctrl` + `-` 同时将正文、代码与终端字号增减 1 px,各自沿用其取值范围。每次实际按下按键,通过一次设置操作保存变化字段;长按不会连续调整字号。这些操作显示在快捷键参考中,可在主文档的对话框和文本输入框内使用,但会让已消费输入的本地控件先处理;终端输入保留自身的按键处理。Web 保留浏览器的缩放快捷键。 ### 字体 @@ -91,7 +93,7 @@ kind: "package-reference" ### 偏好持久化 -在 loopback 浏览器上,服务先以 schema 默认值立即提供自身,随后加载 `ui-theme` 命名空间,并把每次通过的主题、字号或字体变更经 Host settings API 写入。收到推送的设置变更时或重连后都会重新拉取该命名空间。自身写入尚未完成时,服务忽略重新拉取的配置段,避免较早写入的回显覆盖之后的修改;写入全部完成后再采用持久配置段。非 loopback 页面不会创建该 Host-backed scope。该持久化边界由 [Host 支撑的偏好参考](../ui-settings/README.zh.md) 拥有。 +服务先以 schema 默认值立即提供自身,随后加载 `ui-theme` 命名空间,并把每次通过的主题、字号或字体变更经 Host settings API 写入。收到推送的设置变更时或重连后都会重新拉取该命名空间。自身写入尚未完成时,服务忽略重新拉取的配置段,避免较早写入的回显覆盖之后的修改;写入全部完成后再采用持久配置段。该持久化边界由 [Host 支撑的偏好参考](../ui-settings/README.zh.md) 拥有。 @@ -106,7 +108,7 @@ kind: "package-reference" - [ui-sidebar](../ui-sidebar/README.zh.md)——滚动条重新绑定约定的消费方。 - [ui-conversation](../ui-conversation/README.zh.md)——为 composer 席位消费 `--dsh-scrollbar-width` 的消费方。 - [Web 样式](../../../docs/web-styling.zh.md)——Web 客户端组件的权威样式规则。 -- [历史Host 支撑的偏好](../../../.agents/notes/archived/bug-fix/2026-08-06-host-backed-web-preferences.md)——持久化边界决策。 +- [已认证浏览器 Host 偏好](../../../.agents/notes/implemented/bug-fix/2026-09-15-authenticated-browser-host-preferences.zh.md)——持久化策略决策。 ----- diff --git a/packages/client/ui-theme/locale/en.json b/packages/client/ui-theme/locale/en.json new file mode 100644 index 0000000000..d377165424 --- /dev/null +++ b/packages/client/ui-theme/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-theme" + } +} diff --git a/packages/client/ui-theme/locale/zh.json b/packages/client/ui-theme/locale/zh.json new file mode 100644 index 0000000000..82eccc9825 --- /dev/null +++ b/packages/client/ui-theme/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "界面主题", + "description": "提供浅色、深色和跟随系统的主题设置。" + } +} diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 2e43211b92..669685f892 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", "description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row", - "version": "0.2.1-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -24,13 +24,15 @@ }, "./src/*": "./src/*", "./package.json": "./package.json", - "./brand-font.css": "./lib/styles/brand-font.css" + "./brand-font.css": "./lib/styles/brand-font.css", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { "inject": [ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-shortcuts", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-api-remotes" @@ -48,6 +50,7 @@ "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-shortcuts": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", @@ -55,12 +58,12 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-host-webserver": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0", "clsx": "^2.0.0", "@deepseek-ai/dsh-settings": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/styles", diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index e2f47eb3bd..f37bb34f4b 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -6,10 +6,12 @@ * settings scope loads and stores the preference in the user-settings * document. The plugin also registers the Appearance and font settings rows * into the settings General section — the theme feature owns its own settings - * surface. + * surface. Desktop shortcuts adjust text, code, and terminal font sizes together. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' +import type { NormalizedBinding } from '@deepseek-ai/dsh-client-shortcuts/protocol' // Type-only: the ctx.configForms Context merge. Cross-plugin collaboration // goes through the service, never a value import (client bundle purity gate). import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client' @@ -201,6 +203,7 @@ export class ThemeRuntime { } ctx.effect(() => host.subscribe(() => { this.adopt() }), 'ui-theme: settings scope adoption') this.adopt() + installFontSizeShortcuts(ctx, (delta) => { this.adjustFontSizes(delta) }) } /** @@ -242,12 +245,12 @@ export class ThemeRuntime { } if (this.preference === id) return this.preference = id as ThemePreference - if (isThemePreference(id)) this.write(THEME_PREFERENCE_FIELD, id) + if (isThemePreference(id)) this.write(() => this.host.set(THEME_PREFERENCE_FIELD, id)) this.publish() } /** - * Change one role's font size — the only font-size write entry. Accepted + * Change one role's font size. Accepted * values are written through the settings scope and emit `theme/change`. * @param role - font role. * @param px - integer px within the role's `FONT_SIZE_SPECS` range; out-of-range or fractional values throw. @@ -257,7 +260,29 @@ export class ThemeRuntime { if (!isFontSize(role, px)) throw new Error(`${role} font size ${px} is outside ${spec.min}..${spec.max}`) if (this.fontSizes[role] === px) return this.fontSizes = Object.freeze({ ...this.fontSizes, [role]: px }) - this.write(spec.field, px) + this.write(() => this.host.set(spec.field, px)) + this.publish() + } + + /** + * Step all font sizes within their own ranges and save changed fields in one mutation. + * Emits one `theme/change`; no write or event occurs when every role is at its limit. + * @param delta - one pixel to add or remove from each role's current size. + */ + private adjustFontSizes(delta: 1 | -1): void { + const fontSizes = { ...this.fontSizes } + const fields: Record = {} + for (const role of FONT_ROLES) { + const { field, min, max } = FONT_SIZE_SPECS[role] + const px = Math.max(min, Math.min(max, fontSizes[role] + delta)) + if (px === fontSizes[role]) continue + fontSizes[role] = px + fields[field] = px + } + const ops = Object.entries(fields).map(([field, value]) => ({ op: 'set' as const, path: [field], value })) + if (ops.length === 0) return + this.fontSizes = Object.freeze(fontSizes) + this.write(() => this.host.mutate(ops)) this.publish() } @@ -272,14 +297,14 @@ export class ThemeRuntime { const list = normalizeFontFamily(value) if (this.fontFamilies[role] === list) return this.fontFamilies = Object.freeze({ ...this.fontFamilies, [role]: list }) - this.write(FONT_FAMILY_FIELDS[role], list) + this.write(() => this.host.set(FONT_FAMILY_FIELDS[role], list)) this.publish() } - /** Write one field through the settings scope, then adopt the durable section once no own write remains. */ - private write(field: string, value: string | number): void { + /** Queue one settings write, then adopt the durable section once no own write remains. */ + private write(operation: () => Promise): void { this.pendingWrites += 1 - void this.host.set(field, value).finally(() => { + void operation().finally(() => { this.pendingWrites -= 1 this.adopt() }) @@ -470,6 +495,39 @@ function dynamicToken(name: string): ThemeTokenInspection { } } +/** Register desktop font-size keys when keyboard and locale services are available. */ +function installFontSizeShortcuts(ctx: ClientContext, adjust: (delta: 1 | -1) => void): void { + ctx.inject(['shortcuts', 'locale'], (scope) => { + if (scope.shortcuts.runtime !== 'desktop') return + const t = scope.locale.bind(SETTINGS_NS) + const combinations: { binding: NormalizedBinding; delta: 1 | -1 }[] = [] + // Fixed bindings drive both reservation and matching. + for (const [direction, code, delta] of [['increase', 'Equal', 1], ['decrease', 'Minus', -1]] as const) { + const binding = { code, modifiers: ['primary'] as const } + const bindings = direction === 'increase' + ? [binding, { code, modifiers: ['primary', 'shift'] as const }] as const : [binding] as const + for (const candidate of bindings) { + combinations.push({ binding: scope.shortcuts.describeBinding(candidate).binding as NormalizedBinding, delta }) + } + scope.effect(() => scope.shortcuts.registerFixed({ + id: `fontSize.${direction}` as ShortcutCommandId, label: () => t(`fontSize.text.${direction}`), + keys: scope.shortcuts.describeBinding(binding).keys, + bindings, group: 'application', + }), `ui-theme: ${direction} font size reference`) + } + scope.effect(() => scope.shortcuts.observeFixedInput((input) => { + if (input.type === 'reset') return + const { gesture, context } = input + if (gesture.composing || gesture.defaultPrevented || gesture.repeat || context.region === 'terminal') return + const matched = combinations.find(({ binding }) => binding.code === gesture.code + && (['control', 'alt', 'shift', 'meta'] as const).every(modifier => gesture[modifier] === binding.modifiers.includes(modifier))) + if (matched === undefined) return + input.consume() + adjust(matched.delta) + }), 'ui-theme: font size input') + }) +} + /** * Required services: settings transport plus slots/locale for the Appearance * row. `remote` carries the forwarded settings invalidation that @@ -480,17 +538,17 @@ export const inject = ['slots', 'locale', 'remote', 'configForms'] /** * Client plugin body: provide the theme service and register the * feature-owned Appearance and font settings rows into the General section's item - * slot (a feature owns its settings surface). + * slot (a feature owns its settings surface). Desktop font-size shortcuts share + * all three font-size settings when the keyboard service is available. * @param ctx - client cordis context. */ export function apply(ctx: ClientContext): void { installThemeStyles(ctx) + ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries') const host = ctx.configForms.get(THEME_SETTINGS_NAMESPACE) const theme = new ThemeRuntime(ctx, host) ctx.provide('theme', theme) - ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries') - const store = createAppearanceRowStore() let bound: BoundActions | undefined const fontStore = createFontRowStore() diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index b49e22c548..877ce818c3 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -2,17 +2,20 @@ * locale service, declaration-aware Appearance row registration, snapshot * projection into the row store, and HMR collapse recovery. */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' -import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' -import type { AppearanceRowInjected, FontSettingsInjected, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' -import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts' +import { apply, inject, SETTINGS_NS, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' +import type { AppearanceRowInjected, FontSettingsInjected, ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' +import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema, type ThemeSettings } from '../src/theme-settings.ts' import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { createAppearanceRowStore, createFontRowStore } from '../src/client/settings-store.ts' import { FontSettingsGroup } from '../src/client/FontSettingsGroup.tsx' +import type { Shortcuts, ShortcutContext, ShortcutFixedInput, ShortcutGesture, ShortcutPlatform } from '@deepseek-ai/dsh-client-shortcuts/client' +import { bindingKey, normalizeBinding, presentBinding } from '@deepseek-ai/dsh-client-shortcuts/protocol' +import { ShortcutRegistry } from '../../shortcuts/src/client/registry.ts' // These specs assert the shipped Chinese copy. The lane has no jsdom `window`, // so browser-language detection never runs and a fresh LocaleRuntime opens on @@ -46,8 +49,7 @@ async function bench(isLoopback = true) { value: { writable: true, hasDocument: true, namespaces: [namespace()] }, })) const mutate = vi.fn((_ns: string, ops: { path: string[]; value: unknown }[]) => { - const op = ops[0]! - section[op.path[0]!] = op.value + for (const op of ops) section[op.path[0]!] = op.value return Promise.resolve({ ok: true as const, value: namespace() }) }) const events = new TestRemote(ctx, { settings: { describe, mutate } }) @@ -85,7 +87,213 @@ function fontSettingsFaceOf(slots: SlotRegistry) { return { entry, instance, face: face as FontSettingsInjected } } +/** Deliver the keyboard service's arbitrated input while keeping its real catalog and bindings. */ +function fontKeyboard(ctx: Context, runtime: Shortcuts['runtime'] = 'desktop', platform: ShortcutPlatform = 'macos') { + onTestFinished(() => ctx.fiber.dispose()) + const registry = new ShortcutRegistry(runtime, platform) + const listeners = new Set<(input: ShortcutFixedInput) => void>() + const shortcuts: Pick = { + runtime, platform, + registerFixed: command => registry.registerFixed(command), + describeBinding: (candidate) => { + const binding = candidate === null ? null : normalizeBinding(candidate, platform) + return { binding, keys: presentBinding(binding, platform).keys, issue: null, conflicts: [] } + }, + observeFixedInput: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + } + ctx.provide('shortcuts', shortcuts) + return { + registry, + press: (gesture: Partial = {}, context: Partial = {}) => { + const consume = vi.fn() + for (const listener of listeners) listener({ + type: 'keydown', consume, + gesture: { code: 'Equal', meta: platform === 'macos', control: platform !== 'macos', alt: false, + shift: false, repeat: false, composing: false, defaultPrevented: false, ...gesture }, + context: { region: 'page', modal: null, target: null, ...context }, + }) + return consume + }, + reset: () => { for (const listener of listeners) listener({ type: 'reset' }) }, + } +} + +/** Isolate theme write outcomes behind the same registered keyboard input. */ +async function keyboardTheme(host = stubConfigForm()) { + const ctx = new Context() + ctx.provide('locale', new LocaleRuntime(ctx)) + const keyboard = fontKeyboard(ctx) + const events: ThemeSnapshot[] = [] + ctx.on('theme/change', (snapshot) => { events.push(snapshot) }) + await ctx.plugin({ apply: (scope) => { + scope.provide('theme', new ThemeRuntime(scope, host.scope)) + } }).await() + return { theme: ctx.get('theme') as ThemeRuntime, keyboard, events, host } +} + describe('ui-theme apply', () => { + it('steps all font sizes in one settings mutation and one snapshot', async () => { + const { theme, keyboard, events, host } = await keyboardTheme() + keyboard.press() + expect(theme.getTheme().fontSizes).toEqual({ text: 15, code: 12, terminal: 14 }) + expect(host.mutate).toHaveBeenCalledExactlyOnceWith([ + { op: 'set', path: ['fontSize'], value: 15 }, + { op: 'set', path: ['codeFontSize'], value: 12 }, + { op: 'set', path: ['terminalFontSize'], value: 14 }, + ]) + expect(host.set).not.toHaveBeenCalled() + expect(events).toHaveLength(1) + expect(events[0]?.fontSizes).toEqual({ text: 15, code: 12, terminal: 14 }) + }) + + it('continues stepping other font roles when one has reached its own limit', async () => { + const { theme, keyboard, host } = await keyboardTheme() + theme.setFontSize('text', 22) + theme.setFontSize('code', 10) + theme.setFontSize('terminal', 20) + keyboard.press() + expect(theme.getTheme().fontSizes).toEqual({ text: 22, code: 11, terminal: 20 }) + expect(host.mutate).toHaveBeenCalledExactlyOnceWith([ + { op: 'set', path: ['codeFontSize'], value: 11 }, + ]) + keyboard.press({ code: 'Minus' }) + expect(theme.getTheme().fontSizes).toEqual({ text: 21, code: 10, terminal: 19 }) + keyboard.press({ code: 'Minus' }) + expect(theme.getTheme().fontSizes).toEqual({ text: 20, code: 10, terminal: 18 }) + expect(host.mutate).toHaveBeenLastCalledWith([ + { op: 'set', path: ['fontSize'], value: 20 }, + { op: 'set', path: ['terminalFontSize'], value: 18 }, + ]) + }) + + it('adopts all durable font sizes together after a refused batch settles', async () => { + const host = stubConfigForm() + const section: ThemeSettings = { preference: 'system', fontSize: 14, codeFontSize: 11, terminalFontSize: 13, + textFontFamily: '', codeFontFamily: '', terminalFontFamily: '' } + host.publish({ status: 'ready', value: section, revision: 1, writable: true }) + const { theme, keyboard } = await keyboardTheme(host) + const write = Promise.withResolvers() + host.mutate.mockReturnValue(write.promise) + keyboard.press() + host.publish({ value: section, revision: 2 }) + expect(theme.getTheme().fontSizes).toEqual({ text: 15, code: 12, terminal: 14 }) + write.resolve(false) + await vi.waitFor(() => { expect(theme.getTheme().fontSizes).toEqual({ text: 14, code: 11, terminal: 13 }) }) + }) + + it.each(['macos', 'windows', 'linux'] as const)('shares %s font shortcuts with the settings control and durable values', async (platform) => { + const b = await bench() + const keyboard = fontKeyboard(b.ctx, 'desktop', platform) + declareItems(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const theme = b.ctx.get('theme') as ThemeRuntime + const { instance, face } = fontSettingsFaceOf(b.slots) + + expect(keyboard.press()).toHaveBeenCalledOnce() + expect(theme.getTheme().fontSizes).toEqual({ text: 15, code: 12, terminal: 14 }) + keyboard.press({ shift: true }, { region: 'editable' }) + expect(instance.getSnapshot().fontSizes).toEqual({ text: 16, code: 13, terminal: 15 }) + keyboard.press({ code: 'Minus' }) + expect(instance.getSnapshot().fontSizes).toEqual({ text: 15, code: 12, terminal: 14 }) + face.setFontSize('text', 20) + keyboard.press() + expect(theme.getTheme().fontSizes).toEqual({ text: 21, code: 13, terminal: 15 }) + await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(5) }) + expect(b.mutate.mock.calls.at(-1)?.[1]).toEqual([ + { op: 'set', path: ['fontSize'], value: 21 }, + { op: 'set', path: ['codeFontSize'], value: 13 }, + { op: 'set', path: ['terminalFontSize'], value: 15 }, + ]) + expect(theme.getTheme().fontSizes).toEqual({ text: 21, code: 13, terminal: 15 }) + + await fiber.dispose() + expect(keyboard.registry.fixedCatalog.getSnapshot()).toEqual([]) + expect(keyboard.press()).not.toHaveBeenCalled() + expect(theme.getTheme().fontSizes.text).toBe(21) + await b.ctx.fiber.dispose() + }) + + it('presents both desktop font actions with their accepted physical combinations', async () => { + const b = await bench() + const keyboard = fontKeyboard(b.ctx) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(keyboard.registry.fixedCatalog.getSnapshot().map(row => ({ + label: row.label, keys: row.keys, bindings: row.bindings.map(bindingKey), + }))).toMatchInlineSnapshot(` + [ + { + "bindings": [ + "meta+Equal", + "shift+meta+Equal", + ], + "keys": [ + "⌘", + "=", + ], + "label": "增大字号", + }, + { + "bindings": [ + "meta+Minus", + ], + "keys": [ + "⌘", + "-", + ], + "label": "减小字号", + }, + ] + `) + await b.ctx.fiber.dispose() + }) + + it('consumes font shortcuts at either size limit without persisting another write', async () => { + const b = await bench() + const keyboard = fontKeyboard(b.ctx) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const theme = b.ctx.get('theme') as ThemeRuntime + theme.setFontSize('text', 22) + theme.setFontSize('code', 16) + theme.setFontSize('terminal', 20) + await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(3) }) + expect(keyboard.press()).toHaveBeenCalledOnce() + expect(keyboard.press({ shift: true })).toHaveBeenCalledOnce() + theme.setFontSize('text', 10) + theme.setFontSize('code', 10) + theme.setFontSize('terminal', 10) + await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(6) }) + expect(keyboard.press({ code: 'Minus' })).toHaveBeenCalledOnce() + expect(theme.getTheme().fontSizes).toEqual({ text: 10, code: 10, terminal: 10 }) + expect(b.mutate).toHaveBeenCalledTimes(6) + await b.ctx.fiber.dispose() + }) + + it('leaves browser zoom and locally owned input unchanged', async () => { + const browser = await bench() + const web = fontKeyboard(browser.ctx, 'web') + await browser.ctx.plugin({ inject: [...inject], apply }).await() + expect(web.registry.fixedCatalog.getSnapshot()).toEqual([]) + expect(web.press()).not.toHaveBeenCalled() + await browser.ctx.fiber.dispose() + + const b = await bench() + const keyboard = fontKeyboard(b.ctx) + await b.ctx.plugin({ inject: [...inject], apply }).await() + for (const gesture of [ + { composing: true }, { defaultPrevented: true }, { repeat: true }, { alt: true }, + { meta: false }, { control: true }, { code: 'KeyA' }, { code: 'Minus', shift: true }, + ]) expect(keyboard.press(gesture)).not.toHaveBeenCalled() + expect(keyboard.press({}, { region: 'terminal' })).not.toHaveBeenCalled() + keyboard.reset() + expect((b.ctx.get('theme') as ThemeRuntime).getTheme().fontSizes.text).toBe(14) + expect(b.mutate).not.toHaveBeenCalled() + await b.ctx.fiber.dispose() + }) + it('declares the slot and locale services', () => { expect(inject).toEqual(['slots', 'locale', 'remote', 'configForms']) }) @@ -169,7 +377,7 @@ describe('ui-theme apply', () => { await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(3) }) }) - it('loads Host settings at boot, refreshes its namespace, and keeps remote browsers process-local', async () => { + it('loads Host settings at boot, refreshes its namespace, and persists for a trusted remote page', async () => { const b = await bench() // The shared mirror read once at bench time; a Host-side change reaches it // through the document invalidation, exactly as production announces one. @@ -192,14 +400,15 @@ describe('ui-theme apply', () => { b.ctx.emit('connection/reset') await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') }) + // A non-loopback page persists through the same path as loopback. const remote = await bench(false) declareItems(remote.slots) await remote.ctx.plugin({ inject: [...inject], apply }).await() const remoteTheme = remote.ctx.get('theme') as ThemeRuntime remoteTheme.setTheme('dark') - await Promise.resolve() - expect(remote.describe).not.toHaveBeenCalled() - expect(remote.mutate).not.toHaveBeenCalled() + await vi.waitFor(() => { + expect(remote.mutate).toHaveBeenCalledWith(THEME_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'dark' }], 0) + }) }) it('activates before a slow settings refresh and converges when it settles', async () => { diff --git a/packages/client/ui-theme/tests/expected/radius-exceptions.expected.json b/packages/client/ui-theme/tests/expected/radius-exceptions.expected.json index 2f962a9444..274fc5b552 100644 --- a/packages/client/ui-theme/tests/expected/radius-exceptions.expected.json +++ b/packages/client/ui-theme/tests/expected/radius-exceptions.expected.json @@ -5,6 +5,9 @@ "ui-deliverables/src/client/Deliverables.module.css": [ ".fileIcon: border-radius: 10px" ], + "ui-plan/src/client/PlanPreview.module.css": [ + ".cardIcon: border-radius: 10px" + ], "ui-schedule/src/client/ClockPicker.module.css": [ ".option: border-radius: 10px" ], @@ -23,7 +26,6 @@ ".deleteButton: border-radius: 6px" ], "ui-schedule/src/client/ScheduleCreateCard.module.css": [ - ".card: border-radius: 18px", ".leading: border-radius: 10px" ], "ui-schedule/src/client/TaskManagerPage.module.css": [ @@ -42,10 +44,6 @@ ".delivery: border-radius: 8px", ".linkedSession: border-radius: 14px" ], - "ui-schedule/src/client/TaskMenu.module.css": [ - ".list: border-radius: 16px", - ".item: border-radius: 8px" - ], "ui-settings-account/src/client/AccountNotice.module.css": [ ".card: border-radius: 16px" ], diff --git a/packages/client/ui-theme/tsconfig.client.json b/packages/client/ui-theme/tsconfig.client.json index 1e68a788f4..5e56d362dc 100644 --- a/packages/client/ui-theme/tsconfig.client.json +++ b/packages/client/ui-theme/tsconfig.client.json @@ -15,6 +15,9 @@ { "path": "../locale/tsconfig.client.json" }, + { + "path": "../shortcuts/tsconfig.client.json" + }, { "path": "../store" }, diff --git a/packages/client/ui-theme/tsdown.config.ts b/packages/client/ui-theme/tsdown.config.ts index d0be379b73..9bf50e3060 100644 --- a/packages/client/ui-theme/tsdown.config.ts +++ b/packages/client/ui-theme/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-client-ui-theme', + import.meta.dirname, ['lib/types/index.js'], { lib: { diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 04158a1ca7..01eb372ca9 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -21,8 +21,8 @@ en: 2e3c306b13d3bc25 zh: 77127f01763b3976 /deepseek-ai-dsh-client-ui-tool/use-this-package/built-in-views: - en: 4d885fc693b497f4 - zh: 2f68d9ea92f2ebd6 + en: 3643ffbb8cf80e4c + zh: b49654dc3b94b2be /deepseek-ai-dsh-client-ui-tool/understand-the-implementation: en: 00bc7b0c84edc603 zh: 1cfcf5b435248a91 @@ -30,8 +30,8 @@ en: 672e63b2f92e689b zh: ba950ecb480325a6 /deepseek-ai-dsh-client-ui-tool/understand-the-implementation/cards: - en: 679c7bb0f49d7929 - zh: 93bba9a6da95d303 + en: d3cfe0abdce4718d + zh: c2b5708e8a028226 /deepseek-ai-dsh-client-ui-tool/understand-the-implementation/declared-optional-capabilities: en: c1926deb4e5f3734 zh: c5845f465b94a459 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index c8f88095b1..d3c98dbeeb 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -51,7 +51,7 @@ Every registered view receives the explicit `preparing`, `start`, and `result` p File-tool rows prefer a complete `file_path`; other generic rows, including search and unregistered tools, prefer a non-empty `description` before their variant-specific argument fallback. Read `args` during rendering or view construction: a preparing reader grows in place, and only previously observed answer changes trigger subsequent block publication. -This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running atomic rows share one left-to-right highlight across their titles, separators, summaries, and suffixes, while settled rows stay static. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable. Background Bash calls expand to their original arguments and job acknowledgement. Their input scrolls horizontally without soft wrapping and remains selectable; only horizontal overflow adds a named keyboard focus target. Inspect is available when provided. The acknowledgement does not report the job's exit status. A native or PTC dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. `ui-skill` demonstrates a business-owned registration for `skill`. +This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running atomic rows share one left-to-right highlight across their titles, separators, summaries, and suffixes, while settled rows stay static. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable. Valid explicit background `bash`/`pwsh` calls use the terminal Start presentation; a successful launch acknowledgement retains the command and cwd in a static command-only card without output or execution status. Launch failures retain generic arguments and errors, while background `terminal_send` stays generic. Inspect preserves the original arguments and acknowledgement; later job output is not attached to this card ([decision](../../../.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.md)). Expanded terminal commands and background inputs wrap to the available width while preserving authored line breaks and selectable text; terminal output keeps its column-preserving layout. Only horizontal overflow adds a named keyboard focus target. Inspect is available when provided. The acknowledgement does not report the job's exit status. A native or PTC dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. `ui-skill` demonstrates a business-owned registration for `skill`. Question rows pair answers with questions by id, using the tool result or the `userQuestions` projection for late replies. An answerable call has a button to reopen its panel. Once a late reply is in the agent Inbox, the row shows its submitted answers; “View answers” expands its read-only transcript while admission is pending. A settled call with answers opens the panel read-only. Closed calls without answers and invalid records offer no button. Cancelled and interrupted rows show their verdict without inventing answers. If no panel provider accepts the button action, it expands the row. Unsupported or ambiguous input falls back to flattened Tool text. @@ -79,7 +79,7 @@ Tool Definitions own the [lazy argument view](../../util/values/README.md). Rows ### Cards -Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot. These models validate raw call arguments, result content, failure state, persisted metadata, PTC dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view and is absent when that View is unavailable; cards then omit Inspect. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules. +Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot, followed by the full recorded path and each attached image's pixel size (with the original size when downscaled) and byte count. These models validate raw call arguments, result content, failure state, persisted metadata, PTC dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view and is absent when that View is unavailable; cards then omit Inspect. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules. Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The tool row shows the primitive's exact or coarse-replacement counts; the expanded card contains the diff body without a totals footer. Hovering the tool header colors its added count green and removed count red; both retain those colors while the card is expanded. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 6b0d28087b..f898a9eeb1 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -51,7 +51,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字 文件工具行优先使用完整的 `file_path`;其他通用行(包括搜索和未注册的工具)优先使用非空 `description`,再回退到各自类别的参数摘要。应在渲染或构造视图时读取 `args`:准备态读器原地增长,后续仅在已观察答案变化时重新发布块。 -本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中的原子工具行以同一道高光从左到右扫过标题、分隔符、摘要与后缀;已完成行保持静态。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开。后台 Bash 调用可展开查看原始参数与后台任务回执。输入内容可横向滚动,不自动折行,并可选中;仅横向溢出时添加具名的键盘聚焦目标。提供 Inspect 时也可使用。回执不表示后台任务的退出状态。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 +本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中的原子工具行以同一道高光从左到右扫过标题、分隔符、摘要与后缀;已完成行保持静态。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开。合法的主动后台 `bash`/`pwsh` 调用使用 terminal Start 展示;成功启动回执后,静态仅命令卡片保留 command 和 cwd,不显示输出或执行状态。启动失败保留通用参数与错误展示,后台 `terminal_send` 仍为 generic。Inspect 保留原始参数及回执,后续 job 输出不接回此卡片([决定](../../../.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.zh.md))。展开的终端命令与后台输入按可用宽度自动折行,保留原有换行和可选文本;终端输出保持按列对齐的排版。仅横向溢出时添加具名的键盘聚焦目标。提供 Inspect 时也可使用。回执不表示后台任务的退出状态。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 问题行按 id 配对问题与答案;答案来自工具结果,迟到回答则来自 `userQuestions` 投影。可回答的调用提供按钮以重新打开面板。迟到回复进入 agent Inbox 后,问题行显示已提交的答案;等待准入期间,「查看回答」展开只读的行内记录。带有答案的已结算调用则以只读方式打开面板。没有答案的已关闭调用和无效记录不提供按钮。已取消和已中断的行显示裁决,不虚构答案。若面板提供方不接受按钮操作,点击会展开该行。不受支持或含糊的输入回退为压平的工具文本。 @@ -79,7 +79,7 @@ Tool Definition 拥有[懒计算参数视图](../../util/values/README.zh.md)。 ### 卡片 -每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、PTC dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图;该视图不可用时不提供此回调,卡片随之隐藏 Inspect。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。 +每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染,其下显示完整的记录路径以及每张附件图片的像素尺寸(缩小时附原图尺寸)和字节数。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、PTC dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图;该视图不可用时不提供此回调,卡片随之隐藏 Inspect。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。 Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。工具行显示原语提供的精确或粗粒度替换统计;展开卡片包含差异正文,不显示底部统计。悬停工具标题行时,新增数量显示绿色、删除数量显示红色;卡片展开期间保留这两种颜色。 diff --git a/packages/client/ui-tool/locale/en.json b/packages/client/ui-tool/locale/en.json new file mode 100644 index 0000000000..a071d79746 --- /dev/null +++ b/packages/client/ui-tool/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-tool" + } +} diff --git a/packages/client/ui-tool/locale/zh.json b/packages/client/ui-tool/locale/zh.json new file mode 100644 index 0000000000..2eb3bc4b7c --- /dev/null +++ b/packages/client/ui-tool/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "工具调用展示", + "description": "展示工具调用过程、嵌套关系和各工具的结果界面。" + } +} diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 6058888f57..2d0e0f2001 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -64,7 +65,6 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", @@ -80,6 +80,7 @@ "@deepseek-ai/dsh-user-questions": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index 5be4d641be..10432d7041 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -275,22 +275,23 @@ color: var(--dsw-alias-label-tertiary); } -/* The image card's in-card label: the view's replacement title, else the - shortened path — the same role ReadBlock's own label plays, so the presentation - contract's replacement-title rule is honoured here too. */ -.imageLabel { - margin-bottom: 4px; +/* The image card's full path and size lines under the gallery, in the small + code font of the expanded IN/OUT card. When the attachment slot renders + nothing, these lines are the only evidence an image was returned. pre-wrap + keeps repeated whitespace in a path. The enclosing .imageBody already carries + the row indent. */ +.imageMeta { + margin-top: 4px; + white-space: pre-wrap; overflow-wrap: anywhere; - font: var(--dsw-font-sm-13); - color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-markdown-code-block-small); + color: var(--dsw-alias-label-tertiary); } -/* The image card's own result text (media type, dimensions, byte size) under the - gallery, in the same muted tone the search recovery footer uses. Always - rendered: the attachment presentation slot is optional, so when nothing - occupies it the gallery is empty and this line is the only evidence an image - was returned. The enclosing .imageBody already carries the row indent. */ -.imageMeta { +/* Text blocks other than the envelope (for example, appended by a post-execute + hook), in the muted tone the search recovery footer uses. */ +.imageExtra { + margin-top: 4px; white-space: pre-wrap; overflow-wrap: anywhere; font: var(--dsw-font-xs-13); @@ -304,6 +305,7 @@ --dsl-terminal-font: var(--dsw-font-markdown-code-block-small); --dsl-terminal-line-height: 18px; --dsl-terminal-output-max-height: 224px; + --dsl-terminal-command-whitespace: pre-wrap; border: 0.5px solid var(--dsw-alias-border-l1); } 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 b62d581d8b..4b4674d452 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -270,22 +270,33 @@ export const ToolRow = memo(function ToolRow({ ? : imageBody !== null ? ( - /* Label, gallery, then the result's OWN envelope text. The text - comes from the image card model (which reads the result's text - block), never from the row's flattened output: an image read's - content is [text envelope, image block] and flattening - JSON.stringifies the image block, printing the raw attachment - object under the picture. It is not redundant either — the - attachment slot can render nothing, and then this line is the - only evidence an image was returned. */ + /* Gallery, then the full path and each attached image's size. + These come from the image card model, never from the row's + flattened output, which JSON.stringifies the image block. They + stay visible when the attachment slot renders nothing. */
    -
    {imageBody.label}
    {renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', { images: imageBody.images, loadImage, align: 'start', })} -
    {imageBody.text}
    +
    +
    {imageBody.path}
    + {imageBody.images.map(({ attachment }, index) => ( +
    + {attachment.originalDimensions === undefined + ? t('row.imageSize', { width: attachment.width, height: attachment.height, bytes: attachment.bytes }) + : t('row.imageSizeDownscaled', { + width: attachment.width, + height: attachment.height, + originalWidth: attachment.originalDimensions.width, + originalHeight: attachment.originalDimensions.height, + bytes: attachment.bytes, + })} +
    + ))} +
    + {imageBody.extraText !== null &&
    {imageBody.extraText}
    }
    ) : searchBody !== null 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 085451b35b..b2d0f36073 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 @@ -1,31 +1,28 @@ /** Pure image-card derivation from raw result content and metadata. @module */ import type { AttachmentId, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' -import { abbreviateHomePath, relativizeToCwd } from '@deepseek-ai/dsh-util-workspace-path' import type { ToolCallBlock } from './tool-call-model.ts' import { parsedToolCall } from './raw-tool-call.ts' /** - * The image-card material one settled call contributes: the display label plus - * the durable references the attachment slot renders as a gallery. + * The image-card material one settled call contributes: the read path plus the + * 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 * it nor derives a path from it. */ export interface ImageCardModel { - /** Card label: the read path, shortened the way every other card's is. */ - label: string + /** The resolved path as recorded, unshortened; the row summary already shows the short form. */ + path: string /** The durable images this result returned, in result order. */ images: readonly { readonly attachment: ImageAttachmentRef }[] /** - * The model-facing envelope text, for the line under the gallery. - * - * Taken from the result's own text block rather than the row's flattened - * result text: an image read's content is `[text envelope, image block]`, and - * flattening JSON.stringifies the image block, which would print the raw - * attachment object under the picture — the symptom this card exists to remove. + * Text blocks other than the model-facing envelope (for example, text a + * post-execute hook appended), joined in order; null when there are none. + * The envelope itself is not repeated: its path and image facts are already + * `path` and `images`. */ - text: string + extraText: string | null } /** @@ -35,7 +32,7 @@ export interface ImageCardModel { * * Root calls persist it; a nested call (a read_image dispatched from inside * run_code) settles without `meta`, so the card falls back to the call's own - * `file_path` argument for the label. + * `file_path` argument for the path. * * The attachment reference deliberately does NOT come from here. The settled * `content` already carries the image block with its complete reference, so @@ -150,24 +147,17 @@ function imageReferences(content: readonly unknown[]): ImageAttachmentRef[] | nu } /** - * Read the text of every text block of a settled image result, joined in order. - * - * The envelope is one of them; a post-execute hook that appends further text - * blocks keeps them visible under the gallery instead of being dropped. The - * envelope shape is still the recognition gate: a result without it is not a - * well-formed image read and declines. - * @param content - the settled result's content blocks. - * @returns the joined text, or null when no envelope-shaped block is present. + * Join the text blocks other than the image envelope, in order. A post-execute + * hook that appends further text blocks keeps them visible under the gallery + * instead of being dropped. + * @param content - the settled result's text blocks. + * @returns the joined text, or null when there is none. */ -function imageTexts(content: readonly { type: string; text?: string }[]): string | null { - const parts: string[] = [] - let sawEnvelope = false - for (const part of content) { - if (part.type !== 'text' || typeof part.text !== 'string') continue - if (IMAGE_ENVELOPE.test(part.text)) sawEnvelope = true - parts.push(part.text) - } - return sawEnvelope && parts.length > 0 ? parts.join('\n') : null +function extraTexts(content: readonly { type: string; text?: string }[]): string | null { + const parts = content.flatMap(part => part.type === 'text' && typeof part.text === 'string' && !IMAGE_ENVELOPE.test(part.text) + ? [part.text] + : []) + return parts.length > 0 ? parts.join('\n') : null } /** @@ -197,18 +187,11 @@ function fullyRendered(content: readonly unknown[]): boolean { * returns, so a running `read_image` has none and this returns null for it. * Both root and nested calls settle as ToolResultNode. Historical nested * records without presentation metadata fall back to the call's own - * `file_path` argument for the label. + * `file_path` argument for the path. * @param block - running or settled Tool block. - * @param sessionCwd - the session workspace root; a workspace-rooted absolute - * path label displays relative to it. Absent leaves the path as authored. - * @param home - host account home; a leftover POSIX home path displays as `~`. * @returns the image-card props, or null for the generic path. */ -export function imageCardModel( - block: ToolCallBlock, - sessionCwd?: string, - home?: string, -): ImageCardModel | null { +export function imageCardModel(block: ToolCallBlock): ImageCardModel | null { // Result-side only: a running call is not a ToolResultNode and carries no // content, so it has no card here. if (!('kind' in block) || block.isError) return null @@ -217,7 +200,7 @@ export function imageCardModel( const { file_path: filePath } = call.args if (typeof filePath !== 'string' || filePath.trim() === '') return null // Historical nested records without metadata use the authored file_path - // for the label. A root call with missing or malformed meta + // for the path. A root call with missing or malformed meta // declines — malformed tool data falls back to the generic card, which shows // the flattened content rather than an author-typed path. const metaPath = imageMeta(block.meta)?.path @@ -230,11 +213,11 @@ export function imageCardModel( // truth; `meta` contributes only the path, which the content does not carry. const refs = imageReferences(block.content) if (refs === null) return null - const text = imageTexts(block.content) - if (text === null) return null + // A result without the envelope is not a well-formed image read. + if (!block.content.some(part => part.type === 'text' && typeof part.text === 'string' && IMAGE_ENVELOPE.test(part.text))) return null return { - label: abbreviateHomePath(relativizeToCwd(path, sessionCwd), home), + path, images: refs.map(ref => ({ attachment: ref })), - text, + extraText: extraTexts(block.content), } } diff --git a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts index d6fe0fa3e0..6331f89592 100644 --- a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts @@ -44,7 +44,7 @@ export interface TerminalCardModel { * The locale-neutral props {@link TerminalBlock} draws. The render site adds * `command` after resolving {@link copy} through its locale seat. */ - card: Pick + card: Pick /** * Verbatim Tool data or semantic `terminal_send` data. Product copy stays * unresolved until a render site supplies its locale seat. @@ -55,7 +55,7 @@ export interface TerminalCardModel { } interface LocalizedTerminalCardModel { - readonly card: Pick + readonly card: Pick readonly description: string | undefined } @@ -288,8 +288,9 @@ function parseExitStatus(text: string): { output: string; exitCode?: number; sig /** * Derive terminal props for supported shell and terminal-send calls, including * nested PTC dispatch calls. Standard shell results parse their final status - * marker; persistent shell results, spill previews, background calls, errors, - * and malformed input use the generic path. {@link isSettledPersistentShellCall} lets that generic + * marker. Successful background shell launches retain only their command after + * settlement; persistent shell results, spill previews, background terminal sends, + * errors, and malformed input use the generic path. {@link isSettledPersistentShellCall} lets that generic * persistent result remain expandable without inventing one process status. * @param block - running or settled Tool block. * @param sessionCwd - original workspace root used only for old results without recorded cwd. @@ -302,7 +303,7 @@ export function terminalCardModel( const parsed = parsedToolCall(block) if (parsed === null) return null const call = shellCall(parsed.name, parsed.args) ?? terminalSendCall(parsed.name, parsed.args) - if (call === null || call.background) return null + if (call === null || (call.kind === 'terminal-send' && call.background)) return null const copy: TerminalCardModel['copy'] = call.kind === 'shell' ? { kind: 'shell', command: call.command, description: call.description } @@ -322,10 +323,14 @@ export function terminalCardModel( exitCode: undefined, signal: undefined, running: true, + ...(call.kind === 'shell' && call.background ? { background: call.background } : {}), }, } } if (block.isError || (call.kind === 'shell' && call.persistent) || isSpilledShellCall(block)) return null + if (call.kind === 'shell' && call.background) { + return { copy, card: { cwd, running: false, background: call.background } } + } const output = singleResultText(block) if (output === undefined) return null const status = call.kind === 'terminal-send' ? { output } : parseExitStatus(output) diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css index e6ba3f505e..52792b8722 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css @@ -16,6 +16,7 @@ --dsl-terminal-font: var(--dsw-font-markdown-code-block-small); --dsl-terminal-line-height: 18px; --dsl-terminal-output-max-height: 224px; + --dsl-terminal-command-whitespace: pre-wrap; margin: 4px 0 4px 4px; border: 0.5px solid var(--dsw-alias-border-l1); } @@ -78,6 +79,7 @@ } .commandInput { + --dsl-command-whitespace: pre-wrap; color: var(--dsw-alias-label-secondary); } diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index f23e369d0b..52ffd1555a 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -57,9 +57,8 @@ export const BashRow = memo(function BashRow({ toolName, block, sessionId, useSe const status = stateStatus(state, t) const { expanded, toggle: toggleExpand } = useDisclosure() const background = isBackgroundShellCall(block) - // Background launches expose their acknowledgement without assigning the - // job an exit status. Failures, persistent results, and spill previews also - // keep the generic input/output presentation. + // Failed or interrupted background launches retain raw input/output, as do + // other execution failures, persistent results, and spill previews. const genericBody = terminal === null && (model.state === 'error' || isSettledPersistentShellCall(block) || isSpilledShellCall(block) || background) && (model.bodyRaw !== null || model.output !== null) 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 e0095b0904..b2cd520281 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 @@ -28,9 +28,9 @@ import { CONVERSATION_NS as NS } from '../../locale.ts' * slot this entry declares. */ export function ReadImageRow(props: ReadImageRowProps) { - const { block, cwd, home, renderSlot, loadImage } = props + const { block, renderSlot, loadImage } = props return readFamilyRow(props, { - image: imageCardModel(block, cwd, home), + image: imageCardModel(block), renderSlot, loadImage, }) 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 8b0d642012..621e4cbcad 100644 --- a/packages/client/ui-tool/tests/image-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/image-card.client.spec.tsx @@ -9,7 +9,7 @@ // 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 // renders through the tool-owned `tool.call.images` slot, and an empty slot must -// still leave the media type and dimensions visible. +// still leave the path and image size visible. import { afterEach, describe, expect, it, vi } from 'vitest' import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' @@ -113,11 +113,9 @@ const loadImage: MessageImageLoader = vi.fn(() => Promise.reject(new Error('not describe('imageCardModel', () => { it('derives the card from settled image metadata and its raw envelope', () => { expect(imageCardModel(settled())).toEqual({ - label: 'shots/card.png', + path: 'shots/card.png', images: [{ attachment: sampleImage }], - // The fallback line is the result's own envelope, not the row's flattened - // output (which would stringify the image block). - text: ENVELOPE, + extraText: null, }) }) @@ -128,16 +126,9 @@ describe('imageCardModel', () => { expect(attachment).not.toHaveProperty('name') }) - it('relativizes a workspace-rooted path label, and leaves others as authored', () => { - expect(imageCardModel(settled({ meta: imageMeta({ path: '/w/app/shots/card.png' }) }), '/w/app')?.label) - .toBe('shots/card.png') - expect(imageCardModel(settled({ meta: imageMeta({ path: '/srv/other.png' }) }), '/w/app')?.label) - .toBe('/srv/other.png') - }) - - it('abbreviates a leftover POSIX home path label', () => { - expect(imageCardModel(settled({ meta: imageMeta({ path: '/Users/u/card.png' }) }), '/tmp/ws', '/Users/u')?.label) - .toBe('~/card.png') + it('keeps the recorded path unshortened', () => { + expect(imageCardModel(settled({ meta: imageMeta({ path: 'G:\\breeze\\shots\\card.png' }) }))?.path) + .toBe('G:\\breeze\\shots\\card.png') }) it('accepts any non-empty attachment id, because the shape is provider-owned', () => { @@ -163,7 +154,7 @@ describe('imageCardModel', () => { { type: 'image', attachment: appended }, ], } as never)) - expect(model?.text).toBe(`${ENVELOPE}\nanalysis: the card area is empty`) + expect(model?.extraText).toBe('analysis: the card area is empty') expect(model?.images).toEqual([{ attachment: sampleImage }, { attachment: appended }]) }) @@ -205,7 +196,7 @@ describe('imageCardModel', () => { // Metadata arrives unvalidated on replay, so an obsolete or hand-edited log // must never crash the tool message. A ROOT call whose meta does not match // declines to the generic card — malformed tool data must not render a - // card labeled with the author-typed path. Only a nested call (which + // card showing the author-typed path. Only a nested call (which // persists no meta by design) falls back to its own file_path argument. for (const meta of [undefined, null, 'meta', [{ path: 'a.png' }], {}, { path: '' }, { path: 7 }]) { expect(() => imageCardModel(settled({ meta }))).not.toThrow() @@ -213,7 +204,7 @@ describe('imageCardModel', () => { } const nested = imageCardModel(settled({ parentCallId: 'parent', meta: undefined })) expect(nested).not.toBeNull() - expect(nested?.label).toBe('shots/card.png') + expect(nested?.path).toBe('shots/card.png') }) it('declines a malformed attachment reference in the content', () => { @@ -257,14 +248,14 @@ describe('imageCardModel', () => { expect(imageCardModel(settled({ isError: true }))).toBeNull() // A nested call (a read_image dispatched from inside run_code) settles as a // ToolResultNode too and renders the card; it persists no presentationMeta, - // so the label falls back to the call's file_path argument. + // so the path falls back to the call's file_path argument. const nested = imageCardModel(settled({ parentCallId: 'parent', meta: undefined })) expect(nested).not.toBeNull() - expect(nested?.label).toBe('shots/card.png') + expect(nested?.path).toBe('shots/card.png') expect(nested?.images).toHaveLength(1) // Persisted meta still wins over the argument when a nested call has one. const withMeta = imageCardModel(settled({ parentCallId: 'parent', meta: { path: 'shots/persisted.png' } })) - expect(withMeta?.label).toBe('shots/persisted.png') + expect(withMeta?.path).toBe('shots/persisted.png') }) it('declines a call head that is not read_image', () => { @@ -332,7 +323,30 @@ describe('ReadImageRow keyed toolview', () => { const text = view.container.textContent ?? '' expect(text).not.toContain('"attachmentId"') expect(text).not.toContain('"type": "image"') - expect(text).toContain('image/png image, 1496x260 px') + expect(text).not.toContain('') + expect(text).not.toContain('image/png image') + }) + + it('shows the full path once, then the image size, under the picture', () => { + const view = render() + toggleRow(view) + const lines = [...view.container.querySelectorAll('[class*="imageMeta"] > div')].map(line => line.textContent) + expect(lines).toEqual(['/w/app/shots/card.png', '1496x260px,24588 字节']) + }) + + it('names the original size of a downscaled image', () => { + const downscaled = { ...sampleImage, originalDimensions: { width: 2992, height: 520 } } + const view = render() + toggleRow(view) + expect(view.container.textContent).toContain('1496x260px(由 2992x520px 缩小),24588 字节') + }) + + it('keeps appended non-envelope text under the picture', () => { + const view = render() + toggleRow(view) + expect(view.container.textContent).toContain('analysis: empty card') }) it('keeps the result text readable when the attachment slot renders nothing', () => { @@ -343,7 +357,7 @@ describe('ReadImageRow keyed toolview', () => { toggleRow(view) expect(emptySlot).toHaveBeenCalled() expect(view.container.querySelector('[data-images]')).toBeNull() - expect(view.container.textContent).toContain('image/png image, 1496x260 px') + expect(view.container.textContent).toContain('1496x260px,24588 字节') }) it('degrades to the text body when neither the slot nor the loader is supplied', () => { diff --git a/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts b/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts index b28154732d..6aed393a37 100644 --- a/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts +++ b/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts @@ -72,8 +72,8 @@ async function executeShell(text: string, nested: boolean, name = 'bash', maxInl async execute() { return [{ type: 'text', text }] }, }))) const session = Session.create(SessionId('spill-terminal')) - // The tool pipeline reads only the owner session; no agent loop runs here. - const agent = { session } as Agent + // Nested PTC registers retained-environment cleanup on the owning context. + const agent = { ctx, session } as Agent const callId = ToolCallId('shell-call') const result = await ctx.tools.execute({ callId, diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index a891bad758..24117339cd 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' -import { cleanup, fireEvent, render } from '@testing-library/react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' @@ -264,8 +264,35 @@ describe('terminalCardModel', () => { }))).not.toBeNull() }) - it('returns null for background, errors, malformed args, unsupported tools, and non-text results', () => { - expect(terminalCardModel(running({ argsRaw: shellArgs({ run_in_background: true }) }))).toBeNull() + it.each(['bash', 'pwsh'])('derives %s background launch commands without treating acknowledgement as output', (name) => { + const argsRaw = shellArgs({ run_in_background: true, workdir: '/requested' }) + for (const parentCallId of [undefined, 'parent']) { + const parent = parentCallId === undefined ? {} : { parentCallId } + expect(terminalCardModel(running({ name, argsRaw, ...parent }))).toEqual({ + copy: { kind: 'shell', command: 'ls -la', description: 'List files' }, + card: { cwd: '/requested', running: true, background: true, output: undefined, exitCode: undefined, signal: undefined }, + }) + for (const content of [[], [{ type: 'text' as const, text: 'started background job bash-1\n[exit code: 7]' }]]) { + const block = settled({ call: { name, argsRaw }, ...parent, meta: { cwd: '/recorded' }, content }) + const model = terminalCardModel(block)! + expect(model).toEqual({ + copy: { kind: 'shell', command: 'ls -la', description: 'List files' }, + card: { cwd: '/recorded', running: false, background: true }, + }) + expect(terminalFailed(model)).toBe(false) + expect(localizeTerminalCardModel(model, t).card).toEqual({ + command: 'ls -la', cwd: '/recorded', running: false, background: true, + }) + expect(terminalCardModel({ ...block, isError: true })).toBeNull() + expect(block.content).toBe(content) + } + } + }) + + it('returns null for background terminal sends, errors, malformed args, unsupported tools, and non-text results', () => { + expect(terminalCardModel(running({ name: 'terminal_send', argsRaw: JSON.stringify({ + sessionId: 'pty-3', text: 'make', run_in_background: true, + }) }))).toBeNull() expect(terminalCardModel(settled({ isError: true }))).toBeNull() expect(terminalCardModel(running({ argsRaw: '{' }))).toBeNull() expect(terminalCardModel(running({ name: 'read' }))).toBeNull() @@ -405,12 +432,13 @@ describe('chat row terminal body', () => { expect(view.getByText(command)).toBeTruthy() }) - it('a non-terminal call keeps the args-JSON text body', () => { + it('a background terminal_send keeps the args-JSON text body', () => { const view = render() + call: { name: 'terminal_send', argsRaw: JSON.stringify({ sessionId: 'pty-3', text: 'make', run_in_background: true }) }, + }))} toolName="terminal_send" />) toggleRow(view) - expect(view.getByText(/"command"/)).toBeTruthy() + expect(view.getByText(/"sessionId"/)).toBeTruthy() + expect(view.container.querySelector('[data-terminal]')).toBeNull() }) it('malformed empty args use the generic output body', () => { @@ -527,45 +555,112 @@ describe('BashRow terminal card', () => { expect(view.container.querySelectorAll('[class*="_ioText_"]')[1]?.textContent).toBe(output) }) - it.each([undefined, 'dispatch-parent'])('expands a background launch and its acknowledgement without reporting a job exit (parent: %s)', (parentCallId) => { - const command = 'pnpm --filter @deepseek-ai/dsh-client-ui-tool run build --watch --verbose' - const args = { command, description: 'Wait', run_in_background: true } - const argsRaw = JSON.stringify(args) + it.each([ + ['bash', undefined], ['bash', 'dispatch-parent'], ['pwsh', undefined], ['pwsh', 'dispatch-parent'], + ] as const)('keeps the %s background command open after launch without reporting job status (parent: %s)', (name, parentCallId) => { + const command = ` node worker.mjs --trace=${'background-worker-'.repeat(24)}\n\tprintf "done"\n` + const argsRaw = shellArgs({ command, description: 'Wait', workdir: '/workspace/app', run_in_background: true }) const inspect = vi.fn() const parent = parentCallId === undefined ? {} : { parentCallId } - const view = render() - const row = view.container.querySelector('[data-sample="bash"]')! + const renderRow = (block: StartedToolCall | ToolResultNode) => { + const props = { ...rowProps(block), toolName: name, inspect } + return name === 'bash' ? : + } + const view = render(renderRow(running({ name, argsRaw, ...parent }))) + const row = view.getByRole('button') expect(view.getByText('Wait')).toBeTruthy() expect(row.getAttribute('aria-expanded')).toBe('false') - - fireEvent.keyDown(row, { key: 'Enter' }) - - expect(row.getAttribute('aria-expanded')).toBe('true') - expect(view.container.querySelector('[data-command-text]')?.textContent) - .toBe(JSON.stringify(args, null, 2)) - expect(view.container.querySelector('[data-command-text]')?.hasAttribute('tabindex')).toBe(false) - expect(view.queryByText('输出')).toBeNull() - expect(view.container.querySelector('[data-terminal]')).toBeNull() + if (name === 'bash') fireEvent.keyDown(row, { key: 'Enter' }) + else fireEvent.click(row) + const terminal = view.container.querySelector('[data-terminal]') + const firstCommand = view.container.querySelector('[data-command-text]') + expect(terminal).not.toBeNull() + expect(runStateOf(view.container)).toBe('ongoing') + expect([...view.container.querySelectorAll('[data-command-text]')].map(line => line.textContent)) + .toEqual(command.slice(0, -1).split('\n')) + expect(view.getByText('app')).toBeTruthy() + expect(view.queryByText('无输出')).toBeNull() + expect(view.queryByRole('button', { name: '复制' })).toBeNull() const acknowledgement = 'started background job job-1' - view.rerender() - + const result = settled({ call: { name, argsRaw }, ...parent, meta: { cwd: '/workspace/app' }, + content: [{ type: 'text', text: acknowledgement }] }) + view.rerender(renderRow(result)) expect(row.getAttribute('aria-expanded')).toBe('true') - expect(view.container.querySelector('[data-command-text]')?.textContent) - .toBe(JSON.stringify(args, null, 2)) - expect(view.getByText(acknowledgement)).toBeTruthy() - expect(view.container.querySelector('[data-terminal], [data-state="done"]')).toBeNull() + expect(view.container.querySelector('[data-terminal]')).toBe(terminal) + expect(view.container.querySelector('[data-command-text]')).toBe(firstCommand) + expect(view.container.querySelector('[data-terminal] [data-state], [data-running]')).toBeNull() + expect(view.queryByText('运行中')).toBeNull() + expect(view.queryByText('已完成')).toBeNull() + expect(view.queryByText('无输出')).toBeNull() + expect(view.queryByText(acknowledgement)).toBeNull() + expect(view.queryByRole('button', { name: '复制' })).toBeNull() + expect(result.call?.argsRaw).toBe(argsRaw) + expect(result.content).toEqual([{ type: 'text', text: acknowledgement }]) fireEvent.click(view.getByRole('button', { name: t('row.inspect') })) expect(inspect).toHaveBeenCalledOnce() fireEvent.click(row) expect(row.getAttribute('aria-expanded')).toBe('false') + if (name === 'bash') fireEvent.keyDown(row, { key: ' ' }) + else fireEvent.click(row) + expect(row.getAttribute('aria-expanded')).toBe('true') + expect(view.container.querySelector('[data-command-text]')?.textContent).toBe(command.split('\n')[0]) expect(view.queryByText(acknowledgement)).toBeNull() }) + it.each([ + ['bash', false], ['bash', true], ['pwsh', false], ['pwsh', true], + ] as const)('keeps %s launch failures on the original generic body (interrupted: %s)', (name, interrupted) => { + const argsRaw = shellArgs({ run_in_background: true }) + const renderRow = (block: StartedToolCall | ToolResultNode) => { + const props = { ...rowProps(block), toolName: name } + return name === 'bash' ? : + } + const view = render(renderRow(running({ name, argsRaw }))) + const row = view.getByRole('button') + fireEvent.click(row) + view.rerender(renderRow(settled({ call: { name, argsRaw }, isError: true, + ...interrupted ? { error: { name: 'Interrupted', code: 'interrupted' } } : {}, + content: [{ type: 'text', text: 'Launch did not finish' }] }))) + expect(row.getAttribute('aria-expanded')).toBe('true') + expect(view.container.querySelector('[data-terminal]')).toBeNull() + expect(view.getByText(/"run_in_background": true/)).toBeTruthy() + expect(view.getAllByText('Launch did not finish').length).toBeGreaterThan(0) + expect(view.container.querySelector(`[data-state="${interrupted ? 'stopped' : 'error'}"]`)).not.toBeNull() + }) + + it.each(['BashRow', 'GenericToolCard'] as const)('%s preserves authored command lines and copies only raw output', async (renderer) => { + const clipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + const writeText = vi.fn().mockResolvedValue(undefined) + const lines = [` node worker.mjs --trace=${'command-token-'.repeat(24)} `, '', '\tprintf "done"'] + const command = `${lines.join('\n')}\n` + const output = ' first\tcolumn\n\nlast \n' + const props = rowProps(settled({ + call: { name: 'bash', argsRaw: shellArgs({ command }) }, + content: [{ type: 'text', text: `${output}\n[exit code: 0]` }], + })) + vi.useFakeTimers() + try { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const view = render(renderer === 'BashRow' ? : ) + fireEvent.click(view.container.querySelector('[data-expandable]')!) + expect([...view.container.querySelectorAll('[data-command-text]')].map(line => line.textContent)).toEqual(lines) + expect(view.getByText(' first\tcolumn', RAW)).toBeTruthy() + expect(view.getByText('last ', RAW)).toBeTruthy() + await act(async () => { fireEvent.click(view.getByRole('button', { name: '复制' })) }) + expect(writeText).toHaveBeenCalledOnce() + expect(writeText).toHaveBeenCalledWith(output) + await act(async () => { await vi.runOnlyPendingTimersAsync() }) + } finally { + cleanup() + vi.clearAllTimers() + vi.useRealTimers() + if (clipboard === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', clipboard) + } + }) + it('keeps malformed shell arguments collapsed even when background execution is requested', () => { const view = render( null, inspectCall: vi.fn(), forkAt: vi.fn(), loadImage: vi.fn(() => Promise.reject(new Error('not used'))), diff --git a/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts b/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts index 399c588713..33cf6d52be 100644 --- a/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts +++ b/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts @@ -1,9 +1,4 @@ -/** - * The one-line contract of the ToolRow summary line as CSS text. jsdom has no - * layout, so the rendering specs (chat-tool-row.spec.tsx) can pin which spans - * exist but not whether a narrow row still fits on one line; these read the - * declarations the layout depends on. - */ +/** Consumer-local command wrapping and collapsed summary declarations; jsdom has no layout. */ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' @@ -12,15 +7,31 @@ const css = readFileSync(fileURLToPath(new URL('../src/client/tool/components/To /** Declarations only: the sheet's prose names the properties it explains. */ const declarationText = css.replace(/\/\*[\s\S]*?\*\//g, ' ') -function declarations(selector: string): string[] { +function declarations(selector: string, sheet = declarationText): string[] { // Anchored at a rule boundary: an unanchored match would silently read a // compound rule that merely contains the selector (`.root:hover .summarySuffix`) // if one ever lands above the base rule. - const rule = new RegExp(`(?:^|\\})\\s*\\${selector}\\s*\\{([^{}]*)\\}`).exec(declarationText) - if (rule === null) throw new Error(`ToolRow.module.css has no \`${selector}\` rule`) + const rule = new RegExp(`(?:^|\\})\\s*\\${selector}\\s*\\{([^{}]*)\\}`).exec(sheet) + if (rule === null) throw new Error(`Stylesheet has no \`${selector}\` rule`) return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean) } +describe('expanded command wrapping', () => { + it.each([ + ['../src/client/tool/toolviews/bash-sample.module.css', '.terminal', '--dsl-terminal-command-whitespace'], + ['../src/client/tool/toolviews/bash-sample.module.css', '.commandInput', '--dsl-command-whitespace'], + ['../src/client/tool/components/ToolRow.module.css', '.terminalBody', '--dsl-terminal-command-whitespace'], + ] as const)('%s opts %s into command wrapping only', (path, selector, property) => { + const sheet = readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, ' ') + expect(declarations(selector, sheet)).toContain(`${property}: pre-wrap`) + expect(sheet).not.toContain('--dsl-terminal-line-whitespace') + expect(declarations('.summary', sheet)).toEqual(expect.arrayContaining([ + 'white-space: nowrap', 'overflow: hidden', 'text-overflow: ellipsis', + ])) + }) +}) + describe('ToolRow.module.css summary line', () => { it.each([ ['ToolRow', '../src/client/tool/components/ToolRow.module.css'], diff --git a/packages/client/ui-tool/tsdown.config.ts b/packages/client/ui-tool/tsdown.config.ts index cd27a6e92b..7aa03cebc1 100644 --- a/packages/client/ui-tool/tsdown.config.ts +++ b/packages/client/ui-tool/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-tool', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 153776c82e..6db4c334a3 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -18,8 +18,8 @@ en: 4bd17d05b8385675 zh: 5174e27f754f2a03 /deepseek-ai-dsh-client-ui-trajectory/use-this-package/inspecting-records: - en: e4ba2d9cab2fb258 - zh: 7db041270420b2ba + en: 130d568589ae108e + zh: 2a52a19499e9c0a7 /deepseek-ai-dsh-client-ui-trajectory/use-this-package/the-timing-overview: en: 0f4ee2189324ac06 zh: 12e76fed22c09bc1 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index eaf559d464..6a9fa9698d 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -31,7 +31,7 @@ Open the Trajectory tab in the conversation's view ring to inspect agent activit Calls are identified by their recorded tool name. For `run_code`, the row shows the program description and the inspector opens numbered, highlighted source. The Code tab provides wrapping, a `{}` toggle for the original JSON arguments, and exact source copying, including trailing newlines. Copying the original arguments retains their recorded JSON whitespace. Each newly opened code view takes the last wrapping choice; changing it leaves other open views as they are. Output preserves the recorded text, using a tree for complete JSON objects or arrays. Highlighting uses only an unambiguous TypeScript or Python hint in the recorded tool schema; missing or conflicting hints leave plain source. -Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows both nonzero image and ordinary-file counts beside its text, including attachment-only records. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. +Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. The Trajectory view declares `conversation.trajectory.images`; ledger children call this Slot through the framework `renderSlot` seat, passing image references, display options, and the authorized loader explicitly. Its official `MessageImages` entry calls the shared `conversation.images.content` Factory. A user record shows both nonzero image and ordinary-file counts beside its text, including attachment-only records. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Summary and Preview share one ordered attachment list after the message text, preserving repeated references. Each row shows a contained image thumbnail or file-type icon, the recorded filename (a localized numbered label for unnamed images), and recorded size, type, and image dimensions where available. Zero-byte files retain their size, and truncated filenames expose the full name in a tooltip. Images open the existing lightbox. Raw keeps content-block order and unrendered text, with images and files in initially collapsed disclosures containing their complete recorded fields. diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 2cfc3d7ca3..c37632a926 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -31,7 +31,7 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 调用按已记录的工具名识别。对于 `run_code`,记录行展示程序说明,检查器显示带行号和语法高亮的源码。“代码”页提供换行、查看原始 JSON 参数的 `{}` 切换按钮,以及保留末尾换行的源码原样复制。复制原始参数时保留记录中 JSON 的空白。每个新打开的代码视图采用上次选择的换行状态;切换时,其他已打开视图保持原状。输出保留记录中的文本,完整 JSON 对象或数组使用树形展示。高亮只采用已记录工具 Schema 中明确且无冲突的 TypeScript 或 Python 提示;提示缺失或冲突时显示纯文本源码。 -选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留快照。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久保留的图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁同时显示非零的图片与普通文件数量,仅含附件的记录也显示这些数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 +选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留快照。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久保留的图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。Trajectory 视图声明 `conversation.trajectory.images`;账本子组件通过框架 `renderSlot` 席位调用该 Slot,显式传入图片引用、显示参数及授权 loader。官方 `MessageImages` 条目调用共享的 `conversation.images.content` Factory。用户记录会在文本旁同时显示非零的图片与普通文件数量,仅含附件的记录也显示这些数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 概述与预览共用正文之后的有序附件列表,保留重复引用。每行展示完整缩放的图片缩略图或文件类型图标、已记录的文件名(未命名图片使用本地化的编号名称),以及可用的已记录大小、类型和图片尺寸。零字节文件保留大小显示,截断的文件名通过提示显示完整名称。图片打开现有灯箱。原始内容保留内容块顺序和未渲染文本,图片与文件使用默认折叠的展开项,包含完整的已记录字段。 diff --git a/packages/client/ui-trajectory/locale/en.json b/packages/client/ui-trajectory/locale/en.json new file mode 100644 index 0000000000..f1f5eb2fb6 --- /dev/null +++ b/packages/client/ui-trajectory/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-trajectory" + } +} diff --git a/packages/client/ui-trajectory/locale/zh.json b/packages/client/ui-trajectory/locale/zh.json new file mode 100644 index 0000000000..3591dbed97 --- /dev/null +++ b/packages/client/ui-trajectory/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "运行轨迹", + "description": "按轮次查看消息、工具调用、耗时和 Token 等运行记录。" + } +} diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index f184db05d2..d41c148836 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -55,8 +56,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-compaction": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -68,10 +67,11 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", - "@tanstack/react-virtual": "^3.14.10", + "@tanstack/react-virtual": "3.14.10", "diff": "^9.0.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 33c8ee1b91..4cf15a0847 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -23,9 +23,10 @@ import { writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import type { JsonTreeLabels, JsonTreeProps, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { structuredPatch } from 'diff' import type { - AssistantRequestConfig, ConversationPromptSnapshot, RenderMessageImages, + AssistantRequestConfig, ConversationPromptSnapshot, MessageImageLoader, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock, @@ -392,7 +393,9 @@ export interface TrajectoryTableProps { /** Trajectory locale seat. */ t: TrajectoryTranslate /** Slot-backed durable image renderer shared with the Chat gallery. */ - renderImages: RenderMessageImages + renderSlot: PropsRenderSlots<'conversation.trajectory.images'>['renderSlot'] + /** Session-bound loader for durable message image attachments. */ + loadImage: MessageImageLoader /** Session-global request numbers for the request groups visible in this context. */ requestNumbers?: readonly TrajectoryRequestNumber[] /** Grouped records in display order. */ @@ -1238,12 +1241,14 @@ function recordAttachments( function RecordAttachments({ blocks, preview, - renderImages, + renderSlot, + loadImage, t, }: { blocks: readonly TrajectorySourceBlock[] | undefined preview: boolean - renderImages: RenderMessageImages + renderSlot: PropsRenderSlots<'conversation.trajectory.images'>['renderSlot'] + loadImage: MessageImageLoader t: TrajectoryTranslate }) { const attachments = recordAttachments(blocks, t) @@ -1256,10 +1261,11 @@ function RecordAttachments({ {attachments.map(({ block, index, name, metadata }) => (
  • {block.attachment !== undefined - ? renderImages({ + ? renderSlot('conversation.trajectory.images', { images: [{ attachment: block.attachment, label: name }], align: 'start', thumbnail: true, + loadImage, }) : }
    @@ -1474,14 +1480,16 @@ function ToolOutputBlocks({ error, errorDetail, preview, - renderImages, + renderSlot, + loadImage, }: { blocks: readonly TrajectorySourceBlock[] error: boolean /** Failure name and code preserved beside image-only error content. */ errorDetail?: string | undefined preview: boolean - renderImages: RenderMessageImages + renderSlot: PropsRenderSlots<'conversation.trajectory.images'>['renderSlot'] + loadImage: MessageImageLoader }) { return (
    - {renderImages({ images: [{ attachment: block.attachment }], align: 'start' })} + {renderSlot('conversation.trajectory.images', { images: [{ attachment: block.attachment }], align: 'start', loadImage })}
    ) : block.content !== '' @@ -1514,7 +1522,8 @@ function MarkdownRecordContent({ thinkingExpanded, onThinkingExpandedChange, onOpenCall, - renderImages, + renderSlot, + loadImage, t, }: { record: TableRecord @@ -1523,7 +1532,8 @@ function MarkdownRecordContent({ thinkingExpanded: boolean onThinkingExpandedChange: (expanded: boolean) => void onOpenCall: (callId: string) => void - renderImages: RenderMessageImages + renderSlot: PropsRenderSlots<'conversation.trajectory.images'>['renderSlot'] + loadImage: MessageImageLoader t: TrajectoryTranslate }) { if (record.cell.sourceBlocks?.length && record.cell.sourceBlocks.every(block => @@ -1593,7 +1603,8 @@ function MarkdownRecordContent({
    @@ -1623,7 +1634,8 @@ function MarkdownRecordContent({ t={t} /> )} - +
) } @@ -1692,14 +1704,16 @@ function RecordPayload({ record, direction, preview = false, - renderImages, + renderSlot, + loadImage, stringWrapping, t, }: { record: TableRecord direction: 'input' | 'output' preview?: boolean - renderImages: RenderMessageImages + renderSlot: PropsRenderSlots<'conversation.trajectory.images'>['renderSlot'] + loadImage: MessageImageLoader stringWrapping: JsonTreeProps['stringWrapping'] t: TrajectoryTranslate }) { @@ -1740,7 +1754,8 @@ function RecordPayload({ error={error} errorDetail={error ? value : undefined} preview={preview} - renderImages={renderImages} + renderSlot={renderSlot} + loadImage={loadImage} /> ) } @@ -2070,7 +2085,8 @@ function OverviewSection({ export function TrajectoryTable({ t, stringWrapping, - renderImages, + renderSlot, + loadImage, requestNumbers: sessionRequestNumbers, turns, streamingCells = [], @@ -3294,7 +3310,8 @@ export function TrajectoryTable({ > { activateTab('rendered') }}> {selected.cell.inputDetail && ( { activateTab('input') }}> - + )} {selected.cell.outputDetail && ( { activateTab('output') }}> - + )} { activateTab('schema') }}> @@ -3470,7 +3488,8 @@ export function TrajectoryTable({ {!promptSelected && selected !== undefined && activeTab === 'rendered' && ( + ? : )} {!promptSelected && selected !== undefined && activeTab === 'output' && ( selectedProgram === undefined - ? + ? : )} {!promptSelected && selected !== undefined && activeTab === 'schema' && ( diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 0a4b50c9d4..e36386e819 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { - AssistantBlock, AssistantMessageNode, ConvViewProps, MessageImageLoader, RenderMessageImages, + AssistantBlock, AssistantMessageNode, ConvViewProps, MessageImageLoader, ToolCallBlock, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace, PropsLocale, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' @@ -133,14 +133,10 @@ export function TrajectoryView({ useSession, useTrajectory, useDuration, loadOlder, loadImage, setActualDuration, viewRequest, completeViewRequest, renderSlot, t, jsonStringWrapping, }: ConvViewProps - & PropsRenderSlots<'conversation.trajectory.images'> + & Pick, 'renderSlot'> & InjectFace & PropsLocale<'trajectory'>) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_TURN_IDS) - const renderImages = useCallback( - owner => renderSlot('conversation.trajectory.images', { ...owner, loadImage }), - [loadImage, renderSlot], - ) const [collapsedAssistants, setCollapsedAssistants] = useState>(EMPTY_RECORD_IDS) const [timelineSelection, setTimelineSelection] = useState(null) @@ -554,7 +550,8 @@ export function TrajectoryView({ ...jsonStringWrapping, label: t('record.wrapLines'), }} - renderImages={renderImages} + renderSlot={renderSlot} + loadImage={loadImage} requestNumbers={requestNumbers} turns={timelineTurns} streamingCells={streamingCells} diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index 935a43ff2c..0b438d1aae 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -82,9 +82,7 @@ export function apply(ctx: Context): void { order: 10, locale: NS, label: () => t('view.trajectory'), - children: { - 'conversation.trajectory.images': { kind: 'single', scope: 'session' }, - }, + children: { 'conversation.trajectory.images': { kind: 'single', scope: 'session' } }, inject: (sessionId: SessionId): TrajectoryViewInjected => { const session = ctx.sessions.binding(sessionId)?.session if (session === undefined) { diff --git a/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts index 8a6751993a..6bdac1f28f 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts @@ -50,6 +50,7 @@ describe('tsdown client artifact', () => { ['react', await import('react')], ['react/jsx-runtime', await import('react/jsx-runtime')], ['react-dom', await import('react-dom')], + ['@tanstack/react-virtual', await import('@tanstack/react-virtual')], ['@deepseek-ai/dsh-client-store', await import('@deepseek-ai/dsh-client-store')], ['@deepseek-ai/dsh-client-ui-conversation/client', await import('@deepseek-ai/dsh-client-ui-conversation/client')], ['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')], diff --git a/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx b/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx index fab29e0637..ab9bd12d41 100644 --- a/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx @@ -26,7 +26,7 @@ function Harness({ cell = CELL, getDefault = () => false, setDefault = () => {} }) { return null} t={t} + renderSlot={() => null} loadImage={async () => 'blob:unused'} t={t} stringWrapping={{ label: 'Wrap lines', getDefault, setDefault }} collapsedTurns={new Set()} onToggleTurn={() => {}} collapsedAssistants={new Set()} onToggleAssistant={() => {}} diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 1401a5a67c..188b96bb5f 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -1,17 +1,23 @@ // @vitest-environment jsdom /** Trajectory ledger selection, details, status, and fold behavior. */ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { ComponentProps } from 'react' -import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { useState, type ComponentProps, type ReactNode } from 'react' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import type { MessageImageLoader, MessageImagesOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { TrajectoryTable as LocalizedTrajectoryTable } from '../src/client/TrajectoryTable.tsx' import { deriveTrajectoryLayout, type TrajectoryTurnModel } from '../src/client/layout.ts' import { trajectoryRecordId } from '../src/client/trajectory-record.ts' import { t, tZh } from './locale.client.ts' -const renderImagesStub: RenderMessageImages = ({ images }) => ( +const loadImage: MessageImageLoader = async () => 'blob:trajectory' +const renderNoImages: PropsRenderSlots<'conversation.trajectory.images'>['renderSlot'] = () => null + +const RecordImages = ({ images }: MessageImagesOwnerProps) => (
{images.map((image, index) => ( (
) +async function imageSlotFixture() { + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + await runtime.sessions.add({ id: 'trajectory-images' }) + const session = runtime.sessions.retainFor(runtime.ctx, SessionId('trajectory-images')) + type Seats = PropsRenderSlots<'conversation.trajectory.images'> + const captured: { seats?: Seats; setContent?: (content: ReactNode) => void } = {} + await runtime.root.declare({ 'conversation.trajectory.images': { kind: 'single', scope: 'session' } }, function TestRoot(props) { + const [content, setContent] = useState(null) + captured.seats = props + captured.setContent = setContent + const { SessionProvider } = props + return {content} + }) + runtime.slots.register({ name: 'conversation.trajectory.images' }, RecordImages) + const view = runtime.renderRoot() + const { seats, setContent } = captured + if (seats === undefined || setContent === undefined) throw new Error('trajectory image fixture root did not render') + return { + renderSlot: seats.renderSlot, + render: (content: ReactNode) => { + act(() => { setContent(content) }) + return view + }, + } +} + +type TableProps = ComponentProps + function TrajectoryTable( - props: Omit, 't' | 'renderImages'> - & { renderImages?: RenderMessageImages }, + props: Omit + & Partial>, ) { const inferred: Array[number] & { firstIndex: number }> = [] for (const turn of props.turns) { @@ -58,7 +93,7 @@ function TrajectoryTable( .map(({ firstIndex: _firstIndex, ...request }, index) => ({ ...request, number: index + 1 })) return ( { render( ()} onToggleTurn={() => {}} @@ -963,7 +998,7 @@ describe('TrajectoryTable', () => { }] const table = (status: 'running' | 'complete' | 'error') => ( { it.each([['English', t, 'Attachments', 'Image 1', 'Summary', 'Preview', 'Raw'], ['Chinese', tZh, '附件', '图片 1', '概述', '预览', '原始内容']] as const)( 'keeps mixed attachments ordered and raw fields complete in %s', - (_locale, translate, listLabel, imageLabel, summaryTab, previewTab, rawTab) => { + async (_locale, translate, listLabel, imageLabel, summaryTab, previewTab, rawTab) => { const attachment = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), mediaType: 'image/png' as const, @@ -1132,8 +1167,10 @@ describe('TrajectoryTable', () => { partial: null, runningCalls: [], }, translate) - const renderImages = vi.fn(renderImagesStub) - const view = render() + const { render, renderSlot: slotRenderer } = await imageSlotFixture() + const renderSlot = vi.fn((name: 'conversation.trajectory.images', owner: object) => slotRenderer(name, owner as MessageImagesOwnerProps)) + const view = render() fireEvent.click(view.container.querySelector('[data-trajectory-row-key]')!) for (const tab of [summaryTab, previewTab]) { fireEvent.click(screen.getByRole('tab', { name: tab })) @@ -1142,7 +1179,9 @@ describe('TrajectoryTable', () => { expect(names).toEqual([imageLabel, file.name, imageLabel.replace('1', '2')]) expect(within(list).getByText('TXT · 0B')).toBeTruthy() expect(within(list).getAllByText('image/png · 68B · 40 × 800')).toHaveLength(2) - expect(renderImages).toHaveBeenCalledWith({ images: [{ attachment, label: imageLabel }], align: 'start', thumbnail: true }) + expect(renderSlot).toHaveBeenCalledWith('conversation.trajectory.images', { + images: [{ attachment, label: imageLabel }], align: 'start', thumbnail: true, loadImage, + }) } fireEvent.click(screen.getByRole('tab', { name: rawTab })) expect(screen.queryByTestId('record-images')).toBeNull() @@ -1158,15 +1197,16 @@ describe('TrajectoryTable', () => { }, ) - it('renders a tool-result image through the shared gallery in the Result tab', () => { + it('renders a tool-result image through the shared gallery in the Result tab', async () => { + const { render, renderSlot } = await imageSlotFixture() const attachment = { - attachmentId: `sha256:${'b'.repeat(64)}`, + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), mediaType: 'image/png', bytes: 68, width: 320, height: 640, name: 'capture.png', - } as unknown as NonNullable< + } satisfies NonNullable< NonNullable[number]['attachment'] > const turns: readonly TrajectoryTurnModel[] = [{ @@ -1184,7 +1224,7 @@ describe('TrajectoryTable', () => { }], }] - render() + render() fireEvent.click(screen.getByRole('row', { name: /TOOL/ })) fireEvent.click(screen.getByRole('tab', { name: 'Result' })) @@ -1194,15 +1234,16 @@ describe('TrajectoryTable', () => { .toBe(String(attachment.attachmentId)) }) - it('keeps the failure name beside an image-only error result', () => { + it('keeps the failure name beside an image-only error result', async () => { + const { render, renderSlot } = await imageSlotFixture() const attachment = { - attachmentId: `sha256:${'c'.repeat(64)}`, + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), mediaType: 'image/png', bytes: 68, width: 320, height: 320, name: 'failed.png', - } as unknown as NonNullable< + } satisfies NonNullable< NonNullable[number]['attachment'] > const turns: readonly TrajectoryTurnModel[] = [{ @@ -1221,7 +1262,7 @@ describe('TrajectoryTable', () => { }], }] - render() + render() fireEvent.click(screen.getByRole('row', { name: /TOOL/ })) fireEvent.click(screen.getByRole('tab', { name: 'Result' })) diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index e9557677df..2f5dd4a45e 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -7,7 +7,7 @@ * event ledger with its timing overview, and fiber disposal removes the tab. * Timeline projection and inclusive focus edge cases ride along. */ -import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import type { GlobalStandardProps, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { createElement, type ComponentProps, type FC, type ReactNode } from 'react' @@ -245,7 +245,6 @@ function standaloneProps( completeViewRequest: () => {}, // Image seats the outlet would bake: standalone renders omit the gallery. renderSlot: () => null, - SessionProvider: ({ children }) => <>{children}, loadImage: () => Promise.reject(new Error('standalone views load no images')), // The locale seat the outlet would inject for the declared namespace. t: tZh, @@ -387,13 +386,16 @@ function mount(fixture: Awaited>) { const trajectory = injected as TrajectoryViewInjected return { loadOlder: trajectory.loadOlder, + loadImage: trajectory.loadImage, setActualDuration: trajectory.setActualDuration, useDuration: bindSnapshotSelector(trajectory.hooks.duration), t: tZh, } })() : injected - const viewProps: ConvViewProps = { ...owner, ...standardProps } + const viewProps: ConvViewProps & Pick, 'renderSlot'> = { + ...owner, ...standardProps, renderSlot: () => null, + } return ( = { kind: 'user-question-reply', target: 'chat', match: (event) => { if (event.type !== 'user/message' || !isAppendSurfaceEvent(event)) return null - return replySource(event) === null ? null : { id: String(event.data.id), role: 'start' } + return replySource(event) === null ? null : { id: String(event.data.id), role: 'start', exclusive: true } }, start: (_context, match) => { if (match.event.type !== 'user/message') throw new Error('user-question-reply start requires user/message') diff --git a/packages/client/ui-user-questions/tests/QuestionReplyView.client.spec.tsx b/packages/client/ui-user-questions/tests/QuestionReplyView.client.spec.tsx index faa41d9436..dddc3c5bc9 100644 --- a/packages/client/ui-user-questions/tests/QuestionReplyView.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/QuestionReplyView.client.spec.tsx @@ -18,8 +18,6 @@ function renderView(data: QuestionReplyData) { location: { kind: 'session' }, visibility: 'visible', data, }, openFile: vi.fn(), - openSkill: vi.fn(), - renderMessageImages: () => null, inspectCall: vi.fn(), forkAt: vi.fn(), loadImage: vi.fn(() => Promise.reject(new Error('not used'))), diff --git a/packages/client/ui-user-questions/tests/question-reply.client.spec.ts b/packages/client/ui-user-questions/tests/question-reply.client.spec.ts index f7570515d4..789371b623 100644 --- a/packages/client/ui-user-questions/tests/question-reply.client.spec.ts +++ b/packages/client/ui-user-questions/tests/question-reply.client.spec.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { SessionSeq } from '@deepseek-ai/dsh-session' +import type { ConversationViewNode } from '@deepseek-ai/dsh-client-ui-conversation/client' import { messageDefinition } from '../../ui-chat/src/client/conversation-nodes/message.ts' +import { ConversationNodeAssembler } from '../../ui-conversation/src/client/conversation/assembler.ts' import { en } from '../src/client/locales.ts' import type { QuestionReplyData } from '../src/client/question-reply.ts' import { questionReplyDefinition, replyAnswerValues, replyClipboardText, replyPairsOf } from '../src/client/question-reply.ts' @@ -106,16 +108,33 @@ describe('replyClipboardText', () => { describe('questionReplyDefinition', () => { it('claims only appended user messages carrying the reply source', () => { - expect(questionReplyDefinition.match(message(replySource('answered'), answered))).toEqual({ id: 'msg-1', role: 'start' }) + expect(questionReplyDefinition.match(message(replySource('answered'), answered))) + .toEqual({ id: 'msg-1', role: 'start', exclusive: true }) expect(questionReplyDefinition.match(message({ kind: 'user' }, answered))).toBeNull() expect(questionReplyDefinition.match(message({ kind: 'plugin', plugin: 'user-questions' }, answered))).toBeNull() expect(questionReplyDefinition.match(message(replySource('answered'), answered, null))).toBeNull() }) - it('shares the message id with the generic projection', () => { + it('claims the message the generic projection also matches, so Chat holds one Node for it', () => { const event = message(replySource('answered'), answered) expect(messageDefinition.match(event)).toEqual({ id: 'msg-1', role: 'start' }) - expect(questionReplyDefinition.match(event)).toEqual(messageDefinition.match(event)) + let chat: readonly ConversationViewNode[] = [] + const definitions = [messageDefinition, questionReplyDefinition] + const assembler = new ConversationNodeAssembler( + { entries: () => definitions, fallbackEntry: () => undefined }, + { entries: () => [{ + target: 'chat', + create: () => ({ + empty: chat, + replace: ({ nodes }) => (chat = nodes), + apply: ({ upserts }) => (chat = [...chat, ...upserts]), + }), + }] }, + ) + assembler.activateTarget('chat') + assembler.replaceWindow([{ type: 'event', event }], false) + assembler.flush() + expect(chat.map(node => node.kind)).toEqual(['question-reply']) }) it('projects the outcome from the source and the pairs from the text into its own node', () => { diff --git a/packages/client/ui-user-questions/tsdown.config.ts b/packages/client/ui-user-questions/tsdown.config.ts index a60da00031..7aa03cebc1 100644 --- a/packages/client/ui-user-questions/tsdown.config.ts +++ b/packages/client/ui-user-questions/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-user-questions', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-workflow-run/locale/en.json b/packages/client/ui-workflow-run/locale/en.json new file mode 100644 index 0000000000..ed487bf62e --- /dev/null +++ b/packages/client/ui-workflow-run/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-workflow-run" + } +} diff --git a/packages/client/ui-workflow-run/locale/zh.json b/packages/client/ui-workflow-run/locale/zh.json new file mode 100644 index 0000000000..319be876a6 --- /dev/null +++ b/packages/client/ui-workflow-run/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "工作流运行展示", + "description": "在会话中展示工作流运行记录及其成员详情。" + } +} diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index 79d2efd4fb..a1f452e556 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -44,6 +45,7 @@ "watch": "tsdown --watch" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" @@ -67,7 +69,6 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", "@deepseek-ai/dsh-workflow": "workspace:*", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0" } diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 18d5ff95b8..3fa00ccc54 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -363,12 +363,10 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi useTurnData: () => undefined, useDisclosure: () => { throw new Error('unused') }, useGroupAction: () => { throw new Error('unused') }, - openSkill: vi.fn(), openFile: () => {}, inspectCall: () => {}, forkAt: () => {}, loadImage: () => Promise.reject(new Error('not used')), - renderMessageImages: () => null, fileMentions: () => undefined, openSession, t: makeTranslate(zh), diff --git a/packages/client/ui-workflow-run/tsdown.config.ts b/packages/client/ui-workflow-run/tsdown.config.ts index 87d4cd34c1..7aa03cebc1 100644 --- a/packages/client/ui-workflow-run/tsdown.config.ts +++ b/packages/client/ui-workflow-run/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-workflow-run', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index cf3f7896bd..eaa7774534 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-workspace/use-this-package: - en: b164014ee565be16 - zh: 6a741647932b93e6 + en: d042b4b1dd7e7f56 + zh: c12fa070ef5a02a4 /deepseek-ai-dsh-client-ui-workspace/use-this-package/reordering-and-view-options: en: 8673afc1d72ad077 zh: 9fbb83a4029c5d10 @@ -27,14 +27,14 @@ en: 9eab3bfbaf98c987 zh: 54e7e141dae5c9e8 /deepseek-ai-dsh-client-ui-workspace/use-this-package/managing-sessions: - en: cafc87e8e0ac4058 - zh: 4c35bc5bfa24ba6c + en: e96b714aeb6dcd03 + zh: 75b63aa2cafbb57a /deepseek-ai-dsh-client-ui-workspace/use-this-package/pending-interactions: en: 9fb88260380bf093 zh: 3bace2b96ecd4a82 /deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers: - en: 04956ad6ba31cc86 - zh: 0e5fb72cfa932d25 + en: 68423607516bb58e + zh: 550e5c55cc6a25db /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation: en: 721d5a49fe231191 zh: 3ef1dd720219e00e @@ -42,8 +42,8 @@ en: 868904dbfb5713f8 zh: 225906bfef7398bf /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/session-row-actions: - en: a3b80d0e844d2929 - zh: 85fe5dfb2968b491 + en: 24d196702ca9a5a0 + zh: 351b4896a0217ac1 /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/session-row-actions/packaged-client-plugin: en: 36934671b80b11ae zh: 8e0aea6162e35e77 @@ -54,8 +54,8 @@ en: 83031e7505fc228a zh: 03e17c9428fe29fd /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/hover-cards: - en: 34668e3c2e9c77b7 - zh: c8bb72e29a3f2490 + en: ea89ec5a3ee1f167 + zh: e11f350571bcfff7 /deepseek-ai-dsh-client-ui-workspace/further-exploration: en: 2abbf27abbd71ba4 zh: ace33fa2ea042357 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index e1dbed75c4..f694548654 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -27,7 +27,7 @@ This package lets users browse grouped or flat Session lists, choose a Workspace ## Use this package -Use the sidebar to browse Workspaces and their Sessions, reorder them, and start new ones; use the picker in the Session Intent hero to choose a Workspace for a new session. An open Workspace shows five idle, non-blank Sessions by default. Running Sessions, including parents with running children, remain visible in their ordered positions without using that quota; the selected blank **New Session** is also an extra row until its first prompt. Each **Show more** click reveals up to five more idle Sessions; after the final batch, **Show less** restores the initial rows while keeping running Sessions visible. Closing and reopening the Workspace also restores this folded projection. +Use the sidebar to browse Workspaces and their Sessions, reorder them, and start new ones; use the picker in the Session Intent hero to choose a Workspace for a new session. An open Workspace shows its first non-blank Sessions in list order up to a per-Workspace watermark, initially five. The watermark rises to cover the last Session within the first 50 that is running, has a running child, awaits the user, has an unread completion, or is open in the main view; a Session opened from search raises it to that Session at any position. The watermark is a row count that never falls on its own, so the rows stay visible after a run or notification ends; a reorder that moves a Session past the watermark hides it. Each **Show more** click raises it by five, without the 50-row limit, and reveals the rest when five or fewer remain; after the final batch, **Show less** resets it. Folding a Workspace resets its watermark and those of its nested Workspaces; a reset watermark still covers the qualifying Sessions. The selected blank **New Session** is an extra row outside the watermark until its first prompt. Watermarks are not persisted; any unmount of the grouped tree resets them. History rows without a stored title use the localized unnamed label (未命名 / Untitled), rather than a directory name. The current blank row remains New Session; other blank rows remain hidden. Rename drafts use the stored title, or start empty when unnamed; unnamed rows do not offer title copying. @@ -51,13 +51,15 @@ Collapsed search is one header action beside the view and add actions: activatin ### Managing sessions -The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action reads ordinary cold history normally. When the Host reports that source migration is required, an app-wide Toast offers to open that Session; the action only navigates and does not retry fork. A successful fork uses the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, and Archive are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it. +The final built-in item in a Session row's "..." menu is Copy Session ID. It closes the menu and copies the complete ID directly, without opening a dialog. Clipboard success or failure appears in the global toast; a failed copy can be retried from the menu. Copying does not retain the Session, load its history, or call the Host. + +The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action reads ordinary cold history normally. When the Host reports that source migration is required, an app-wide Toast offers to open that Session; the action only navigates and does not retry fork. A successful fork uses the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, Archive, and Copy Session ID are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it. Archive commits without a confirmation dialog for a quiet Session and retains the Session's account position. A Session with running work is the one case that asks first: the Host refuses the plain archive and names that work, and the sidebar opens a stop-and-archive dialog listing it by family — the turn in progress, running subagents, background jobs, scheduled reminders, each with its labels — with the restore path stated; confirming asks the Host to stop the work the way the stop button does and archives once the archive set is durable, while the stops settle in the background, and cancelling leaves the Session running and visible. View options control visibility through one explicit three-way choice: Hide archived (the default) hides archived Sessions, All conversations (show archived) includes them, and Archived only hides ordinary Sessions and drops Workspaces without an archived Session; in tree grouping, children of a dropped Workspace nest under the nearest shown ancestor. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a notice with an undo action and — while archived rows are hidden — a "filter archived sessions" action that switches the filter to All conversations (show archived); a stop-and-archive shows the same notice under its own wording, and undo restores the Session without resuming the stopped work. Unarchive removes the archive mark without restoring a pin or changing the saved position. An empty list shows a centered glyph-over-text placeholder; the Archived-only view names its own emptiness (No archived sessions yet) and adds a View other sessions text action that switches the filter back to Hide archived. Session update times use the tertiary label color, including on archived rows. A Session title wider than its row is clipped with an ellipsis at rest. Hovering the row scrolls the title to its far edge — the incremented title of a fork, for example — and reveals it without the ellipsis; leaving the row returns the title to its start. -The keyboard reference exposes New Session, Search sessions, Add workspace, Rename session, Fork session, and Archive session. Desktop defaults use the platform's primary modifier, with Mod+K for search; Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves these commands unbound until configured. Button tooltips and Session row menus display the effective bindings. Clicking a menu item targets that row; pressing its shortcut targets the main Session. Desktop and Windows/macOS Web rename use Mod+Alt+G, leaving Mod+Shift+R to browser reload. Rename requires a nonblank main Conversation with no modal obscuring it; a retained Session behind another main panel is unavailable. Other Windows and macOS Desktop bindings also execute from terminal input and modal dialogs; other environments follow the command's region and modal restrictions. Search and rename requests belong to this package; input drafts remain in the browser. The directory picker rejects another open while selection or Workspace adoption is pending. Fork captures the retained main-view Session, for which navigation has requested history opening, and keeps automatic migration. It uses the row action's Host operation to select its last completed turn, without loading older Client history. Missing or blank Sessions are unavailable; the Host rejects a source with no completed turn. A rejected shortcut fork preserves the current selection, displays a localized notification, and can be retried; unexpected failures also retain diagnostic logging. +The keyboard reference exposes New Session, Search sessions, Add workspace, Rename session, Fork session, and Archive session. Desktop defaults use the platform's primary modifier, with Mod+K for search; Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves these commands unbound until configured. Button tooltips and Session row menus display the effective bindings. Clicking a menu item targets that row; pressing its shortcut targets the main Session. Desktop and Windows/macOS Web rename use Mod+Alt+G, leaving Mod+Shift+R to browser reload. Rename requires a nonblank main Conversation with no modal obscuring it; a retained Session behind another main panel is unavailable. Other Windows and macOS Desktop bindings also execute from terminal input and modal dialogs; other environments follow the command's region and modal restrictions. Search and rename requests belong to this package; input drafts remain in the browser. The directory picker rejects another open while selection or Workspace adoption is pending. Fork captures the retained main-view Session, for which navigation has requested history opening, and keeps automatic migration. It uses the row action's Host operation to select its last completed turn, without loading older Client history. Missing or blank Sessions are unavailable; the Host rejects a source with no completed turn. A rejected shortcut fork preserves the current selection, displays a localized notification, and can be retried; unexpected failures also retain diagnostic logging. Its notification shares the frame-wide Workspace notice channel: only the latest notice remains visible, and removing the browsing region does not dismiss it. ### Pending interactions @@ -69,11 +71,11 @@ Grouped and flat Session rows show a clock mark when the Session has active sche ----- -`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?)` opens a Workspace blank only if no later navigation has superseded the request; an explicit New Session creates a fresh Session. `forkSession(id)` creates the child without navigating or superseding a pending navigation. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel. +`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?, options?)` opens a Workspace blank only if no later navigation has superseded the request; an explicit New Session creates a fresh Session. `forkSession(id)` creates the child without navigating or superseding a pending navigation. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel. -Navigation and startup restoration obtain the selected Session's projections from its `follow`; neither issues a separate projection refresh for that Session or its parent. +Navigation and startup restoration obtain the selected Session's projections from its `follow`; neither issues a separate projection refresh for that Session or its parent, except that restoring a saved blank first reloads that Session's projections, because listed values are cached hints that can lag its log after an unclean Host exit. -Workspace reopening and explicit draft preparation try to acquire the first eligible blank in catalog order; startup restoration tries the saved blank. If that writer is held, navigation creates a new Session without trying other blanks. Other acquisition failures abort the request: an explicit New Session or hero Workspace pick shows the failure as a transient notice quoting the Host's code and message (for example a preset that fails to mount) or, for a failure that is not a Host refusal, that failure's own message; a request a later navigation or owner disposal superseded raises no notice, and startup restoration reports only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection. +Workspace reopening and explicit draft preparation name the first eligible blank in catalog order when they ask the Host for a Session; startup restoration names the saved blank. The Host reuses the named blank under the requested preset, or under its default when the request names none, and creates another Session when the blank has started, left the Workspace, or is held by another writer; navigation opens the returned Session without trying other blanks. `startSession` with draft options, `openWorkspace`, and `connectWorkspace` accept `{ agentPreset }` for a flow that knows its preset before it starts, such as the creator entry; a connection joins the latest in-flight connection to its Workspace only when both request the same preset, so the latest request always reaches the Host. Startup restoration passes the preset the saved blank's log recorded, so the draft keeps that preset; when the Host reports the preset as missing (`agent-preset/not-found`) or failing to activate (`agent-preset/invalid`), restoration asks again without a preset and the draft continues under the Host default. Other failures abort the request: an explicit New Session or hero Workspace pick shows the failure as a transient notice quoting the Host's code and message (for example a preset that fails to mount) or, for a failure that is not a Host refusal, that failure's own message; a request a later navigation or owner disposal superseded raises no notice, and startup restoration reports only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection. Once both Workspace and Session startup baselines are ready, an empty installation calls `workspaces.initializeDefault` and creates or reuses its blank Session. The composer becomes editable when that Session is selected; no message is submitted automatically. Later navigation or owner disposal prevents startup from selecting its result. Ineligible first use leaves the folder picker available without an error. Default Workspace creation failure shows a transient notice directing the user to Choose workspace, and is not retried until the next startup. Session creation failures use the ordinary restoration error handling. The registered Workspace remains available if Session creation or later submission fails. @@ -97,10 +99,12 @@ Each registration declares a **directory-flow child hole** (`single` kind: `conv ### Session row actions -The Session row's "..." menu and its hover buttons are two `list` slots declared by the WorkspaceBrowser entry: `sidebar.workspaces.session.menu.item` and `sidebar.workspaces.session.row.action`. Every menu row and every hover button is an entry, this package's own actions included: `apply` registers `pin` (menu 100, button 200), `rename` (200), `fork` (300), and `archive` (menu 400, button 100) the way a client plugin registers its actions, so a plugin action lands wherever its `order` says, and reusing a shipped id at another `priority` shadows that action. +The Session row's "..." menu and its hover buttons are two `list` slots declared by the WorkspaceBrowser entry: `sidebar.workspaces.session.menu.item` and `sidebar.workspaces.session.row.action`. Every menu row and every hover button is an entry, this package's own actions included: `apply` registers `pin` (menu 100, button 200), `rename` (200), `fork` (300), `archive` (menu 400, button 100), and `session-id` (450) the way a client plugin registers its actions, so a plugin action lands wherever its `order` says, and reusing a shipped id at another `priority` shadows that action. An entry receives only the row identity (`sessionId`, `displayTitle`) and owns everything else. It reads the Host state it cares about through its own injected hooks (the pin and archive sets, as Sets derived once per Workspace snapshot), decides its own visibility (pin renders nothing on an archived row, because the Host keeps the two sets exclusive), carries its whole behavior in its registration's `inject` face (a pin also fronts the Session in its saved orders; an archive raises the notice), and raises its own surfaces: the rename dialog, the stop-and-archive dialog, and the row-action notice are this package's `shell.overlay` entries, fed by the requests the actions inject. A menu entry renders one `role="menuitem"` button (this package's rows use `MenuItemButton` from ui-primitives, which adds the host styling and `separatorBefore` for a group start; the hairline comes and goes with the row) and dismisses the menu through the slot-level `useMenuOpenState` hook, the menu's own open state bound from the row's render occurrence; a hover button entry renders one icon button, and the strip keeps its clicks from opening the row. The browser passes no action callbacks to its rows; its remaining verbs are the search results' restore button and the title double-click, which raises the same rename request. +The existing notice overlay dispatches `workspace.toast` by notice kind. Keyed registrations inject localized display mappings and their own actions; only archive notices subscribe to the viewing filter. They render the package-local `workspace.toast.content` Factory, which reuses the Toast primitive once for all presentations. The existing notice state, display sequence, latest-notice replacement, and dismissal remain shared; locale or filter changes do not restart the same notice's timer. + #### Packaged client plugin Declare `ui-workspace`, `ui-slots`, `ui-renderer`, `client-locale`, and `ui-primitives` as browser/type development dependencies according to the Client dependency policy. The type-only `ui-workspace/client` import loads this package's `SlotMap` declaration; without it, an independently compiled plugin does not know the slot keys. Keep the Component at module scope and own visible copy in the contributing package's locale namespace. @@ -178,7 +182,7 @@ Row motion belongs to [AnimatedRows](src/client/rows/AnimatedRows.tsx). It measu ### Hover cards -Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. A Session card also renders its `sidebar.session.row.hover` seat between the relative time and the trailing status lines whenever the card is open, independent of the row's own state. +Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. A Session card also renders its `sidebar.session.row.hover` seat between the relative time and the trailing status lines whenever the card is open, independent of the row's own state. The first Workspace or Session card in a physical list waits 800ms; after a card is displayed, moving to another Workspace or Session in the same list replaces it immediately. The list and its active portaled card form one pointer interaction: entering the card to read or copy preserves immediate handoff to a neighboring Workspace or Session. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle, with no separate group timer. Leaving a list with no active card resets the dwell immediately. Small action tooltips coexist with the right-side hover card; pin, archive, Workspace new-session, and search-result unarchive tooltips use portals to stay above it. Hovering actions, including More, preserves the card; opening the More menu or starting a Session drag dismisses it. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index a1e1a0fd5a..920562c4e7 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" ## 使用本包 -用侧边栏浏览 Workspace 及其 Session、重排它们并新建会话;在 Session Intent 主视觉区用选择器为新会话选择 Workspace。打开的 Workspace 默认显示五条空闲的非空白 Session。正在运行的 Session(包括有子会话正在运行的父会话)始终按原顺序显示,不占用这五条配额;当前选中的空白**新会话**在首条提示词落地前也作为额外行。每次点击**展开其余**最多再显示五条空闲 Session;全部显示后,**收起**恢复初始行数,但仍显示正在运行的 Session。关闭再打开 Workspace 也会恢复该折叠投影。 +用侧边栏浏览 Workspace 及其 Session、重排它们并新建会话;在 Session Intent 主视觉区用选择器为新会话选择 Workspace。打开的 Workspace 按列表顺序显示前面的非空白 Session,直到该 Workspace 自己的水位线,水位线初始为五。水位线会升高,以覆盖前 50 条中最后一条正在运行、有子会话正在运行、等待用户、有未读完成提醒或在主视图中打开的 Session;从搜索打开的 Session 会把水位线升高到该 Session,无论其位置。水位线是一个行数,不会自行回落,因此运行或通知结束后这些行仍保持可见;重排若把某个 Session 移到水位线之后,该 Session 会被隐藏。每次点击**展开其余**将水位线升高五条,不受 50 行限制;剩余五条或更少时显示全部;最后一批显示后,**收起**重置水位线。折叠 Workspace 会重置其水位线及其嵌套 Workspace 的水位线;重置后的水位线仍覆盖符合条件的 Session。当前选中的空白**新会话**在首条提示词落地前是水位线之外的额外行。水位线不持久化;分组树的任何卸载都会重置水位线。 未保存标题的历史会话显示本地化名称「未命名 / Untitled」,不使用目录名兜底。当前空会话仍显示「新会话」,其他空会话仍隐藏。重命名草稿使用已保存标题,无标题时留空;无标题行不提供标题复制。 @@ -51,13 +51,15 @@ kind: "package-reference" ### 管理会话 -Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。Rename 使用临时 `workspaceOperation` reference,并等待首次历史打开。行内 Fork 正常读取普通冷历史。Host 报告源会话需要迁移时,应用级 Toast 提供打开该会话的操作;该操作仅导航,不重试 fork。成功的 fork 在源会话最后一个已完成轮次处创建分支,通过 Session Controller 递增继承的持久化标题,不 retain 子会话、不打开其历史,也不改变选择。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。Pin、Rename、Fork、Archive 本身就是 `sidebar.workspaces.session.menu.item` 列表的条目(pin 与 archive 同时也是 `sidebar.workspaces.session.row.action` 的条目),因此客户端插件的 action 由其 `order` 决定落在哪个位置。 +Session 行“...”菜单的最后一个内置项为“复制 Session ID”。点击后关闭菜单并直接复制完整 ID,不打开对话框。复制成功或失败通过全局提示反馈;失败时可从菜单重试。复制不会 retain 会话、加载历史或调用 Host。 + +Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。Rename 使用临时 `workspaceOperation` reference,并等待首次历史打开。行内 Fork 正常读取普通冷历史。Host 报告源会话需要迁移时,应用级 Toast 提供打开该会话的操作;该操作仅导航,不重试 fork。成功的 fork 在源会话最后一个已完成轮次处创建分支,通过 Session Controller 递增继承的持久化标题,不 retain 子会话、不打开其历史,也不改变选择。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。Pin、Rename、Fork、Archive 和复制 Session ID 本身就是 `sidebar.workspaces.session.menu.item` 列表的条目(pin 与 archive 同时也是 `sidebar.workspaces.session.row.action` 的条目),因此客户端插件的 action 由其 `order` 决定落在哪个位置。 对静止的 Session,Archive 不经确认对话框直接提交,并保留 Session 的记账位置。仍有工作在跑的 Session 是唯一会先询问的情形:Host 拒绝普通归档并列出这些工作,侧栏随即打开"停止并归档"对话框,按族列出——进行中的回合、运行中的子代理、后台任务、定时提醒,各带名称——并写明恢复路径;确认后请 Host 按停止按钮同样的方式停止这些工作,归档集合持久化后即完成归档,停止在后台收敛;取消则让 Session 继续运行并保持可见。视图选项以一组显式三选一控制显隐:隐藏已归档(默认项)隐藏已归档 Session,全部对话(显示已归档)将其纳入列表,仅显示已归档则隐藏普通 Session,并丢弃没有归档 Session 的 Workspace;树形分组下,被丢弃 Workspace 的子级挂到最近一个仍显示的祖先下。可见的归档行置灰,并提供无障碍说明,告知取消归档后才能打开;Rename、Fork 与取消归档仍然可用。归档成功后的提示提供"撤销"动作,并在归档行仍被隐藏时附带"筛选已归档会话"动作,后者直接把筛选切到全部对话(显示已归档);停止并归档显示同样的提示但措辞不同,撤销只恢复 Session,不会让被停止的工作继续。取消归档移除归档标记,但不恢复置顶,也不改变保存的位置。列表为空时显示居中的"图标在上、文字在下"占位;仅显示已归档视图用自己的文案(暂无已归档会话),并附"查看其他会话"文字按钮,点击把筛选切回隐藏已归档。 会话更新时间使用 tertiary 文本色,包括已归档行。标题宽于所在行时,静止状态以省略号裁切。把指针停在行上,标题会滚动到远端——例如 fork 递增后的标题——并在揭示时不显示省略号;指针离开后标题回到开头。 -快捷键速查提供新建会话、搜索会话、添加工作区、重命名会话、分叉会话和归档会话。桌面默认使用平台的主修饰键,搜索为 Mod+K;Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 在用户配置前不绑定这些命令。按钮提示和会话行菜单显示当前有效绑定。点击菜单项操作该行,按快捷键操作主会话。Desktop 和 Windows/macOS Web 的重命名均使用 Mod+Alt+G,将 Mod+Shift+R 留给浏览器刷新。重命名要求主区域正在显示非空会话,且没有模态对话框遮挡;切换到其他主面板后,后台保留的会话不可作为重命名目标。其他 Windows 和 macOS Desktop 绑定也可从终端输入区域和模态对话框中执行;其他环境遵循命令的区域和模态限制。搜索和重命名的打开请求归本包管理,输入草稿保留在浏览器中。目录选择或工作区接纳尚未结束时,目录选择器拒绝重复打开。分叉捕获主视图已持有并发起历史打开的会话,保留自动迁移;它使用与行菜单相同的 Host 操作选择最近已完成轮次,不读取更早的 Client 历史。没有会话或会话为空时不可用;没有已完成轮次的源会话由 Host 拒绝。快捷键分叉被拒绝时保留当前选择、显示本地化提示,并允许重试;非预期失败还会保留诊断日志。 +快捷键速查提供新建会话、搜索会话、添加工作区、重命名会话、分叉会话和归档会话。桌面默认使用平台的主修饰键,搜索为 Mod+K;Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 在用户配置前不绑定这些命令。按钮提示和会话行菜单显示当前有效绑定。点击菜单项操作该行,按快捷键操作主会话。Desktop 和 Windows/macOS Web 的重命名均使用 Mod+Alt+G,将 Mod+Shift+R 留给浏览器刷新。重命名要求主区域正在显示非空会话,且没有模态对话框遮挡;切换到其他主面板后,后台保留的会话不可作为重命名目标。其他 Windows 和 macOS Desktop 绑定也可从终端输入区域和模态对话框中执行;其他环境遵循命令的区域和模态限制。搜索和重命名的打开请求归本包管理,输入草稿保留在浏览器中。目录选择或工作区接纳尚未结束时,目录选择器拒绝重复打开。分叉捕获主视图已持有并发起历史打开的会话,保留自动迁移;它使用与行菜单相同的 Host 操作选择最近已完成轮次,不读取更早的 Client 历史。没有会话或会话为空时不可用;没有已完成轮次的源会话由 Host 拒绝。快捷键分叉被拒绝时保留当前选择、显示本地化提示,并允许重试;非预期失败还会保留诊断日志。该提示共用应用级 Workspace 通知通道:只显示最新提示,移除浏览区域不会关闭它。 ### 待处理交互 @@ -69,11 +71,11 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** ----- -`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?)` 仅在请求未被后续导航替代时打开工作区空白 Session;显式新会话操作会创建新的 Session。`forkSession(id)` 创建子会话,不导航,也不替代尚未完成的导航。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。 +`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?, options?)` 仅在请求未被后续导航替代时打开工作区空白 Session;显式新会话操作会创建新的 Session。`forkSession(id)` 创建子会话,不导航,也不替代尚未完成的导航。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。 -导航和启动恢复通过所选 Session 的 `follow` 获取投影,不会另行刷新该 Session 或其父会话的投影。 +导航和启动恢复通过所选 Session 的 `follow` 获取投影,不会另行刷新该 Session 或其父会话的投影;例外是恢复已保存的空白会话时先重新加载该 Session 的投影,因为列表中的值是缓存提示,Host 非正常退出后可能落后于其日志。 -重新打开工作区和显式准备草稿尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求:显式新建会话或在 hero 中选择工作区时,失败以短暂提示展示,引用 Host 的错误码和消息(例如 preset 挂载失败),非 Host 拒绝的失败则显示其自身消息;已被后续导航或 owner 销毁取代的请求不弹提示,启动恢复仍只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。 +重新打开工作区和显式准备草稿向 Host 请求 Session 时指明列表中第一个符合条件的空白会话;启动恢复指明已保存的空白会话。Host 以请求的 preset 复用该空白会话,请求未指明 preset 时使用其默认 preset;若该空白会话已开始、已离开该工作区或写锁被其他写者占用,Host 改为新建 Session;导航打开 Host 返回的 Session,不再尝试其他空白会话。带草稿选项的 `startSession`、`openWorkspace` 和 `connectWorkspace` 接受 `{ agentPreset }`,供开始前已确定 preset 的流程使用,例如创造模式入口;一次连接仅在与该工作区最近一次进行中的连接请求相同 preset 时与之合并,因此最新的请求总会到达 Host。启动恢复传入已保存空白会话日志记录的 preset,使草稿保留该 preset;当 Host 报告该 preset 不存在(`agent-preset/not-found`)或无法激活(`agent-preset/invalid`)时,启动恢复改为不带 preset 再请求一次,草稿在 Host 默认 preset 下继续。其他错误会中止请求:显式新建会话或在 hero 中选择工作区时,失败以短暂提示展示,引用 Host 的错误码和消息(例如 preset 挂载失败),非 Host 拒绝的失败则显示其自身消息;已被后续导航或 owner 销毁取代的请求不弹提示,启动恢复仍只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。 Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `workspaces.initializeDefault`,创建或复用其空白 Session。选中该 Session 后输入框才可编辑,不会自动提交消息。后续导航或所属上下文销毁会阻止启动流程选中其结果。不符合首次使用条件时仍可选择文件夹,不显示错误。默认工作区创建失败时显示短暂提示,引导用户通过“选择工作区”选择文件夹,直到下次启动才重试。Session 创建失败沿用普通的恢复错误处理。登记成功的工作区在 Session 创建或后续提交失败时仍然保留。 @@ -97,10 +99,12 @@ Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `works ### Session 行 action -Session 行的 "..." 菜单和行尾悬停按钮是 WorkspaceBrowser 注册项声明的两个 `list` slot:`sidebar.workspaces.session.menu.item` 与 `sidebar.workspaces.session.row.action`。每一个菜单行、每一个悬停按钮都是条目,本包自己的 action 也不例外:`apply` 以客户端插件注册自己 action 的同一方式注册 `pin`(菜单 100、按钮 200)、`rename`(200)、`fork`(300)、`archive`(菜单 400、按钮 100),因此插件 action 落在其 `order` 所指的位置,以另一个 `priority` 复用内置 id 则遮蔽该 action。 +Session 行的 "..." 菜单和行尾悬停按钮是 WorkspaceBrowser 注册项声明的两个 `list` slot:`sidebar.workspaces.session.menu.item` 与 `sidebar.workspaces.session.row.action`。每一个菜单行、每一个悬停按钮都是条目,本包自己的 action 也不例外:`apply` 以客户端插件注册自己 action 的同一方式注册 `pin`(菜单 100、按钮 200)、`rename`(200)、`fork`(300)、`archive`(菜单 400、按钮 100)和 `session-id`(450),因此插件 action 落在其 `order` 所指的位置,以另一个 `priority` 复用内置 id 则遮蔽该 action。 条目只接收行身份(`sessionId`、`displayTitle`),其余一切自己负责:用自己注入的 hook 读自己关心的 Host 状态(置顶与归档集合,以每次 Workspace 快照只派生一次的 Set 形式),自己决定是否显示(Host 规定归档与置顶互斥,所以 pin 在归档行上不渲染),整套行为放在注册项自己的 `inject` face 里(置顶成功后顺带把会话推到保存顺序最前,归档成功后发提示),浮层也自己带——重命名对话框、停止并归档对话框和行 action 的提示是本包注册在 `shell.overlay` 的条目,由 action 注入的请求驱动。菜单条目渲染一个 `role="menuitem"` 的按钮(本包自己的行用 ui-primitives 的 `MenuItemButton`,它带宿主样式,开启新分组的行加 `separatorBefore`,分隔线随行一起出现和消失),并通过 slot 级 `useMenuOpenState` hook(菜单自身的打开状态,从该行的渲染出现处绑定)关闭菜单;悬停按钮条目渲染一个图标按钮,按钮条会拦住点击、不让它打开该行。browser 不再向行传任何 action 回调,它剩下的动作只有搜索结果里的恢复按钮和标题双击,后者发出的是同一个重命名请求。 +现有通知浮层按通知 kind 分派 `workspace.toast`。各 keyed 注册注入本地化展示映射与自己的操作,只有归档通知订阅查看筛选状态。它们渲染包内的 `workspace.toast.content` Factory,所有展示共用一处 Toast primitive。现有通知状态、显示序号、最新提示替换及关闭逻辑仍共享;语言或筛选变化不会重新启动同一条提示的计时。 + #### 打包客户端插件 按照 Client 依赖规则,将 `ui-workspace`、`ui-slots`、`ui-renderer`、`client-locale` 与 `ui-primitives` 声明为浏览器/类型开发依赖。纯类型的 `ui-workspace/client` import 会加载本包的 `SlotMap` 声明;缺少该 import 时,独立编译的插件不会知道这些 slot key。Component 保持模块级稳定身份;用户可见文案由贡献包自己的 locale namespace 持有。 @@ -178,7 +182,7 @@ Workspace 基线就绪后,浏览器持久化的展开状态和 Session 顺序 ### 悬浮卡片 -Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。 Session 卡片在卡片打开期间还会在相对时间与行尾状态行之间渲染 `sidebar.session.row.hover` seat,与行自身状态无关。 +Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。 Session 卡片在卡片打开期间还会在相对时间与行尾状态行之间渲染 `sidebar.session.row.hover` seat,与行自身状态无关。同一物理列表中的首张 Workspace 或 Session 卡片等待 800ms;实际显示一张后,移到该列表的其他 Workspace 或 Session 会立即替换卡片。列表与其当前活动的 portal 卡片构成一次指针交互:进入卡片阅读或复制后,移到相邻 Workspace 或 Session 仍会立即切换。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置,不另设分组计时器。列表没有活动卡片时,指针离开便立即重置等待。小型操作提示与右侧悬浮卡片同时显示;置顶、归档、Workspace 新建会话及搜索结果取消归档的提示通过 portal 保持在卡片上层。悬停操作按钮(包括「更多」)保留卡片;打开「更多」菜单或开始拖拽 Session 会关闭卡片。 diff --git a/packages/client/ui-workspace/locale/en.json b/packages/client/ui-workspace/locale/en.json new file mode 100644 index 0000000000..077d51787e --- /dev/null +++ b/packages/client/ui-workspace/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "ui-workspace" + } +} diff --git a/packages/client/ui-workspace/locale/zh.json b/packages/client/ui-workspace/locale/zh.json new file mode 100644 index 0000000000..d61486204e --- /dev/null +++ b/packages/client/ui-workspace/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "工作区选择界面", + "description": "在侧栏和新会话页面中选择工作区。" + } +} diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index f062d8392c..c2d44e8939 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-alpha.1", + "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" }, @@ -23,7 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -55,6 +56,7 @@ "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", @@ -75,13 +77,13 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/dsh-util-workspace-path": "workspace:*", - "@types/react": "~18.3.1", "clsx": "^2.0.0", "react": "^18.2.0", "@deepseek-ai/dsh-client-shortcuts": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 568368dc63..d00b3658d8 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -26,8 +26,8 @@ * row of a Session's "..." menu is an entry of * `sidebar.workspaces.session.menu.item`, and every hover button at the row's * end is an entry of `sidebar.workspaces.session.row.action`. The shipped - * actions — pin, rename, fork, archive — are ordinary entries this package - * registers from `apply`, each carrying its own behavior in its own inject + * actions — pin, rename, fork, archive, copy ID — are ordinary entries + * registered from `apply`, each carrying its own behavior in its own inject * face and reading its own Host state through hooks that face injects, so a * client plugin's action lands beside them by `order` and needs nothing from * the browser beyond the row identity. @@ -53,6 +53,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' import type { WorkspaceShortcutState } from '../shortcuts.ts' import type { StartSessionOptions } from '../navigation.ts' +import type {} from './toast.ts' import type { createWorkspaceViewStore } from '../stores.ts' /** @@ -136,8 +137,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** * The rows of one Session's "..." menu, in ascending `order`. ui-workspace * registers the shipped rows here — `pin` (100), `rename` (200), `fork` - * (300), `archive` (400) — so a plugin row is placed by its own `order` - * among them. Use a package-namespaced `id`; reusing a shipped id at + * (300), `archive` (400), and `session-id` (450) — so a plugin row is placed + * by its own `order` among them. Use a package-namespaced `id`; reusing a shipped id at * another `priority` shadows that row. Each entry renders one * `role="menuitem"` `
) } diff --git a/packages/client/ui-workspace/src/client/session-actions/ArchiveSession.tsx b/packages/client/ui-workspace/src/client/session-actions/ArchiveSession.tsx index 27a0df9313..aaef1991ad 100644 --- a/packages/client/ui-workspace/src/client/session-actions/ArchiveSession.tsx +++ b/packages/client/ui-workspace/src/client/session-actions/ArchiveSession.tsx @@ -58,7 +58,7 @@ export function ArchiveSessionRowButton({ }: SessionRowActionProps) { const archived = useArchived(set => set.has(sessionId)) return ( - +