Merge master into feat/experimental-dsh-cua
This commit is contained in:
commit
1b781d683b
1361 files changed
+19617
-4073
No files matched your search
+8
-8
@@ -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
|
||||
|
||||
+5
-5
@@ -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 `<executable-stem>-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-<platform>-<arch>` 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-<platform>-<arch>` 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<repository-version>` 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<repository-version>` 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<repository-version>` 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
|
||||
|
||||
|
||||
+5
-5
@@ -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) 及其已安装依赖树位于 `<executable-stem>-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-<platform>-<arch>` 写入 `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-<platform>-<arch>` 写入 `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<repository-version>` 标签流水线,构建一个 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<repository-version>` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `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<repository-version>` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `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)拥有缓存和启用行为的说明。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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)。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
|
||||
+1
-1
@@ -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 的总体积减少,才将库纳入基座。
|
||||
|
||||
请求只有两种提供方:
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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-<platform>-<arch>`. 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.
|
||||
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ SDK 用户通过 profile 自定义插件。`dsh plugin --profile <name> ...` 管
|
||||
|
||||
### 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-<platform>-<arch>`。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 载体。
|
||||
|
||||
|
||||
+10
-10
@@ -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
|
||||
|
||||
+8
-7
@@ -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.
|
||||
|
||||
<a id="terminal-card"></a>
|
||||
### 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 |
|
||||
|
||||
+8
-7
@@ -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。
|
||||
|
||||
<a id="terminal-card"></a>
|
||||
### 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 |
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@
|
||||
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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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 条件导出仍是不同入口;运行时解析不会合并包的双重实现。
|
||||
|
||||
|
||||
+22
@@ -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
|
||||
+54
@@ -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.
|
||||
+54
@@ -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 的生命周期和所有权不同;可复用实现不拥有它出现的所有位置。
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 引用对齐可能遍历输出而不调用匹配器。性能证据是准确的调度次数和查询偏移,语义证据是刷新结果与全新计算器的等价性。本机制不需要浏览器端到端测试或耗时基准测试。
|
||||
+22
@@ -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
|
||||
+29
@@ -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.
|
||||
+29
@@ -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 状态。
|
||||
+19
@@ -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
|
||||
@@ -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.
|
||||
@@ -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` 不变;装配回归的事件用例使用明确标识的构造数据。
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 在父委派获准后保留自己的权限系统。
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <verified-base-ref> --head <verified-head-ref>` 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 `<img>`: 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -150,6 +150,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 +163,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
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
/deepseek-harness-desktop:
|
||||
en: 001b7d11c237078b
|
||||
zh: eda6b580dc224c7f
|
||||
en: 5b8518fd5e0030ef
|
||||
zh: 02efee59c39647dc
|
||||
/deepseek-harness-desktop/terminal-command:
|
||||
en: bef92953c237363f
|
||||
zh: d21d6effd21b8c00
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 069fdfd72d2ea26a
|
||||
zh: 76ae549d43c99bb4
|
||||
/deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies:
|
||||
en: 0f9b6c1d6761d108
|
||||
zh: 77127063f244c361
|
||||
en: 1a3023b9ca699757
|
||||
zh: 45ebed9f64fa4e32
|
||||
/deepseek-harness-desktop/bundled-command-runtime:
|
||||
en: 09f0b75af9f0ed14
|
||||
zh: 60bbc2e67d5e87a8
|
||||
@@ -51,8 +51,8 @@
|
||||
en: 5f05072ec82cc62d
|
||||
zh: a3d63b58d1ef2db7
|
||||
/deepseek-harness-desktop/package/runtime-file-selection:
|
||||
en: e7c1eb8e78fe46e3
|
||||
zh: b7ea497dd75be524
|
||||
en: 66898d7b99807291
|
||||
zh: dfbbbb607ad2709f
|
||||
/deepseek-harness-desktop/package/upload-updates:
|
||||
en: f48baa680e841b0d
|
||||
zh: 25ded1aeaac6dad1
|
||||
@@ -66,8 +66,8 @@
|
||||
en: 64836513eb7eda6e
|
||||
zh: 6ebb9184a7aa51de
|
||||
/deepseek-harness-desktop/package/desktop-runtime-preparation:
|
||||
en: 32ef30fc38462109
|
||||
zh: 093b1b57d084f5bb
|
||||
en: 9a71990bee9867a7
|
||||
zh: 76c5c1fdff6e6908
|
||||
/deepseek-harness-desktop/updates:
|
||||
en: 41d0f50eb7173342
|
||||
zh: ce940ef3cbcc6fa5
|
||||
|
||||
@@ -12,7 +12,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.
|
||||
|
||||
@@ -70,7 +70,9 @@ The payload follows the Desktop release. `runtime.json` records the Desktop vers
|
||||
|
||||
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/<target>/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/<target>/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 |
|
||||
|---|---|---|
|
||||
@@ -272,7 +274,9 @@ The served `time` is the real packing time, so pnpm 11's default 24-hour `minimu
|
||||
|
||||
### 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.
|
||||
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).
|
||||
|
||||
@@ -443,7 +447,7 @@ Desktop [patch policy](scripts/runtime-patch-policy.ts) explicitly classifies ev
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -12,7 +12,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 功能返回栏下方的视口。
|
||||
|
||||
@@ -72,7 +72,9 @@ Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技
|
||||
|
||||
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/<target>/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/<target>/runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。
|
||||
|
||||
Desktop/SDK 共享运行时构建器在复制或签名产物前,裁剪 NumPy 和 pandas 下名为 `tests` 的目录,并移除各分发包 `RECORD` 中对应的记录。它保留 `numpy.testing`、`pandas.testing`、`pandas._testing`、原生扩展和许可证。完整上游测试套件不可用;本机检查验证保留的 testing API、测试目录已移除,以及清单所列文件存在。
|
||||
|
||||
| 决策 | 原因 | 直接结果 |
|
||||
|---|---|---|
|
||||
@@ -274,7 +276,9 @@ pnpm run dev:bundle-registry
|
||||
|
||||
### 运行时文件筛选
|
||||
|
||||
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、捆绑的包管理器或用户安装的插件文件。
|
||||
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。
|
||||
|
||||
@@ -445,7 +449,7 @@ Desktop [补丁策略](scripts/runtime-patch-policy.ts) 将每份工作区补丁
|
||||
|
||||
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 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
|
||||
每条打包命令都会构建仓库,打包以 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;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。
|
||||
|
||||
|
||||
@@ -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 ['**/*'] },
|
||||
]
|
||||
|
||||
@@ -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,7 +68,7 @@
|
||||
"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",
|
||||
|
||||
@@ -135,6 +135,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: ['**/*'] },
|
||||
|
||||
@@ -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: ['**/*'] },
|
||||
],
|
||||
|
||||
@@ -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<string[]> {
|
||||
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<number> {
|
||||
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
|
||||
|
||||
@@ -43,7 +43,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 }
|
||||
@@ -158,7 +158,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
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')))
|
||||
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'), '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')))
|
||||
|
||||
@@ -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<typeof desktopTargetPlatform>
|
||||
|
||||
async function main(): Promise<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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()
|
||||
@@ -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'
|
||||
|
||||
@@ -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<void> {
|
||||
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<typeof setTimeout> | 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,6 +92,15 @@ 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 {
|
||||
@@ -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<string, string>
|
||||
dependencies?: Record<string, string>
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 ?? ''}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
+69
@@ -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<void>} 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')
|
||||
}
|
||||
+2
-28
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -93,9 +93,9 @@ describe('desktop macOS release signature', () => {
|
||||
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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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<typeof import('../scripts/desktop-build-paths.mjs')>(),
|
||||
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<typeof import('node:child_process')>(),
|
||||
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<typeof import('node:fs')>()
|
||||
return { ...actual, cpSync: (...args: Parameters<typeof actual.cpSync>) => {
|
||||
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)
|
||||
})
|
||||
@@ -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<PlatformSpecificBuildOptions>, matcher: FileMatcher) => Promise<ResolvedFileSet[]>
|
||||
}
|
||||
const { AsarPackager } = require('app-builder-lib/out/asar/asarUtil.js') as {
|
||||
AsarPackager: new (packager: object, configuration: object) => { pack(files: ResolvedFileSet[]): Promise<void> }
|
||||
}
|
||||
|
||||
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<string, Buffer>()
|
||||
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)
|
||||
})
|
||||
@@ -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() }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}}')
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<ScrollWor
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { 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<HTMLElement>('[data-conversation-scroll]')!
|
||||
const group = button.closest<HTMLElement>('[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<HTMLElement>('[data-composer-seat]')!
|
||||
const body = [...host.querySelectorAll<HTMLElement>('[data-chat-turn="8"][data-chat-group-part="response"]')].at(-1)!
|
||||
const groups = [...host.querySelectorAll<HTMLElement>('[data-chat-turn="8"][data-chat-group-key]')]
|
||||
const candidates = [...host.querySelectorAll<HTMLElement>('[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<HTMLElement>('[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<undefined>()
|
||||
const finishBlocked = Promise.withResolvers<undefined>()
|
||||
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<undefined>()
|
||||
const promptBlocked = Promise.withResolvers<undefined>()
|
||||
const holdPrompt = async (route: Route): Promise<void> => {
|
||||
promptBlocked.resolve(undefined)
|
||||
await promptGate.promise
|
||||
await route.continue()
|
||||
}
|
||||
await page.route('**/api/session/prompt', holdPrompt)
|
||||
const trace = page.evaluate(({ marker, race }) => new Promise<FoldMotionSample[]>((resolveTrace) => {
|
||||
const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')!
|
||||
const column = host.querySelector<HTMLElement>('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')!
|
||||
const body = [...column.querySelectorAll<HTMLElement>('[data-chat-turn="8"][data-chat-group-part="response"]')].at(-1)!
|
||||
const earlier = [...column.querySelectorAll<HTMLElement>('*')].find(element =>
|
||||
element.children.length === 0 && element.textContent === 'VISIBLE_FOLD_EARLIER_ANCHOR')!
|
||||
const samples: FoldMotionSample[] = []
|
||||
const visibleStep = [...column.querySelectorAll<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[data-chat-turn-spacer]')!.getBoundingClientRect().height,
|
||||
folding: column.querySelector('[data-chat-motion="collapse"]') !== null, raceApplied,
|
||||
composerTop: host.querySelector<HTMLElement>('[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<WebScaffold['whenTurnSettled']> | undefined
|
||||
let samples: Awaited<typeof trace> = []
|
||||
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<void>((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<HTMLElement>('[data-conversation-scroll]')!
|
||||
const column = host.querySelector<HTMLElement>('[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<HTMLElement>('*'))
|
||||
.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<HTMLElement>('[data-chat-turn-spacer]')?.style.height ?? '') || 0,
|
||||
folding: column.querySelector('[data-chat-motion="collapse"]') !== null })
|
||||
const selectPreference = async (title: string, choice: string): Promise<void> => {
|
||||
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<WebScaffold['whenTurnSettled']> | 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<void> => {
|
||||
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<HTMLElement>('[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<ReturnFrame[]> => {
|
||||
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<ReturnFrame[]>((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<HTMLElement>('[data-conversation-scroll]')]
|
||||
.find(element => !element.closest('[inert], [hidden]') && element.checkVisibility())
|
||||
const column = host?.querySelector<HTMLElement>('[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<HTMLElement>('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<HTMLElement>('[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<undefined>(), Promise.withResolvers<undefined>()]
|
||||
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<WebScaffold['whenTurnSettled']> | 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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/** 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<ReturnType<typeof selectionState>>): 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<string[]> {
|
||||
const input = page.locator('[data-composer-input][contenteditable="true"]')
|
||||
await input.click()
|
||||
await page.keyboard.insertText('alpha beta gamma')
|
||||
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))}`)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -96,14 +96,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([])
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -100,6 +100,16 @@ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () =>
|
||||
|
||||
async function snapshot(name: string): Promise<void> {
|
||||
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()
|
||||
|
||||
@@ -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]
|
||||
@@ -2,6 +2,4 @@
|
||||
- menuitem "仅可查看"
|
||||
- menuitem "工作区内修改"
|
||||
- menuitem "完全权限"
|
||||
- menuitem "Auto review EXP":
|
||||
- text: Auto review
|
||||
- superscript: EXP
|
||||
- menuitem "自动审查 EXP"
|
||||
@@ -4,7 +4,7 @@
|
||||
- option "仅可查看"
|
||||
- option "工作区内修改" [selected]
|
||||
- option "完全权限"
|
||||
- option "Auto review EXP":
|
||||
- text: Auto review
|
||||
- option "自动审查 EXP":
|
||||
- text: 自动审查
|
||||
- superscript: EXP
|
||||
- text: 无沙箱运行;每次原生工具调用和 PTC 内层调用前由同一模型进行实验性审查。
|
||||
@@ -3,6 +3,7 @@
|
||||
- menuitem "Rename"
|
||||
- menuitem "Fork session"
|
||||
- menuitem "Archive session"
|
||||
- menuitem "Copy Session ID"
|
||||
- separator
|
||||
- menuitem "Export session"
|
||||
- menuitem "Copy session ID"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,5 +1,5 @@
|
||||
- textbox "描述你想要构建的内容, / 调用指令, @ 文件或对话"
|
||||
- button "添加文件或调用指令"
|
||||
- button "访问模式,当前:工作区内修改": 工作区内修改
|
||||
- button "选择模型,当前 acme-gateway/acme-large": acme-gateway/acme-large
|
||||
- button "请选择模型"
|
||||
- button "发送消息" [disabled]
|
||||
@@ -3,3 +3,4 @@
|
||||
- menuitem "Rename"
|
||||
- menuitem "Fork session"
|
||||
- menuitem "Archive session"
|
||||
- menuitem "Copy Session ID"
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
- group "模型与推理等级":
|
||||
- status: 暂无可用模型
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -3,3 +3,4 @@
|
||||
- menuitem "Rename"
|
||||
- menuitem "Fork session"
|
||||
- menuitem "Archive session"
|
||||
- menuitem "Copy Session ID"
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
|
||||
Loaded 100 of 1361 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user