mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
Merge master into subagent tree stack and reconcile fixtures
This commit is contained in:
+2
-2
@@ -21,8 +21,8 @@
|
||||
en: 985e0eb63f3d1fd6
|
||||
zh: 337cc44fadc6227e
|
||||
/agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/build-pipeline-and-artifacts:
|
||||
en: 9d885e8d58bc8bbb
|
||||
zh: 41a9779fbdea8a1a
|
||||
en: 9a4ba22142a7be24
|
||||
zh: 8553b02a923641a3
|
||||
/agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/python-sdk-distribution-two-carriers-exe-for-production-node-for-development:
|
||||
en: b2a740a1ab7a5002
|
||||
zh: a48be6ca6fc65912
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed de
|
||||
|
||||
### Build pipeline and artifacts
|
||||
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea --compress Brotli` once per target → write `deepseek-harness-sdk-runtime-<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.
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea --compress Brotli` once per target, where the vendored pkg patch pins Brotli's quality to 6: pkg passes no options, so its default quality 11 measured 132.8 s against 3.2 s at quality 6 for an archive 12% larger → write `deepseek-harness-sdk-runtime-<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.
|
||||
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真
|
||||
|
||||
### 构建流水线与产物
|
||||
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea --compress Brotli` → 将 `deepseek-harness-sdk-runtime-<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 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea --compress Brotli`,其中 vendor 的 pkg 补丁把 Brotli 质量固定为 6:pkg 不传参数时默认质量 11,同一载荷实测 132.8 s,质量 6 为 3.2 s,产物大 12% → 将 `deepseek-harness-sdk-runtime-<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 的明确排除。
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: e5c984fa32cc5e69
|
||||
zh: 25d5742b2106d830
|
||||
/agent-note-pr-artifact-builds-and-aggregate-typescript-checks/decision:
|
||||
en: 8353d57b20ca01c3
|
||||
zh: e0b74906846d759c
|
||||
en: 2b047b7d8ffaaeb9
|
||||
zh: 035e1a99cb0ffe84
|
||||
/agent-note-pr-artifact-builds-and-aggregate-typescript-checks/alternatives-considered:
|
||||
en: f61da066f9ddcd62
|
||||
zh: 816fd6e0063017cd
|
||||
|
||||
@@ -20,7 +20,7 @@ The Python executable builder selects `build:artifacts` artifact compilation wit
|
||||
|
||||
PR artifact builds in CI invoke the root manifest's `build:artifacts`; a PR that edits these root manifest scripts activates the separate native-addon platform matrix, a trade-off [the build split](2026-10-11-build-artifacts-check-split.md) records. The vendor rehearsal and benchmark lanes invoke the internal build helpers directly.
|
||||
|
||||
Job inventory, runner selectors, and benchmark sample counts stay fixed; gate and worker ceilings do not increase. The two compatibility cases run one job at a time; both x64 Python runtime targets start after the keyless Python SDK job succeeds in CI Python. Four Linux lanes, one compatibility job, three Windows lanes, and the Python SDK job permit at most nine worker jobs initially. Once the SDK job finishes, those four Linux lanes, one compatibility job, and three Windows lanes run beside the two runtime targets, permitting at most ten. Each runtime caller's plan and wheel stages precede its target and each use one job. This permits runtime validation to overlap the Node 24.9 build within the original ten-job ceiling. Coverage partitions reuse compact file-cost metadata within their platform and runner pool. Parallel release packing uses pnpm's existing bounded scheduler; serial publication keeps family order.
|
||||
Benchmark sample counts stay fixed. The Linux artifact consumers emit artifacts only; the aggregate test and script type checks belong to the master lanes, and lint plus duplication run in their own lane, which compiles package declarations because oxlint's type-aware rules read them, so a red lint no longer costs a rerun of the snapshot and artifact consumers. The two compatibility cases run one job at a time; both x64 Python runtime targets start after the keyless Python SDK job succeeds in CI Python. Five Linux lanes, one compatibility job, three Windows lanes, and the Python SDK job permit at most eleven worker jobs initially. Once the SDK job finishes, those five Linux lanes, one compatibility job, and three Windows lanes run beside the two runtime targets, permitting at most eleven. Each runtime caller's plan and wheel stages precede its target and each use one job. This permits runtime validation to overlap the Node 24.9 build within the eleven-job ceiling. Coverage partitions reuse compact file-cost metadata within their platform and runner pool. Parallel release packing uses pnpm's existing bounded scheduler; serial publication keeps family order.
|
||||
|
||||
Each Node version runs the same eight source compatibility specs in one serial Vitest invocation. Both file parallelism and the CLI worker limit are fixed to one, and the gate sets `VITEST_MAX_WORKERS=1` because that environment variable overrides Vitest's CLI limit. Files retain fork isolation; the build-backed Node 24.9 lazy-search smoke keeps its separate build dependency. The original seven-spec paired samples preserve all 253 assertion outcomes and reduce startup-only median time from 11.52 to 8.69 seconds, without increasing owned descendant-process peak.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Python executable builder 在验证 runtime closure 后,通过 `--build-artifa
|
||||
|
||||
CI 的 PR 产物构建调用 root manifest 中的 `build:artifacts`;修改 root manifest 中这些 script 的 PR 会激活独立的 native-addon 平台矩阵,[构建拆分](2026-10-11-build-artifacts-check-split.zh.md)记录这一取舍。vendor 演练与 benchmark lane 直接调用内部 build helper。
|
||||
|
||||
job 清单、runner 选择和 benchmark sample 数量保持固定;gate 与 worker 上限不提高。两个 compatibility case 每次运行一个 job;两个 x64 Python runtime target 都在 CI Python 中于 keyless Python SDK job 成功后启动。四个 Linux lane、一个 compatibility job、三个 Windows lane 与 Python SDK job,初始最多占用九个 worker job。SDK job 完成后,这四个 Linux lane、一个 compatibility job 与三个 Windows lane 与两个 runtime target 并行,最多占用十个 job。每个 runtime 调用方的 plan 与 wheel 阶段先于其 target,各占一个 job。这允许 runtime 验证与 Node 24.9 构建重叠,同时保持原有十个 job 的上限。覆盖率分区在各自的平台和 runner pool 内复用精简的文件成本元数据。并行 release 打包使用 pnpm 现有的有界调度器;串行发布保持 family 顺序。
|
||||
benchmark sample 数量保持固定。Linux 产物消费者只产出产物;聚合的 test 与 script 类型检查归 master lane;lint 与 duplication 单独占用一个 lane,该 lane 会先编译包声明(oxlint 的类型感知规则要读它们),因此 lint 变红不再让 snapshot 与 artifact 消费者整体重跑。两个 compatibility case 每次运行一个 job;两个 x64 Python runtime target 都在 CI Python 中于 keyless Python SDK job 成功后启动。五个 Linux lane、一个 compatibility job、三个 Windows lane 与 Python SDK job,初始最多占用十一个 worker job。SDK job 完成后,这五个 Linux lane、一个 compatibility job 与三个 Windows lane 与两个 runtime target 并行,最多占用十一个 job。每个 runtime 调用方的 plan 与 wheel 阶段先于其 target,各占一个 job。这允许 runtime 验证与 Node 24.9 构建重叠,同时保持十一个 job 的上限。覆盖率分区在各自的平台和 runner pool 内复用精简的文件成本元数据。并行 release 打包使用 pnpm 现有的有界调度器;串行发布保持 family 顺序。
|
||||
|
||||
每个 Node 版本在一次串行 Vitest 调用中运行相同的八个 source compatibility spec。文件并行和 CLI worker 上限均固定为一,gate 还设置 `VITEST_MAX_WORKERS=1`,因为该环境变量会覆盖 Vitest 的 CLI 上限。文件保持 fork 隔离;依赖构建的 Node 24.9 lazy-search smoke 保留独立的 build 依赖。原来七个 spec 的成对样本保持全部 253 个 assertion outcome,将启动开销比较的 median 从 11.52 秒降至 8.69 秒,且不增加所拥有 descendant process 的峰值。
|
||||
|
||||
|
||||
@@ -264,6 +264,10 @@ jobs:
|
||||
- name: Build single-exe
|
||||
env:
|
||||
DSH_BUILD_CLIENT_PROFILE: official
|
||||
# Brotli runs at quality 9 through the vendored pkg patch: the same
|
||||
# payload measured 16.3 s at quality 9 against 132.8 s at pkg's default
|
||||
# quality 11, for an archive 10% larger. Which quality each lane uses is
|
||||
# therefore not a workflow choice.
|
||||
run: >-
|
||||
pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }}
|
||||
${{ inputs.ci && github.event_name == 'pull_request' && '--build-artifacts' || '' }}
|
||||
|
||||
@@ -3,7 +3,9 @@ name: Build PR preview
|
||||
# Every push to a pull request publishes that pull request's preview to
|
||||
# Cloudflare Pages under its own branch alias, behind Cloudflare Access. The
|
||||
# upload carries build products only: the workflow never grants the deployment
|
||||
# platform access to this repository's sources.
|
||||
# platform access to this repository's sources. This lane also owns the
|
||||
# aggregate test and script type checks that the required Linux consumers no
|
||||
# longer repeat on every pull request.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -32,7 +34,13 @@ env:
|
||||
|
||||
jobs:
|
||||
preview:
|
||||
runs-on: ubuntu-24.04
|
||||
# The required outcome is the build, which runs four TypeScript programs
|
||||
# (Host and Client package compilation, then each face's aggregate test and
|
||||
# script check) after the Typert pass. Publishing is best effort: transport,
|
||||
# Access, and comment failures warn instead of failing the check.
|
||||
runs-on: >-
|
||||
${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
|
||||
|| 'ubuntu-24.04' }}
|
||||
name: cloudflare pages preview
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
@@ -70,8 +78,11 @@ jobs:
|
||||
# apps/web consumes workspace packages as built lib products, and
|
||||
# build:preview packs the image through the packer's installed bin
|
||||
# (lib/bin.js), so neither half exists before the artifact build runs.
|
||||
- name: Build workspace
|
||||
run: pnpm run build:artifacts
|
||||
# The required Linux consumers emit artifacts only; the complete build
|
||||
# here also runs the aggregate test and script checks, which share the
|
||||
# Typert output and the two package compilations.
|
||||
- name: Build workspace and check aggregate programs
|
||||
run: pnpm run build
|
||||
|
||||
- name: Build the preview page and pack the VFS image
|
||||
env:
|
||||
@@ -88,6 +99,8 @@ jobs:
|
||||
cp apps/web/dist/preview.html apps/web/dist/index.html
|
||||
|
||||
- name: Upload to Cloudflare Pages
|
||||
id: upload
|
||||
continue-on-error: true
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
@@ -112,6 +125,8 @@ jobs:
|
||||
# Accept-Encoding is sent because a browser sends it; the assertion is
|
||||
# about what the platform does with a body that is already compressed.
|
||||
- name: Verify the protected deployment serves the image
|
||||
id: verify
|
||||
continue-on-error: true
|
||||
env:
|
||||
CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }}
|
||||
CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }}
|
||||
@@ -151,6 +166,8 @@ jobs:
|
||||
# idempotent: a pull request opened before this workflow existed never
|
||||
# sees an `opened` event, and every later push must not restate the URL.
|
||||
- name: Comment the preview URL
|
||||
id: comment
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
@@ -168,3 +185,12 @@ jobs:
|
||||
"$marker" \
|
||||
"[Preview for #$PR](https://pr-$PR.${CF_PROJECT}.pages.dev) (requires Cloudflare Access sign-in)" \
|
||||
| gh pr comment "$PR" --body-file -
|
||||
|
||||
# Publishing stays outside the required verdict: a Cloudflare, Access, or
|
||||
# comment failure warns, while a build failure still fails this check.
|
||||
- name: Report preview publication failures
|
||||
if: steps.upload.outcome == 'failure' || steps.verify.outcome == 'failure' || steps.comment.outcome == 'failure'
|
||||
run: |
|
||||
message='preview publication failed; the build passed, see the upload, verify, and comment steps'
|
||||
echo "::warning::$message"
|
||||
echo "$message" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -202,12 +202,81 @@ jobs:
|
||||
path: .coverage-times.json
|
||||
key: coverage-times-Linux-${{ runner.environment }}-${{ vars.DSH_CI_FAILOVER_LINUX || 'enterprise' }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
# Lint and duplication get their own lane instead of riding the artifact
|
||||
# consumers: a red lint no longer costs the snapshot and artifact consumers a
|
||||
# full rerun. The lane compiles package declarations because oxlint's
|
||||
# type-aware rules read them.
|
||||
node-24-lint:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: >-
|
||||
${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-8vcpu-ubuntu-2404'
|
||||
|| vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
|
||||
&& github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|
||||
|| 'dsh-ubuntu-24-04-16core' }}
|
||||
name: node 24 / lint
|
||||
env:
|
||||
# Shared hosts expose host CPUs, not an isolated allocation.
|
||||
DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}
|
||||
DSH_OXLINT_THREADS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}
|
||||
steps:
|
||||
# Runner cleanup owns tool caches and fixtures, including cancelled runs.
|
||||
- name: Use runner-owned temporary storage
|
||||
run: |
|
||||
echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Redirect Node compile cache to runner temp
|
||||
run: echo "NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: pnpm/action-setup@v5.0.0
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
|
||||
- name: Configure pnpm store path
|
||||
id: pnpm-store
|
||||
run: |
|
||||
store_root="$HOME/.local/share/pnpm/store"
|
||||
echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
|
||||
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
|
||||
echo "path=$store_path" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/cache/restore@v6.1.0
|
||||
if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
|
||||
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# oxlint's type-aware rules resolve workspace imports through the package
|
||||
# declarations, so this lane compiles them without bundling anything.
|
||||
- name: Compile package declarations
|
||||
run: pnpm run typecheck
|
||||
|
||||
- name: Run lint and duplication
|
||||
run: pnpm run check:ci:lint:contracts-ready
|
||||
|
||||
node-24-bench:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: >-
|
||||
${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
|
||||
|| 'ubuntu-24.04' }}
|
||||
name: node 24 / benchmarks
|
||||
# Wall-clock budgets need an otherwise idle runner, so this job runs the
|
||||
# benchmark lane alone instead of joining a concurrent gate aggregate.
|
||||
# benchmark lane alone instead of joining a concurrent gate aggregate, and
|
||||
# keeps its own two-file parallelism inside that runner. The self-hosted
|
||||
# failover pool is shared, so the benchmarks stay on hosted Linux there.
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
@@ -503,7 +572,7 @@ jobs:
|
||||
&& github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|
||||
|| 'ubuntu-latest' }}
|
||||
needs: [node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat]
|
||||
needs: [node-24, node-24-coverage, node-24-lint, node-24-bench, node-24-consumers, node-compat]
|
||||
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
|
||||
steps:
|
||||
- name: Fail if any needed job did not succeed
|
||||
|
||||
@@ -122,6 +122,6 @@ jobs:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}
|
||||
DEEPSEEK_BASE_URL: https://api.deepseek.com/anthropic
|
||||
DSH_PRIMARY_RUNTIME: ${{ github.workspace }}/.cache/e2e-office/primary-runtime
|
||||
DSH_E2E_MAX_WORKERS: 4
|
||||
DSH_E2E_MAX_WORKERS: 6
|
||||
DSH_EXAMPLE_MODE: lib
|
||||
run: pnpm run test:e2e
|
||||
|
||||
@@ -30,7 +30,8 @@ jobs:
|
||||
name: Dependency layout
|
||||
# Persistent runners accept only trusted, credential-free rehearsals.
|
||||
runs-on: >-
|
||||
${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
|
||||
${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
|
||||
|| vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
|
||||
&& github.repository == 'deepseek-harness/deepseek-harness'
|
||||
&& github.actor != 'dependabot[bot]'
|
||||
&& ((github.event_name == 'push' && github.ref == 'refs/heads/master')
|
||||
@@ -80,11 +81,20 @@ jobs:
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify dependency policy
|
||||
run: pnpm run verify-package-dependencies
|
||||
|
||||
- name: Verify npm install layout
|
||||
run: pnpm run verify-npm-install-layout
|
||||
# The two verifications read different inputs (manifests versus an npm
|
||||
# install in a scratch directory), so they run beside each other.
|
||||
- name: Verify dependency policy and npm install layout
|
||||
run: |
|
||||
pnpm run verify-package-dependencies &
|
||||
dependencies_pid=$!
|
||||
pnpm run verify-npm-install-layout &
|
||||
layout_pid=$!
|
||||
dependencies_status=0
|
||||
wait "$dependencies_pid" || dependencies_status=$?
|
||||
layout_status=0
|
||||
wait "$layout_pid" || layout_status=$?
|
||||
if (( dependencies_status != 0 )); then exit "$dependencies_status"; fi
|
||||
exit "$layout_status"
|
||||
|
||||
pack:
|
||||
name: Pack npm tarballs
|
||||
|
||||
@@ -112,6 +112,7 @@ External packages installed for runtime use or embedded in distributed Host or b
|
||||
| [`micromark-factory-space`](https://github.com/micromark/micromark/tree/main/packages/micromark-factory-space) | MIT |
|
||||
| [`micromark-util-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) | MIT |
|
||||
| [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT |
|
||||
| [`micromark-util-decode-string`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-decode-string) | MIT |
|
||||
| [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT |
|
||||
| [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT |
|
||||
| [`mime`](https://github.com/broofa/mime) | MIT |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Account catalog diagnostics preserve the shipped ACP profile's JSON-RPC stream. */
|
||||
/** Account catalog and MCP resource-listing diagnostics keep the shipped ACP profile's stdout JSON-RPC only. */
|
||||
import { once } from 'node:events'
|
||||
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
@@ -6,10 +6,22 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
|
||||
import { launchAcpTestAgent, type AgentUnderTest, type LaunchedAcpTestAgent } from '@deepseek-ai/dsh-session-snapshot'
|
||||
import { expect, it } from 'vitest'
|
||||
import { cleanupAcpExampleTest } from './cleanup.ts'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
|
||||
const binScript = join(repoRoot, 'apps/cli/src/bin.ts')
|
||||
const tsconfigPath = join(repoRoot, 'tsconfig.json')
|
||||
|
||||
/** Parse every stdout line as a JSON-RPC frame; a diagnostic line fails the parse with its leading text. */
|
||||
function jsonRpcFrames(launched: LaunchedAcpTestAgent): Record<string, unknown>[] {
|
||||
const frames = launched.rawStdout().trim().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
for (const frame of frames) expect(frame).toMatchObject({ jsonrpc: '2.0' })
|
||||
return frames
|
||||
}
|
||||
|
||||
it('keeps account model-listing diagnostics on stderr during API-key session creation', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-acp-stdout-'))
|
||||
const token = 'acp-catalog-fixture-token'
|
||||
@@ -100,3 +112,48 @@ it('keeps account model-listing diagnostics on stderr during API-key session cre
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'ACP catalog test cleanup failed')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps MCP resource-listing diagnostics off stdout for a tools-only client server', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-acp-stdout-'))
|
||||
const llm = await startMockLlmServer({
|
||||
sequence: ['tool_call_success', 'success'],
|
||||
toolName: 'list_mcp_resources',
|
||||
toolArguments: JSON.stringify({ server: 'fixture' }),
|
||||
})
|
||||
let launched: LaunchedAcpTestAgent | undefined
|
||||
try {
|
||||
const configPath = join(cwd, 'mcp.patch.yml')
|
||||
await writeFile(configPath, JSON.stringify([
|
||||
{ id: 'llm-deepseek', config: { baseURL: llm.baseURL, models: [{ id: 'fixture-api-key-model' }] } },
|
||||
{ id: 'acp', config: { provider: 'deepseek-official', model: 'fixture-api-key-model' } },
|
||||
{ id: 'session-persistence-jsonl', config: { root: join(cwd, 'sessions'), compression: 'none' } },
|
||||
]) + '\n')
|
||||
launched = launchAcpTestAgent({
|
||||
agent: { binScript, configPath, profile: 'acp', tsconfigPath },
|
||||
cwd,
|
||||
env: { DEEPSEEK_API_KEY: 'acp-fixture-api-key', DEEPSEEK_BASE_URL: llm.baseURL, DSH_TELEMETRY_DISABLED: '1' },
|
||||
})
|
||||
await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
// The fixture server advertises only the tools capability.
|
||||
const mcpServer = join(repoRoot, 'packages/mcp/mcp-client/tests/fixture-server.ts')
|
||||
const created = await launched.client.newSession({
|
||||
cwd, mcpServers: [{ name: 'fixture', command: process.execPath, args: [mcpServer], env: [] }],
|
||||
})
|
||||
await expect(launched.client.prompt({
|
||||
sessionId: created.sessionId,
|
||||
prompt: [{ type: 'text', text: 'list the fixture resources' }],
|
||||
})).resolves.toEqual({ stopReason: 'end_turn' })
|
||||
expect(launched.updates).toContainEqual(expect.objectContaining({ sessionUpdate: 'tool_call_update', status: 'completed' }))
|
||||
await launched.client.closeSession({ sessionId: created.sessionId })
|
||||
await launched.close()
|
||||
|
||||
expect(launched.child.exitCode).toBe(0)
|
||||
expect(llm.requests.map(request => request.behavior)).toEqual(['tool_call_success', 'success'])
|
||||
expect(jsonRpcFrames(launched).length).toBeGreaterThanOrEqual(5)
|
||||
} finally {
|
||||
const cleanup = await Promise.allSettled([cleanupAcpExampleTest(launched, cwd), llm.close()])
|
||||
const failures: unknown[] = []
|
||||
for (const result of cleanup) if (result.status === 'rejected') failures.push(result.reason)
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'ACP MCP test cleanup failed')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/** Unsupported Host platforms leave the profile and running PTC configuration untouched. */
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Browser } from 'playwright'
|
||||
import type {} from '@deepseek-ai/dsh-ptc-runtime'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
it('disables unsupported bundles, shows the Host restriction, and refuses direct activation without changing PTC', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'bundle-platform-web-'))
|
||||
const owned: { scaffold?: WebScaffold; browser?: Browser } = {}
|
||||
onTestFinished(async () => {
|
||||
try { await owned.browser?.close() } finally {
|
||||
try { await owned.scaffold?.close() } finally { await rm(root, { recursive: true, force: true }) }
|
||||
}
|
||||
})
|
||||
const name = 'host-platform-only'
|
||||
await writeFile(join(root, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: {
|
||||
patch: './cordis.patch.yml', platforms: [process.platform === 'darwin' ? 'win32' : 'darwin'],
|
||||
} } }))
|
||||
await writeFile(join(root, 'cordis.patch.yml'), '- id: ptc-runtime\n config:\n executionMode: persistent-in-memory\n')
|
||||
const scaffold = owned.scaffold = await launchWebScaffold({ profile: { packages: [{ dir: root }] } })
|
||||
const browser = owned.browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
|
||||
const reason = '此插件不支持运行 DSH 的主机操作系统'
|
||||
const card = page.locator(`[data-plugin-package="${name}"]`)
|
||||
await card.getByText(reason, { exact: true }).waitFor()
|
||||
expect(await card.getByRole('switch').isEnabled()).toBe(false)
|
||||
expect(await card.getByRole('switch').getAttribute('aria-checked')).toBe('false')
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/bundle-platform/unsupported-platform.expected.md', import.meta.url)),
|
||||
await captureStableAria(page, `[data-plugin-package="${name}"] p[role="status"]`, scaffold.workspaceCwd), webSnapshotMode())
|
||||
const manifest = join(scaffold.harnessHome, 'profiles/scaffold/package.json')
|
||||
const before = await readFile(manifest, 'utf8')
|
||||
const runtimeId = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'ptc-runtime')?.fiber?.uid
|
||||
const beforeRuntime = runtimeId()
|
||||
expect(beforeRuntime).toBeTypeOf('number')
|
||||
expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body')
|
||||
const names = [name]
|
||||
await page.locator('[data-plugin-panel]').getByRole('button', { name: '更多', exact: true }).click()
|
||||
const cua = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]')
|
||||
expect(await cua.count()).toBe(0)
|
||||
if (process.platform !== 'darwin') {
|
||||
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle'))
|
||||
.toMatchObject({ listedByDefault: false, enabled: false, error: { code: 'unsupported-platform' } })
|
||||
names.push('@deepseek-ai/dsh-cua-bundle')
|
||||
}
|
||||
for (const bundle of names) {
|
||||
expect(await scaffold.ctx.pluginManager.setBundleEnabled(bundle, true)).toMatchObject({
|
||||
changed: false, application: 'failed', error: { code: 'unsupported-platform' },
|
||||
})
|
||||
}
|
||||
expect(await readFile(manifest, 'utf8')).toBe(before)
|
||||
expect(runtimeId()).toBe(beforeRuntime)
|
||||
expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body')
|
||||
expect(scaffold.ctx.get('computerUse')).toBeUndefined()
|
||||
})
|
||||
@@ -12,7 +12,8 @@ import { expect, it, vi } from 'vitest'
|
||||
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserving default %s until the explicit persistent-runtime action', async (selectedDefault) => {
|
||||
// The shipped bundle admits only macOS Hosts; unsupported Hosts are exercised in bundle-platform.e2e.ts.
|
||||
it.skipIf(process.platform !== 'darwin').each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserving default %s until the explicit persistent-runtime action', async (selectedDefault) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'cua-optin-web-'))
|
||||
let ownedScaffold: WebScaffold | undefined
|
||||
let browser: Browser | undefined
|
||||
@@ -43,17 +44,31 @@ it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserv
|
||||
expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body')
|
||||
const settingsPath = join(scaffold.harnessHome, 'profiles/scaffold/cordis.patch.yml')
|
||||
const before = await readFile(settingsPath, 'utf8')
|
||||
const selected = () => readProfileManifest('scaffold', join(scaffold.harnessHome, 'profiles/scaffold')).dsh?.profile?.bundles
|
||||
const profileManifest = () => readProfileManifest('scaffold', join(scaffold.harnessHome, 'profiles/scaffold'))
|
||||
const selected = () => profileManifest().dsh?.profile?.bundles
|
||||
const dependencies = profileManifest().dependencies
|
||||
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
|
||||
const plugins = page.locator('[data-plugin-panel]')
|
||||
await plugins.getByRole('button', { name: '更多', exact: true }).waitFor()
|
||||
expect(await page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').count()).toBe(0)
|
||||
await plugins.getByRole('button', { name: '更多', exact: true }).click()
|
||||
const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]')
|
||||
expect(await card.getByText('实验性', { exact: true }).count()).toBe(1)
|
||||
expect(await card.count()).toBe(0)
|
||||
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle'))
|
||||
.toMatchObject({ listedByDefault: false, enabled: false, installed: false })
|
||||
await plugins.getByRole('button', { name: '返回插件', exact: true }).click()
|
||||
const offerCua = async () => {
|
||||
await plugins.getByRole('button', { name: '添加插件', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '添加插件', exact: true })
|
||||
await dialog.getByRole('textbox', { name: '包名或地址', exact: true }).fill('@deepseek-ai/dsh-cua-bundle')
|
||||
await dialog.getByRole('button', { name: '安装', exact: true }).click()
|
||||
const ready = page.getByRole('dialog', { name: '插件已随 DSH 提供', exact: true })
|
||||
await ready.waitFor()
|
||||
expect(await ready.getByText('无需下载,启用即可使用', { exact: true }).count()).toBe(1)
|
||||
return ready.getByRole('button', { name: '立即启用', exact: true })
|
||||
}
|
||||
const enableNow = await offerCua()
|
||||
const toggle = card.getByRole('switch', { name: '启用 Computer Use', exact: true })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('false')
|
||||
expect(await card.textContent()).toContain('请切换到 PTC 模式')
|
||||
const enabling = Promise.withResolvers<undefined>()
|
||||
const setEnabled = scaffold.ctx.pluginManager.setBundleEnabled.bind(scaffold.ctx.pluginManager)
|
||||
let applying: ReturnType<typeof setEnabled> | undefined
|
||||
@@ -63,11 +78,12 @@ it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserv
|
||||
return applying
|
||||
})
|
||||
try {
|
||||
await toggle.click()
|
||||
await card.getByRole('status').getByText('正在应用 Computer Use 的更改', { exact: true }).waitFor()
|
||||
expect(await toggle.isEnabled()).toBe(false)
|
||||
await enableNow.click()
|
||||
await expect.poll(() => enableNow.isEnabled()).toBe(false)
|
||||
expect(await enableNow.getAttribute('aria-busy')).toBe('true')
|
||||
expect(await card.count()).toBe(0)
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/enabling.expected.md', import.meta.url)),
|
||||
await captureStableAria(page, '[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
await captureStableAria(page, '[data-install-phase="done"] button[aria-busy]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
} finally {
|
||||
enabling.resolve(undefined)
|
||||
enableCall.mockRestore()
|
||||
@@ -81,6 +97,9 @@ it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserv
|
||||
expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1)
|
||||
await setup.getByRole('button', { name: '稍后', exact: true }).click()
|
||||
expect(selected()).toContain('@deepseek-ai/dsh-cua-bundle')
|
||||
expect(profileManifest().dependencies).toEqual(dependencies)
|
||||
expect(await card.getByText('实验性', { exact: true }).count()).toBe(1)
|
||||
expect(await card.textContent()).toContain('请切换到 PTC 模式')
|
||||
expect(await readFile(settingsPath, 'utf8')).toBe(before)
|
||||
expect(runtimeFiber() === originalRuntimeFiber).toBe(true)
|
||||
expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body')
|
||||
@@ -94,15 +113,16 @@ it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserv
|
||||
})
|
||||
expect(scaffold.ctx.tools.schemas(fresh.agent).some(tool => tool.name === 'cua')).toBe(true)
|
||||
await toggle.click()
|
||||
await toggle.and(page.locator('[aria-checked="false"]')).waitFor({ state: 'visible' })
|
||||
await card.waitFor({ state: 'hidden' })
|
||||
expect(scaffold.ctx.get('computerUse')).toBeUndefined()
|
||||
for (const agent of [ptcAgent, fresh.agent]) expect(scaffold.ctx.tools.schemas(agent).some(tool => tool.name === 'cua')).toBe(false)
|
||||
expect(runtimeFiber() === originalRuntimeFiber).toBe(true)
|
||||
expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body')
|
||||
expect(await readFile(settingsPath, 'utf8')).toBe(before)
|
||||
await (await offerCua()).click()
|
||||
await setup.getByRole('button', { name: '稍后', exact: true }).click()
|
||||
await card.getByRole('button', { name: '查看 Computer Use', exact: true }).click()
|
||||
const detailToggle = page.locator('[data-plugin-panel]').getByRole('switch', { name: '启用 Computer Use', exact: true })
|
||||
await detailToggle.click()
|
||||
await detailToggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' })
|
||||
const panel = page.locator('[data-cua-settings]')
|
||||
await panel.waitFor()
|
||||
@@ -134,11 +154,13 @@ it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserv
|
||||
expect(scaffold.ctx.agentPresets.defaultId).toBe('ptc')
|
||||
expect(scaffold.ctx.agentPresets.composedPreset(originalAgent.ctx)).toBe(initialDefault)
|
||||
await detailToggle.click()
|
||||
await detailToggle.and(page.locator('[aria-checked="false"]')).waitFor({ state: 'visible' })
|
||||
await panel.waitFor({ state: 'hidden' })
|
||||
await card.waitFor({ state: 'hidden' })
|
||||
expect(await readFile(settingsPath, 'utf8')).toBe(configured)
|
||||
expect(runtimeFiber() === persistentRuntimeFiber).toBe(true)
|
||||
expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(2)
|
||||
await detailToggle.click()
|
||||
await (await offerCua()).click()
|
||||
await panel.waitFor({ state: 'visible' })
|
||||
await detailToggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' })
|
||||
expect(await readFile(settingsPath, 'utf8')).toBe(configured)
|
||||
expect(runtimeFiber() === persistentRuntimeFiber).toBe(true)
|
||||
|
||||
@@ -42,6 +42,8 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
|
||||
profile: { packages: [{ dir: bundle, enabled: true }, { dir: fixtureBundle, enabled: true }] },
|
||||
})
|
||||
resources.scaffold = scaffold
|
||||
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle'))
|
||||
.toMatchObject({ listedByDefault: false, enabled: true, installed: true })
|
||||
await scaffold.ctx.settings.mutate('cua', [{ op: 'set', path: ['allowedApps'],
|
||||
value: [{ id: 'a'.repeat(64), name: 'Fixture', executablePath: '/Applications/Fixture.app' }] }])
|
||||
const browser = await chromium.launch()
|
||||
@@ -82,7 +84,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
|
||||
const plugins = page.locator('[data-plugin-panel]')
|
||||
const openDetails = page.getByRole('button', { name: 'View Computer use', exact: true })
|
||||
await openDetails.scrollIntoViewIfNeeded()
|
||||
expect(await plugins.evaluate(element => element.scrollTop)).toBeGreaterThan(0)
|
||||
expect(await plugins.locator('[data-plugin-body]').evaluate(element => element.scrollTop)).toBeGreaterThan(0)
|
||||
await openDetails.click()
|
||||
const panel = page.locator('[data-cua-settings]')
|
||||
const accessibility = panel.getByRole('group', { name: 'Accessibility', exact: true })
|
||||
@@ -194,6 +196,9 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
|
||||
await trace.evaluate((value) => { value.layouts.length = 0; value.documents.length = 0 })
|
||||
for (let cycle = 0; cycle < 3; cycle++) {
|
||||
await toggle.click()
|
||||
await expect.poll(async () => (await scaffold.ctx.pluginManager.listBundles())
|
||||
.find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle'))
|
||||
.toMatchObject({ enabled: false, installed: true })
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false')
|
||||
await expect.poll(() => toggle.isEnabled(), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe(true)
|
||||
await toggle.click()
|
||||
@@ -219,7 +224,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
|
||||
await diagnostic.waitFor()
|
||||
const diagnosticText = await diagnostic.innerText()
|
||||
const height = await panel.evaluate(element => element.getBoundingClientRect().height)
|
||||
const rowToggle = plugins.getByRole('switch', { name: 'Enable component @deepseek-ai/dsh-experimental-cua-macos', exact: true })
|
||||
const rowToggle = plugins.getByRole('switch', { name: 'Enable component macOS Computer Use provider', exact: true })
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const changeRow = scaffold.ctx.pluginManager.setPluginEnabled.bind(scaffold.ctx.pluginManager)
|
||||
let changing: ReturnType<typeof changeRow> | undefined
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- status: 此插件不支持运行 DSH 的主机操作系统
|
||||
@@ -1,5 +1 @@
|
||||
- listitem:
|
||||
- button "查看 Computer Use": Computer Use
|
||||
- text: 实验性 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。
|
||||
- status: 正在应用 Computer Use 的更改
|
||||
- switch "启用 Computer Use" [disabled]
|
||||
- button "立即启用" [disabled]
|
||||
|
||||
@@ -849,7 +849,7 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count())
|
||||
.toBe(bundles.filter(bundle => bundle.name !== '@deepseek-ai/dsh-experimental-terminal-bundle'
|
||||
&& bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length)
|
||||
for (const name of ['@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle']) {
|
||||
for (const name of ['@deepseek-ai/dsh-cua-bundle', '@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle']) {
|
||||
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name))
|
||||
.toMatchObject({ enabled: false, optional: true, availability: 'installation', listedByDefault: false })
|
||||
expect(await panel.locator(`[data-plugin-package="${name}"]`).count()).toBe(0)
|
||||
@@ -865,7 +865,6 @@ describe('web e2e: plugin manager', () => {
|
||||
['@deepseek-ai/dsh-experimental-auto-review', 1],
|
||||
['@deepseek-ai/dsh-personalized-recommendations', 1],
|
||||
['@deepseek-ai/dsh-experimental-inspector-profile', 1],
|
||||
['@deepseek-ai/dsh-cua-bundle', 1],
|
||||
['@deepseek-ai/dsh-experimental-tool-worktree', 1],
|
||||
['@deepseek-ai/dsh-experimental-cot-translation-bundle', 1],
|
||||
['@deepseek-ai/dsh-experimental-session-titles-bundle', 1],
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Browser, type Page } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import type {} from '@deepseek-ai/dsh-plugin-manager'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
@@ -19,18 +19,26 @@ describe('official plugin recommendation in a conversation', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let restoreCatalog: (() => void) | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ replayFixture: fixture, compareReplaySession: true,
|
||||
profile: { packages: [{ dir: fileURLToPath(new URL('../../../packages/experimental/personalized-recommendations', import.meta.url)), enabled: true }] },
|
||||
})
|
||||
// The recorded conversation exercises voice input; unrelated installed plugins vary by Host.
|
||||
// Keep the real inventory read so accidental enablement still changes the observed selection.
|
||||
const listBundles = scaffold.ctx.pluginManager.listBundles.bind(scaffold.ctx.pluginManager)
|
||||
const catalog = vi.spyOn(scaffold.ctx.pluginManager, 'listBundles')
|
||||
.mockImplementation(async () => (await listBundles()).filter(bundle => bundle.name === voiceBundle))
|
||||
restoreCatalog = () => { catalog.mockRestore() }
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
try { await browser?.close() } finally {
|
||||
try { await scaffold?.close() } finally { restoreCatalog?.() }
|
||||
}
|
||||
})
|
||||
|
||||
it('offers the propose_action call as one card below the answer, and reports a dismissal on the next request', async () => {
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import {
|
||||
VFS_EXAMPLE_SESSION_IDS,
|
||||
VFS_EXAMPLE_REASONING,
|
||||
VFS_EXAMPLE_SPACING_TITLE,
|
||||
VFS_EXAMPLE_WRAPPED_COMMAND,
|
||||
buildVfsExampleFiles,
|
||||
} from '../../../packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts'
|
||||
@@ -470,6 +471,9 @@ async function bootPreview(site: Site, manifest: ImageManifest, browser: Browser
|
||||
const sessions = page.getByRole('tree', { name: 'Sessions' })
|
||||
const showcase = sessions.getByRole('treeitem').filter({ hasText: SHOWCASE_TITLE })
|
||||
await expect.poll(() => showcase.count(), { timeout: 15_000 }).toBe(1)
|
||||
// The current-generation spacing sample ships beside the showcase and must be listed cold.
|
||||
const spacingSample = sessions.getByRole('treeitem').filter({ hasText: VFS_EXAMPLE_SPACING_TITLE })
|
||||
await expect.poll(() => spacingSample.count(), { timeout: 15_000 }).toBe(1)
|
||||
// The packed deployment ships the Schedule rows, and the preview storage
|
||||
// seeds the showcase Session with active tasks.
|
||||
expect(await showcase.locator('[data-session-schedule-mark]').count()).toBe(1)
|
||||
@@ -697,22 +701,17 @@ async function toggleOptionalBundles(page: Page): Promise<void> {
|
||||
})
|
||||
expect(result.optional.map(entry => entry.name).sort()).toEqual([...OPTIONAL_BUNDLES].sort())
|
||||
expect(result.optional.filter(entry => entry.enabled || entry.availability !== 'installation')).toEqual([])
|
||||
expect(result.optional.filter(entry => entry.error !== null)).toEqual([])
|
||||
expect(result.optional.filter(entry => entry.error !== null)).toEqual([
|
||||
expect.objectContaining({ name: '@deepseek-ai/dsh-cua-bundle', error: { code: 'unsupported-platform' } }),
|
||||
])
|
||||
// Metadata resolves through the Worker's Node internal modules: every optional bundle shows its title and icon.
|
||||
expect(result.optional.filter(entry => entry.metaError !== null || entry.title !== 'object' || entry.icon !== 'string')).toEqual([])
|
||||
expect(result.inventoryErrors).toEqual([])
|
||||
console.info(`preview boot: optional bundle toggles ${JSON.stringify(result.toggles.map(({ name, enable, disable, error }) => ({ name, enable, disable, error })))}`)
|
||||
const cua = result.toggles.find(toggle => toggle.name === '@deepseek-ai/dsh-cua-bundle')!
|
||||
expect(cua).toMatchObject({
|
||||
enable: 'applied', disable: 'applied',
|
||||
error: null,
|
||||
restored: true,
|
||||
expect(result.toggles.find(toggle => toggle.name === '@deepseek-ai/dsh-cua-bundle')).toMatchObject({
|
||||
enable: 'failed', disable: 'applied', error: { code: 'unsupported-platform' },
|
||||
changed: 0, inactive: [], restored: true,
|
||||
})
|
||||
expect(cua.changed).toBeGreaterThan(0)
|
||||
expect(cua.inactive).toEqual([])
|
||||
expect(cua.plugins).toContainEqual(expect.objectContaining({
|
||||
moduleName: '@deepseek-ai/dsh-experimental-cua-macos', enabled: false, fiberPhase: null,
|
||||
}))
|
||||
const supported = result.toggles.filter(toggle => toggle.name !== '@deepseek-ai/dsh-cua-bundle')
|
||||
expect(supported.filter(toggle => toggle.enable !== 'applied' || toggle.disable !== 'applied')
|
||||
.map(({ name, enable, disable }) => ({ name, enable, disable }))).toEqual([])
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"tests/plugin-sticky-motion.e2e.ts",
|
||||
"tests/entry-customization.e2e.ts",
|
||||
"tests/cua-optin.e2e.ts",
|
||||
"tests/bundle-platform.e2e.ts",
|
||||
"tests/cua-permissions.e2e.ts",
|
||||
"tests/schedule-header.e2e.ts",
|
||||
"tests/schedule-phone.e2e.ts",
|
||||
|
||||
@@ -102,13 +102,27 @@ function appendRecords(records: readonly (TimingRecord | MemoryRecord)[]): void
|
||||
appendFileSync(RESULTS_PATH, records.map(record => JSON.stringify(record) + '\n').join(''))
|
||||
}
|
||||
|
||||
/** Vitest global setup: discard results of an earlier run. */
|
||||
/**
|
||||
* Which invocation of a phased run this process is.
|
||||
* @returns `first` for the invocation that clears earlier results, `last` for the one that renders the report, and `all` for a single-invocation run.
|
||||
* @throws If the value is not a known phase.
|
||||
*/
|
||||
function reportPhase(): 'first' | 'last' | 'all' {
|
||||
const raw = process.env.DSH_BENCH_REPORT_PHASE
|
||||
if (raw === undefined || raw === '' || raw === 'all') return 'all'
|
||||
if (raw === 'first' || raw === 'last') return raw
|
||||
throw new Error(`DSH_BENCH_REPORT_PHASE must be "first", "last", or "all", got ${JSON.stringify(raw)}.`)
|
||||
}
|
||||
|
||||
/** Vitest global setup: discard results of an earlier run unless this phase continues one. */
|
||||
export function setup(): void {
|
||||
if (reportPhase() === 'last') return
|
||||
rmSync(REPORT_DIRECTORY, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
/** Vitest global teardown: render the report when any case recorded a timing. */
|
||||
/** Vitest global teardown: render the report when a case recorded a timing and this phase owns the report. */
|
||||
export function teardown(): void {
|
||||
if (reportPhase() === 'first') return
|
||||
if (!existsSync(RESULTS_PATH)) return
|
||||
const records = readFileSync(RESULTS_PATH, 'utf8').split('\n').filter(line => line !== '').map(line => JSON.parse(line) as TimingRecord | MemoryRecord)
|
||||
const cpuModel = cpus()[0]?.model.trim() ?? 'unknown CPU'
|
||||
|
||||
@@ -2477,7 +2477,7 @@ export interface PlanModeConfig {
|
||||
|
||||
- `inject`: `loader` · `profileContext`
|
||||
- `refs`: [`NpmFileSource`](subsystems/boot.md)
|
||||
- `source`: [`packages/boot/plugin-manager/src/index.ts:52`](../packages/boot/plugin-manager/src/index.ts)
|
||||
- `source`: [`packages/boot/plugin-manager/src/index.ts:53`](../packages/boot/plugin-manager/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
|
||||
|
||||
@@ -2479,7 +2479,7 @@ export interface PlanModeConfig {
|
||||
|
||||
- `inject`: `loader` · `profileContext`
|
||||
- `refs`: [`NpmFileSource`](subsystems/boot.zh.md)
|
||||
- `source`: [`packages/boot/plugin-manager/src/index.ts:52`](../packages/boot/plugin-manager/src/index.ts)
|
||||
- `source`: [`packages/boot/plugin-manager/src/index.ts:53`](../packages/boot/plugin-manager/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
en: 962b4398c9052959
|
||||
zh: 14471c340a9f6e60
|
||||
/profile-management/management-records:
|
||||
en: 2def418f04e9fc10
|
||||
zh: af0bd1610bdc2f2b
|
||||
en: 8813788cd8a86903
|
||||
zh: 3c30cb180fc1a33d
|
||||
/profile-management/market-records:
|
||||
en: 3dfc62d837d61b9b
|
||||
zh: ec223be315f3dc62
|
||||
|
||||
@@ -10,7 +10,7 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p
|
||||
|
||||
`PluginInfo` carries module identity, effective enablement, fiber phase and optional display `meta`, plus a unique `patchId` or a `readOnlyReason`.
|
||||
|
||||
`BundleInfo.official` identifies project-maintained shipped optional bundles and on-demand catalog entries independently of installation. `availability` identifies a readable installation-provided or profile package, or `missing`; on-demand catalog entries exclude undeclared transitive copies. `installed` records the profile dependency declaration. An on-demand `installTarget` carries the host-derived exact spec and version. `BundleInfo` also carries the package name, optional installed version, selected enablement, removal availability, update eligibility (`updatable`), optional resolution error, and, for a bundle the profile's own dependency supplies and the installation does not, `source`: that dependency as a spec `pnpm add` accepts. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time.
|
||||
`BundleInfo.official` identifies project-maintained shipped optional bundles and on-demand catalog entries independently of installation. `availability` identifies a readable installation-provided or profile package, or `missing`; on-demand catalog entries exclude undeclared transitive copies. `installed` records the profile dependency declaration. An on-demand `installTarget` carries the host-derived exact spec and version. `BundleInfo` also carries the package name, optional installed version, selected enablement, removal availability, update eligibility (`updatable`), optional resolution or unsupported-platform error, and, for a bundle the profile's own dependency supplies and the installation does not, `source`: that dependency as a spec `pnpm add` accepts. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time.
|
||||
|
||||
`InstallBundleOptions.saveExact` passes `--save-exact` to the shared package installer. `InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer. `approvedBuilds` grants persistent script permission to the supplied pending package names before installation. `registry` names the registry asked first; absent, the configured one.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
`PluginInfo` 包含模块标识、实际启停状态、fiber 阶段和可选的展示 `meta`,以及唯一的 `patchId` 或 `readOnlyReason`。
|
||||
|
||||
`BundleInfo.official` 标识项目维护的随附可选组合包和按需目录项,独立于安装状态。`availability` 表示可读取的安装随附包、profile 包或 `missing`;按需目录项不计入未声明的传递依赖副本。`installed` 记录 profile 的依赖声明。按需条目的 `installTarget` 包含 Host 生成的精确 spec 和版本。`BundleInfo` 还包含包名、可选的安装版本、组合层选择状态、删除可用性、更新资格(`updatable`)、可选的解析错误;由 profile 自身依赖提供、且安装不提供的组合包还带有 `source`,即该依赖在 `pnpm add` 中可用的 spec。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。
|
||||
`BundleInfo.official` 标识项目维护的随附可选组合包和按需目录项,独立于安装状态。`availability` 表示可读取的安装随附包、profile 包或 `missing`;按需目录项不计入未声明的传递依赖副本。`installed` 记录 profile 的依赖声明。按需条目的 `installTarget` 包含 Host 生成的精确 spec 和版本。`BundleInfo` 还包含包名、可选的安装版本、组合层选择状态、删除可用性、更新资格(`updatable`)、可选的解析错误或平台不支持错误;由 profile 自身依赖提供、且安装不提供的组合包还带有 `source`,即该依赖在 `pnpm add` 中可用的 spec。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。
|
||||
|
||||
`InstallBundleOptions.saveExact` 向共享包安装器传入 `--save-exact`。`InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。`registry` 指定首先询问的注册表;缺省为配置的那个。
|
||||
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
en: e713a4a11a15d13c
|
||||
zh: e713a4a11a15d13c
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tool-ask-user/ask-user-question:
|
||||
en: 2f86adf8ae823afb
|
||||
zh: a5dad3d821e75e55
|
||||
en: 664dae29d0eae65f
|
||||
zh: 2efa5d3f2cb31bf9
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tool-executor-ptc:
|
||||
en: 8c8643ef46fd075f
|
||||
zh: 8c8643ef46fd075f
|
||||
|
||||
@@ -547,7 +547,7 @@ Declared only to an Agent that loaded the skill of a registered card kind; the k
|
||||
|
||||
### `ask_user_question`
|
||||
|
||||
Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding.
|
||||
Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. An answer item with empty selected and no custom means the user skipped that question: continue without it, do not ask it again, and do not treat the skip as permission.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -551,7 +551,7 @@
|
||||
|
||||
### `ask_user_question`
|
||||
|
||||
继续操作前,如果需要确认、选择或缺失的信息,请向用户提出简明问题。
|
||||
继续操作前,如果需要确认、选择或缺失的信息,请向用户提出简明问题。如果回答项的 `selected` 为空且没有 `custom`,表示用户跳过了该问题:不带该回答继续执行,不要再次提问,也不要将跳过视为许可。
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@
|
||||
"test:coverage:partitioned": "tsx scripts/run-coverage-partitions.ts",
|
||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||
"test:bench": "npm run build:bench && npm run build:web && npm run test:bench:built",
|
||||
"test:bench:built": "vitest run --config vitest.bench.config.ts",
|
||||
"test:bench:built": "tsx scripts/run-bench-suite.ts",
|
||||
"test:bench:memory": "tsx scripts/run-memory-pressure-bench.ts",
|
||||
"test:expected": "vitest run --config vitest.expected.config.ts",
|
||||
"test:expected:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.expected.config.ts",
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 65bf2834b81f87da
|
||||
zh: c864ac60a3005b99
|
||||
/deepseek-ai-dsh-app-boot/use-this-package/profiles:
|
||||
en: 0a1b5f4f40d835a7
|
||||
zh: d8f4b54160aa033b
|
||||
en: 284f006f3304a32e
|
||||
zh: b52ccef70e00114c
|
||||
/deepseek-ai-dsh-app-boot/use-this-package/previewing-the-effective-configuration:
|
||||
en: 646a0ccdd7881ad1
|
||||
zh: e3696acb1573dd7b
|
||||
|
||||
@@ -55,6 +55,8 @@ Admission happens only at composition boundaries DSH owns, and a denied row is d
|
||||
|
||||
Exact-version exemptions live in the profile's own `compatibility.json`, not in its `package.json`, so an exemption write never touches the dependency manifest, the bundle list, or Cordis patch files. It maps exact `package-name@version` keys to lists of exact DSH runtime versions; neither a plugin upgrade nor a DSH upgrade inherits permission. A missing file means no exemptions. A malformed one never stops the profile from starting: records the reader accepts still apply, each rejected record is reported on stderr beside the plugin denials, and the file is then treated as read-only, so a grant or revocation refuses and asks the user to repair it by hand instead of overwriting their content. [Plugin Manager](../plugin-manager/README.md#version-compatibility-and-exemptions) owns the grant, revocation, and risk confirmation each change requires.
|
||||
|
||||
A bundle may declare `dsh.bundle.platforms` as a nonempty list of Node Host platform names, such as `["darwin"]`. The launcher validates this declaration before reading the bundle patch. An unsupported Host skips the entire layer, retains its saved selection, and reports the reason through `skippedBundles`; other layers remain effective. Omission permits every platform. Values must be Node platform names; aliases such as `macos` are invalid. Browser clients do not determine Host support.
|
||||
|
||||
Your machine-local preferences also live in the Harness home:
|
||||
|
||||
- **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. The four proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) are accepted from the Harness-home file only, never from the invoking directory's, which arrives with a clone. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line.
|
||||
|
||||
@@ -55,6 +55,8 @@ profile 导入插件前,DSH 会将其声明的 DSH 版本要求与 `getDshRunt
|
||||
|
||||
精确版本豁免保存在 profile 自己的 `compatibility.json` 中,而不是 `package.json`,因此写豁免不会触及依赖清单、组合包列表或 Cordis patch 文件。它把精确的 `package-name@version` 键映射到精确 DSH 运行时版本列表;插件升级和 DSH 升级都不继承授权。文件缺失表示没有豁免。文件损坏绝不会阻止 profile 启动:读取器接受的记录仍然生效,每条被拒绝的记录会与插件拒绝信息一起输出到 stderr,此后该文件被视为只读,因此授予或撤销会拒绝执行并要求用户手工修复,而不是覆盖用户的内容。[插件管理器](../plugin-manager/README.zh.md#version-compatibility-and-exemptions)负责每次变更所需的授权、撤销与风险确认。
|
||||
|
||||
组合包可以用 `dsh.bundle.platforms` 声明非空的 Node Host 平台名称列表,例如 `["darwin"]`。启动器在读取组合包 patch 前验证此声明。Host 不受支持时会跳过整个配置层,保留已保存的选择,并通过 `skippedBundles` 报告原因;其他配置层继续生效。省略此字段时允许所有平台。值必须是 Node 平台名,`macos` 等别名无效。浏览器所在机器的平台不决定 Host 是否受支持。
|
||||
|
||||
你的机器本地偏好同样位于 harness home 中:
|
||||
|
||||
- **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。在文件中设置的进程启动变量(如 `PATH`、`DSH_*`、`XDG_*`)会被拒绝:请改为导出这些变量。四个代理名(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`)只从 harness home 的文件接受,绝不从调用目录的文件接受——后者随 clone 一起到来。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/** Host operating-system admission for optional bundle layers. */
|
||||
|
||||
const NODE_PLATFORMS: readonly string[] = Object.freeze([
|
||||
'aix', 'android', 'darwin', 'freebsd', 'haiku', 'linux', 'openbsd', 'sunos', 'win32', 'cygwin', 'netbsd',
|
||||
] satisfies NodeJS.Platform[])
|
||||
|
||||
/** A valid bundle declaration excludes the operating system running DSH. */
|
||||
export class UnsupportedBundlePlatformError extends Error {
|
||||
/**
|
||||
* @param platforms Node platform names supported by the bundle.
|
||||
* @param platform Operating system of the DSH Host.
|
||||
*/
|
||||
constructor(platforms: readonly string[], platform: string) {
|
||||
super(`Bundle supports Host platforms ${platforms.join(', ')}; the DSH Host is running ${platform}`)
|
||||
this.name = 'UnsupportedBundlePlatformError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate package metadata and reject an unsupported Host before selecting or composing a bundle.
|
||||
* @param platforms The optional `dsh.bundle.platforms` field read from package.json.
|
||||
* @param platform Operating system of the DSH Host, from Node's `process.platform`.
|
||||
* @throws When the declaration is malformed or excludes the Host; omission allows every platform.
|
||||
*/
|
||||
export function assertBundlePlatform(platforms: unknown, platform: string): void {
|
||||
if (platforms === undefined) return
|
||||
if (!Array.isArray(platforms) || platforms.length === 0
|
||||
|| !platforms.every((value): value is string => typeof value === 'string' && NODE_PLATFORMS.includes(value))) {
|
||||
throw new Error('dsh.bundle.platforms must be a nonempty list of Node platform names')
|
||||
}
|
||||
if (!platforms.includes(platform)) throw new UnsupportedBundlePlatformError(platforms, platform)
|
||||
}
|
||||
@@ -54,6 +54,7 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
export { assertBundlePlatform, UnsupportedBundlePlatformError } from './bundle-platform.ts'
|
||||
export {
|
||||
composeEntries,
|
||||
createRuntimeResolution,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Installed profile dependencies and their bundle activation after package-manager operations. */
|
||||
|
||||
import { join } from 'node:path'
|
||||
import { assertBundlePlatform } from './bundle-platform.ts'
|
||||
import { readProfileManifest, resolveBundleDir, writeProfileManifest, type ProfileManifest } from './profile.ts'
|
||||
|
||||
/** Profile directory and installation used by the shared bundle resolver. */
|
||||
@@ -97,6 +98,8 @@ export function writeProfileBundles(
|
||||
* template entries retain their order and duplicates. New bundle dependencies activate automatically.
|
||||
* @param options - location, inventory captured before pnpm, and whether explicitly disabled bundles remain disabled.
|
||||
* @returns updated inventory and newly added ordinary dependencies for caller-owned warnings.
|
||||
* @throws If a new selection has invalid platform metadata or excludes the Host. Installed dependencies
|
||||
* remain; no bundle selection changes are written.
|
||||
*/
|
||||
export function reconcileProfilePlugins(options: ProfilePluginLocation & {
|
||||
readonly before: ProfilePluginInventory
|
||||
@@ -112,7 +115,10 @@ export function reconcileProfilePlugins(options: ProfilePluginLocation & {
|
||||
const previous = after.manifest.dsh?.profile?.bundles ?? []
|
||||
const bundles = previous.filter(name => !(beforeNames.has(name) || afterNames.has(name)) || bundleNames.has(name))
|
||||
for (const dependency of after.dependencies) {
|
||||
if (dependency.bundle && !disabled.has(dependency.name) && !bundles.includes(dependency.name)) bundles.push(dependency.name)
|
||||
if (dependency.bundle && !disabled.has(dependency.name) && !bundles.includes(dependency.name)) {
|
||||
assertBundlePlatform(bundleManifest(options, dependency.name)?.dsh?.bundle?.platforms, process.platform)
|
||||
bundles.push(dependency.name)
|
||||
}
|
||||
}
|
||||
const changed = bundles.length !== previous.length || bundles.some((name, index) => name !== previous[index])
|
||||
const manifest = changed ? writeProfileBundles(options.profileDir, after.manifest, bundles) : after.manifest
|
||||
|
||||
@@ -29,6 +29,7 @@ import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
||||
import type { DshBundleManifest, DshPackageManifest } from '@deepseek-ai/dsh-package-manifest'
|
||||
import { INSTALLATION_OWNED_PROFILE_TUPLES, PROFILE_TEMPLATES, RETIRED_BUNDLES } from '@deepseek-ai/dsh-official-bundles'
|
||||
import { assertBundlePlatform } from './bundle-platform.ts'
|
||||
import { evaluatePluginCompatibility, pluginCompatibilityWarning } from './plugin-compatibility.ts'
|
||||
import { readProfileVersionExemptions } from './profile-compatibility.ts'
|
||||
import { loadOverlayPatches } from './index.ts'
|
||||
@@ -665,7 +666,8 @@ export function resolveBundleDir(
|
||||
* the shared Harness home. This is used by application-owned profiles whose
|
||||
* package project and lifecycle belong to that application.
|
||||
* Retired bundles are removed from the stored bundle list first, rewriting the
|
||||
* manifest when it listed one. Unreadable bundles, and bundles whose own dsh requirements the profile does not exempt, are skipped
|
||||
* manifest when it listed one. Unreadable bundles, unsupported Host platforms, and bundles whose own dsh requirements
|
||||
* the profile does not exempt are skipped
|
||||
* without changing the manifest and listed in `skippedBundles`; nothing is printed.
|
||||
* @param binName - the diagnostic prefix on thrown errors.
|
||||
* @param dir - absolute profile package directory.
|
||||
@@ -692,6 +694,7 @@ export function loadProfileDirectory(
|
||||
if (bundle === undefined) {
|
||||
throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
|
||||
}
|
||||
assertBundlePlatform(bundle.platforms, process.platform)
|
||||
// A bundle is not a plugin row, so row admission never reads its own dsh requirements.
|
||||
const issue = evaluatePluginCompatibility(bundleManifest, exemptions)
|
||||
if (issue !== undefined && !issue.exempted) throw new Error(pluginCompatibilityWarning(issue))
|
||||
@@ -711,8 +714,8 @@ export function loadProfileDirectory(
|
||||
|
||||
/**
|
||||
* Load a profile: resolve every `dsh.profile.bundles` entry to its patch
|
||||
* layer and parse the profile's own patch file. Unreadable or incompatible bundles
|
||||
* are skipped and listed in `skippedBundles`; profile manifest and user patch errors still throw.
|
||||
* layer and parse the profile's own patch file. Unreadable bundles, unsupported Host platforms,
|
||||
* and incompatible bundles are skipped and listed in `skippedBundles`; profile manifest and user patch errors still throw.
|
||||
* @param binName - the diagnostic prefix on thrown errors.
|
||||
* @param name - the profile name.
|
||||
* @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Bundle declarations admit the Host operating system independently of browser clients. */
|
||||
import { expect, it } from 'vitest'
|
||||
import { assertBundlePlatform, UnsupportedBundlePlatformError } from '../src/bundle-platform.ts'
|
||||
|
||||
it.each(['darwin', 'win32', 'linux'])('allows undeclared platforms and an explicit %s Host', (platform) => {
|
||||
expect(() => { assertBundlePlatform(undefined, platform) }).not.toThrow()
|
||||
expect(() => { assertBundlePlatform([platform], platform) }).not.toThrow()
|
||||
expect(() => { assertBundlePlatform(['darwin', 'win32', 'linux'], platform) }).not.toThrow()
|
||||
})
|
||||
|
||||
it.each(['win32', 'linux'])('rejects macOS-only bundles on %s Hosts', (platform) => {
|
||||
expect(() => { assertBundlePlatform(['darwin'], platform) }).toThrow(UnsupportedBundlePlatformError)
|
||||
expect(() => { assertBundlePlatform(['darwin'], platform) }).toThrow(`the DSH Host is running ${platform}`)
|
||||
})
|
||||
|
||||
it.each([null, true, 'darwin', [], [1], [''], ['Darwin'], [' darwin'], ['macos'], ['darwin', 'linxu'], ['darwin', false]].map(platforms => ({ platforms })))('rejects malformed platform metadata $platforms', ({ platforms }) => {
|
||||
expect(() => { assertBundlePlatform(platforms, 'darwin') }).toThrow('dsh.bundle.platforms must be a nonempty list')
|
||||
})
|
||||
@@ -31,6 +31,18 @@ function installed(location: ProfilePluginLocation, name: string, bundle = true)
|
||||
})
|
||||
}
|
||||
|
||||
it('refuses automatic activation on an unsupported Host without changing the bundle selection', () => {
|
||||
const location = fixture()
|
||||
const before = readProfilePlugins(location)
|
||||
writeManifest(location.profileDir, { dependencies: { restricted: '1.0.0' }, dsh: { profile: { bundles: [] } } })
|
||||
writeManifest(join(location.profileDir, 'node_modules/restricted'), { name: 'restricted', dsh: { bundle: {
|
||||
patch: 'unused.yml', platforms: [process.platform === 'darwin' ? 'win32' : 'darwin'],
|
||||
} } })
|
||||
const saved = readFileSync(join(location.profileDir, 'package.json'), 'utf8')
|
||||
expect(() => reconcileProfilePlugins({ ...location, before, preserveDisabled: true })).toThrow('Host is running')
|
||||
expect(readFileSync(join(location.profileDir, 'package.json'), 'utf8')).toBe(saved)
|
||||
})
|
||||
|
||||
describe('profile plugin inventory', () => {
|
||||
it('uses dependency alias keys, installed versions, and the loader installation precedence', () => {
|
||||
const location = fixture()
|
||||
|
||||
@@ -47,7 +47,7 @@ const tmp = (): string => {
|
||||
|
||||
/** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */
|
||||
function stageInstallation(
|
||||
bundles: Record<string, { patch?: string; deps?: Record<string, string> }>,
|
||||
bundles: Record<string, { patch?: string; deps?: Record<string, string>; platforms?: string[] }>,
|
||||
appName = 'dsh-app',
|
||||
): string {
|
||||
const root = tmp()
|
||||
@@ -64,7 +64,7 @@ function stageInstallation(
|
||||
type: 'module',
|
||||
main: './index.js',
|
||||
dependencies: spec.deps ?? {},
|
||||
...spec.patch === undefined ? {} : { dsh: { bundle: { patch: './cordis.patch.yml' } } },
|
||||
...spec.patch === undefined ? {} : { dsh: { bundle: { patch: './cordis.patch.yml', ...spec.platforms === undefined ? {} : { platforms: spec.platforms } } } },
|
||||
}))
|
||||
writeFileSync(join(dir, 'index.js'), `export const packageName = ${JSON.stringify(name)}\n`)
|
||||
if (spec.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), spec.patch)
|
||||
@@ -76,6 +76,24 @@ function stageInstallation(
|
||||
return join(appDir, 'package.json')
|
||||
}
|
||||
|
||||
it('skips an unsupported saved bundle before composing any runtime overrides and retains the selection', () => {
|
||||
const unsupported = process.platform === 'darwin' ? 'win32' : 'darwin'
|
||||
const anchor = stageInstallation({
|
||||
base: { patch: '- insert:\n - id: ptc-runtime\n name: runtime\n config: { executionMode: isolated }\n' },
|
||||
restricted: { platforms: [unsupported], patch: '- id: ptc-runtime\n config: { executionMode: persistent-in-memory }\n' },
|
||||
})
|
||||
const dir = join(tmp(), 'profile')
|
||||
initProfile(dir, ['base', 'restricted'])
|
||||
const before = readFileSync(join(dir, 'package.json'), 'utf8')
|
||||
const profile = loadProfileDirectory('test', dir, anchor)
|
||||
expect(profile.layers.map(layer => layer.packageName)).toEqual(['base'])
|
||||
expect(profile.skippedBundles).toHaveLength(1)
|
||||
expect(profile.skippedBundles[0]?.packageName).toBe('restricted')
|
||||
expect(profile.skippedBundles[0]?.reason).toContain(`Host is running ${process.platform}`)
|
||||
expect(composeEntries(profile.layers.map(layer => layer.patches))).toMatchObject([{ id: 'ptc-runtime', config: { executionMode: 'isolated' } }])
|
||||
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before)
|
||||
})
|
||||
|
||||
/** Represent one resolved external bundle as a loaded profile layer. */
|
||||
function stageProfile(home: string, name: string, bundleAnchor: string): Profile {
|
||||
const dir = resolveProfileDir(name, home)
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 87679c51aa3693f5
|
||||
zh: 11684a48a5806afb
|
||||
/deepseek-ai-dsh-plugin-manager/use-this-package:
|
||||
en: 7ec407151aa14244
|
||||
zh: 7f09ed0329449c4d
|
||||
en: 4bda68030a6999e0
|
||||
zh: 5ae0b9825341c05e
|
||||
/deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions:
|
||||
en: 483285658e267dd5
|
||||
zh: 846bec2daac59a96
|
||||
@@ -51,8 +51,8 @@
|
||||
en: d26075545fe1e990
|
||||
zh: 068c07d642843c94
|
||||
/deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/failure-behavior:
|
||||
en: c670eb2330b441e8
|
||||
zh: 2c8223e03e1e3ef0
|
||||
en: 3fde425e82bbd0f4
|
||||
zh: 531e2365e7d67480
|
||||
/deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
|
||||
@@ -39,7 +39,9 @@ For a deployment without agent presets, enable the tool in the profile patch. Pr
|
||||
|
||||
A plugin toggle updates only `disabled` in the last matching override in the profile's `cordis.patch.yml`, or appends an override when none matches. Matching uses the entry id and any module-name assertion. An override holding only `id` and `disabled` is removed, and none is appended, when the bundle layers and the remaining profile patches already give the entry the requested state, so the profile patch records only departures from the bundles. Home and invocation patches are left out of that comparison; they apply after the profile patch and do not decide what it records. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority.
|
||||
|
||||
`listBundles` retains default-hidden bundles and projects each package’s `dsh.bundle.listedByDefault` declaration for GUI consumers; it does not change selection or activation. A non-boolean declaration returns a metadata diagnostic. A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable.
|
||||
Bundle platform declarations are checked before saving an enabled selection. Unsupported Hosts return `unsupported-platform`. The bundle remains visible with its display metadata and can still be deselected. Automatic activation after a package operation uses the same check. The agent-facing next step asks the user to run the bundle on a supported Host.
|
||||
|
||||
`listBundles` retains default-hidden bundles and derives `listedByDefault` from each package’s visibility declaration and Host platform support. Unsupported bundles are hidden from default GUI lists, including More; explicit profile dependencies and saved selections remain manageable with the platform reason and deselection available. The offline Official catalog carries the same package-owned platform declaration, so uninstalled entries are checked without resolving provider code or querying a registry. These checks do not change selection or activation. A non-boolean declaration returns a metadata diagnostic. A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable.
|
||||
|
||||
`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. `source` gives a profile dependency as a spec `pnpm add` accepts: a git address or URL as recorded without the user information of an http(s) URL (a query string stays as recorded), a `file:` or `link:` path made absolute against the profile or the home directory for `~`, and a registry range, tag, or alias after the package name, as is any spec of a package installed under another name; a bundle the installation supplies has none, even when the profile also depends on it, because the installation's copy is the one that loads. The `plugin_manager` tool omits UI display metadata and update eligibility from list results.
|
||||
|
||||
@@ -177,7 +179,7 @@ Tool results append to the transcript. Enabling or disabling other tools can cha
|
||||
<a id="failure-behavior"></a>
|
||||
### Failure behavior
|
||||
|
||||
Failures preserve completed steps and report the actual remaining state. Profile dependencies without valid bundle metadata remain visible and removable, with enablement unavailable.
|
||||
Failures preserve completed steps and report the actual remaining state. Profile dependencies without valid bundle metadata remain visible and removable, with enablement unavailable. If `dsh plugin` installs a bundle whose platform declaration excludes the Host, automatic activation returns exit code 1 and a diagnostic naming the package. Installed dependencies remain, and bundle selection stays unchanged. Use a supported Host or remove the dependency with `dsh plugin --profile <name> remove <package>`.
|
||||
|
||||
| Failed operation | Handling |
|
||||
|---|---|
|
||||
|
||||
@@ -39,7 +39,9 @@ kind: "package-reference"
|
||||
|
||||
插件开关只更新 profile 的 `cordis.patch.yml` 中最后一条匹配覆盖项的 `disabled`;没有匹配项时追加。匹配依据是条目 id,以及覆盖项声明的模块名称。如果组合包层和其余 profile patch 已使条目处于所请求的状态,仅含 `id` 与 `disabled` 的覆盖项会被移除,也不会追加覆盖项,因此 profile patch 只记录与组合包不同的设置。该比较不计入 home 和单次启动 patch;它们在 profile patch 之后应用,不决定 profile patch 记录什么。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。
|
||||
|
||||
`listBundles` 保留默认隐藏的组合包,并投影各包的 `dsh.bundle.listedByDefault` 声明供 GUI 使用;它不改变选入或激活行为。声明不是布尔值时返回元数据诊断。已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。
|
||||
组合包的平台声明会在保存启用选择前检查。Host 不受支持时返回 `unsupported-platform`。组合包仍保留显示信息,并允许取消选择。包操作后的自动启用执行同一检查。给模型的下一步说明会提示用户在受支持的 Host 上运行该组合包。
|
||||
|
||||
`listBundles` 保留默认隐藏的组合包,根据各包的展示声明和 Host 平台支持范围生成 `listedByDefault`。不支持的组合包默认不出现在主列表或“更多”中;profile 显式安装的依赖和已保存的选择仍可管理,显示平台原因并允许关闭。官方离线目录携带同一份包级平台声明,因此无需解析提供方代码或查询注册表,也能检查未安装的条目。这些检查不改变选择或激活状态。声明不是布尔值时返回元数据诊断。已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。
|
||||
|
||||
`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`source` 以 `pnpm add` 接受的 spec 给出 profile 依赖:git 地址或 URL 按记录给出,但去掉 http(s) URL 的 user information(查询字符串保持原样);`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,`~` 展开为主目录;注册表版本范围、标签或别名接在包名的 `@` 之后,以别名安装的包的 spec 也是如此。安装提供的组合包没有该字段,即使 profile 也依赖它,因为实际加载的是安装中的副本。`plugin_manager` 工具的列表结果不包含 UI 展示元信息和更新资格。
|
||||
|
||||
@@ -177,7 +179,7 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,
|
||||
<a id="failure-behavior"></a>
|
||||
### 失败行为
|
||||
|
||||
失败保留已完成步骤,并报告实际残留状态。没有有效组合包声明的 profile 依赖仍可见、可删除,但不能启用。
|
||||
失败保留已完成步骤,并报告实际残留状态。没有有效组合包声明的 profile 依赖仍可见、可删除,但不能启用。`dsh plugin` 安装的组合包不支持当前 Host 时,自动启用返回退出码 1,并在诊断中写明包名。已安装的依赖仍保留,组合包选择不变。请使用受支持的 Host,或通过 `dsh plugin --profile <name> remove <package>` 移除依赖。
|
||||
|
||||
| 失败操作 | 处理方式 |
|
||||
|---|---|
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions,
|
||||
setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME,
|
||||
getDshRuntimeVersion, ResolutionRestartRequired,
|
||||
assertBundlePlatform, UnsupportedBundlePlatformError,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { OPTIONAL_BUNDLES, OFFICIAL_ON_DEMAND_CATALOG, PROTECTED_PLUGINS } from '@deepseek-ai/dsh-official-bundles'
|
||||
import type {} from '@deepseek-ai/dsh-hmr'
|
||||
@@ -106,6 +107,7 @@ function messageOf(error: unknown): string { return error instanceof Error ? err
|
||||
|
||||
/** An expected refusal keeps its code; anything else becomes an operation error carrying its exact diagnostic. */
|
||||
function managementError(error: unknown): ManagementError {
|
||||
if (error instanceof UnsupportedBundlePlatformError) return { code: 'unsupported-platform' }
|
||||
if (!(error instanceof ManagementFailure)) return { code: 'operation-error', diagnostic: messageOf(error) }
|
||||
return { code: error.code, ...error.incompatible === undefined ? {} : { incompatible: error.incompatible } }
|
||||
}
|
||||
@@ -492,9 +494,14 @@ export class PluginManager extends TypertRemoteService {
|
||||
}
|
||||
const catalogMeta = offered === undefined ? {} : { meta: offered.meta }
|
||||
if (offered !== undefined && !installed && !shipped) {
|
||||
let platformError: ManagementError | undefined
|
||||
try { assertBundlePlatform(offered.platforms, process.platform) }
|
||||
catch (error) { platformError = managementError(error) }
|
||||
const error = platformError ?? targetError ?? (enabled ? { code: 'not-bundle' as const } : undefined)
|
||||
bundles.push({ name, official, availability: 'missing', ...catalogMeta, ...target, enabled, installed, optional,
|
||||
...platformError?.code === 'unsupported-platform' ? { listedByDefault: false } : {},
|
||||
removable: enabled, rows: [], overrides: [],
|
||||
...targetError !== undefined ? { error: targetError } : enabled ? { error: { code: 'not-bundle' } } : {} })
|
||||
...error === undefined ? {} : { error } })
|
||||
continue
|
||||
}
|
||||
// Installation-owned copies remain protected even if a profile also declares their names.
|
||||
@@ -525,13 +532,18 @@ export class PluginManager extends TypertRemoteService {
|
||||
if (compatibility !== undefined && !compatibility.exempted) throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)])
|
||||
const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
|
||||
const meta = offered?.meta ?? readPluginMeta(info.name ?? name, pathToFileURL(join(dir, 'package.json')).href)
|
||||
let platformError: ManagementError | undefined
|
||||
try { assertBundlePlatform(info.dsh?.bundle?.platforms, process.platform) }
|
||||
catch (error) { platformError = managementError(error) }
|
||||
if (platformError?.code === 'unsupported-platform') listing = { listedByDefault: false }
|
||||
const error = platformError ?? targetError
|
||||
bundles.push({ name, official, availability, ...listing, ...target, ...(version === undefined ? {} : { version }),
|
||||
...(info.description === undefined || info.description === '' ? {} : { description: info.description }),
|
||||
...meta === undefined ? {} : { meta },
|
||||
...sourceOf(info.name), enabled, installed, optional, removable: removable && readOnlyReason === undefined,
|
||||
...(readOnlyReason === undefined ? {} : { readOnlyReason }),
|
||||
...this.declaredRows(name, info),
|
||||
...targetError === undefined ? {} : { error: targetError } })
|
||||
...error === undefined ? {} : { error } })
|
||||
} catch (error) {
|
||||
if (enabled || installed || optional || offered !== undefined) {
|
||||
bundles.push({ name, official, availability, ...listing, ...catalogMeta, ...target, ...(version === undefined ? {} : { version }),
|
||||
@@ -1126,6 +1138,7 @@ export class PluginManager extends TypertRemoteService {
|
||||
const metadata = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
|
||||
if (metadata === undefined) throw new ManagementFailure('not-bundle')
|
||||
if (enabled) {
|
||||
assertBundlePlatform(metadata.dsh?.bundle?.platforms, process.platform)
|
||||
const compatibility = evaluatePluginCompatibility(metadata, readProfileVersionExemptions(this.profile.dir))
|
||||
if (compatibility !== undefined && !compatibility.exempted) throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)])
|
||||
this.bundleRows(name)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { execa } from 'execa'
|
||||
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import {
|
||||
bundlePatchPaths, initProfile, readProfileManifest,
|
||||
assertBundlePlatform, UnsupportedBundlePlatformError, bundlePatchPaths, initProfile, readProfileManifest,
|
||||
resolveBundleDir, resolveProfileDir, loadOverlayPatches, composeEntries, readProfileVersionExemptions,
|
||||
evaluatePluginCompatibility, pluginCompatibilityWarning, type ProfileManifest,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
@@ -86,12 +86,13 @@ export async function saveManifest(dir: string, manifest: ProfileManifest): Prom
|
||||
await writeFileAtomic(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n', { mode: 0o600 })
|
||||
}
|
||||
|
||||
/** Reconcile package removals and newly installed bundles without re-enabling retained dependencies. */
|
||||
async function reconcile(before: ProfileManifest, dir: string, anchor: string, options: PackageOperationOptions): Promise<void> {
|
||||
/** Reconcile bundle selection, returning platform denials without saving any selection changes. */
|
||||
async function reconcile(before: ProfileManifest, dir: string, anchor: string, options: PackageOperationOptions): Promise<string[]> {
|
||||
const after = readProfileManifest('dsh', dir)
|
||||
const dependencies = Object.keys(after.dependencies ?? {})
|
||||
const beforeDeps = new Set(Object.keys(before.dependencies ?? {}))
|
||||
const previous = after.dsh?.profile?.bundles ?? []
|
||||
const unsupported: string[] = []
|
||||
const bundles = previous.filter((name) => {
|
||||
if (!beforeDeps.has(name) && !dependencies.includes(name)) return true
|
||||
return dependencies.includes(name) && bundleManifest(name, dir, anchor) !== undefined
|
||||
@@ -103,14 +104,21 @@ async function reconcile(before: ProfileManifest, dir: string, anchor: string, o
|
||||
options.onOutput?.(`dsh: warning: ${name} declares no dsh.bundle — installed as a plain dependency, not a profile layer\n`, 'stderr')
|
||||
continue
|
||||
}
|
||||
try { assertBundlePlatform(metadata.dsh.bundle.platforms, process.platform) } catch (error) {
|
||||
if (!(error instanceof UnsupportedBundlePlatformError)) throw error
|
||||
unsupported.push(`${name}: ${error.message}`)
|
||||
continue
|
||||
}
|
||||
for (const file of bundlePatchPaths(resolveBundleDir('dsh', name, anchor, dir), metadata.dsh.bundle)) loadOverlayPatches('dsh', file)
|
||||
if (!bundles.includes(name)) {
|
||||
bundles.push(name)
|
||||
}
|
||||
}
|
||||
if (JSON.stringify(previous) === JSON.stringify(bundles)) return
|
||||
if (unsupported.length > 0) return unsupported
|
||||
if (JSON.stringify(previous) === JSON.stringify(bundles)) return []
|
||||
after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles } }
|
||||
await saveManifest(dir, after)
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -281,6 +289,8 @@ function bundleComponentManifests(manifest: ProfileManifest, dir: string, anchor
|
||||
* @param options Output, activation and cancellation policy.
|
||||
* @returns Exit status, whether the silence bound stopped the run, and the diagnostic path.
|
||||
* A compatibility denial returns exit code 1. Service output is bounded; CLI output uses inherited descriptors.
|
||||
* Unsupported Host activation returns exit code 1 with named diagnostics; installed dependencies remain,
|
||||
* and no bundle selection changes are saved. Other reconciliation or filesystem errors reject.
|
||||
* The diagnostic file is closed on return or rejection, including preparation and spawn failures.
|
||||
*/
|
||||
export async function runProfilePnpm(
|
||||
@@ -521,7 +531,14 @@ export async function runProfilePnpm(
|
||||
options.onOutput?.(diagnostic, 'stderr')
|
||||
append(Buffer.from(diagnostic))
|
||||
} else if (options.activateNewBundles !== false) {
|
||||
await reconcile(before, dir, context.installAnchor, options)
|
||||
const unsupported = await reconcile(before, dir, context.installAnchor, options)
|
||||
if (unsupported.length > 0) {
|
||||
exitCode = 1
|
||||
const diagnostic = `\ndsh: bundle activation rejected: ${unsupported.join('\n')}\ndsh: installed dependencies remain; bundle selection is unchanged. Use a supported Host or remove the unwanted dependencies with 'dsh plugin remove <package>'.\n`
|
||||
await log.write(diagnostic)
|
||||
options.onOutput?.(diagnostic, 'stderr')
|
||||
append(Buffer.from(diagnostic))
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -23,6 +23,7 @@ const ERROR_STEPS: Record<ManagementError['code'], string> = {
|
||||
'bundle-in-use': 'Call list_plugins, disable the entries from other layers that still use this bundle, then retry.',
|
||||
'stale-approval': 'Call install_bundle again without approvedBuilds to read the current pendingBuilds.',
|
||||
'incompatible-version': 'Install a version compatible with the running DSH. A version exemption needs the user\'s explicit acceptance of crash and data-loss risk.',
|
||||
'unsupported-platform': 'This bundle cannot run on the current Host platform; ask the user to use a supported Host.',
|
||||
'operation-error': 'Report the diagnostic to the user; retry only after its cause is addressed.',
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface IncompatiblePlugin {
|
||||
|
||||
/** Localizable management failure and optional external diagnostic. */
|
||||
export interface ManagementError {
|
||||
code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'already-installed' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'incompatible-version' | 'operation-error'
|
||||
code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'already-installed' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'incompatible-version' | 'unsupported-platform' | 'operation-error'
|
||||
diagnostic?: string
|
||||
/** Present with `incompatible-version`: the packages the running DSH version rejects. */
|
||||
incompatible?: IncompatiblePlugin[]
|
||||
@@ -83,7 +83,7 @@ export interface BundleInfo {
|
||||
meta?: PluginLocalizedMeta
|
||||
/** Untranslated `description` of this bundle's package manifest. */
|
||||
description?: string
|
||||
/** GUI listing before explicit selection or profile installation; omission means true. */
|
||||
/** Default GUI visibility after package and Host checks; selected or profile-installed bundles remain manageable. */
|
||||
listedByDefault?: boolean
|
||||
/** Selected in the profile manifest; a load error means its layer was skipped. */
|
||||
enabled: boolean
|
||||
|
||||
@@ -91,6 +91,40 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre
|
||||
return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr, overlays, connection }
|
||||
}
|
||||
|
||||
it.each(['live', 'startup'] as const)('refuses an unsupported bundle before saving or applying it with %s reload', async (reload) => {
|
||||
const { manager, dir, ctx } = await fixture(reload, false, undefined, {}, undefined, (dir) => {
|
||||
const path = join(dir, 'node_modules/extra/package.json')
|
||||
writeFileSync(path, JSON.stringify({ name: 'extra', version: '1.0.0', description: 'Platform-limited bundle',
|
||||
dsh: { bundle: { patch: './cordis.patch.yml', platforms: [process.platform === 'darwin' ? 'win32' : 'darwin'] } } }))
|
||||
const manifest = readProfileManifest('test', dir)
|
||||
manifest.dsh = { profile: { bundles: ['core'] } }
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
||||
})
|
||||
const before = readFileSync(join(dir, 'package.json'), 'utf8')
|
||||
const patchBefore = readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')
|
||||
expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({
|
||||
enabled: false, installed: true, listedByDefault: false, description: 'Platform-limited bundle', error: { code: 'unsupported-platform' },
|
||||
})
|
||||
expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: false, application: 'failed', error: { code: 'unsupported-platform' } })
|
||||
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before)
|
||||
expect(ctx.get('managedProbe')).toBeUndefined()
|
||||
expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).toBe(patchBefore)
|
||||
expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: false })
|
||||
})
|
||||
|
||||
it('allows supported bundle activation and permits deselecting an unsupported saved bundle', async () => {
|
||||
const { manager, dir } = await fixture()
|
||||
const path = join(dir, 'node_modules/extra/package.json')
|
||||
const metadata = readProfileManifest('test', join(dir, 'node_modules/extra'))
|
||||
metadata.dsh = { bundle: { patch: './cordis.patch.yml', platforms: [process.platform] } }
|
||||
writeFileSync(path, JSON.stringify(metadata))
|
||||
expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'applied' })
|
||||
expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: true, application: 'applied' })
|
||||
metadata.dsh.bundle!.platforms = [process.platform === 'darwin' ? 'win32' : 'darwin']
|
||||
writeFileSync(path, JSON.stringify(metadata))
|
||||
expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'applied' })
|
||||
})
|
||||
|
||||
it.each(['network', 'timeout'] as const)('stops a GitHub %s before pnpm and attributes it to the repository', async (kind) => {
|
||||
const { manager, dir, connection } = await fixture()
|
||||
const before = readFileSync(join(dir, 'package.json'), 'utf8')
|
||||
@@ -1613,7 +1647,6 @@ it.each([false, true])('rechecks installed bundle peers before accepting a disab
|
||||
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra'])
|
||||
})
|
||||
|
||||
|
||||
it('lists absent Official entries offline without resolving their packages or consulting registries', async () => {
|
||||
const { manager } = await fixture()
|
||||
const resolve = vi.spyOn(operations, 'bundleManifest')
|
||||
@@ -1631,6 +1664,30 @@ it('lists absent Official entries offline without resolving their packages or co
|
||||
expect(registry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([false, true])('applies offline platform admission to an uninstalled Official bundle (selected=%s)', async (selected) => {
|
||||
const entry = OFFICIAL_ON_DEMAND_CATALOG[0]!
|
||||
const previous = Object.getOwnPropertyDescriptor(entry, 'platforms')
|
||||
onTestFinished(() => {
|
||||
if (previous === undefined) Reflect.deleteProperty(entry, 'platforms')
|
||||
else Object.defineProperty(entry, 'platforms', previous)
|
||||
})
|
||||
Object.defineProperty(entry, 'platforms', { configurable: true, value: [process.platform === 'darwin' ? 'linux' : 'darwin'] })
|
||||
const { manager, dir } = await fixture('startup')
|
||||
if (selected) {
|
||||
const manifest = readProfileManifest('test', dir)
|
||||
manifest.dsh!.profile!.bundles!.push(entry.packageName)
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
||||
}
|
||||
expect((await manager.listBundles()).find(bundle => bundle.name === entry.packageName)).toMatchObject({
|
||||
installed: false, availability: 'missing', enabled: selected, listedByDefault: false,
|
||||
error: { code: 'unsupported-platform' },
|
||||
})
|
||||
if (selected) {
|
||||
expect(await manager.setBundleEnabled(entry.packageName, false)).toMatchObject({ changed: true, enabled: false })
|
||||
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).not.toContain(entry.packageName)
|
||||
}
|
||||
})
|
||||
|
||||
it('distinguishes an unreadable profile dependency from an absent catalog selection', async () => {
|
||||
const { manager, dir, profile } = await fixture()
|
||||
const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName
|
||||
|
||||
@@ -172,6 +172,40 @@ function install(dir: string, name: string) {
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
||||
}
|
||||
|
||||
it.each(['service', 'cli'] as const)('reports unsupported activation through %s diagnostics without saving any selection changes', async (execution) => {
|
||||
const { dir, context, pnpm } = fixture()
|
||||
pnpm.mutate = (target) => {
|
||||
install(target, 'supported')
|
||||
install(target, 'restricted')
|
||||
writeFileSync(join(target, 'node_modules/restricted/package.json'), JSON.stringify({ name: 'restricted', version: '1',
|
||||
dsh: { bundle: { patch: './cordis.patch.yml', platforms: [process.platform === 'darwin' ? 'win32' : 'darwin'] } } }))
|
||||
}
|
||||
const messages: string[] = []
|
||||
const result = await runPluginCommand(context, ['add', 'restricted'], {
|
||||
execution, outputBytes: 8192, onOutput: (text) => { messages.push(text) },
|
||||
})
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.output).toContain('restricted: Bundle supports Host platforms')
|
||||
expect(result.output).toContain('installed dependencies remain; bundle selection is unchanged')
|
||||
expect(result.output).toContain('dsh plugin remove <package>')
|
||||
expect(readFileSync(result.logPath, 'utf8')).toContain(result.output)
|
||||
expect(messages.join('')).toContain(result.output)
|
||||
expect(readProfileManifest('test', dir).dependencies).toMatchObject({ supported: '1', restricted: '1' })
|
||||
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves malformed platform metadata errors without selecting the installed bundle', async () => {
|
||||
const { dir, context, pnpm } = fixture()
|
||||
pnpm.mutate = (target) => {
|
||||
install(target, 'malformed')
|
||||
writeFileSync(join(target, 'node_modules/malformed/package.json'), JSON.stringify({ name: 'malformed', version: '1',
|
||||
dsh: { bundle: { patch: './cordis.patch.yml', platforms: ['macos'] } } }))
|
||||
}
|
||||
await expect(runProfilePnpm(context, ['add', 'malformed'], { execution: 'service', outputBytes: 8192 }))
|
||||
.rejects.toThrow('dsh.bundle.platforms must be a nonempty list of Node platform names')
|
||||
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual([])
|
||||
})
|
||||
|
||||
function installGuarded(dir: string, dependency: string, name = dependency, version = '1.0.0', peer = '>=999.0.0') {
|
||||
install(dir, dependency)
|
||||
writeFileSync(join(dir, 'node_modules', dependency, 'package.json'), JSON.stringify({
|
||||
|
||||
@@ -306,4 +306,6 @@ it('tells the agent the next step for each change outcome it can act on', async
|
||||
expect(await outcome({ changed: false, application: 'failed', packageResult: { kind: 'unknown' }, error: { code: 'operation-error' } }))
|
||||
.toContain('Report the diagnostic')
|
||||
expect(await outcome({ changed: false, application: 'failed' })).toContain('Report the diagnostic')
|
||||
expect(await outcome({ changed: false, application: 'failed', error: { code: 'unsupported-platform' } }))
|
||||
.toBe('This bundle cannot run on the current Host platform; ask the user to use a supported Host.')
|
||||
})
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
en: b0d806604bc17006
|
||||
zh: 18dabe5dc2d0d419
|
||||
/deepseek-ai-dsh-client-ui-chat/grouped-rendering:
|
||||
en: a14c74ed7de0ef9c
|
||||
zh: d5e618b2ef809150
|
||||
en: 3afad0b4c6bf9ce0
|
||||
zh: d8179ba4ee9fcd61
|
||||
/deepseek-ai-dsh-client-ui-chat/scroll-ownership:
|
||||
en: 009ecb4fecd4ee9e
|
||||
zh: ec2714337a1102c5
|
||||
|
||||
@@ -113,7 +113,7 @@ Live tool deltas share reasoning's frame-batched publication; durable calls and
|
||||
|
||||
The process group uses a stable `div` layout box, a scroll body, and an uncapped content box that reports growth inside the body. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception. The `conversation.chat.flow` slot adds a `display: contents` DOM anchor; row selectors match that anchor directly, without repeating its column ancestry. The running status shares the rows' parent, so its divider uses the same visible-sibling rules. The anchor contributes no spacing of its own. The earlier-history control directly precedes the flow anchor; their sibling relationship supplies the leading gap. CSS variables do not belong in the Group Definition.
|
||||
|
||||
Running group titles, reasoning previews, tool and command rows, the active retry line, and the bottom running status use the shared [TextShimmer](../ui-primitives/README.md#component-catalog). Each row’s text shares one left-to-right highlight, including collapsed group titles; icons and hover chevrons retain their base appearance. Tool and process rows sit 6px apart. An expanded group title has 8px before its content, Assistant responses have 12px from adjacent process rows, and completed-Turn duration/status controls have 16px from their input and from a directly following visible row. A reply that follows the control after folded rows keeps the 12px reply gap, so folding never changes its spacing. Hidden and empty rows, including retained empty slot anchors, add no gap. The running “Deep diving” text retains its blue base while its theme-specific highlight sweeps across it.
|
||||
Running group titles, reasoning previews, tool and command rows, the active retry line, and the bottom running status use the shared [TextShimmer](../ui-primitives/README.md#component-catalog). Each row’s text shares one left-to-right highlight, including collapsed group titles; icons and hover chevrons retain their base appearance. Ordinary flow rows sit 4px apart. An expanded group title has 8px before its content, Assistant responses have 12px from adjacent process rows, and completed-Turn duration/status controls have 16px from their input and from a directly following visible row. A reply that follows the control after folded rows keeps the 12px reply gap, so folding never changes its spacing. Hidden and empty rows, including retained empty slot anchors, add no gap. The running “Deep diving” text retains its blue base while its theme-specific highlight sweeps across it.
|
||||
|
||||
The running status shows a whale whose APNG mask inherits the text color. CSS selects the static SVG when reduced motion or forced colors are active, or alpha masks are unsupported. The APNG is a [package asset](../AGENTS.md#dependency-declaration) referenced by the plugin stylesheet; React does not pass image data through DOM styles.
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的
|
||||
|
||||
过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。`conversation.chat.flow` 插槽添加一个 `display: contents` DOM 锚点;行选择器直接匹配该锚点,不重复匹配其正文容器祖先。运行状态与各行共享父级,使分隔线使用同一套可见兄弟节点规则。锚点自身不产生间距。更早历史控件紧邻流锚点之前,两者的兄弟关系决定首行间距。CSS 变量不属于 Group Definition。
|
||||
|
||||
运行中的分组标题、思考预览、工具与命令行、当前重试行以及底部运行状态使用共享的 [TextShimmer](../ui-primitives/README.zh.md#component-catalog)。每行的文字共用一道从左到右的高光,包括折叠的分组标题;图标和悬停箭头保留底色。工具和过程行之间相隔 6px;展开的分组标题与内容之间相隔 8px;Assistant 回复与相邻过程行之间相隔 12px;每轮完成/耗时控件与其输入、以及紧随其后的可见行之间保留 16px。控件与回复之间隔着已收起的行时,回复保持 12px 回复间距,因此折叠不会改变它的间距。隐藏行与空行(包括保留的空 slot 锚点)不产生间距。“深度求索”运行文字保留蓝色底色,并使用主题专用高光扫过文字。
|
||||
运行中的分组标题、思考预览、工具与命令行、当前重试行以及底部运行状态使用共享的 [TextShimmer](../ui-primitives/README.zh.md#component-catalog)。每行的文字共用一道从左到右的高光,包括折叠的分组标题;图标和悬停箭头保留底色。普通流程行之间相隔 4px;展开的分组标题与内容之间相隔 8px;Assistant 回复与相邻过程行之间相隔 12px;每轮完成/耗时控件与其输入、以及紧随其后的可见行之间保留 16px。控件与回复之间隔着已收起的行时,回复保持 12px 回复间距,因此折叠不会改变它的间距。隐藏行与空行(包括保留的空 slot 锚点)不产生间距。“深度求索”运行文字保留蓝色底色,并使用主题专用高光扫过文字。
|
||||
|
||||
运行状态显示一只鲸鱼,其 APNG 遮罩继承文字颜色。启用减少动态效果或强制颜色模式、或浏览器不支持 alpha 遮罩时,CSS 选择静态 SVG。APNG 是插件样式表引用的[包资源](../AGENTS.md#dependency-declaration);React 不通过 DOM 样式传递图片数据。
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
}
|
||||
|
||||
.body {
|
||||
--dsh-chat-flow-gap: 6px;
|
||||
--dsh-chat-flow-gap: 4px;
|
||||
max-height: min(400px, 50vh);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: auto;
|
||||
@@ -146,7 +146,7 @@
|
||||
}
|
||||
.content > * { flex-shrink: 0; }
|
||||
.content > :not([hidden]):not(:empty) ~ :not([hidden]):not(:empty) {
|
||||
margin-top: var(--dsh-chat-flow-gap, 6px);
|
||||
margin-top: var(--dsh-chat-flow-gap, 4px);
|
||||
}
|
||||
.expandedBody {
|
||||
max-height: none;
|
||||
|
||||
@@ -78,10 +78,10 @@
|
||||
~ :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))),
|
||||
.column > .older + :global([data-slot="conversation.chat.flow"])
|
||||
> :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))) {
|
||||
margin-top: var(--dsh-chat-flow-gap, 6px);
|
||||
margin-top: var(--dsh-chat-flow-gap, 4px);
|
||||
}
|
||||
|
||||
/* Process rows sit 6px apart; an Assistant response is separated from its
|
||||
/* Process rows sit 4px apart; an Assistant response is separated from its
|
||||
neighbours by 12px. */
|
||||
.flowItem[data-chat-group-part="response"] {
|
||||
--dsh-chat-flow-gap: 12px;
|
||||
@@ -136,7 +136,7 @@
|
||||
steering row of each claim that Chat orders after the Turn's process
|
||||
control; that row keeps the slot above it, the way a completed Turn's
|
||||
actions row precedes the next user message, while rows claimed together
|
||||
stay 6px apart and opening inputs keep ordinary spacing. The first waiting
|
||||
stay 4px apart and opening inputs keep ordinary spacing. The first waiting
|
||||
echo sits directly below the running status, so admission swaps the two
|
||||
without moving any bubble. Neighbour gap rules exclude the batch start so
|
||||
this slot always applies. */
|
||||
@@ -178,7 +178,7 @@
|
||||
/* While the running Step has no output, the status sits directly below the
|
||||
input. When the first output row appears, the status enters its slot below
|
||||
that row from where it stood: it travels the row's gap plus one 24px row,
|
||||
which follows the content font size. The gap is 6px for a process row or
|
||||
which follows the content font size. The gap is 4px for a process row or
|
||||
group header (the default declared on `.running`), 12px for a response, and
|
||||
16px after the running Turn's process control, which renders empty until the
|
||||
Turn closes, so it precedes a new Turn's first output. The default sits on
|
||||
@@ -260,7 +260,7 @@
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
--dsh-chat-flow-gap: 12px;
|
||||
--dsh-running-follow: calc(6px + 24px + var(--dsh-content-font-delta, 0px));
|
||||
--dsh-running-follow: calc(4px + 24px + var(--dsh-content-font-delta, 0px));
|
||||
--dsw-alias-label-shimmer: var(--dsw-alias-label-deep-diving-shimmer);
|
||||
color: var(--dsw-alias-label-deep-diving);
|
||||
font-size: calc(var(--dsh-content-font-size, 14px) - 2px);
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
en: 8e775c43878ae31a
|
||||
zh: 4c1063c0071ebdbd
|
||||
/chat-conversation-node-rules/display-modes/group-title-rules:
|
||||
en: 8c5b57a66fe0247c
|
||||
zh: 5a9e37f255761ac6
|
||||
en: f78fb52d4733e6ce
|
||||
zh: 119e5319b23762ba
|
||||
/chat-conversation-node-rules/process-grouping:
|
||||
en: 3d28b3bb8757bf55
|
||||
zh: 3b0f451b48db6048
|
||||
|
||||
@@ -187,7 +187,7 @@ The labels below describe recorded activity, not successful outcomes. For exampl
|
||||
|
||||
English lowercases the initial letter of joined labels after the first. Closing a group immediately selects the completed summary; the 150ms minimum applies to running-title changes, not to delaying completion. Detailed hides group headers in running Turns, including groups ended by a reply or steering before their Turn ends. Verbose also hides historical group headers.
|
||||
|
||||
Group headers show a category icon, replace it with a down arrow on hover or keyboard focus, and show an up arrow while open. Manually expanded group bodies use 6px row spacing, a `min(400px, 50vh)` height cap, and 24px directional fades. Wheel scrolling can continue into the outer transcript at an edge. Detailed removes the group-level cap in running Turns; Verbose applies this layout to historical Turns as well. Process rows retain 6px spacing in both modes; Assistant responses have 12px clearance. Group headers have the same row height as process rows. Expanded group titles sit 8px above their content; Turn process controls retain 16px clearance from their input and from a directly following visible row; a reply separated from the control by folded rows keeps the 12px reply gap, so folding never changes its spacing.
|
||||
Group headers show a category icon, replace it with a down arrow on hover or keyboard focus, and show an up arrow while open. Manually expanded group bodies use 4px row spacing, a `min(400px, 50vh)` height cap, and 24px directional fades. Wheel scrolling can continue into the outer transcript at an edge. Detailed removes the group-level cap in running Turns; Verbose applies this layout to historical Turns as well. Process rows retain 4px spacing in both modes; Assistant responses have 12px clearance. Group headers have the same row height as process rows. Expanded group titles sit 8px above their content; Turn process controls retain 16px clearance from their input and from a directly following visible row; a reply separated from the control by folded rows keeps the 12px reply gap, so folding never changes its spacing.
|
||||
|
||||
An open capped group follows content growth only while its own scroll position is at the bottom. Scrolling away pauses that group's following; returning to the bottom resumes it, independently of outer transcript following. Manually opening an unclosed group starts at the bottom and follows growth; manually opening a closed group starts at the top with following disabled, even when its initial content fits without scrolling. Closing the group in the data or restoring its height cap through a mode change does not reset an already-open reader's position. Browser find retains its own reveal position.
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
|
||||
|
||||
英文组合标题将第一项之后各项的首字母转为小写。组结束时立即切换完成摘要;150ms 最短保留时间针对运行中标题变化,不延迟完成状态。详细模式隐藏运行中轮次的组头,包括被回复或 steering 截断、但所属轮次尚未结束的小组;完全展开模式也隐藏历史组头。
|
||||
|
||||
组头显示类别图标,悬停或键盘聚焦时替换为向下箭头,展开时显示向上箭头。手动展开的组正文使用 6px 行间距、`min(400px, 50vh)` 限高及 24px 方向渐隐;到达边缘时滚轮可以继续滚动外层文本记录。详细模式对运行中轮次取消组级限高;完全展开模式对历史轮次也采用该布局。两种模式下过程行均保留 6px 间距,Assistant 回复周围保留 12px 间距。组头与过程行同高。展开组头与内容相隔 8px,整轮过程控件与其输入、以及紧随其后的可见行之间保留 16px 间距;与控件之间隔着已收起行的回复保持 12px 回复间距,因此折叠不会改变它的间距。
|
||||
组头显示类别图标,悬停或键盘聚焦时替换为向下箭头,展开时显示向上箭头。手动展开的组正文使用 4px 行间距、`min(400px, 50vh)` 限高及 24px 方向渐隐;到达边缘时滚轮可以继续滚动外层文本记录。详细模式对运行中轮次取消组级限高;完全展开模式对历史轮次也采用该布局。两种模式下过程行均保留 4px 间距,Assistant 回复周围保留 12px 间距。组头与过程行同高。展开组头与内容相隔 8px,整轮过程控件与其输入、以及紧随其后的可见行之间保留 16px 间距;与控件之间隔着已收起行的回复保持 12px 回复间距,因此折叠不会改变它的间距。
|
||||
|
||||
已展开的限高组仅在自己的滚动位置处于底部时跟随内容增长。滚离底部暂停该组跟随,滚回底部后恢复,与外层文本记录的跟随状态独立。手动展开未关闭的组时定位到底部并跟随增长;手动展开已关闭的组时从顶部开始且不启用跟随,即使初始内容无需滚动即可容纳。数据中的组变为已关闭,或通过模式切换恢复限高时,不重置已展开组的阅读位置。浏览器查找沿用自身的展开定位。
|
||||
|
||||
|
||||
@@ -8,12 +8,18 @@ const read = (name: string): string =>
|
||||
|
||||
describe('Turn tail slot spacing', () => {
|
||||
it('combines the flow and footer offsets around turn-tail content', () => {
|
||||
expect(read('ChatView.module.css')).toMatch(/margin-top:\s*var\(--dsh-chat-flow-gap, 6px\)/)
|
||||
expect(read('ChatView.module.css')).toMatch(/margin-top:\s*var\(--dsh-chat-flow-gap, 4px\)/)
|
||||
const tail = read('TurnTailNodeView.module.css')
|
||||
expect(tail).toMatch(/\.root\s*\{[^}]*gap:\s*16px/s)
|
||||
expect(tail).toMatch(/\.actions\s*\{[^}]*margin-top:\s*4px/s)
|
||||
})
|
||||
|
||||
it('keeps process rows inside and outside a group on the same gap', () => {
|
||||
const group = read('ChatGroupSeat.module.css')
|
||||
expect(group).toMatch(/\.body \{[^}]*--dsh-chat-flow-gap: 4px;/s)
|
||||
expect(group).toMatch(/margin-top:\s*var\(--dsh-chat-flow-gap, 4px\)/)
|
||||
})
|
||||
|
||||
it('keeps the tail slot above a marked steering batch start and out of neighbour gaps', () => {
|
||||
const view = read('ChatView.module.css')
|
||||
const marked = String.raw`:has\(> \[data-slot="conversation\.chat\.node"\] > \[data-chat-steering-batch`
|
||||
@@ -44,7 +50,7 @@ describe('Running status move below the first output', () => {
|
||||
const view = read('ChatView.module.css')
|
||||
// A process row or group header after a mid-Turn input: the default sits on the single-class
|
||||
// status rule, the lowest-specificity selector, so each variant below outranks it.
|
||||
expect(view).toMatch(new RegExp(String.raw`\n\.running \{[^}]*${travel(6)}`))
|
||||
expect(view).toMatch(new RegExp(String.raw`\n\.running \{[^}]*${travel(4)}`))
|
||||
expect(view.match(/--dsh-running-follow: calc\(/g)).toHaveLength(3)
|
||||
// A response keeps its 12px clearance.
|
||||
expect(view).toMatch(new RegExp(String.raw`\)\)\)\)\[data-chat-group-part="response"\] ~ \.running \{\s*${travel(12)}`))
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 7871f9fe6bcc4459
|
||||
zh: 0d51565c600b3958
|
||||
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
|
||||
en: e8f4f754b7ef2ae9
|
||||
zh: 8c6486315fbb9812
|
||||
en: a5f1d59f1b9861b1
|
||||
zh: e581bd33439f0c55
|
||||
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
|
||||
en: bb1244310f5c0382
|
||||
zh: 824b84077432b559
|
||||
|
||||
@@ -82,7 +82,7 @@ The context-occupancy button shows a ring and percentage below the input card, a
|
||||
|
||||
With Coding Tools off, the shell hides only Trajectory; other plugin-contributed Views remain available. The View tab bar is hidden when fewer than two Views are available. Turning Coding Tools off while Trajectory is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes Trajectory available again. View owners receive the available roster so their navigation actions can follow the same visibility.
|
||||
|
||||
While the root-scoped `conversation.session.header.views.actions` list has registrations, a **More conversation views** button with a tooltip follows the View tabs in the tab row. It opens a menu whose rows are that list's registrations. Each receives `entries` (Views outside the header in registration order with `id`, `label`, `pinned`, and `active`; Chat is never listed), `onOpen(id)`, which selects the View in this Session, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The header keeps an in-memory list of unpinned View ids shared by every Session; the tab bar omits unpinned Views and places registered Chat first so it always remains outside More. Plugin reload resets pin choices, and legacy localStorage values are ignored. The tab bar's visibility counts available Views, not pinned ones, so the bar and More stay while every other View is unpinned. Unpinning the active View hides its tab immediately without changing the selected content. More lists only Views outside the header and identifies the current View through `active`. The header reads its available row width, reserves 48px for More and padding, and estimates 120px per tab. Remaining Views keep registration order in More. A single ResizeObserver watches the tab row so window resizing and sidebar expansion update the split; active selection does not change the order. The observer disconnects when the row is removed. If ResizeObserver is unavailable, the initial width calculation still applies without subsequent observation. The More button highlights and names the active View in its tooltip when that View is folded. Individual tabs are not measured. At least one preferred tab remains outside, and long labels truncate with a full-name tooltip. Unregistered View ids remain in the in-memory pin list until plugin reload.
|
||||
While the root-scoped `conversation.session.header.views.actions` list has registrations, a **More conversation views** button with a tooltip follows the View tabs in the tab row. It opens a menu whose rows are that list's registrations. Each receives `entries` (Views outside the header in registration order with `id`, `label`, `pinned`, and `active`; Chat is never listed), `onOpen(id)`, which selects the View in this Session, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The header keeps an in-memory list of unpinned View ids shared by every Session; the tab bar omits unpinned Views and places registered Chat first so it always remains outside More. Plugin reload resets pin choices, and legacy localStorage values are ignored. The tab bar's visibility counts available Views, not pinned ones, so the bar and More stay while every other View is unpinned. Unpinning the active View hides its tab immediately without changing the selected content. More lists only Views outside the header and identifies the current View through `active`. The header reads its available row width, reserves 48px for More and padding, and estimates 120px per tab. Remaining Views keep registration order in More. A single ResizeObserver watches the tab row so window resizing and sidebar expansion update the split; active selection does not change the order. The observer disconnects when the row is removed. If ResizeObserver is unavailable, the initial width calculation still applies without subsequent observation. The More button highlights and names the active View in its tooltip when that View is folded. Each tab label estimates its text width from the glyphs; its full-name tooltip appears only when that estimate exceeds the 88px cell. At least one preferred tab remains outside. Unregistered View ids remain in the in-memory pin list until plugin reload.
|
||||
|
||||
The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module.
|
||||
|
||||
@@ -98,7 +98,7 @@ Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and c
|
||||
|
||||
The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the resident `conversation.header` outside the optional-Session `conversation.content` Component Factory. The header hosts root-scoped navigation without a selected Session; titles, actions, and View tabs remain in its strict Session child. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
|
||||
|
||||
A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident header keeps a 40px window strip on macOS desktop that it owns as its drag row, and no empty vertical space on Web, Windows, or Linux. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered.
|
||||
A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident header keeps a 40px window strip on macOS desktop that it owns as its drag row, and no empty vertical space on Web, Windows, or Linux. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered. When the title and actions do not fit, the title area clips them and fades its trailing edge before the utilities, which stay fully visible and clickable; overlays in the title and actions slots portal to stay outside that clip.
|
||||
|
||||
View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell.
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ composer 在挂载、切换 Session 和解除锁定时使用共享的[文字输
|
||||
|
||||
关闭代码工作工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭代码工作工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。
|
||||
|
||||
root 作用域的 `conversation.session.header.views.actions` list 有注册项时,标签行中的 View 标签之后显示带提示框的**更多会话视图**按钮。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的头部之外的 View,含 `id`、`label`、`pinned` 与 `active`;不含 Chat)、在本 Session 选择 View 的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。头部在内存中保存所有 Session 共用的已收起 View id 列表;标签栏不显示已收起的 View,并将已注册的 Chat 放在首位,确保它始终留在更多菜单外。插件重新加载时 Pin 选择重置,不读取旧 localStorage 值。标签栏是否显示按可用 View 计算,不按已固定的 View 计算,因此其他 View 全部收起时,标签栏和“更多”仍然显示。收起当前激活的 View 会立即隐藏其标签,但不会切走其内容。更多菜单仅包含头部之外的 View,通过 `active` 标识当前 View。头部读取当前行可用宽度,为更多按钮与内边距预留 48px,每个标签估算 120px,其余 View 按注册顺序进入更多。单个 ResizeObserver 观察标签行,使窗口缩放和侧栏展开能够更新分组;当前选中项变化不改变顺序。标签行移除时断开观察。如果环境没有 ResizeObserver,仍按初始宽度计算,但不继续观察。当前 View 收起时,更多按钮高亮并在提示框中显示其名称。不逐项测量标签。至少保留一个优先展示的外部标签,长标题截断并通过提示框提供全名。未注册 View 的 id 保留在内存 Pin 列表中,直到插件重新加载。
|
||||
root 作用域的 `conversation.session.header.views.actions` list 有注册项时,标签行中的 View 标签之后显示带提示框的**更多会话视图**按钮。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的头部之外的 View,含 `id`、`label`、`pinned` 与 `active`;不含 Chat)、在本 Session 选择 View 的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。头部在内存中保存所有 Session 共用的已收起 View id 列表;标签栏不显示已收起的 View,并将已注册的 Chat 放在首位,确保它始终留在更多菜单外。插件重新加载时 Pin 选择重置,不读取旧 localStorage 值。标签栏是否显示按可用 View 计算,不按已固定的 View 计算,因此其他 View 全部收起时,标签栏和“更多”仍然显示。收起当前激活的 View 会立即隐藏其标签,但不会切走其内容。更多菜单仅包含头部之外的 View,通过 `active` 标识当前 View。头部读取当前行可用宽度,为更多按钮与内边距预留 48px,每个标签估算 120px,其余 View 按注册顺序进入更多。单个 ResizeObserver 观察标签行,使窗口缩放和侧栏展开能够更新分组;当前选中项变化不改变顺序。标签行移除时断开观察。如果环境没有 ResizeObserver,仍按初始宽度计算,但不继续观察。当前 View 收起时,更多按钮高亮并在提示框中显示其名称。每个标签按字符宽度估算文本宽度;只有估算结果超过 88px 单元格时,才通过提示框提供全名。至少保留一个优先展示的外部标签。未注册 View 的 id 保留在内存 Pin 列表中,直到插件重新加载。
|
||||
|
||||
输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。
|
||||
|
||||
@@ -98,7 +98,7 @@ root 作用域的 `conversation.session.header.views.actions` list 有注册项
|
||||
|
||||
本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` 外壳将常驻的 `conversation.header` 放在可选 Session 的 `conversation.content` Component Factory 外。未选中 Session 时,头部仍承载根作用域导航;标题、操作和 View 标签保留在严格 Session 子组件中。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
|
||||
|
||||
blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻 header 在 macOS 桌面保留 40px 的窗口顶条,由它自己作为拖拽行持有,在 Web、Windows 或 Linux 上不预留空白高度。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。
|
||||
blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻 header 在 macOS 桌面保留 40px 的窗口顶条,由它自己作为拖拽行持有,在 Web、Windows 或 Linux 上不预留空白高度。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。标题与 actions 放不下时,标题区域会裁剪它们,并在 utilities 之前渐隐其末端;utilities 始终完整可见且可点击;标题与 actions slot 中的浮层通过 portal 渲染,不受该裁剪影响。
|
||||
|
||||
View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific 快照。
|
||||
|
||||
|
||||
@@ -237,13 +237,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
hideChrome: boolean
|
||||
}
|
||||
}
|
||||
/** Optional replacement for one Session breadcrumb title. */
|
||||
/** Optional replacement for one Session breadcrumb title; overlays must portal because the title area clips overflow. */
|
||||
'conversation.session.header.lineage': {
|
||||
kind: 'single'
|
||||
scope: 'session'
|
||||
owner: ConversationHeaderLineageOwnerProps
|
||||
}
|
||||
/** Title-adjacent Session actions in ascending order. */
|
||||
/** Title-adjacent Session actions in ascending order; overflow is clipped and faded before the utilities, so overlays must portal. */
|
||||
'conversation.session.header.actions': {
|
||||
kind: 'list'
|
||||
scope: 'session'
|
||||
|
||||
@@ -81,12 +81,29 @@
|
||||
padding-inline-start: max(0px, calc(var(--dsh-frame-leading-clearance, 0px) - 20px));
|
||||
}
|
||||
|
||||
/* Header actions do not shrink, so on a narrow row they overflow the cluster.
|
||||
The cluster clips them, hit testing included, so the next control keeps
|
||||
every click. The trailing padding spans the gap before that control and
|
||||
holds the fade: content that fits ends before it, and only overflowing
|
||||
content reaches it. The 4px block and start padding, offset by negative
|
||||
margins, keeps focus rings inside the clip. `clip` rather than `hidden`
|
||||
keeps the cluster from becoming a scroll container that focus could scroll. */
|
||||
.titleCluster {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
--title-cluster-fade: 20px;
|
||||
margin: -4px calc(-1 * var(--title-cluster-fade)) -4px -4px;
|
||||
padding: 4px var(--title-cluster-fade) 4px 4px;
|
||||
overflow: clip;
|
||||
mask-image: linear-gradient(to left, transparent calc(0.3 * var(--title-cluster-fade)), #000 var(--title-cluster-fade));
|
||||
}
|
||||
|
||||
/* Without utilities the corner's 8px margin is the gap the fade spans. */
|
||||
.titleCluster:has(+ .headerUtilities:empty) {
|
||||
--title-cluster-fade: 8px;
|
||||
}
|
||||
|
||||
/* The auto grid column collapses with an empty or CSS-hidden occupant.
|
||||
|
||||
@@ -43,6 +43,29 @@ function ViewsMoreMenu({ label, current, entries, onOpen, onPinnedChange, render
|
||||
)
|
||||
}
|
||||
|
||||
/** Approximate 13px text advance: full-width glyphs take one em, other glyphs about half. */
|
||||
const WIDE_GLYPH = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/u
|
||||
const WIDE_GLYPH_WIDTH = 13
|
||||
const NARROW_GLYPH_WIDTH = 7
|
||||
/** Mirrors the `.tabLabel` max-width in `ConversationRoot.module.css`. */
|
||||
const TAB_LABEL_MAX_WIDTH = 88
|
||||
|
||||
/** Estimate whether a View tab label exceeds its 88px cell. */
|
||||
function labelNeedsTooltip(label: string): boolean {
|
||||
let width = 0
|
||||
for (const glyph of label) width += WIDE_GLYPH.test(glyph) ? WIDE_GLYPH_WIDTH : NARROW_GLYPH_WIDTH
|
||||
return width > TAB_LABEL_MAX_WIDTH
|
||||
}
|
||||
|
||||
/** One View tab label; its full-name tooltip is offered only when the text is expected to overflow. */
|
||||
function ViewTabLabel({ label }: { readonly label: string }) {
|
||||
return (
|
||||
<Tooltip label={label} portal disabled={!labelNeedsTooltip(label)}>
|
||||
<span className={css.tabLabel}>{label}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
interface Breadcrumb {
|
||||
readonly id: SessionId
|
||||
readonly displayTitle: string
|
||||
@@ -205,7 +228,7 @@ export function ConversationSessionHeader({
|
||||
className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
|
||||
onClick={() => { selectView(viewTab.id) }}
|
||||
>
|
||||
<Tooltip label={viewTab.label} portal><span className={css.tabLabel}>{viewTab.label}</span></Tooltip>
|
||||
<ViewTabLabel label={viewTab.label} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,18 @@ describe('conversation header geometry', () => {
|
||||
expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('padding: 10px var(--dsh-tab-hit-inline) 9px')
|
||||
})
|
||||
|
||||
it('clips overflowing title actions and fades them inside the gap before the next control', () => {
|
||||
const cluster = css.match(/\.titleCluster\s*\{([^}]*)\}/)?.[1]
|
||||
expect(cluster).toContain('overflow: clip')
|
||||
expect(cluster).toContain('--title-cluster-fade: 20px')
|
||||
expect(cluster).toContain('margin: -4px calc(-1 * var(--title-cluster-fade)) -4px -4px')
|
||||
expect(cluster).toContain('padding: 4px var(--title-cluster-fade) 4px 4px')
|
||||
expect(cluster).toContain('mask-image: linear-gradient(to left, transparent calc(0.3 * var(--title-cluster-fade)), #000 var(--title-cluster-fade))')
|
||||
expect(css.match(/^\.headerUtilities\s*\{([^}]*)\}/m)?.[1]).toContain('margin-left: 20px')
|
||||
expect(css.match(/\.titleCluster:has\(\+ \.headerUtilities:empty\)\s*\{([^}]*)\}/)?.[1]).toContain('--title-cluster-fade: 8px')
|
||||
expect(css.match(/^\.headerCorner\s*\{([^}]*)\}/m)?.[1]).toContain('margin-left: 8px')
|
||||
})
|
||||
|
||||
it('uses 32px between view labels without changing the action gap', () => {
|
||||
expect(css.match(/\.viewTabs\s*\{([^}]*)\}/)?.[1]).toContain('gap: 32px')
|
||||
expect(css.match(/\.tabs\s*\{([^}]*)\}/)?.[1]).toContain('gap: 16px 20px')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { GlobalStandardProps, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
@@ -735,6 +735,29 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(b.view.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('offers a View tab tooltip only when the label is expected to overflow', () => {
|
||||
const b = mount(sessionSnapshotOf(), undefined, undefined, {
|
||||
viewTabs: [
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
{ id: 'short', label: '轨迹' },
|
||||
{ id: 'long-cjk', label: '很长的视图名称' },
|
||||
{ id: 'long-latin', label: 'A very long view name' },
|
||||
],
|
||||
headerOnly: true,
|
||||
})
|
||||
const tooltipFor = (label: string): string | null => {
|
||||
const span = within(b.view.getByRole('tab', { name: label })).getByText(label)
|
||||
fireEvent.pointerEnter(span)
|
||||
const tooltip = screen.queryByRole('tooltip', { hidden: true })?.textContent ?? null
|
||||
fireEvent.pointerLeave(span)
|
||||
return tooltip
|
||||
}
|
||||
expect(tooltipFor('Chat')).toBeNull()
|
||||
expect(tooltipFor('轨迹')).toBeNull()
|
||||
expect(tooltipFor('很长的视图名称')).toBe('很长的视图名称')
|
||||
expect(tooltipFor('A very long view name')).toBe('A very long view name')
|
||||
})
|
||||
|
||||
it('reuses the width budget on View roster and selection changes without a window resize listener', () => {
|
||||
let width = 480
|
||||
const readWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => width)
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package:
|
||||
en: 216dcea8d6903007
|
||||
zh: 160888842501694e
|
||||
en: 535d6322bcfb329b
|
||||
zh: fbf05155ccbb3e0e
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle:
|
||||
en: fcb2e69c8b028c0a
|
||||
zh: d505a075679974eb
|
||||
|
||||
@@ -41,6 +41,8 @@ On-demand Official cards are visible offline before installation. Turning one On
|
||||
|
||||
The first list read shows a short group-heading placeholder and four skeleton cards, using the real cards' row, icon, and text-line spacing, with the action area left blank. They use the same opacity pulse as Session search and remain static with reduced motion. The accessible loading label and read timing are unchanged; refreshes retain the existing list, and installation and component-loading indicators keep their own states.
|
||||
|
||||
A bundle unavailable on the DSH Host operating system shows the reason on its card and details page. Its disabled switch prevents enabling it; an already selected bundle can still be switched off. [Plugin Manager](../../boot/plugin-manager/README.md#use-this-package) enforces the same restriction for direct requests.
|
||||
|
||||
The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard focus. It reads the list again, then checks every updatable bundle for newer versions one after another; a failed read checks nothing. During a manual refresh, the button shows a spinner and stays disabled until all pending reads and those checks settle and at least 400 ms have elapsed, preventing a brief spinner flash; repeated refresh requests are ignored and the existing list remains visible. Success shows no toast. With a loaded list, a failed manual refresh keeps the cached content and shows a failure toast, including after leaving Plugins; select the button again to retry. Before any successful list read, failures show an in-place message with **Retry**. Background reads do not show the refresh spinner.
|
||||
|
||||
### Installing a bundle
|
||||
|
||||
@@ -41,6 +41,8 @@ kind: "package-reference"
|
||||
|
||||
首次读取列表时,页面显示一条较短的分组标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。
|
||||
|
||||
组合包不支持 DSH Host 的操作系统时,卡片和详情页会显示原因,关闭状态的开关不可启用;已选中的组合包仍可关闭。[插件管理器](../../boot/plugin-manager/README.zh.md#use-this-package)对直接请求执行同一限制。
|
||||
|
||||
**刷新**按钮在悬停或键盘聚焦 500 毫秒后显示提示。它重新读取列表,再依次检查每个可更新组合包的新版本;读取失败时不做检查。手动刷新期间,按钮显示旋转加载指示并保持禁用,直到所有待处理读取和这些检查结束且至少经过 400 毫秒,避免加载指示一闪而过;重复刷新请求会被忽略,已有列表保持可见。成功不显示 toast。已有列表时,手动刷新失败保留缓存内容并显示失败 toast,即使已离开插件页也能看到;再次选择该按钮即可重试。在首次成功读取列表之前,失败仍原位显示提示与**重试**。后台读取不显示刷新按钮的加载指示。
|
||||
|
||||
### 安装一个组合包
|
||||
|
||||
@@ -392,7 +392,8 @@ function EnableSwitch({ pkg, title, t, busy, onSetEnabled }: {
|
||||
<Switch
|
||||
checked={pkg.enabled}
|
||||
label={t('enableToggle', { name: title })}
|
||||
disabled={busy || pkg.readOnlyReason !== undefined || (!pkg.enabled && pkg.error !== undefined && !(pkg.availability === 'missing' && pkg.installTarget !== undefined))}
|
||||
disabled={busy || pkg.readOnlyReason !== undefined || (!pkg.enabled && pkg.error !== undefined
|
||||
&& (pkg.error.code === 'unsupported-platform' || !(pkg.availability === 'missing' && pkg.installTarget !== undefined)))}
|
||||
{...pkg.readOnlyReason === undefined ? {} : { title: managementText({ code: pkg.readOnlyReason }, t) }}
|
||||
onChange={onSetEnabled}
|
||||
/>
|
||||
@@ -548,6 +549,7 @@ function PackageCard({ pkg, t, resolveText, busy, highlighted, newer, onOpen, on
|
||||
end={<EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />}
|
||||
/>
|
||||
<MetadataError error={pkg.meta?.error} t={t} />
|
||||
{pkg.error?.code === 'unsupported-platform' && <p className={css.reason} role="status">{managementText(pkg.error, t)}</p>}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -284,6 +284,7 @@ export const zh = {
|
||||
reasonStaleApproval: '待允许的安装脚本列表已变化,请重新安装以刷新',
|
||||
reasonIncompatibleVersion: '{plugin} 与 DSH {runtime} 不兼容(要求 {requirements}),运行它可能导致崩溃或数据丢失。',
|
||||
reasonIncompatibleVersionUnnamed: '这个插件与当前 DSH 版本不兼容,运行它可能导致崩溃或数据丢失。',
|
||||
reasonUnsupportedPlatform: '此插件不支持运行 DSH 的主机操作系统',
|
||||
reasonIncompatibleInstall: '请安装与当前 DSH 兼容的插件版本。',
|
||||
reasonIncompatibleInstalled: '请卸载后重新安装与当前 DSH 兼容的版本。',
|
||||
reasonOperationError: 'Host 报告了一个错误',
|
||||
@@ -578,6 +579,7 @@ export const en = {
|
||||
reasonStaleApproval: 'The pending script approvals changed; install again to refresh them.',
|
||||
reasonIncompatibleVersion: '{plugin} is incompatible with DSH {runtime} (requires {requirements}); running it may cause crashes or data loss.',
|
||||
reasonIncompatibleVersionUnnamed: 'This plugin is incompatible with the running DSH version; running it may cause crashes or data loss.',
|
||||
reasonUnsupportedPlatform: 'This plugin does not support the operating system running DSH.',
|
||||
reasonIncompatibleInstall: 'Install a plugin version compatible with this DSH.',
|
||||
reasonIncompatibleInstalled: 'Uninstall it and install a version compatible with this DSH.',
|
||||
reasonOperationError: 'The Host reported an error.',
|
||||
@@ -592,6 +594,7 @@ export const en = {
|
||||
|
||||
/** Japanese copy, checked against the English keys. */
|
||||
export const ja = {
|
||||
'reasonUnsupportedPlatform': 'このプラグインはDSHを実行しているホストOSに対応していません。',
|
||||
'panel': 'プラグイン',
|
||||
'title': 'プラグイン',
|
||||
'intro': 'プラグインのインストール、有効化、設定',
|
||||
@@ -883,6 +886,7 @@ export const ja = {
|
||||
|
||||
/** Spanish dictionary, checked against the English keys. */
|
||||
export const es = {
|
||||
reasonUnsupportedPlatform: 'Este complemento no es compatible con el sistema operativo que ejecuta DSH.',
|
||||
panel: 'Complementos',
|
||||
title: 'Complementos',
|
||||
intro: 'Instalar, habilitar y configurar complementos',
|
||||
@@ -1174,6 +1178,7 @@ export const es = {
|
||||
|
||||
/** French dictionary, checked against the English keys. */
|
||||
export const fr = {
|
||||
reasonUnsupportedPlatform: 'Ce plugin ne prend pas en charge le système d’exploitation sur lequel DSH s’exécute.',
|
||||
panel: 'Plugins',
|
||||
title: 'Plugins',
|
||||
intro: 'Installer, activer et configurer les plugins',
|
||||
@@ -1465,6 +1470,7 @@ export const fr = {
|
||||
|
||||
/** German dictionary, checked against the English keys. */
|
||||
export const de = {
|
||||
reasonUnsupportedPlatform: 'Dieses Plugin unterstützt das Betriebssystem, auf dem DSH läuft, nicht.',
|
||||
panel: 'Plugins',
|
||||
title: 'Plugins',
|
||||
intro: 'Plugins installieren, aktivieren und konfigurieren',
|
||||
@@ -1756,6 +1762,7 @@ export const de = {
|
||||
|
||||
/** Russian dictionary, checked against the English keys. */
|
||||
export const ru = {
|
||||
reasonUnsupportedPlatform: 'Этот плагин не поддерживает операционную систему, в которой работает DSH.',
|
||||
panel: 'Плагины',
|
||||
title: 'Плагины',
|
||||
intro: 'Установите, включите и настройте плагины',
|
||||
@@ -2047,6 +2054,7 @@ export const ru = {
|
||||
|
||||
/** Portuguese dictionary, checked against the English keys. */
|
||||
export const pt = {
|
||||
reasonUnsupportedPlatform: 'Este plugin não oferece suporte ao sistema operacional em que o DSH está sendo executado.',
|
||||
panel: 'Plug-ins',
|
||||
title: 'Plug-ins',
|
||||
intro: 'Instalar, habilitar e configurar plug-ins',
|
||||
|
||||
@@ -113,7 +113,7 @@ export interface PackageView {
|
||||
readonly meta?: PluginLocalizedMeta
|
||||
/** Whether the profile declares this dependency; availability separately describes its readable files. */
|
||||
readonly installed: boolean
|
||||
/** GUI listing before explicit selection or profile installation; omission means true. */
|
||||
/** Default GUI visibility after package and Host checks; selected or profile-installed bundles remain manageable. */
|
||||
readonly listedByDefault?: boolean
|
||||
/** Present for a profile dependency the installation does not also supply: the spec `pnpm add` accepts. */
|
||||
readonly source?: string
|
||||
@@ -127,7 +127,7 @@ export interface PackageView {
|
||||
readonly enabled: boolean
|
||||
/** Why the Host refuses to switch the bundle off or remove it, when it does. */
|
||||
readonly readOnlyReason?: ReadOnlyReason
|
||||
/** Why the Host cannot read the bundle, when it cannot. */
|
||||
/** Why the Host cannot read or enable the bundle. */
|
||||
readonly error?: ManagementError
|
||||
readonly rows: readonly PackageRow[]
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ const CODE_KEYS = {
|
||||
'bundle-in-use': 'reasonBundleInUse',
|
||||
'stale-approval': 'reasonStaleApproval',
|
||||
'incompatible-version': 'reasonIncompatibleVersionUnnamed',
|
||||
'unsupported-platform': 'reasonUnsupportedPlatform',
|
||||
'operation-error': 'reasonOperationError',
|
||||
} satisfies Record<ManagementError['code'], PluginManagerLocaleKey>
|
||||
|
||||
|
||||
@@ -1179,6 +1179,7 @@ describe('PluginManagerPage', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
'@deepseek-ai/dsh-cua-bundle',
|
||||
'@deepseek-ai/dsh-experimental-session-search',
|
||||
'@deepseek-ai/dsh-experimental-ralph-bundle',
|
||||
'@acme/shipped-exploration',
|
||||
@@ -1209,6 +1210,33 @@ describe('PluginManagerPage', () => {
|
||||
expect(card()).not.toBeNull()
|
||||
})
|
||||
|
||||
it.each([en, zh])('hides unsupported CUA by default and retains installed or selected entries for management', (dict) => {
|
||||
const name = '@deepseek-ai/dsh-cua-bundle'
|
||||
const entry = pkg({ name, installed: false, enabled: false, optional: true, listedByDefault: false,
|
||||
error: { code: 'unsupported-platform' } })
|
||||
const other = pkg({ name: '@deepseek-ai/dsh-experimental-session-titles-bundle', installed: false, optional: true, enabled: false })
|
||||
const { actions, set, setLanguage } = renderTab({ packages: [other, entry] })
|
||||
setLanguage(dict)
|
||||
const card = () => document.querySelector(`[data-plugin-package="${name}"]`)
|
||||
expect(card()).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: dict.moreAction }))
|
||||
expect(card()).toBeNull()
|
||||
for (const availability of ['profile', 'missing'] as const) {
|
||||
const installed = { ...entry, installed: true, availability, installTarget: { spec: `${name}@1.0.0`, version: '1.0.0' } }
|
||||
set({ packages: [other, installed] })
|
||||
expect(card()).not.toBeNull()
|
||||
expect(screen.getByText(dict.reasonUnsupportedPlatform)).toBeTruthy()
|
||||
expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', name) })).toHaveProperty('disabled', true)
|
||||
}
|
||||
set({ packages: [other, { ...entry, enabled: true }] })
|
||||
fireEvent.click(screen.getByRole('button', { name: dict.openDetail.replace('{name}', name) }))
|
||||
const toggle = screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', name) })
|
||||
expect(toggle).toHaveProperty('disabled', false)
|
||||
expect(screen.getByText(`${dict.reasonLabel}: ${dict.reasonUnsupportedPlatform}`)).toBeTruthy()
|
||||
fireEvent.click(toggle)
|
||||
expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith(name, false)
|
||||
})
|
||||
|
||||
it.each([en, zh])('keeps disabled CUA on the secondary page with experimental labels', (dict) => {
|
||||
const name = '@deepseek-ai/dsh-cua-bundle'
|
||||
const entry = (enabled: boolean) => pkg({ name, official: true, enabled, meta: { title: 'Computer Use' } })
|
||||
@@ -1513,6 +1541,11 @@ describe('PluginManagerPage', () => {
|
||||
expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonIncompatibleVersionUnnamed} ${en.reasonIncompatibleInstalled}`)).toBeTruthy()
|
||||
fireEvent.click(within(detail).getByRole('button', { name: en.backToList }))
|
||||
expect(document.querySelector('[data-plugin-detail]')).toBeNull()
|
||||
set({ packages: [pkg({ enabled: false, error: { code: 'unsupported-platform' } })] })
|
||||
expect(screen.getByText(en.reasonUnsupportedPlatform)).toBeTruthy()
|
||||
expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') })).toHaveProperty('disabled', true)
|
||||
set({ packages: [pkg({ enabled: true, error: { code: 'unsupported-platform' } })] })
|
||||
expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') })).toHaveProperty('disabled', false)
|
||||
// A bundle that leaves the list drops back to the cards.
|
||||
const { version: _version, ...unversioned } = pkg()
|
||||
set({ packages: [unversioned] })
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: b3744373b4b2c1fb
|
||||
zh: 2dbde8ee0bfa5f28
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
|
||||
en: 31ed1a6239427512
|
||||
zh: cb489d77de109d3f
|
||||
en: 2daec758b1f35563
|
||||
zh: 32684be1f8b1b4c3
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy:
|
||||
en: a943603b74080ef5
|
||||
zh: 43f7a0188aff271b
|
||||
|
||||
@@ -108,7 +108,7 @@ The catalog above lists what each export is for; this section covers the behavio
|
||||
|
||||
The pure Markdown helper `formatQuoteText` normalizes CRLF and CR line endings, then prefixes each line with `> `.
|
||||
|
||||
The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener.
|
||||
The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. A destination with a backslash and no URL scheme (a drive letter is not one) is a Windows path when it has no `/` or has a backslash before a letter or digit. Such a path keeps every backslash, including one before punctuation, and still decodes character references, so `D:\repo\.github` is not read as an escaped `.`. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener.
|
||||
|
||||
`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. Strikethrough requires `~~`; a single `~`, as in `10~20`, stays literal. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. Each mounted image appends a `load` query parameter to a resolved Host `api/file` URL, because browsers reuse a same-URL image within one document despite `no-store`; a newly rendered image of an overwritten file therefore loads its current bytes, and every remount requests the file again; the Host file route redirects that request to a cacheable URL for the current file version. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text).
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ kind: "package-library"
|
||||
|
||||
纯 Markdown 工具 `formatQuoteText` 规范化 CRLF 和 CR 换行符,再为每行添加 `> ` 前缀。
|
||||
|
||||
最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
|
||||
最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。含反斜杠且无 URL 协议(盘符不算 URL 协议)的目标,若不含 `/`,或在字母或数字前有反斜杠,即为 Windows 路径。此类路径保留所有反斜杠(包括标点前的反斜杠),仍会解码字符引用,因此 `D:\repo\.github` 不会被读作转义的 `.`。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
|
||||
|
||||
`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。删除线需要 `~~`;单个 `~`(如 `10~20`)保持字面文本。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。每个挂载的图片会为解析得到的 Host `api/file` 地址追加 `load` 查询参数,因为浏览器在同一文档内会无视 `no-store` 复用相同 URL 的图片;因此覆盖后的文件在新渲染的图片中会加载当前内容,每次重新挂载都会重新请求该文件;Host 文件路由会把该请求重定向到当前文件版本的可缓存地址。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"katex": "^0.18.7",
|
||||
"micromark-core-commonmark": "^2.0.3",
|
||||
"micromark-util-character": "^2.1.1",
|
||||
"micromark-util-decode-string": "^2.0.1",
|
||||
"micromark-util-classify-character": "^2.0.1",
|
||||
"micromark-util-symbol": "^2.0.1",
|
||||
"micromark-util-types": "^2.0.2",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { gfm } from 'micromark-extension-gfm'
|
||||
import { math } from 'micromark-extension-math'
|
||||
import { cjkFriendlyStrong } from './cjkFriendlyStrong.ts'
|
||||
import { mathCompatibility } from './mathCompatibility.ts'
|
||||
import { windowsPathDestinations } from './windows-path-destinations.ts'
|
||||
|
||||
/** GFM strikethrough requires `~~`; a single `~` stays literal, as in ranges such as `10~20`. */
|
||||
const gfmOptions = { singleTilde: false }
|
||||
@@ -30,7 +31,7 @@ const gfmOptions = { singleTilde: false }
|
||||
export function parseGfm(text: string): Root {
|
||||
return recoverLocalImages(fromMarkdown(text, {
|
||||
extensions: [gfm(gfmOptions), cjkFriendlyStrong()],
|
||||
mdastExtensions: [gfmFromMarkdown()],
|
||||
mdastExtensions: [gfmFromMarkdown(), windowsPathDestinations()],
|
||||
}), text)
|
||||
}
|
||||
|
||||
@@ -46,6 +47,6 @@ export function parseGfmWithMath(text: string): Root {
|
||||
// registered last first, so its dollar flow takes precedence over `math()`
|
||||
// and keeps the opening line's text as math instead of upstream's fence meta.
|
||||
extensions: [gfm(gfmOptions), cjkFriendlyStrong(), math(), mathCompatibility()],
|
||||
mdastExtensions: [gfmFromMarkdown(), mathFromMarkdown()],
|
||||
mdastExtensions: [gfmFromMarkdown(), mathFromMarkdown(), windowsPathDestinations()],
|
||||
}), text)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Keep backslash separators in Markdown destinations written as Windows paths. */
|
||||
import type { Definition, Image, Link } from 'mdast'
|
||||
import type { CompileContext, Extension, Token } from 'mdast-util-from-markdown'
|
||||
import { decodeString } from 'micromark-util-decode-string'
|
||||
|
||||
/**
|
||||
* A raw destination is a Windows path when it has a backslash and no URL
|
||||
* scheme, and either has no `/` or has a backslash before a letter or digit,
|
||||
* which CommonMark cannot read as an escape. A drive letter is not a scheme.
|
||||
*/
|
||||
function isWindowsPath(raw: string): boolean {
|
||||
return raw.includes('\\') && !/^[a-z][a-z\d+.-]+:/iu.test(raw)
|
||||
&& (!raw.includes('/') || /\\[\p{L}\p{N}]/u.test(raw))
|
||||
}
|
||||
|
||||
function exitDestination(this: CompileContext, token: Token): void {
|
||||
const parsed = this.resume()
|
||||
const raw = this.sliceSerialize(token)
|
||||
const node = this.stack.at(-1) as Link | Image | Definition
|
||||
// Doubling each backslash makes every one literal; character references still decode.
|
||||
node.url = isWindowsPath(raw) ? decodeString(raw.replaceAll('\\', '\\\\')) : parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* CommonMark reads `\.` or `\_` in a destination as an escaped character, so
|
||||
* `D:\repo\.github` would parse as `D:\repo.github`. For a destination that
|
||||
* is a Windows path, this `fromMarkdown` extension keeps every backslash
|
||||
* literally, including one before punctuation such as `\(`, while character
|
||||
* references such as `&` still decode; other destinations parse unchanged.
|
||||
* @returns The mdast extension.
|
||||
*/
|
||||
export function windowsPathDestinations(): Extension {
|
||||
return { exit: { resourceDestinationString: exitDestination, definitionDestinationString: exitDestination } }
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MarkdownDelegateProvider } from '../src/index.ts'
|
||||
import { parseGfm, parseGfmWithMath } from '../src/markdown/parse.ts'
|
||||
import { MarkdownText } from './markdown-test-components.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -29,6 +30,41 @@ describe('Markdown file links', () => {
|
||||
expect(view.container.querySelector('a')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['D:\\dsh\\toc-agent\\.deploy\\notes.md', 'D:\\dsh\\toc-agent\\.deploy\\notes.md', undefined],
|
||||
['<D:\\My Files\\.config\\a_b.md>', 'D:\\My Files\\.config\\a_b.md', undefined],
|
||||
['D:\\.deploy\\notes.md#L4', 'D:\\.deploy\\notes.md', { line: 4 }],
|
||||
['D:/repo\\.github\\ci.yml', 'D:/repo\\.github\\ci.yml', undefined],
|
||||
['D:\\R&D\\a b.md', 'D:\\R&D\\a b.md', undefined],
|
||||
['D:\\a\\(b\\).md', 'D:\\a\\(b\\).md', undefined],
|
||||
['src\\.github\\ci.yml', 'src\\.github\\ci.yml', undefined],
|
||||
['.\\.env', '.\\.env', undefined],
|
||||
['..\\.gitignore', '..\\.gitignore', undefined],
|
||||
['docs\\_draft.md', 'docs\\_draft.md', undefined],
|
||||
['docs/\\_draft.md', 'docs/_draft.md', undefined],
|
||||
['C:/work/\\_draft.md', 'C:/work/_draft.md', undefined],
|
||||
])('keeps backslashes only where %s is a Windows path', (target, path, options) => {
|
||||
const openFile = vi.fn()
|
||||
const view = render(
|
||||
<MarkdownDelegateProvider openFile={openFile}>
|
||||
<MarkdownText text={`[source](${target})\n\n[ref][r]\n\n[r]: ${target}`} />
|
||||
</MarkdownDelegateProvider>,
|
||||
)
|
||||
expect(view.getAllByRole('button').map(button => button.getAttribute('title'))).toEqual([path, path])
|
||||
fireEvent.click(view.getByRole('button', { name: 'source' }))
|
||||
expect(openFile).toHaveBeenCalledExactlyOnceWith(path, options)
|
||||
})
|
||||
|
||||
it.each([parseGfm, parseGfmWithMath])('keeps Windows image destinations in %o', (parse) => {
|
||||
const [paragraph] = parse('').children
|
||||
expect(paragraph).toMatchObject({ children: [{ type: 'image', url: 'D:\\img\\.cache\\a.png' }] })
|
||||
})
|
||||
|
||||
it('reads backslash escapes in URLs', () => {
|
||||
const view = render(<MarkdownText text={'[source](https://example.com/a\\b\\_c)'} />)
|
||||
expect(view.getByRole('link').getAttribute('href')).toBe('https://example.com/a%5Cb_c')
|
||||
})
|
||||
|
||||
it.each(['/workspace/chart.png', 'C:/work/chart.png'])('keeps %s accessible when inline images are unavailable', (path) => {
|
||||
const openFile = vi.fn()
|
||||
const view = render(
|
||||
|
||||
@@ -9,17 +9,17 @@
|
||||
en: 9084fb48d0f6489c
|
||||
zh: ce692972d91cf663
|
||||
/deepseek-ai-dsh-cua-bundle/summary:
|
||||
en: 06d8bf4ee1ef6f3b
|
||||
zh: 8568f649fb846615
|
||||
en: 5f213491dd724c91
|
||||
zh: 228439436d3b339c
|
||||
/deepseek-ai-dsh-cua-bundle/table-of-contents:
|
||||
en: b563e4ec4727d0de
|
||||
zh: d0228da978238e05
|
||||
/deepseek-ai-dsh-cua-bundle/use-this-package:
|
||||
en: e19d1fd30ca77a8b
|
||||
zh: b192eacfac12a5fe
|
||||
en: 1f9182a05ea238a8
|
||||
zh: 0d8e2b0ef4d28e1a
|
||||
/deepseek-ai-dsh-cua-bundle/understand-the-implementation:
|
||||
en: 057771fe13aaa337
|
||||
zh: 4fdb2b98ff3e7a78
|
||||
en: b1011db52cfc17cd
|
||||
zh: 6fa8798fedc5c0b6
|
||||
/deepseek-ai-dsh-cua-bundle/further-exploration:
|
||||
en: 9f15d56b5b16e1ca
|
||||
zh: 75eb08e8828c8877
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Enable macOS Computer Use from Plugins for existing and new PTC sessions. Windows support is in development. This bundle remains in `experimental` until that support is complete, then moves to the `computer-use` group. It is off by default; disabled profiles prepare no native components and register neither CUA tools nor their model guidance.
|
||||
Enable macOS Computer Use from Plugins for existing and new PTC sessions. Windows support is in development. This bundle remains in `experimental` until that support is complete, then moves to the `computer-use` group. It is off by default and hidden from the default plugin lists until enabled or separately installed; disabled profiles prepare no native components and register neither CUA tools nor their model guidance.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -24,7 +24,7 @@ Enable macOS Computer Use from Plugins for existing and new PTC sessions. Window
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
This workflow targets developers running macOS Web or unpackaged Desktop from source. Open Plugins, select More beside Experimental plugins, then open Computer Use and enable the switch. The permission page identifies the responsible application reported by macOS when the optional system query is available. Request access and enable that application in System Settings; follow the application named by macOS if it differs from the detected source. For Web, run these commands from the repository root:
|
||||
This workflow targets developers running macOS Web or unpackaged Desktop from source. In **Plugins → Add plugin**, enter `@deepseek-ai/dsh-cua-bundle`, choose **Install**, then **Enable now**. DSH enables its shipped copy without downloading another one. Enabled bundles remain in Plugins, where you can open Computer Use to manage permissions or switch it off. Switching off hides its card again unless the profile separately installed it. The permission page identifies the responsible application reported by macOS when the optional system query is available. Request access and enable that application in System Settings; follow the application named by macOS if it differs from the detected source. For Web, run these commands from the repository root:
|
||||
|
||||
```sh
|
||||
pnpm run build
|
||||
@@ -33,11 +33,11 @@ pnpm dsh web
|
||||
|
||||
For the source Desktop launcher and its separate development profile, follow [Desktop development](../../../apps/desktop/README.md). Enable Computer Use in each profile that needs it.
|
||||
|
||||
The switch saves bundle selection in the profile’s `package.json`. Computer Use is installed only in PTC sessions. Enabling from the plugin list offers setup guidance when system permissions need attention; enabling from its detail page displays the permission controls directly. The detail page offers **Set to PTC** for the new-session default; existing sessions keep their preset. The switch leaves PTC execution mode and other settings unchanged. The detail page’s **Use persistent mode** action separately enables in-memory state across PTC calls; its effects and reload behavior are described in the [permission page](../cua/README.md). Native preparation runs when the provider loads and reuses the source-hashed executable when available.
|
||||
The switch saves bundle selection in the profile’s `package.json`. Computer Use is installed only in PTC sessions. Enabling through Add plugin or a visible card offers setup guidance when system permissions need attention; enabling from its detail page displays the permission controls directly. The detail page offers **Set to PTC** for the new-session default; existing sessions keep their preset. The switch leaves PTC execution mode and other settings unchanged. The detail page’s **Use persistent mode** action separately enables in-memory state across PTC calls; its effects and reload behavior are described in the [permission page](../cua/README.md). Native preparation runs when the provider loads and reuses the source-hashed executable when available.
|
||||
|
||||
Disabling unloads the provider and withdraws CUA tools from its Agent scopes. Runtime configuration, the new-session default and saved grants remain. Session-only application decisions end with provider disposal.
|
||||
|
||||
Computer Use requires a macOS DSH Host. On other operating systems, installation omits the optional macOS provider and the bundle skips its Loader entry. Shared CUA logic and the permission-page entry remain available, but Computer Use operations require the provider. A browser on another operating system can connect to a supported Mac Host.
|
||||
Computer Use is available only on a macOS DSH Host. The native provider is an optional dependency installed only on macOS; shared CUA and Client packages remain in the installation. Other Hosts hide the bundle from default plugin lists and model recommendations and refuse enablement before saving settings. Explicit profile dependencies and saved selections remain visible for management. An already selected bundle is skipped at startup with a diagnostic and remains available for deselection; saved user settings remain. A browser on another operating system can connect to a supported Mac Host.
|
||||
|
||||
If preparation fails, the saved selection remains and CUA operations stay unavailable. Read the Host startup diagnostic, correct its cause, then restart DSH.
|
||||
|
||||
@@ -51,7 +51,7 @@ Explicit activation runs a read-only permission check, which may start a helper
|
||||
<details>
|
||||
<summary>Maintainer details</summary>
|
||||
|
||||
The bundle patch mounts the native provider and permission UI with `agentPreset: ptc`. Existing Agent scopes register the tools without changing preset documents. The detail-page action uses the existing settings form to save `agent-preset-registry.selectedDefault: ptc` only when clicked. [CUA](../cua/README.md) owns registration and disposal.
|
||||
The bundle patch mounts the native provider and permission UI with `agentPreset: ptc`. Using the exported `cordis.patch.yml` directly as an overlay bypasses bundle admission; its row-level condition skips the optional macOS provider on other Hosts. Shared entries alone do not provide computer control. Existing Agent scopes register the tools without changing preset documents. The detail-page action uses the existing settings form to save `agent-preset-registry.selectedDefault: ptc` only when clicked. [CUA](../cua/README.md) owns registration and disposal.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-bundle"
|
||||
|
||||
## 概述
|
||||
|
||||
从插件页为已有和新建 PTC 会话开启 macOS Computer Use。Windows 支持正在开发中。本组合包暂放在 `experimental`,待 Windows 支持完成后迁回 `computer-use` 组。默认关闭,关闭时不准备本机组件、不注册 CUA 工具或添加其模型指引。
|
||||
从插件页为已有和新建 PTC 会话开启 macOS Computer Use。Windows 支持正在开发中。本组合包暂放在 `experimental`,待 Windows 支持完成后迁回 `computer-use` 组。默认关闭,启用或单独安装前不在默认插件列表中显示;关闭时不准备本机组件、不注册 CUA 工具或添加其模型指引。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -24,7 +24,7 @@ kind: "package-bundle"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
本流程面向从源码运行 macOS Web 或未打包 Desktop 的开发者。打开“插件”,点击“实验性插件”旁的“更多”,再打开 Computer Use 并启用。权限页通过可选系统查询显示 macOS 报告的责任应用,指引用户申请权限并在系统设置中开启对应开关。若 macOS 显示其他应用名称,以系统提示为准。Web 版在仓库根目录运行以下命令:
|
||||
本流程面向从源码运行 macOS Web 或未打包 Desktop 的开发者。在「插件 → 添加插件」中输入 `@deepseek-ai/dsh-cua-bundle`,点击「安装」,再点击「立即启用」。DSH 会启用随应用提供的副本,无需重新下载。已启用的组合包保留在插件页,可打开 Computer Use 管理权限或将其关闭。关闭后卡片再次隐藏,除非该 profile 单独安装了它。权限页通过可选系统查询显示 macOS 报告的责任应用,指引用户申请权限并在系统设置中开启对应开关。若 macOS 显示其他应用名称,以系统提示为准。Web 版在仓库根目录运行以下命令:
|
||||
|
||||
```sh
|
||||
pnpm run build
|
||||
@@ -33,11 +33,11 @@ pnpm dsh web
|
||||
|
||||
源码 Desktop 的启动方式和独立开发配置档见[桌面开发说明](../../../apps/desktop/README.zh.md)。需要使用 Computer Use 的配置档分别开启该功能。
|
||||
|
||||
开关将组合包选择保存到 profile 的 `package.json`。Computer Use 仅安装在 PTC 会话中。从插件列表启用时,系统权限未就绪会显示设置引导;从详情页启用后直接显示权限控件。详情页提供“设为 PTC”按钮来修改新会话默认模式,已有会话保留原 preset。开关保持 PTC 执行方式和其他设置不变。详情页的“设为持久化”按钮单独开启 PTC 跨调用的内存状态保留,作用范围和重载行为见[权限页面](../cua/README.zh.md)。提供方加载时准备本机组件,已有与源码摘要匹配的可执行文件时直接复用。
|
||||
开关将组合包选择保存到 profile 的 `package.json`。Computer Use 仅安装在 PTC 会话中。通过「添加插件」或可见卡片启用时,系统权限未就绪会显示设置引导;从详情页启用后直接显示权限控件。详情页提供“设为 PTC”按钮来修改新会话默认模式,已有会话保留原 preset。开关保持 PTC 执行方式和其他设置不变。详情页的“设为持久化”按钮单独开启 PTC 跨调用的内存状态保留,作用范围和重载行为见[权限页面](../cua/README.zh.md)。提供方加载时准备本机组件,已有与源码摘要匹配的可执行文件时直接复用。
|
||||
|
||||
关闭会卸载提供方,并从其 Agent 作用域中撤回 CUA 工具。执行器配置、新会话默认值和已保存的授权保留。会话级应用授权随提供方卸载而失效。
|
||||
|
||||
Computer Use 要求 DSH Host 运行在 macOS 上。其他系统安装时跳过可选的 macOS 提供方,组合包也不加载该提供方。共享 CUA 逻辑和权限页入口仍保留,Computer Use 操作需要提供方才能使用。其他操作系统上的浏览器可以连接受支持的 Mac Host。
|
||||
Computer Use 仅支持 macOS DSH Host。原生提供方是仅在 macOS 安装的可选依赖,共享 CUA 和 Client 包仍随安装提供。其他 Host 默认不在插件列表和模型推荐中展示此组合包,并在保存设置前拒绝启用。profile 显式安装的依赖和已保存的选择仍保留管理入口。已选中的组合包会在启动时跳过并报告原因,仍可取消选择;已保存的用户设置保留。其他操作系统上的浏览器可以连接受支持的 Mac Host。
|
||||
|
||||
准备失败时,已保存的选择保留,CUA 操作不可用。查看 Host 的启动诊断,修复原因后重启 DSH。
|
||||
|
||||
@@ -51,7 +51,7 @@ Computer Use 要求 DSH Host 运行在 macOS 上。其他系统安装时跳过
|
||||
<details>
|
||||
<summary>维护者说明</summary>
|
||||
|
||||
组合包 patch 通过 `agentPreset: ptc` 挂载原生提供方和权限界面。工具通过现有 Agent 作用域注册,preset 文档保持不变。详情页按钮复用现有设置表单,仅在点击后保存 `agent-preset-registry.selectedDefault: ptc`。[CUA](../cua/README.zh.md)负责注册和销毁。
|
||||
组合包 patch 通过 `agentPreset: ptc` 挂载原生提供方和权限界面。直接将导出的 `cordis.patch.yml` 用作覆盖层时不会经过组合包准入;行级条件会在其他 Host 上跳过可选的 macOS 提供方。仅加载共享条目不能提供计算机控制。工具通过现有 Agent 作用域注册,preset 文档保持不变。详情页按钮复用现有设置表单,仅在点击后保存 `agent-preset-registry.selectedDefault: ptc`。[CUA](../cua/README.zh.md)负责注册和销毁。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -35,7 +35,11 @@
|
||||
"license": "MIT",
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
"listedByDefault": false,
|
||||
"patch": "./cordis.patch.yml",
|
||||
"platforms": [
|
||||
"darwin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** The shipped patch leaves shared entries available while skipping an absent native provider. */
|
||||
/** Explicit patch overlays bypass bundle admission and must not import an absent native provider. */
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader, { interpolate, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
||||
@@ -6,7 +6,7 @@ import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { load } from 'js-yaml'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
it.each(['darwin', 'win32', 'linux'])('loads the native provider only on Darwin for %s', async (platform) => {
|
||||
it.each(['darwin', 'win32', 'linux'])('loads the exported patch directly on %s and imports the native provider only on Darwin', async (platform) => {
|
||||
const patch = load(await readFile(new URL('../cordis.patch.yml', import.meta.url), 'utf8'), {
|
||||
schema: entryListSchema,
|
||||
}) as { insert: EntryOptions[] }[]
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 9.5C8 8.67157 8.67157 8 9.5 8H15.5C16.3284 8 17 8.67157 17 9.5V15.5C17 16.3284 16.3284 17 15.5 17H9.5C8.67157 17 8 16.3284 8 15.5V9.5ZM19 9.5C19 8.67157 19.6716 8 20.5 8H26.5C27.3284 8 28 8.67157 28 9.5V15.5C28 16.3284 27.3284 17 26.5 17H20.5C19.6716 17 19 16.3284 19 15.5V9.5ZM8 20.5C8 19.6716 8.67157 19 9.5 19H15.5C16.3284 19 17 19.6716 17 20.5V26.5C17 27.3284 16.3284 28 15.5 28H9.5C8.67157 28 8 27.3284 8 26.5V20.5ZM22.25 19H24.75V22.25H28V24.75H24.75V28H22.25V24.75H19V22.25H22.25V19Z" fill="url(#paint0_linear_plugin_market)" />
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_plugin_market" x1="8.5" y1="10" x2="30" y2="26" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#67C7FE" />
|
||||
<stop offset="1" stop-color="#3F77D8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M8 9.8C8 8.81 8.81 8 9.8 8H14.7C15.69 8 16.5 8.81 16.5 9.8V14.7C16.5 15.69 15.69 16.5 14.7 16.5H9.8C8.81 16.5 8 15.69 8 14.7V9.8ZM8 21.3C8 20.31 8.81 19.5 9.8 19.5H14.7C15.69 19.5 16.5 20.31 16.5 21.3V26.2C16.5 27.19 15.69 28 14.7 28H9.8C8.81 28 8 27.19 8 26.2V21.3ZM19.5 21.3C19.5 20.31 20.31 19.5 21.3 19.5H26.2C27.19 19.5 28 20.31 28 21.3V26.2C28 27.19 27.19 28 26.2 28H21.3C20.31 28 19.5 27.19 19.5 26.2V21.3Z" fill="url(#pr_modules_blue)"/>
|
||||
<path d="M23.75 7.25C24.47 10.58 25.67 11.78 29 12.5C25.67 13.22 24.47 14.42 23.75 17.75C23.03 14.42 21.83 13.22 18.5 12.5C21.83 11.78 23.03 10.58 23.75 7.25Z" fill="#9586CB"/>
|
||||
<defs><linearGradient id="pr_modules_blue" x1="8" y1="8" x2="28" y2="28" gradientUnits="userSpaceOnUse"><stop stop-color="#7EB8EA"/><stop offset="1" stop-color="#6174CE"/></linearGradient></defs>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 895 B After Width: | Height: | Size: 937 B |
@@ -25,6 +25,9 @@ describe('official recommendations', () => {
|
||||
{ ...bundle, name: 'custom', official: false }, { ...bundle, name: 'missing', availability: 'missing' },
|
||||
{ ...bundle, name: 'on-demand', optional: false }, { ...bundle, name: 'read-only', readOnlyReason: 'management-required' },
|
||||
{ ...bundle, name: 'failed', error: { code: 'operation-error', diagnostic: 'load failed' } },
|
||||
{ ...bundle, name: 'unsupported-shipped', listedByDefault: false, error: { code: 'unsupported-platform' } },
|
||||
{ ...bundle, name: 'unsupported-installed', installed: true, error: { code: 'unsupported-platform' } },
|
||||
{ ...bundle, name: 'unsupported-catalog', availability: 'missing', error: { code: 'unsupported-platform' } },
|
||||
])
|
||||
const result = await executeToolFixture(f.ctx, { name: 'skill', arguments: { name: 'official-plugins' }, agent: f.agent,
|
||||
callId: ToolCallId('discovery'), signal: new AbortController().signal })
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('Node preparation of Preview Session data', () => {
|
||||
const original = packVfsOverlay(fixture.trees)
|
||||
const projected = packPreviewFixture(fixture.trees)
|
||||
const currentFiles = Object.keys(projected.files).filter(path => path.endsWith('/' + sessionFormatLogFilename(currentVersion)))
|
||||
expect(currentFiles).toHaveLength(3)
|
||||
expect(currentFiles).toHaveLength(4)
|
||||
for (const [path, bytes] of Object.entries(original.files)) expect(projected.files[path], path).toEqual(bytes)
|
||||
for (const path of currentFiles) {
|
||||
const current = restore(projected.files[path])
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 4e53164159b5ce41
|
||||
zh: 89582dc0294d0776
|
||||
/deepseek-ai-dsh-experimental-webworker-runtime/use-this-package:
|
||||
en: e55bf1e080a6e075
|
||||
zh: 1d3829a5b7fd2d13
|
||||
en: 24f1bb4adc4a9bb6
|
||||
zh: 4a2caa4bfe2d8911
|
||||
/deepseek-ai-dsh-experimental-webworker-runtime/model-experience:
|
||||
en: 61fce9b4c7dc96e6
|
||||
zh: dc2d931063a38327
|
||||
|
||||
@@ -35,7 +35,7 @@ Three build artifacts and one source-owned process layer:
|
||||
|
||||
Callbacks and timers registered under an explicit root context retain its empty stores, so they cannot inherit an unrelated transaction.
|
||||
|
||||
Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The showcase mixes empty, whitespace-only, and non-empty reasoning with a long multiline command and a parent-path file link; these are authored fixtures, not live model output. The [packer](../webworker-packer/README.md) prepares current-generation successors from committed historical logs in Node; those source logs remain byte-identical in the overlay. The fixture generator owns the acceptance test's current projection cache. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture.
|
||||
Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The showcase mixes empty, whitespace-only, and non-empty reasoning with a long multiline command and a parent-path file link; these are authored fixtures, not live model output. A second root Session, `preview-turn-spacing`, carries three multi-step Turns whose bodies compare the tightened process-row spacing with the reply clearance. The [packer](../webworker-packer/README.md) prepares current-generation successors from committed historical logs in Node; those source logs remain byte-identical in the overlay. The fixture generator owns the acceptance test's current projection cache. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture.
|
||||
|
||||
The [built-bundle import sweep](tests/compile/transform-corpus-check.ts) checks bare Node imports after the library build. Its Dockkit exception accepts Node’s unknown-`.css`-extension refusal for any stylesheet rather than one pinned path; another extension, error code, or message, and an unexpectedly successful exempt import, remain errors.
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ kind: "package-library"
|
||||
|
||||
在显式根上下文中登记的回调和定时器保留其空 store,避免继承无关事务。
|
||||
|
||||
验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。showcase 混排空字符串、纯空白与非空推理块,并包含多行长命令和父路径文件链接;这些是人工构造的 fixture,不是真实模型输出。[打包器](../webworker-packer/README.zh.md)在 Node 中根据已提交的历史日志准备当前代后继文件;这些源日志在 overlay 中逐字节保持不变。fixture 生成器负责验收测试的当前投影缓存。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。
|
||||
验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。showcase 混排空字符串、纯空白与非空推理块,并包含多行长命令和父路径文件链接;这些是人工构造的 fixture,不是真实模型输出。另一个根 Session `preview-turn-spacing` 含三个多步骤轮次,其正文用于对照收紧后的过程行间距与回复间距。[打包器](../webworker-packer/README.zh.md)在 Node 中根据已提交的历史日志准备当前代后继文件;这些源日志在 overlay 中逐字节保持不变。fixture 生成器负责验收测试的当前投影缓存。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。
|
||||
|
||||
[已构建 bundle 导入检查](tests/compile/transform-corpus-check.ts)在库构建后检查裸 Node 导入。Dockkit 例外接受 Node 针对任意样式表报告的未知 `.css` 扩展名错误,而不限定某一固定路径;其他扩展名、错误码或消息,以及意外成功的豁免导入,仍然报错。
|
||||
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{"type":"session","version":4,"id":"preview-turn-spacing","createdAt":1787472300000,"cwd":"/dsh/workspace","isSeeded":false,"delegationDepth":0,"agentPreset":"standard"}
|
||||
{"type":"turn/start","data":{"turn":1},"seq":0,"time":1787472300000}
|
||||
{"type":"user/message","data":{"id":"preview-spacing-user-1","role":"user","content":[{"type":"text","text":"Show how multiple Turns and their process steps sit on the tightened spacing."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":1,"time":1787472300001}
|
||||
{"type":"session/title","data":{"title":"Turn spacing sample","messageSeqs":[],"source":{"kind":"user"}},"seq":2,"time":1787472300002}
|
||||
{"type":"step/start","data":{"turn":1,"step":1},"seq":3,"time":1787472300003}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"id":"preview-spacing-1-tools","role":"assistant","content":[{"type":"reasoning","text":"Read the workspace guide, then list the files this sample references."},{"type":"tool-call","id":"preview-spacing-read","name":"read","arguments":"{\"file_path\":\"PREVIEW.md\"}"},{"type":"tool-call","id":"preview-spacing-glob","name":"glob","arguments":"{\"pattern\":\"**/*\",\"path\":\".\"}"}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":0,"index":0,"dt":[],"texts":["Read the workspace guide, then list the files this sample references."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Read the workspace guide, then list the files this sample references."}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":1,"dt":[],"id":"preview-spacing-read","name":"read","args":["{\"file_path\":\"PREVIEW.md\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"preview-spacing-read","name":"read","arguments":"{\"file_path\":\"PREVIEW.md\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":2,"dt":[],"id":"preview-spacing-glob","name":"glob","args":["{\"pattern\":\"**/*\",\"path\":\".\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"preview-spacing-glob","name":"glob","arguments":"{\"pattern\":\"**/*\",\"path\":\".\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append","seq":4,"time":1787472300004}
|
||||
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"preview-spacing-read","name":"read","arguments":"{\"file_path\":\"PREVIEW.md\"}"},"seq":5,"time":1787472300005}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"preview-spacing-read-result","role":"tool","toolCallId":"preview-spacing-read","source":{"kind":"tool","callId":"preview-spacing-read"},"content":[{"type":"text","text":"<path>PREVIEW.md</path>\n<type>file</type>\n<content>\n1: # Preview Workspace\n2: \n3: This deterministic workspace is bundled with the browser-only preview.\n4: \n5: - `src/preview.ts` is the file changed by the example write result.\n6: - `data/tasks.json` mirrors the completed preview checklist.\n7: - `.agents/skills/preview-tour/SKILL.md` proves dot directories survive image packing.\n8: \n9: Refresh the preview to restore these image bytes.\n\n(End of file - total 9 lines)\n</content>"}],"isError":false}},"surfaceOp":"append","seq":6,"time":1787472300006}
|
||||
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"preview-spacing-glob","name":"glob","arguments":"{\"pattern\":\"**/*\",\"path\":\".\"}"},"seq":7,"time":1787472300007}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"preview-spacing-glob-result","role":"tool","toolCallId":"preview-spacing-glob","source":{"kind":"tool","callId":"preview-spacing-glob"},"content":[{"type":"text","text":"PREVIEW.md\ndata/tasks.json\nsrc/preview.ts"}],"isError":false}},"surfaceOp":"append","seq":8,"time":1787472300008}
|
||||
{"type":"step/end","data":{"turn":1,"step":1},"seq":9,"time":1787472300009}
|
||||
{"type":"step/start","data":{"turn":1,"step":2},"seq":10,"time":1787472300010}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"id":"preview-spacing-1-command","role":"assistant","content":[{"type":"reasoning","text":"Confirm the sample command result before writing the body."},{"type":"tool-call","id":"preview-spacing-bash","name":"bash","arguments":"{\"command\":\"printf 'spacing sample 1\\\\n'\",\"description\":\"Print the first spacing sample marker\"}"}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":0,"index":0,"dt":[],"texts":["Confirm the sample command result before writing the body."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Confirm the sample command result before writing the body."}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":1,"dt":[],"id":"preview-spacing-bash","name":"bash","args":["{\"command\":\"printf 'spacing sample 1\\\\n'\",\"description\":\"Print the first spacing sample marker\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"preview-spacing-bash","name":"bash","arguments":"{\"command\":\"printf 'spacing sample 1\\\\n'\",\"description\":\"Print the first spacing sample marker\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append","seq":11,"time":1787472300011}
|
||||
{"type":"tool/call","data":{"turn":1,"step":2,"callId":"preview-spacing-bash","name":"bash","arguments":"{\"command\":\"printf 'spacing sample 1\\\\n'\",\"description\":\"Print the first spacing sample marker\"}"},"seq":12,"time":1787472300012}
|
||||
{"type":"tool/result","data":{"turn":1,"step":2,"message":{"id":"preview-spacing-bash-result","role":"tool","toolCallId":"preview-spacing-bash","source":{"kind":"tool","callId":"preview-spacing-bash"},"content":[{"type":"text","text":"spacing sample 1\n"}],"isError":false}},"surfaceOp":"append","seq":13,"time":1787472300013}
|
||||
{"type":"step/end","data":{"turn":1,"step":2},"seq":14,"time":1787472300014}
|
||||
{"type":"step/start","data":{"turn":1,"step":3},"seq":15,"time":1787472300015}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"id":"preview-spacing-1-body","role":"assistant","content":[{"type":"text","text":"## Turn 1 — setup\n\nThis Turn has three steps: two process steps and one closing body. Inside an expanded group the process rows use the tightened gap, while this body keeps the reply clearance from the rows above.\n\nExpand the Turn control above to compare the tightened process rhythm with the reply rhythm."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["## Turn 1 — setup\n\nThis Turn has three steps: two process steps and one closing body. Inside an expanded group the process rows use the tightened gap, while this body keeps the reply clearance from the rows above.\n\nExpand the Turn control above to compare the tightened process rhythm with the reply rhythm."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"## Turn 1 — setup\n\nThis Turn has three steps: two process steps and one closing body. Inside an expanded group the process rows use the tightened gap, while this body keeps the reply clearance from the rows above.\n\nExpand the Turn control above to compare the tightened process rhythm with the reply rhythm."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":16,"time":1787472300016}
|
||||
{"type":"step/end","data":{"turn":1,"step":3},"seq":17,"time":1787472300017}
|
||||
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}},"seq":18,"time":1787472300018}
|
||||
{"type":"turn/start","data":{"turn":2},"seq":19,"time":1787472300019}
|
||||
{"type":"user/message","data":{"id":"preview-spacing-user-2","role":"user","content":[{"type":"text","text":"Continue the sample with a second body and a shorter process."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":20,"time":1787472300020}
|
||||
{"type":"step/start","data":{"turn":2,"step":1},"seq":21,"time":1787472300021}
|
||||
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"id":"preview-spacing-2-tools","role":"assistant","content":[{"type":"reasoning","text":"Search the sample workspace for the spacing note."},{"type":"tool-call","id":"preview-spacing-grep","name":"grep","arguments":"{\"pattern\":\"preview\",\"path\":\".\",\"include\":\"*.{md,ts,json}\"}"}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":0,"index":0,"dt":[],"texts":["Search the sample workspace for the spacing note."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Search the sample workspace for the spacing note."}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":1,"dt":[],"id":"preview-spacing-grep","name":"grep","args":["{\"pattern\":\"preview\",\"path\":\".\",\"include\":\"*.{md,ts,json}\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"preview-spacing-grep","name":"grep","arguments":"{\"pattern\":\"preview\",\"path\":\".\",\"include\":\"*.{md,ts,json}\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append","seq":22,"time":1787472300022}
|
||||
{"type":"tool/call","data":{"turn":2,"step":1,"callId":"preview-spacing-grep","name":"grep","arguments":"{\"pattern\":\"preview\",\"path\":\".\",\"include\":\"*.{md,ts,json}\"}"},"seq":23,"time":1787472300023}
|
||||
{"type":"tool/result","data":{"turn":2,"step":1,"message":{"id":"preview-spacing-grep-result","role":"tool","toolCallId":"preview-spacing-grep","source":{"kind":"tool","callId":"preview-spacing-grep"},"content":[{"type":"text","text":"PREVIEW.md:3:This deterministic workspace is bundled with the browser-only preview.\nsrc/preview.ts:1:export const previewStatus = 'ready'"}],"isError":false}},"surfaceOp":"append","seq":24,"time":1787472300024}
|
||||
{"type":"step/end","data":{"turn":2,"step":1},"seq":25,"time":1787472300025}
|
||||
{"type":"step/start","data":{"turn":2,"step":2},"seq":26,"time":1787472300026}
|
||||
{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"id":"preview-spacing-2-body","role":"assistant","content":[{"type":"text","text":"## Turn 2 — follow-up\n\nA shorter Turn still separates its process group from this body by the reply clearance, so consecutive Turns stay readable.\n\nThe gap between the previous Turn and this user message is the ordinary flow gap."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["## Turn 2 — follow-up\n\nA shorter Turn still separates its process group from this body by the reply clearance, so consecutive Turns stay readable.\n\nThe gap between the previous Turn and this user message is the ordinary flow gap."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"## Turn 2 — follow-up\n\nA shorter Turn still separates its process group from this body by the reply clearance, so consecutive Turns stay readable.\n\nThe gap between the previous Turn and this user message is the ordinary flow gap."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":27,"time":1787472300027}
|
||||
{"type":"step/end","data":{"turn":2,"step":2},"seq":28,"time":1787472300028}
|
||||
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}},"seq":29,"time":1787472300029}
|
||||
{"type":"turn/start","data":{"turn":3},"seq":30,"time":1787472300030}
|
||||
{"type":"user/message","data":{"id":"preview-spacing-user-3","role":"user","content":[{"type":"text","text":"Finish the sample with one more multi-step Turn."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":31,"time":1787472300031}
|
||||
{"type":"step/start","data":{"turn":3,"step":1},"seq":32,"time":1787472300032}
|
||||
{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"id":"preview-spacing-3-tools","role":"assistant","content":[{"type":"reasoning","text":"Read the task list and print the final sample marker."},{"type":"tool-call","id":"preview-spacing-tasks","name":"read","arguments":"{\"file_path\":\"data/tasks.json\"}"},{"type":"tool-call","id":"preview-spacing-final-bash","name":"bash","arguments":"{\"command\":\"printf 'spacing sample complete\\\\n'\",\"description\":\"Print the final spacing sample marker\"}"}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":0,"index":0,"dt":[],"texts":["Read the task list and print the final sample marker."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Read the task list and print the final sample marker."}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":1,"dt":[],"id":"preview-spacing-tasks","name":"read","args":["{\"file_path\":\"data/tasks.json\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"preview-spacing-tasks","name":"read","arguments":"{\"file_path\":\"data/tasks.json\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":2,"dt":[],"id":"preview-spacing-final-bash","name":"bash","args":["{\"command\":\"printf 'spacing sample complete\\\\n'\",\"description\":\"Print the final spacing sample marker\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"preview-spacing-final-bash","name":"bash","arguments":"{\"command\":\"printf 'spacing sample complete\\\\n'\",\"description\":\"Print the final spacing sample marker\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append","seq":33,"time":1787472300033}
|
||||
{"type":"tool/call","data":{"turn":3,"step":1,"callId":"preview-spacing-tasks","name":"read","arguments":"{\"file_path\":\"data/tasks.json\"}"},"seq":34,"time":1787472300034}
|
||||
{"type":"tool/result","data":{"turn":3,"step":1,"message":{"id":"preview-spacing-tasks-result","role":"tool","toolCallId":"preview-spacing-tasks","source":{"kind":"tool","callId":"preview-spacing-tasks"},"content":[{"type":"text","text":"<path>data/tasks.json</path>\n<type>file</type>\n<content>\n1: {\n2: \"title\": \"Preview verification\",\n3: \"tasks\": [\n4: {\n5: \"name\": \"Inspect tool cards\",\n6: \"status\": \"completed\"\n7: },\n8: {\n9: \"name\": \"Open both subagents\",\n10: \"status\": \"completed\"\n11: },\n12: {\n13: \"name\": \"Load earlier history\",\n14: \"status\": \"completed\"\n15: }\n16: ]\n17: }\n\n(End of file - total 17 lines)\n</content>"}],"isError":false}},"surfaceOp":"append","seq":35,"time":1787472300035}
|
||||
{"type":"tool/call","data":{"turn":3,"step":1,"callId":"preview-spacing-final-bash","name":"bash","arguments":"{\"command\":\"printf 'spacing sample complete\\\\n'\",\"description\":\"Print the final spacing sample marker\"}"},"seq":36,"time":1787472300036}
|
||||
{"type":"tool/result","data":{"turn":3,"step":1,"message":{"id":"preview-spacing-final-bash-result","role":"tool","toolCallId":"preview-spacing-final-bash","source":{"kind":"tool","callId":"preview-spacing-final-bash"},"content":[{"type":"text","text":"spacing sample complete\n"}],"isError":false}},"surfaceOp":"append","seq":37,"time":1787472300037}
|
||||
{"type":"step/end","data":{"turn":3,"step":1},"seq":38,"time":1787472300038}
|
||||
{"type":"step/start","data":{"turn":3,"step":2},"seq":39,"time":1787472300039}
|
||||
{"type":"assistant/message","data":{"turn":3,"step":2,"message":{"id":"preview-spacing-3-body","role":"assistant","content":[{"type":"text","text":"## Turn 3 — closing\n\nThe last Turn has a two-step shape: one process step and one closing body.\n\nSpacing sample complete: compare these process rows with the reply clearance around each Turn body."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["## Turn 3 — closing\n\nThe last Turn has a two-step shape: one process step and one closing body.\n\nSpacing sample complete: compare these process rows with the reply clearance around each Turn body."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"## Turn 3 — closing\n\nThe last Turn has a two-step shape: one process step and one closing body.\n\nSpacing sample complete: compare these process rows with the reply clearance around each Turn body."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":40,"time":1787472300040}
|
||||
{"type":"step/end","data":{"turn":3,"step":2},"seq":41,"time":1787472300041}
|
||||
{"type":"turn/end","data":{"turn":3,"reason":{"kind":"completed"}},"seq":42,"time":1787472300042}
|
||||
+16
@@ -21,6 +21,22 @@
|
||||
"val": "WebWorker Preview Showcase"
|
||||
}
|
||||
}
|
||||
},
|
||||
"preview-turn-spacing": {
|
||||
"identity": {
|
||||
"formatVersion": 4,
|
||||
"createdAt": 1787472300000,
|
||||
"cwd": "/dsh/workspace",
|
||||
"isSeeded": false,
|
||||
"inheritedEventCount": 0
|
||||
},
|
||||
"rows": {
|
||||
"title": {
|
||||
"ver": 1,
|
||||
"seq": 42,
|
||||
"val": "Turn spacing sample"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
VFS_EXAMPLE_OLDEST_MESSAGE,
|
||||
VFS_EXAMPLE_ROOT,
|
||||
VFS_EXAMPLE_REASONING,
|
||||
VFS_EXAMPLE_SPACING_TITLE,
|
||||
VFS_EXAMPLE_WRAPPED_COMMAND,
|
||||
VFS_EXAMPLE_SCHEDULE_IDS,
|
||||
VFS_EXAMPLE_SESSION_IDS,
|
||||
@@ -61,24 +62,30 @@ function textOf(event: SessionEvent): string {
|
||||
}
|
||||
|
||||
describe('WebWorker preview VFS example', () => {
|
||||
it('keeps the current showcase and strictly restorable historical child inputs beside their predecessors', () => {
|
||||
it('keeps current Sessions byte-identical and historical child inputs restorable beside their predecessors', () => {
|
||||
const expected = buildVfsExampleFiles()
|
||||
const currentName = generationLogFilename(SESSION_FORMAT_VERSION, 'none')
|
||||
const main = `home/sessions/--dsh-workspace--/${VFS_EXAMPLE_SESSION_IDS.main}/`
|
||||
const committedPath = (path: string): string => path.endsWith(`/${currentName}`) && !path.startsWith(main)
|
||||
// The showcase and the spacing sample ship their current generation; only the children keep a
|
||||
// released v3 predecessor that the packer upgrades at build time.
|
||||
const currentOnly = new Set<string>([VFS_EXAMPLE_SESSION_IDS.main, VFS_EXAMPLE_SESSION_IDS.spacing])
|
||||
const sessionIdOf = (path: string): string => path.split('/').at(-2) ?? ''
|
||||
const committedPath = (path: string): string => path.endsWith(`/${currentName}`) && !currentOnly.has(sessionIdOf(path))
|
||||
? `${path.slice(0, -currentName.length)}session.v3.jsonl`
|
||||
: path
|
||||
const predecessors = Object.values(VFS_EXAMPLE_SESSION_IDS)
|
||||
.map(id => `home/sessions/--dsh-workspace--/${id}/session.v2.jsonl`)
|
||||
const predecessors = [
|
||||
VFS_EXAMPLE_SESSION_IDS.main, VFS_EXAMPLE_SESSION_IDS.oneShot, VFS_EXAMPLE_SESSION_IDS.continuable,
|
||||
].map(id => `home/sessions/--dsh-workspace--/${id}/session.v2.jsonl`)
|
||||
predecessors.push(`${main}session.v3.jsonl`)
|
||||
expect(filesUnder(VFS_EXAMPLE_ROOT)).toEqual([...expected.keys()].map(committedPath).concat(predecessors).sort())
|
||||
for (const [path, content] of expected) {
|
||||
const committed = readFileSync(join(VFS_EXAMPLE_ROOT, committedPath(path)), 'utf8')
|
||||
if (path.endsWith(`/${currentName}`) && !path.startsWith(main)) {
|
||||
const current = path.endsWith(`/${currentName}`) && currentOnly.has(sessionIdOf(path))
|
||||
if (path.endsWith(`/${currentName}`) && !current) {
|
||||
expect(restoreSession(committed, 3), path).toEqual(restoreSession(content, SESSION_FORMAT_VERSION))
|
||||
} else {
|
||||
expect(committed, path).toBe(content)
|
||||
if (path.startsWith(main)) restoreSession(committed, SESSION_FORMAT_VERSION)
|
||||
if (current) restoreSession(committed, SESSION_FORMAT_VERSION)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -182,6 +189,28 @@ describe('WebWorker preview VFS example', () => {
|
||||
expect(events.some(event => event.type === 'tool/result' && event.data.message.isError === true)).toBe(true)
|
||||
})
|
||||
|
||||
it('restores the multi-Turn spacing sample with a body and several steps per Turn', () => {
|
||||
const { meta, events } = readSession(VFS_EXAMPLE_SESSION_IDS.spacing)
|
||||
expect(meta).toMatchObject({
|
||||
id: VFS_EXAMPLE_SESSION_IDS.spacing,
|
||||
cwd: '/dsh/workspace',
|
||||
delegationDepth: 0,
|
||||
})
|
||||
expect(events.map(event => event.seq)).toEqual(events.map((_, index) => index))
|
||||
expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } })
|
||||
expect(events.some(event => event.type === 'session/title'
|
||||
&& event.data.title === VFS_EXAMPLE_SPACING_TITLE)).toBe(true)
|
||||
for (const turn of [1, 2, 3]) {
|
||||
const steps = events.filter(event => event.type === 'step/start' && event.data.turn === turn)
|
||||
expect(steps.length).toBeGreaterThanOrEqual(2)
|
||||
const body = events
|
||||
.filter(event => event.type === 'assistant/message' && event.data.turn === turn)
|
||||
.map(textOf)
|
||||
.join('\n')
|
||||
expect(body).toContain('## Turn')
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves the 28-turn history prefix and both child catalog identities', () => {
|
||||
const main = readSession(VFS_EXAMPLE_SESSION_IDS.main)
|
||||
const child = readSession(VFS_EXAMPLE_SESSION_IDS.oneShot)
|
||||
|
||||
@@ -27,11 +27,15 @@ export const VFS_EXAMPLE_SESSION_IDS = {
|
||||
main: SessionId('preview-showcase'),
|
||||
oneShot: SessionId('preview-architecture-review'),
|
||||
continuable: SessionId('preview-follow-up-builder'),
|
||||
spacing: SessionId('preview-turn-spacing'),
|
||||
} as const
|
||||
|
||||
/** Stable title rendered in the root Session list. */
|
||||
export const VFS_EXAMPLE_TITLE = 'WebWorker Preview Showcase'
|
||||
|
||||
/** Title of the multi-Turn spacing sample in the root Session list. */
|
||||
export const VFS_EXAMPLE_SPACING_TITLE = 'Turn spacing sample'
|
||||
|
||||
/** Oldest prompt, intentionally outside the first 50-message history page. */
|
||||
export const VFS_EXAMPLE_OLDEST_MESSAGE = 'History checkpoint 01: verify deterministic preview state.'
|
||||
|
||||
@@ -198,6 +202,12 @@ function readResult(): { text: string; meta: unknown } {
|
||||
}
|
||||
}
|
||||
|
||||
/** Render the workspace task file the way a `read` result shows it. */
|
||||
function tasksReadResult(): string {
|
||||
const lines = TASKS.trimEnd().split('\n')
|
||||
return `<path>data/tasks.json</path>\n<type>file</type>\n<content>\n${lines.map((text, index) => `${String(index + 1)}: ${text}`).join('\n')}\n\n(End of file - total ${String(lines.length)} lines)\n</content>`
|
||||
}
|
||||
|
||||
function galleryCalls(): GalleryCall[] {
|
||||
const read = readResult()
|
||||
return [
|
||||
@@ -432,6 +442,149 @@ function continuableLog(): SessionEvent[] {
|
||||
return log.events
|
||||
}
|
||||
|
||||
/** One step of the multi-Turn spacing sample: reasoning, tool calls, or a closing body. */
|
||||
interface SpacingStep {
|
||||
/** Assistant message id. */
|
||||
readonly id: string
|
||||
/** Reasoning text shown in the step's Think row. */
|
||||
readonly reasoning?: string
|
||||
/** Tool calls issued in this step, each with its recorded result. */
|
||||
readonly calls?: readonly GalleryCall[]
|
||||
/** Closing Markdown body for a step without tool calls. */
|
||||
readonly body?: string
|
||||
}
|
||||
|
||||
function addSpacingTurn(
|
||||
log: EventLog,
|
||||
turn: number,
|
||||
prompt: { readonly id: string; readonly text: string },
|
||||
steps: readonly SpacingStep[],
|
||||
): void {
|
||||
log.add({ type: 'turn/start', data: { turn } })
|
||||
log.add(userMessage(prompt.id, prompt.text))
|
||||
if (turn === 1) {
|
||||
log.add({
|
||||
type: 'session/title',
|
||||
data: { title: VFS_EXAMPLE_SPACING_TITLE, messageSeqs: [], source: { kind: 'user' } },
|
||||
})
|
||||
}
|
||||
for (const [index, step] of steps.entries()) {
|
||||
const stepNumber = index + 1
|
||||
log.add({ type: 'step/start', data: { turn, step: stepNumber } })
|
||||
const content: unknown[] = []
|
||||
if (step.reasoning !== undefined) content.push({ type: 'reasoning', text: step.reasoning })
|
||||
for (const call of step.calls ?? []) {
|
||||
content.push({ type: 'tool-call', id: call.id, name: call.name, arguments: JSON.stringify(call.args) })
|
||||
}
|
||||
if (step.body !== undefined) content.push({ type: 'text', text: step.body })
|
||||
log.add(assistantMessage(step.id, turn, stepNumber, content))
|
||||
for (const call of step.calls ?? []) {
|
||||
log.add({
|
||||
type: 'tool/call',
|
||||
data: { turn, step: stepNumber, callId: call.id, name: call.name, arguments: JSON.stringify(call.args) },
|
||||
})
|
||||
log.add({
|
||||
type: 'tool/result',
|
||||
data: {
|
||||
turn,
|
||||
step: stepNumber,
|
||||
message: {
|
||||
id: `${call.id}-result`,
|
||||
role: 'tool',
|
||||
toolCallId: call.id,
|
||||
source: { kind: 'tool', callId: call.id },
|
||||
content: [{ type: 'text', text: call.result }],
|
||||
isError: false,
|
||||
},
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
})
|
||||
}
|
||||
log.add({ type: 'step/end', data: { turn, step: stepNumber } })
|
||||
}
|
||||
log.add({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Three Turns with multi-step processes and multi-paragraph bodies, so the
|
||||
* preview shows the tightened process-row gap beside the reply clearance.
|
||||
*/
|
||||
function spacingLog(): SessionEvent[] {
|
||||
const log = new EventLog(CREATED_AT + 300_000)
|
||||
addSpacingTurn(log, 1, {
|
||||
id: 'preview-spacing-user-1',
|
||||
text: 'Show how multiple Turns and their process steps sit on the tightened spacing.',
|
||||
}, [
|
||||
{
|
||||
id: 'preview-spacing-1-tools',
|
||||
reasoning: 'Read the workspace guide, then list the files this sample references.',
|
||||
calls: [
|
||||
{ id: 'preview-spacing-read', name: 'read', args: { file_path: 'PREVIEW.md' }, result: readResult().text },
|
||||
{
|
||||
id: 'preview-spacing-glob', name: 'glob', args: { pattern: '**/*', path: '.' },
|
||||
result: 'PREVIEW.md\ndata/tasks.json\nsrc/preview.ts',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'preview-spacing-1-command',
|
||||
reasoning: 'Confirm the sample command result before writing the body.',
|
||||
calls: [{
|
||||
id: 'preview-spacing-bash', name: 'bash',
|
||||
args: { command: "printf 'spacing sample 1\\n'", description: 'Print the first spacing sample marker' },
|
||||
result: 'spacing sample 1\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'preview-spacing-1-body',
|
||||
body: '## Turn 1 — setup\n\nThis Turn has three steps: two process steps and one closing body. Inside an expanded group the process rows use the tightened gap, while this body keeps the reply clearance from the rows above.\n\nExpand the Turn control above to compare the tightened process rhythm with the reply rhythm.',
|
||||
},
|
||||
])
|
||||
addSpacingTurn(log, 2, {
|
||||
id: 'preview-spacing-user-2',
|
||||
text: 'Continue the sample with a second body and a shorter process.',
|
||||
}, [
|
||||
{
|
||||
id: 'preview-spacing-2-tools',
|
||||
reasoning: 'Search the sample workspace for the spacing note.',
|
||||
calls: [{
|
||||
id: 'preview-spacing-grep', name: 'grep',
|
||||
args: { pattern: 'preview', path: '.', include: '*.{md,ts,json}' },
|
||||
result: 'PREVIEW.md:3:This deterministic workspace is bundled with the browser-only preview.\nsrc/preview.ts:1:export const previewStatus = \'ready\'',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'preview-spacing-2-body',
|
||||
body: '## Turn 2 — follow-up\n\nA shorter Turn still separates its process group from this body by the reply clearance, so consecutive Turns stay readable.\n\nThe gap between the previous Turn and this user message is the ordinary flow gap.',
|
||||
},
|
||||
])
|
||||
addSpacingTurn(log, 3, {
|
||||
id: 'preview-spacing-user-3',
|
||||
text: 'Finish the sample with one more multi-step Turn.',
|
||||
}, [
|
||||
{
|
||||
id: 'preview-spacing-3-tools',
|
||||
reasoning: 'Read the task list and print the final sample marker.',
|
||||
calls: [
|
||||
{
|
||||
id: 'preview-spacing-tasks', name: 'read', args: { file_path: 'data/tasks.json' },
|
||||
result: tasksReadResult(),
|
||||
},
|
||||
{
|
||||
id: 'preview-spacing-final-bash', name: 'bash',
|
||||
args: { command: "printf 'spacing sample complete\\n'", description: 'Print the final spacing sample marker' },
|
||||
result: 'spacing sample complete\n',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'preview-spacing-3-body',
|
||||
body: '## Turn 3 — closing\n\nThe last Turn has a two-step shape: one process step and one closing body.\n\nSpacing sample complete: compare these process rows with the reply clearance around each Turn body.',
|
||||
},
|
||||
])
|
||||
return log.events
|
||||
}
|
||||
|
||||
function header(
|
||||
id: SessionHeader['id'],
|
||||
createdAt: number,
|
||||
@@ -524,6 +677,7 @@ function scheduleTasks(): Readonly<Record<string, unknown>> {
|
||||
/** Build current-generation fixture files as fixture-relative UTF-8 text; committed predecessors remain untouched. */
|
||||
export function buildVfsExampleFiles(): ReadonlyMap<string, string> {
|
||||
const main = mainLog()
|
||||
const spacing = spacingLog()
|
||||
const project = projectKey(WORKSPACE)
|
||||
const sessionPath = (id: string): string =>
|
||||
`home/sessions/${project}/${id}/${generationLogFilename(SESSION_FORMAT_VERSION, 'none')}`
|
||||
@@ -544,6 +698,18 @@ export function buildVfsExampleFiles(): ReadonlyMap<string, string> {
|
||||
title: { ver: 1, seq: main.events.at(-1)?.seq ?? -1, val: VFS_EXAMPLE_TITLE },
|
||||
},
|
||||
},
|
||||
[VFS_EXAMPLE_SESSION_IDS.spacing]: {
|
||||
identity: {
|
||||
formatVersion: SESSION_FORMAT_VERSION,
|
||||
createdAt: CREATED_AT + 300_000,
|
||||
cwd: WORKSPACE,
|
||||
isSeeded: false,
|
||||
inheritedEventCount: 0,
|
||||
},
|
||||
rows: {
|
||||
title: { ver: 1, seq: spacing.at(-1)?.seq ?? -1, val: VFS_EXAMPLE_SPACING_TITLE },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, null, 2)}\n`
|
||||
@@ -557,6 +723,10 @@ export function buildVfsExampleFiles(): ReadonlyMap<string, string> {
|
||||
header(VFS_EXAMPLE_SESSION_IDS.main, CREATED_AT),
|
||||
main.events,
|
||||
)],
|
||||
[sessionPath(VFS_EXAMPLE_SESSION_IDS.spacing), renderLog(
|
||||
header(VFS_EXAMPLE_SESSION_IDS.spacing, CREATED_AT + 300_000),
|
||||
spacing,
|
||||
)],
|
||||
[sessionPath(VFS_EXAMPLE_SESSION_IDS.oneShot), renderLog(
|
||||
header(VFS_EXAMPLE_SESSION_IDS.oneShot, CREATED_AT + 100_000, {
|
||||
parentSession: VFS_EXAMPLE_SESSION_IDS.main,
|
||||
|
||||
@@ -1676,8 +1676,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
key: 'conversation.session.header.actions',
|
||||
kind: 'list',
|
||||
scope: 'session',
|
||||
summary: 'Title-adjacent Session actions in ascending order.',
|
||||
doc: 'Title-adjacent Session actions in ascending order.',
|
||||
summary: 'Title-adjacent Session actions in ascending order; overflow is clipped and faded before the utilities, so overlays must portal.',
|
||||
doc: 'Title-adjacent Session actions in ascending order; overflow is clipped and faded before the utilities, so overlays must portal.',
|
||||
registerOptions: [
|
||||
{
|
||||
name: 'id',
|
||||
@@ -1782,8 +1782,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
key: 'conversation.session.header.lineage',
|
||||
kind: 'single',
|
||||
scope: 'session',
|
||||
summary: 'Optional replacement for one Session breadcrumb title.',
|
||||
doc: 'Optional replacement for one Session breadcrumb title.',
|
||||
summary: 'Optional replacement for one Session breadcrumb title; overlays must portal because the title area clips overflow.',
|
||||
doc: 'Optional replacement for one Session breadcrumb title; overlays must portal because the title area clips overflow.',
|
||||
registerOptions: [],
|
||||
ownerProps: [
|
||||
'/** Plain breadcrumb data handed to the optional lineage renderer. */\nexport interface ConversationHeaderLineageOwnerProps {\n /** Session represented by this breadcrumb title. */\n lineageSessionId: SessionId\n /** Display title available to a combined title/control renderer. */\n displayTitle: string\n /** Navigate to an ancestor title when present. */\n openTitle?: () => void\n}',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user