diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index b2376419b5..c6946018d9 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 01081f1e0b8027420fedbc599f99c67e67a4639b -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: fadfc8cdcb0651665b965126ab0b25ed4218a533 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 4fe9ea3c4073e249a8c961634be9d329b35a3f4e +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: d7a4b91a48175edcc624e6af44efa3e4c7676554 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 01081f1e0b..4fe9ea3c40 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -40,15 +40,15 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru ### 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-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. -CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three 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 only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. +CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly by `workflow_dispatch`, the `build-exe` label on a pull request, or the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects both the executable and native addon's GLIBC requirements and runs in a manylinux 2.28 container, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with `deepseek-harness-sdk` depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms. The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-jsonrpc` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index fadfc8cdcb..d7a4b91a48 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -40,15 +40,15 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建管线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 -CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR(Pull Request)添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 +CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),由手动派发 `workflow_dispatch`、PR(Pull Request)的 `build-exe` 标签或[公开发布工作流](../process/2026-08-11-python-publication-workflow.md)显式触发。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 `deepseek-harness-sdk` 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 exe「必须显式配置」的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-jsonrpc` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 0a007fc184..9d49d9b636 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: b316d1a42c4f69c8e1163f1f36249b61a7d126ef -2026-08-02-typert-remote-method-calls.zh.md: 02714de3c522ce597e89976b6e03d455ff0bf872 +2026-08-02-typert-remote-method-calls.md: 04d4581a8f9a265e5374dae4b76ac8d7e1f91fc6 +2026-08-02-typert-remote-method-calls.zh.md: d0940d0cd95b04901caafa214058a272b23ded5d diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index b316d1a42c..04d4581a8f 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -238,7 +238,7 @@ import goalsRemote from '@deepseek-ai/dsh-goal/remote' This import brings the `.d.ts` map augmentation into the current TypeScript project while supplying the JS descriptor for the same contract as a value to the runtime. A business package that is not imported does not extend the current project's Remote API types. -The business package's published files must include both `lib/typert.remote-client.d.ts.map` and the `src` file referenced by that map. The generated DTS refers to its adjacent map with `//# sourceMappingURL=typert.remote-client.d.ts.map`; the map source points from `lib` to the business source by a relative path such as `../src/index.ts`. The `/remote` export does not list the map separately; the package `files` field publishes it together with the source. +The business package's published files must include `lib/typert.remote-client.d.ts.map`. The generated DTS refers to its adjacent map with `//# sourceMappingURL=typert.remote-client.d.ts.map`; the map source points from `lib` to the business source by a relative path such as `../src/index.ts`. The `/remote` export does not list the map separately; the package `files` field publishes it. That target is a development-time path: a workspace consumer resolves it through the package link, so the published payload keeps excluding `src` and a published map simply resolves nothing. Code that needs only static types may use `import type {} from '@deepseek-ai/dsh-goal/remote'`. This import is erased at runtime, loads no JS, and cannot trigger runtime registration. An environment that makes real calls must pass the contribution from a normal value import to the Client Remote Service. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 02714de3c5..d0940d0cd9 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -238,7 +238,7 @@ import goalsRemote from '@deepseek-ai/dsh-goal/remote' 该 import 让 `.d.ts` 的 map augmentation 进入当前 TypeScript project,同时把同一约定的 JS descriptor 作为值交给运行时。未 import 的业务包不会扩展当前 project 的 Remote API 类型。 -业务 package 的发布文件必须同时包含 `lib/typert.remote-client.d.ts.map` 和 map 指向的 `src` 文件。生成 DTS 以 `//# sourceMappingURL=typert.remote-client.d.ts.map` 引用相邻 map;map 中的 source 从 `lib` 相对指向业务源码,例如 `../src/index.ts`。`/remote` export 不单独列出 map,package `files` 负责把它与源码一起发布。 +业务 package 的发布文件必须包含 `lib/typert.remote-client.d.ts.map`。生成 DTS 以 `//# sourceMappingURL=typert.remote-client.d.ts.map` 引用相邻 map;map 中的 source 从 `lib` 相对指向业务源码,例如 `../src/index.ts`。`/remote` export 不单独列出 map,package `files` 负责发布它。该目标是开发期路径:workspace 消费者经 package link 解析它,因此发布产物仍然不含 `src`,已发布的 map 只是解析不到东西。 仅需要静态类型时可以使用 `import type {} from '@deepseek-ai/dsh-goal/remote'`;这种 import 在运行时会被擦除,不会加载 JS,也不能触发任何运行时注册。需要真实调用的环境必须把普通 value import 得到的 contribution 交给 Client Remote Service。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml index b7fc5cca1d..4c832ba450 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md -2026-08-04-declaring-a-provider-from-the-models-page.md: 3e7ff9c3bd58b73185c669a224a59cd017ecfb42 -2026-08-04-declaring-a-provider-from-the-models-page.zh.md: a4255f8e29b696e84ff3accf636714cdfc0855f1 +2026-08-04-declaring-a-provider-from-the-models-page.md: 179a31396d34228e7a0cd510edac4c535af3162e +2026-08-04-declaring-a-provider-from-the-models-page.zh.md: 3fca60554769fbf5f5be0d2bc76edf3ab3b71578 diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md index 3e7ff9c3bd..179a31396d 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md @@ -48,4 +48,4 @@ What it costs: only pi-ai routes can be hand-declared, because `llm-pi-ai` is th ## Testing -`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not. The stylesheet gate reads the package's own sources and fails any `` that takes `.input` without `.selectInput`, because the OS arrow it would otherwise keep sits flush inside the 240px cap `select.input` imposes. The editor's own field inventory is asserted per route kind — a catalog route stops at the key and the endpoint, a declared one also carries the protocol — along with the protocol edit travelling as a single `api` path op, a rename travelling as a single `displayName` one, a cleared name unsetting rather than storing the empty string the adapter refuses, and a declared profile naming no protocol selecting nothing rather than the first choice. `apps/web/tests/models-settings.e2e.ts` reopens the declared route through the real wire, captures the card, and asserts the chosen protocol and the new name both reach `settings.yaml` and the row re-registers under the rename. diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md index a4255f8e29..3fca605547 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md @@ -48,4 +48,4 @@ Status: implemented ## Testing -`packages/client/ui-models/tests/provider-form.spec.tsx` 在脚本化的协议面之上驱动渲染后的页面:添加、编辑与移除行;被清空的可选字段离开 profile、非整数容量从不进入;询问携带已修改的端点、未保存的密钥,以及 profile 自身的协议;选择框的默认选中、勾选切换、取消,以及「采纳保留已调优的行」;空列表、被拒、传输被拒三条路径;创建写入一份 profile 加其凭据;创建按钮上的每一道门控;以及只读姿态。`protocolChoices` 针对「声明了该 union」与「没有声明」两种 schema 都有覆盖。样式 gate 读取本包自己的源码,任何只取 `.input` 而不取 `.selectInput` 的 `` 都会失败——否则它保留的系统箭头会紧贴 `select.input` 所设 240px 上限的右边缘。编辑器自身的字段清单按路由种类各有断言——内置目录路由止于密钥与端点,已声明路由还带着协议——同时覆盖协议改动只以单条 `api` path op 传出、改名只以单条 `displayName` path op 传出、清空名称是取消设置而不是存入适配器会拒绝的空串,以及不写协议的已声明 profile 什么都不选中、而非选中第一个候选。`apps/web/tests/models-settings.e2e.ts` 经真实协议层重新打开这条已声明路由,捕获该卡片,并断言选定的协议与新名称都抵达了 `settings.yaml`、该行也以新名重新注册。 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 5ee9d06358..37ab609908 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: 2480775f654fd5c2fecebc8d59e311acee878920 -2026-08-06-app-owned-command-line.zh.md: d754c125d5bc683156f5ac3f285e2cd711e6773b +2026-08-06-app-owned-command-line.md: 6d84ba457564ef250e1acfbcc71fcc91b1d49aee +2026-08-06-app-owned-command-line.zh.md: f964f7a7de7aae7e97b52fbc572443352dc5ae26 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index 2480775f65..6d84ba4575 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -12,7 +12,7 @@ After profiles, compositions were installable but their command lines were not. The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help. -The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program, plan)` with its own commander program, and provide the returned value as an app-owned service. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program)` with its own commander program, and provide the resolved value as an app-owned service from the program's action. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` leaves the provider's service absent, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index d754c125d5..f964f7a7de 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -12,7 +12,7 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍 启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。 -新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program, plan)`,再把返回值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 +新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program)`,再在 program 自己的 action 中把解析出的取值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 会让提供方服务保持缺失,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 diff --git a/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml new file mode 100644 index 0000000000..a3249dcddb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.md +2026-08-11-loader-entry-disabled-interpolation.md: fd760ea0f15f19e5f287aaddc36fb8eeb5f519ba +2026-08-11-loader-entry-disabled-interpolation.zh.md: 15f5a80931c58555dceab359d05e8515334513b2 diff --git a/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.md b/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.md new file mode 100644 index 0000000000..fd760ea0f1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.md @@ -0,0 +1,25 @@ +# Agent Note: Loader interpolates the entry `disabled` field + +Status: implemented + +English | [中文](2026-08-11-loader-entry-disabled-interpolation.zh.md) + +## Problem + +The Windows platform layer (then a separate `windows.cordis.patch.yml` beside the base patch, since folded into the base rows — see Decision) disabled `tool-bash` on win32, but the shipped presets each mount a `tool-bash` row. Preset rows compose last, so the same-id row re-enabled the tool on Windows — the session had both `tool-bash` (PowerShell-backed) and `tool-pwsh`, silently, because no spec pinned the composed preset layer. Entry metadata had no conditional mechanism: `!!js` interpolates only under plugin `config`, and [postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) documents that `disabled: !!js ...` stays a truthy expression object, disabling the row everywhere. + +## Decision + +The Loader interpolates the entry `disabled` field (`vendor/loader/src/config/entry.ts`): a `!!js` expression evaluates against the loader context at every mount decision. `disabled` is the only interpolated metadata field; `id`, `name`, `group`, and `inject` stay static. The raw node stays in the options, so write-back keeps the `!!js` form. The shipped presets (standard, code, cordis) declare the shell tool rows themselves and gate them by platform — `tool-bash` with `disabled: !!js process.platform === 'win32'` and its `tool-pwsh` twin with the inverted expression — so the preset layer exposes exactly one shell tool per host; the web-app overlay disables the host rows of both tools, letting each session's preset decide. `verify-cordis-config` now allows expressions in `disabled` only. + +The mechanism completes the platform-layer fold: the base bundle's `cordis.patch.yml` gates both shell stacks on its own rows — `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'`, and their twins `pwsh-sandbox`/`tool-pwsh` mount only on win32 with the inverted expression. The launcher's separate Windows platform layer (`windows.cordis.patch.yml` plus `apps/cli/src/windows-shell.ts` and its injection into boot, live recomposition, and config dumps) is deleted — the layer existed only because entry metadata was static, and with `disabled` interpolated the condition lives on the row it governs. + +## Alternatives considered + +**A declarative `platform` field on the row.** Static and gate-checkable, but a second composition mechanism beside `!!js`, and platform is only today's condition. + +**Preset-level platform overlays.** Rejected: the condition belongs on the row it governs — the same principle folds the launcher's separate Windows platform layer into the base rows. + +## Consequences + +A row can gate itself on platform or environment; a bad expression fails loud at boot. Every other metadata field remains literal and the gate keeps rejecting expressions there — the postmortem-0002 hazard is closed for `disabled` by evaluation, not prohibition. The Windows shell swap moved from a launcher-injected patch layer to the base bundle's own rows: win32 mounts the confined pwsh stack, POSIX carries the pwsh rows disabled, and one shared patch file serves both rosters — the [Windows pwsh default](../feature/2026-08-01-windows-pwsh-default.md) note's layer mechanism is superseded. The shell TOOL rows follow the same one-plane rule as every other preset-declared row: the web-app overlay disables the host `tool-bash`/`tool-pwsh` rows and the presets declare both with inverted platform gates, so a preset can drop or replace the shell tool per session on either host. The `minimal` preset's missing win32 PTY stack is a preset-metadata follow-up. diff --git a/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.zh.md b/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.zh.md new file mode 100644 index 0000000000..15f5a80931 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.zh.md @@ -0,0 +1,25 @@ +# Agent Note:Loader 插值条目 `disabled` 字段 + +Status: implemented + +[English](2026-08-11-loader-entry-disabled-interpolation.md) | 中文 + +## 问题 + +Windows 平台层(当时是 base patch 旁独立的 `windows.cordis.patch.yml`,现已折入 base 行——见「决策」)在 win32 上禁用 `tool-bash`,但 shipped 预设各自挂载了一行 `tool-bash`。预设行最后组合,同名行在 Windows 上重新启用了该工具——会话同时拥有 `tool-bash`(PowerShell 后端)与 `tool-pwsh`,且是静默的,因为没有 spec pin 组合后的预设层。条目元数据没有条件机制:`!!js` 只在插件 `config` 下插值,[postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) 记录了 `disabled: !!js ...` 保持真值表达式对象、在所有平台上禁用该行的事故。 + +## 决策 + +Loader 插值条目 `disabled` 字段(`vendor/loader/src/config/entry.ts`):`!!js` 表达式在每次挂载决策时基于 loader 上下文求值。`disabled` 是唯一被插值的元数据字段;`id`、`name`、`group`、`inject` 保持静态。原始节点保留在 options 中,写回保持 `!!js` 形式。shipped 预设(standard、code、cordis)自己声明 shell 工具行并按平台门控——`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`,其孪生行 `tool-pwsh` 以取反的表达式——因此预设层每台宿主恰好暴露一个 shell 工具;web-app overlay 禁用两个工具的 host 行,由每个会话的预设决定。`verify-cordis-config` 现在只允许 `disabled` 中的表达式。 + +该机制补全了平台层折叠:base bundle 的 `cordis.patch.yml` 在自身行上按平台门控两个 shell 栈——`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`,它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载。启动器的独立 Windows 平台层(`windows.cordis.patch.yml` 以及 `apps/cli/src/windows-shell.ts` 及其注入到 boot、live 重组合、config dump 的逻辑)被删除——该层只因条目元数据是静态的而存在,`disabled` 可插值后条件就落在它所治理的行上。 + +## 备选方案 + +**行上的声明式 `platform` 字段。** 静态且可被门禁检查,但它是 `!!js` 之外的第二种组合机制,且平台只是今天的条件。 + +**预设级平台 overlay。** 被否:条件应当属于它所治理的行——同一原则把启动器独立的 Windows 平台层折入 base 行。 + +## 后果 + +行可以按平台或环境门控自身;错误的表达式在启动时响亮失败。其余元数据字段保持字面值,门禁继续拒绝那里的表达式——`disabled` 上的 postmortem-0002 隐患以「求值」而非「禁止」关闭。Windows shell 栈的切换从启动器注入的 patch 层移到 base bundle 自身的行上:win32 挂载受限 pwsh 栈,POSIX 携带被禁用的 pwsh 行,同一份 patch 文件服务两种阵容——[Windows 默认 pwsh](../feature/2026-08-01-windows-pwsh-default.md) note 的层机制已被取代。shell 工具行遵循与其他预设声明行相同的 one-plane 规则:web-app overlay 禁用 host 面的 `tool-bash`/`tool-pwsh` 行,预设以互逆的平台门控声明两者,因此任一宿主的每个会话都可以按预设丢弃或替换 shell 工具。`minimal` 预设缺失的 win32 PTY 栈是预设元数据的后续工作。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml index 852d3816e2..95b52ad014 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md -2026-07-28-themed-scrollbars-and-reserved-gutter.md: 8ad5d801358823576c37d6b6824d7ce3fdcdac11 -2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: d6d2c64703df95ea2a4ff79435512eabdb3d2f81 +2026-07-28-themed-scrollbars-and-reserved-gutter.md: a820a92406ce4054f16922064772d03c6ac3ab83 +2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 80104c4aca5986c3a1f49186ac4adf3169f46da5 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md index 8ad5d80135..a820a92406 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md @@ -20,7 +20,7 @@ The rules sit on `body`, not `html`. `design-platform.css` declares the `--dsw-a The two renderings are mutually exclusive, and the exclusion is enforced rather than assumed. A non-`auto` `scrollbar-width` or `scrollbar-color` makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included. Declaring both unconditionally therefore leaves the hover token rendering nowhere at all: the engines that implement the hover pseudo-element are exactly the ones the standard properties silence, and Firefox has no hover pseudo-element to fall back on. The standard properties consequently sit inside `@supports not selector(::-webkit-scrollbar)`, which is true only where the pseudo-element is unimplemented, so Firefox takes the standard path and WebKit-based engines take the pseudo-element path. The WebKit rules are not gated in turn: an engine without those pseudo-elements drops them as unknown selectors, so a gate would only restate what selector matching already does. An engine too old for the `selector()` function makes the condition invalid, which evaluates false and selects the pseudo-element path — the correct side for the pre-16.4 Safari that is the realistic case for that reading. -Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. `transparent` is the pair's other legal target, added when the sidebar's bars [started following the pointer](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md); the gate below admits those two and nothing else. The mechanically discoverable subset is owned by `packages/client/ui-theme/tests/scrollbar-styles.spec.ts`: any sheet that both scrolls and paints an elevated surface must rebind, so this note no longer maintains a complete surface inventory. Most declare the pair on the elevated card rather than on the scrolling descendant, because elevation belongs to the surface and custom properties inherit to whichever child actually scrolls. +Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. `transparent` is the pair's other legal target, added when the sidebar's bars [started following the pointer](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md); the gate below admits those two and nothing else. The mechanically discoverable subset is owned by `packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts`: any sheet that both scrolls and paints an elevated surface must rebind, so this note no longer maintains a complete surface inventory. Most declare the pair on the elevated card rather than on the scrolling descendant, because elevation belongs to the surface and custom properties inherit to whichever child actually scrolls. Four surfaces — `Menu`, `InputBar`, `QuestionComposer`, and `TodoPanel` — were initially missed, which is why the per-sheet rebinding contract is checked mechanically rather than by inspection. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md index d6d2c64703..80104c4aca 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md @@ -20,7 +20,7 @@ Status: implemented 两种渲染互斥,而这种互斥是被强制的,不是假定的。`scrollbar-width` 或 `scrollbar-color` 只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中。因此无条件地同时声明会让 hover token 在任何地方都得不到渲染:实现了 hover 伪元素的引擎,恰恰就是被标准属性静音的那些,而 Firefox 没有 hover 伪元素可作退路。于是标准属性写在 `@supports not selector(::-webkit-scrollbar)` 之内,该条件只在伪元素未被实现处为真,因此 Firefox 走标准属性路径,WebKit 系引擎走伪元素路径。WebKit 规则不再反向加门禁:不实现这些伪元素的引擎会把它们当作未知选择器丢弃,因此加门禁只是重述选择器匹配本身已经做的事。对于旧到不支持 `selector()` 函数的引擎,该条件无效,从而求值为假并选中伪元素路径——对于这条判断下现实存在的 16.4 之前的 Safari,这正是正确的一侧。 -两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定约定,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。这组变量另一个合法的目标是 `transparent`,它随侧边栏滚动条[改为跟随指针](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md)一并引入;下文的门禁只接受这两种目标。可由机械检查发现的子集归 `packages/client/ui-theme/tests/scrollbar-styles.spec.ts` 所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再维护完整的表面清单。多数把这组变量声明在抬升卡片上而非滚动的后代元素上,因为抬升层级属于这个表面,而自定义属性会继承到真正滚动的那个子元素。 +两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定约定,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。这组变量另一个合法的目标是 `transparent`,它随侧边栏滚动条[改为跟随指针](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md)一并引入;下文的门禁只接受这两种目标。可由机械检查发现的子集归 `packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts` 所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再维护完整的表面清单。多数把这组变量声明在抬升卡片上而非滚动的后代元素上,因为抬升层级属于这个表面,而自定义属性会继承到真正滚动的那个子元素。 `Menu`、`InputBar`、`QuestionComposer` 与 `TodoPanel` 这四个表面最初被漏掉,因此逐样式表的重新绑定约定由机械检查而非人工审阅把关。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml index 6e364d9a3e..4db552343c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md -2026-07-30-hover-popup-pointer-grace.md: 3f60c98ec6453b633feebe408cbc0c0c49eedea1 -2026-07-30-hover-popup-pointer-grace.zh.md: f53d77bae7b7f223621c4c42779e1a6a616aa485 +2026-07-30-hover-popup-pointer-grace.md: 302592a2e9de8e749ef2450e8877a3ccbffd15d5 +2026-07-30-hover-popup-pointer-grace.zh.md: 4020a6f1f7ab52b6340a07f4c099906a7353a8a5 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md index 3f60c98ec6..302592a2e9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md @@ -32,4 +32,4 @@ The hover card is now hit-testable and covers 244px of whatever it overlays whil ## Testing -`packages/client/ui-primitives/tests/hover-card.spec.tsx` and `tests/atoms.spec.tsx` pin the grace boundary, cancel-on-return, no-second-dwell, disarm-on-owner-close, and the no-arming-while-closed case. The reachability gestures themselves — hovering onto the card, and moving between an open list and its trigger — are pinned in the real browser by `apps/web/tests/workspace-management.e2e.ts`, since they depend on hit testing and layout that jsdom does not model. +`packages/client/ui-primitives/tests/hover-card.client.spec.tsx` and `tests/atoms.spec.tsx` pin the grace boundary, cancel-on-return, no-second-dwell, disarm-on-owner-close, and the no-arming-while-closed case. The reachability gestures themselves — hovering onto the card, and moving between an open list and its trigger — are pinned in the real browser by `apps/web/tests/workspace-management.e2e.ts`, since they depend on hit testing and layout that jsdom does not model. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md index f53d77bae7..4020a6f1f7 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md @@ -32,4 +32,4 @@ Status: implemented ## 测试 -`packages/client/ui-primitives/tests/hover-card.spec.tsx` 与 `tests/atoms.spec.tsx` 固定验证宽限期边界、折返取消、不重启停留计时、所有者关闭时解除待执行关闭,以及列表关闭时不启动关闭。可抵达性手势本身——把指针移到卡片上,以及在打开的列表与其触发按钮之间移动——由 `apps/web/tests/workspace-management.e2e.ts` 在真实浏览器中固定验证,因为它们依赖 jsdom 无法建模的命中测试与布局。 +`packages/client/ui-primitives/tests/hover-card.client.spec.tsx` 与 `tests/atoms.spec.tsx` 固定验证宽限期边界、折返取消、不重启停留计时、所有者关闭时解除待执行关闭,以及列表关闭时不启动关闭。可抵达性手势本身——把指针移到卡片上,以及在打开的列表与其触发按钮之间移动——由 `apps/web/tests/workspace-management.e2e.ts` 在真实浏览器中固定验证,因为它们依赖 jsdom 无法建模的命中测试与布局。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index 8245d59db4..82df356c6b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: 1c65eeec0fbf0dc550043bb809ae8d7310f0602b -2026-07-30-web-transcript-log-ordered-projection.zh.md: 67bb41adde46ba7112896404ddc92a05f08f7aca +2026-07-30-web-transcript-log-ordered-projection.md: f2eda6983ab88a420a4506be7766320dc5da0fa2 +2026-07-30-web-transcript-log-ordered-projection.zh.md: 4c8bb5d0b9b49762d12aba837cef9c0818740cb0 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index 1c65eeec0f..f2eda6983a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -39,7 +39,7 @@ const COMPACT_PLUGIN: CompactCheckpointSource['plugin'] = 'compact' Renaming the Service Definition's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compact/compact"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`. -`packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it. +`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it. The divergence from the terminal is therefore narrow: both frontends recognize a checkpoint from the same declaration — the terminal value-imports `isCompactCheckpointSource` host-side, where no gate applies, and the client pins the type. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index 67bb41adde..4c8bb5d0b9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -39,7 +39,7 @@ const COMPACT_PLUGIN: CompactCheckpointSource['plugin'] = 'compact' 重命名 Service Definition 的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compact/compact"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。 -`packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts` 是行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根及经由它可达的宿主侧 `Context` 合并隔离。 +`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` 是行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根及经由它可达的宿主侧 `Context` 合并隔离。 因此与终端的分歧很窄:两个前端都从同一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里不适用任何门禁),客户端钉住类型。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml index d9a9857cc8..92dcd28a7e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md -2026-07-31-composer-text-layers-share-one-scrollport.md: ba11384409714d6a64a964d63197705acf39d213 -2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 7a2500a6253c7cf50a57458b66c9b80503414ace +2026-07-31-composer-text-layers-share-one-scrollport.md: 6097779529f86e6d994296ae396f108c63f01abc +2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 753d67d538d0c17512444639d60b7b5c8ff80e9a diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md index ba11384409..6097779529 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md @@ -71,7 +71,7 @@ Revealing the caret is the one thing that now depends on the browser rather than ## Testing -The unit spec in [input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.spec.tsx) asserts what jsdom can see: that one scrolling box contains both the textarea and the backdrop, that the backdrop's text is now the draft and nothing else, and that a late persisted draft reveals its caret without taking focus from another control. jsdom reports `scrollHeight === clientHeight` for every element and never scrolls one, so the geometry belongs to the browser scenario; the wheel-chaining cases stub the scrollport's metrics rather than the textarea's. +The unit spec in [input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) asserts what jsdom can see: that one scrolling box contains both the textarea and the backdrop, that the backdrop's text is now the draft and nothing else, and that a late persisted draft reveals its caret without taking focus from another control. jsdom reports `scrollHeight === clientHeight` for every element and never scrolls one, so the geometry belongs to the browser scenario; the wheel-chaining cases stub the scrollport's metrics rather than the textarea's. [composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) measures the rest in chromium against the built client: a 40-line draft in a fresh workspace's blank composer, zero model calls. Every metric is read in the caret's own coordinate frame — where the textarea places line n, offset included — against a DOM Range over the backdrop's text for the same line, because that difference is what a user sees. The decisive case changes the offset and re-reads that difference **before the task ends**, which is before any `scroll` listener could have run: 0 with one scrollport, and the full delta with a mirror. A vacuity guard asserts the draft overflows the capped box first, and separate cases cover the cap, one wrap width across all three layers, a wheel gesture, a trailing-newline draft, and the caret-reveal path that the textarea's own scrolling used to handle — typing after scrolling away must bring the scrollport back to the caret. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md index 7a2500a625..753d67d538 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md @@ -71,7 +71,7 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl ## 测试 -[input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.spec.tsx) 中的单元用例断言 jsdom 能看见的部分:同一个滚动盒同时包含 textarea 与 backdrop,backdrop 的文本现在就是草稿本身、不多不少,且渲染后才到达的持久化草稿会回视其光标,同时不从其他控件夺走焦点。jsdom 对任何元素都报告 `scrollHeight === clientHeight` 且从不滚动,因此几何属于浏览器场景;滚轮接力用例改为桩接滚动容器的度量,而非 textarea 的。 +[input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) 中的单元用例断言 jsdom 能看见的部分:同一个滚动盒同时包含 textarea 与 backdrop,backdrop 的文本现在就是草稿本身、不多不少,且渲染后才到达的持久化草稿会回视其光标,同时不从其他控件夺走焦点。jsdom 对任何元素都报告 `scrollHeight === clientHeight` 且从不滚动,因此几何属于浏览器场景;滚轮接力用例改为桩接滚动容器的度量,而非 textarea 的。 [composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) 在 chromium 中针对构建产物度量其余部分:全新工作区的空白 composer 中一份 40 行草稿,零模型调用。每个度量都在光标自己的坐标系里读取——即 textarea 把第 n 行放在哪,含其自身偏移——再与 backdrop 同一行文本上的 DOM Range 相比,因为这个差值正是用户看到的东西。决定性的用例改变偏移,并**在本任务结束之前**重新读取该差值,也就是在任何 `scroll` 监听可能运行之前:单一滚动容器下为 0,镜像方案下则是整个增量。空洞性保护先断言草稿确实超过了带上限的盒子;其余用例分别覆盖高度上限、三层同一折行宽度、滚轮手势、以换行结尾的草稿,以及过去由 textarea 自身滚动承担的光标回视路径——滚离光标后输入,必须把滚动容器带回光标处。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml index d40263ad98..2a780fd889 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md -2026-08-05-context-meter-blind-to-compaction.md: 10ded250cec9c92d90803bdf5969cc7f5aa54c50 -2026-08-05-context-meter-blind-to-compaction.zh.md: ca3fe59eecde82bb97b44af4c3383546d5672753 +2026-08-05-context-meter-blind-to-compaction.md: b90208eda25101aeb55130cd1b6563dbfaee8a9a +2026-08-05-context-meter-blind-to-compaction.zh.md: 85387c50ebc981fc2f7f02d07432da677f69ef90 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md index 10ded250ce..b90208eda2 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md @@ -43,4 +43,4 @@ The panel's composition rows still do not sum to the header, and now for one cle ## Testing -`packages/llm/token-meter/tests/token-usage-projection.spec.ts` covers the carry-forward across surface growth and a compaction (the sample holding still while the projection shrinks) and the zero clamp when heuristic error would drive the figure negative. `packages/client/ui-conversation/tests/context-meter.spec.tsx` pins the ring reading the projected figure, and `chat-stats.spec.tsx` pins `contextOccupancy`'s preference and its fallback. The end-to-end numbers above came from driving `BasicCompactService.compactNow` through a real `AgentLoop` with the projection registry mounted. +`packages/llm/token-meter/tests/token-usage-projection.spec.ts` covers the carry-forward across surface growth and a compaction (the sample holding still while the projection shrinks) and the zero clamp when heuristic error would drive the figure negative. `packages/client/ui-conversation/tests/context-meter.client.spec.tsx` pins the ring reading the projected figure, and `chat-stats.spec.tsx` pins `contextOccupancy`'s preference and its fallback. The end-to-end numbers above came from driving `BasicCompactService.compactNow` through a real `AgentLoop` with the projection registry mounted. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md index ca3fe59eec..85387c50eb 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md @@ -43,4 +43,4 @@ AFTER compact: ring=4% header=~4227/100000 rows=[system 18, tools 0, messag ## 测试 -`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 覆盖了投影值在表层增长与一次压缩期间的延续更新(样本保持不动而投影值缩小),以及启发式误差会把数字压到负数时的零钳制。`packages/client/ui-conversation/tests/context-meter.spec.tsx` 钉住圆环读取投影值这一点,`chat-stats.spec.tsx` 钉住 `contextOccupancy` 的优先级与回退。上面那组端到端数字来自在挂载了投影注册表的真实 `AgentLoop` 上驱动 `BasicCompactService.compactNow`。 +`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 覆盖了投影值在表层增长与一次压缩期间的延续更新(样本保持不动而投影值缩小),以及启发式误差会把数字压到负数时的零钳制。`packages/client/ui-conversation/tests/context-meter.client.spec.tsx` 钉住圆环读取投影值这一点,`chat-stats.spec.tsx` 钉住 `contextOccupancy` 的优先级与回退。上面那组端到端数字来自在挂载了投影注册表的真实 `AgentLoop` 上驱动 `BasicCompactService.compactNow`。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml index e82314c28b..41d1df6605 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md -2026-08-05-workspace-blank-session-reuse-membership.md: 910a10e9ada1a835df7a38a04fb04c504b0921df -2026-08-05-workspace-blank-session-reuse-membership.zh.md: 0581b768679d78716c1bf70cfffec825c7281783 +2026-08-05-workspace-blank-session-reuse-membership.md: 0d46a0ccf6924c508db2e6c0f3591468e2956722 +2026-08-05-workspace-blank-session-reuse-membership.zh.md: e67810dc3a45d494f64a2b774557d04297b6fce3 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md index 910a10e9ad..0d46a0ccf6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md @@ -26,4 +26,4 @@ Stray blank sessions remain visible in Ungrouped (the user can still open them) ## Testing -`packages/client/runtime/tests/workspaces-service.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green. +`packages/client/runtime/tests/workspaces-service.client.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md index 0581b76867..e67810dc3a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md @@ -26,4 +26,4 @@ Status: implemented ## 测试 -`packages/client/runtime/tests/workspaces-service.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次提示词被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。 +`packages/client/runtime/tests/workspaces-service.client.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次提示词被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.i18n.yaml index f789ed37a0..33f4d398c9 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md -2026-08-06-onboarding-step-owned-takeover-chrome.md: 35b9d16aaba4ca9f124a9972c43b2a107ea0309f -2026-08-06-onboarding-step-owned-takeover-chrome.zh.md: 2f3331965aa94aa01bf0ce21d4bbb421397ee5bd +2026-08-06-onboarding-step-owned-takeover-chrome.md: 97f5335412f256bf7e60f36731c9e0c4475ee99e +2026-08-06-onboarding-step-owned-takeover-chrome.zh.md: 054072764a7b9a25586899a93596fdc922e71dcb diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md index 35b9d16aab..97f5335412 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md @@ -32,4 +32,4 @@ A future step that registers without wrapping its visible content in `Onboarding ## Testing -`packages/client/ui-primitives/tests/onboarding-surface.spec.tsx` pins the primitive: body portal around the content, mask/stage class presence, `#root` inert held for exactly the mount lifetime, and the no-`#root` composition. `packages/client/ui-settings-general/tests/settings-root.spec.tsx` pins the inverted shell contract: no takeover chrome and no inert while a mounted step renders nothing. `apps/web/tests/onboarding-deepseek-config.e2e.ts` gains the defect's assembled regression pin: a configured world reloads while every `settings.describe` response is held open at the browser's network boundary — widening the steps' deciding window from loopback-invisible to hundreds of milliseconds, which is what keeps the assertions non-vacuous — and an 8 ms in-page sampler proves the takeover chrome never mounts and `#root` never turns inert. The file's existing scenarios and the step specs (`ui-settings-general`, `ui-models`) pass unchanged — the mask selector and geometry pins survive because the stylesheet moved verbatim. +`packages/client/ui-primitives/tests/onboarding-surface.client.spec.tsx` pins the primitive: body portal around the content, mask/stage class presence, `#root` inert held for exactly the mount lifetime, and the no-`#root` composition. `packages/client/ui-settings-general/tests/settings-root.client.spec.tsx` pins the inverted shell contract: no takeover chrome and no inert while a mounted step renders nothing. `apps/web/tests/onboarding-deepseek-config.e2e.ts` gains the defect's assembled regression pin: a configured world reloads while every `settings.describe` response is held open at the browser's network boundary — widening the steps' deciding window from loopback-invisible to hundreds of milliseconds, which is what keeps the assertions non-vacuous — and an 8 ms in-page sampler proves the takeover chrome never mounts and `#root` never turns inert. The file's existing scenarios and the step specs (`ui-settings-general`, `ui-models`) pass unchanged — the mask selector and geometry pins survive because the stylesheet moved verbatim. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.zh.md index 2f3331965a..054072764a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.zh.md @@ -32,4 +32,4 @@ ## 测试 -`packages/client/ui-primitives/tests/onboarding-surface.spec.tsx` 钉住原语行为:内容外的 body portal、遮罩/展示层类名存在、`#root` 的 `inert` 恰好持续挂载生命周期,以及无 `#root` 的组合。`packages/client/ui-settings-general/tests/settings-root.spec.tsx` 钉住反转后的外壳约定:已挂载步骤什么都不渲染时,无接管界面框架、无 inert。`apps/web/tests/onboarding-deepseek-config.e2e.ts` 新增本缺陷的整装回归钉:已配置世界刷新页面,同时在浏览器网络边界扣住所有 `settings.describe` 响应——把步骤的判定窗口从 loopback 下不可见拉宽到数百毫秒,这正是断言保持非空洞的关键——页内 8ms 采样器证明接管界面框架从未挂载、`#root` 从未变为 inert。该文件的既有场景与步骤 spec(`ui-settings-general`、`ui-models`)原样通过——样式表逐字迁移,遮罩选择器与几何钉子得以幸存。 +`packages/client/ui-primitives/tests/onboarding-surface.client.spec.tsx` 钉住原语行为:内容外的 body portal、遮罩/展示层类名存在、`#root` 的 `inert` 恰好持续挂载生命周期,以及无 `#root` 的组合。`packages/client/ui-settings-general/tests/settings-root.client.spec.tsx` 钉住反转后的外壳约定:已挂载步骤什么都不渲染时,无接管界面框架、无 inert。`apps/web/tests/onboarding-deepseek-config.e2e.ts` 新增本缺陷的整装回归钉:已配置世界刷新页面,同时在浏览器网络边界扣住所有 `settings.describe` 响应——把步骤的判定窗口从 loopback 下不可见拉宽到数百毫秒,这正是断言保持非空洞的关键——页内 8ms 采样器证明接管界面框架从未挂载、`#root` 从未变为 inert。该文件的既有场景与步骤 spec(`ui-settings-general`、`ui-models`)原样通过——样式表逐字迁移,遮罩选择器与几何钉子得以幸存。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml index d7e6553fd3..f25c5622fa 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md -2026-08-06-reader-scroll-attribution-observed-top-ledger.md: ef5ddbeb9bea1393f474dfb4c809ae2e19bfea5c -2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: 7a1142cdd793cfce46bfd3908ab28345701aec7a +2026-08-06-reader-scroll-attribution-observed-top-ledger.md: 66a1ca361cf28bf0beab95fa81da9cac3527474c +2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: ce7cd6d0ade2702a6f717340cb7c9b354d0387c2 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md index ef5ddbeb9b..66a1ca361c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md @@ -18,7 +18,7 @@ A shrink clamp whose layout regrows within the same rendering update before the ## Testing -Unit specs in `packages/client/ui-conversation/tests/chat-view.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, and the stream-finalization shrink clamp keeps following. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger. +Unit specs in `packages/client/ui-conversation/tests/chat-view.client.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, and the stream-finalization shrink clamp keeps following. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger. The lane's Chromium cannot synthesize any non-wheel device scrolling, which bounds what the e2e can drive for real: `Input.synthesizeScrollGesture` with a touch source and hand-rolled `Input.dispatchTouchEvent` sequences deliver DOM events but never move a scroller (headless and headed-under-Xvfb alike); the `default` gesture source synthesizes wheel events; and compositor scrollbars ignore synthetic mouse input entirely, with a gutter visible only when `--hide-scrollbars` is removed. Keyboard is the one working non-wheel primitive, so it carries the real-input-pipeline proof, and the fling scenario replays touch's signature — per-frame decaying displacements the component never authored — through the scrollport directly. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md index 7a1142cdd7..ce7cd6d0ad 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md @@ -18,7 +18,7 @@ ChatView 的贴底跟随此前只把滚轮/触控板手势识别为读者输 ## 测试 -`packages/client/ui-conversation/tests/chat-view.spec.tsx` 中的单元测试直接钉住 ledger 约定:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md):在已停稳的 transcript 上做键盘翻页,以及对着按节奏推进的流式输出做一次触控式惯性快滑(momentum fling);两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。 +`packages/client/ui-conversation/tests/chat-view.client.spec.tsx` 中的单元测试直接钉住 ledger 约定:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md):在已停稳的 transcript 上做键盘翻页,以及对着按节奏推进的流式输出做一次触控式惯性快滑(momentum fling);两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。 该车道的 Chromium 无法合成任何非滚轮的设备滚动,这限定了 e2e 能真实驱动的范围:触控来源的 `Input.synthesizeScrollGesture` 与手工构造的 `Input.dispatchTouchEvent` 序列都能交付 DOM 事件,却从不移动滚动容器(无头模式与 Xvfb 下的有头模式皆然);`default` 手势来源合成的是滚轮事件;合成器滚动条则完全无视合成的鼠标输入,且只有移除 `--hide-scrollbars` 后才能看到滚动条槽。键盘是唯一可用的非滚轮原语,因此由它承担真实输入流水线的证明;快滑场景则把触控的特征(组件从未写入过的逐帧衰减位移)直接回放进滚动容器。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml index 67d2b330b1..72878df41d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md -2026-08-11-preset-authoring-agent-validates-its-own-composition.md: 6b9cdf32b70e3ab4adc9f3b0e20bb3d2245486c7 -2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: e6e8dabcd886a6331d294744b667552caa01e7b4 +2026-08-11-preset-authoring-agent-validates-its-own-composition.md: eb21094f0d859a31d5f16d780cada6818a508b36 +2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: 02c245348a9c7e9968472044d7ff95e1ff21120c diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md index 6b9cdf32b7..eb21094f0d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md @@ -32,7 +32,9 @@ The agent reaches the roster service the way `cordis_mount` documents: a tempora "Whether a row publishes a service" resolves through `cordis_inspect what:"services"`, which names the owning fiber of every live service. -The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" — it is where every `dsh` launcher puts them — while routing the path an agent actually reads or edits through `list()` or `resolve()`. `Config.roots` defaults to `[]` and `apps/cli` patches both roots in, `writableRoot()` takes the first `user` one, and no call reports either path; `authorable` answers only whether a writable root exists, and `list()` cannot reveal a user root that holds nothing yet. Stating the path is therefore right for talking to a person and wrong for feeding a file tool. +The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" while routing the path an agent actually reads or edits through `list()` or `resolve()`. Stating the path is right for talking to a person and wrong for feeding a file tool: a deployment may configure other roots, and `list()` cannot reveal a user root that holds nothing yet. + +That path is now a property of the package rather than of one launcher. `AgentPresets` derives `/.agent-presets` as a `user` root unless `includeUserRoot` is false, the way [`dsh-skill-local`](../../../../packages/skill/skill-local/README.md) derives `/skills`, and `apps/cli` supplies only the SHIPPED root — the one path an installed app alone can resolve. The asymmetry it replaces cost a bug: with both roots patched in by one launcher, `dsh run` booted a roster with no roots at all and failed resolving `standard` (fixed then by teaching every launcher the patch). The derived root is appended after every configured root, so a shipped id still shadows a home directory claiming it, and `writableRoot()` still prefers an explicitly configured `user` root. It is resolved once at construction: a root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw. The prohibition on touching the shipped install is promoted from a paragraph inside the authoring steps to a top `## Off-limits` section, extended to cover editing the host composition as a workaround. The new self-validation calls do not weaken it: `copy()` refuses an id any root supplies, and `remove()` refuses a preset that ships with the deployment. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md index e6e8dabcd8..02c245348a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md @@ -32,7 +32,9 @@ agent 按 `cordis_mount` 自身文档所述的方式够到 roster 服务:挂 「某行是否发布服务」改由 `cordis_inspect what:"services"` 回答,它会给出每个存活服务的持有 fiber。 -指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案——每个 `dsh` 启动器都把它们放在那里——同时把 agent 实际读取或编辑的路径改走 `list()` 或 `resolve()`。`Config.roots` 默认为 `[]`,两个根均由 `apps/cli` 补入,`writableRoot()` 取其中第一个 `user` 根,且没有任何调用会报告任一路径;`authorable` 只回答是否存在可写根,而 `list()` 无法揭示一个尚且为空的用户根。因此写出该路径对人讲是对的,喂给文件工具是错的。 +指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案,同时把 agent 实际读取或编辑的路径改走 `list()` 或 `resolve()`。写出该路径对人讲是对的,喂给文件工具是错的:部署可以配置其他根目录,而 `list()` 无法揭示一个尚且为空的用户根。 + +该路径如今是本包的属性,而非某个启动器的属性。除非 `includeUserRoot` 为 false,`AgentPresets` 自行推导 `/.agent-presets` 作为 `user` 根,正如 [`dsh-skill-local`](../../../../packages/skill/skill-local/README.md) 推导 `/skills`;`apps/cli` 只提供**随附**根——那是唯有已安装 app 才能解析的路径。它取代的那种不对称曾付出过代价:两个根都由单一启动器补入时,`dsh run` 启动的 roster 一个根都没有,解析 `standard` 直接失败(当时的修法是让每个启动器都执行该 patch)。推导出的根追加在全部已配置根之后,因此随附 id 仍会遮蔽占用它的家目录目录,而 `writableRoot()` 仍优先选择显式配置的 `user` 根。它在构造时解析一次:若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。 禁止改动随发布安装的约束,从创作步骤中的一段提升为顶部的 `## Off-limits` 一节,并扩展到禁止改宿主组装绕行。新增的自校验调用不削弱它:`copy()` 拒绝任何根已提供的 id,`remove()` 拒绝随部署发布的 preset。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml new file mode 100644 index 0000000000..13b8524906 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md +2026-08-11-synchronous-subprocess-exit-cleanup.md: fba5014d67f5152d6f8e42b3b41c1bbd20c7ede3 +2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: 33e13b7a1af9a943a266ea3ec979bf14e24f3802 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md new file mode 100644 index 0000000000..fba5014d67 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md @@ -0,0 +1,51 @@ +# Agent Note: Synchronous cleanup of managed subprocesses on host exit + +Status: implemented + +English | [中文](2026-08-11-synchronous-subprocess-exit-cleanup.zh.md) + +## Problem + +The local subprocess provider owns ordinary detached process trees and terminal sessions, but it previously reached them only through asynchronous Cordis disposal. A fatal launcher may call `process.exit()` before that disposal finishes: the [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) waits at most two seconds, while a local process can have a longer termination grace. Once Node enters its synchronous exit phase, pending promises and escalation timers do not continue, so a TERM-resistant child can outlive the host and keep CPU, memory, or ports. Some ACP, JSON-RPC, and SDK entry points also have no root release callback. + +The public subprocess seam correctly promises awaited quiescence during normal disposal. The defect is a separate final host-exit path below that seam, not a reason to weaken the normal lifecycle or duplicate process ownership in every launcher. + +## Decision + +`LocalSubprocessService` installs one synchronous Node `exit` listener in its Cordis effect. The same effect removes the listener only after normal disposal settles. Ordinary and terminal handles remain in the service's existing live sets while asynchronous cleanup is pending, so a shorter outer exit bound still sees and force-terminates them. If awaited disposal reports a cleanup failure, the service invokes the same synchronous final operations before clearing the sets and removing the listener. + +The listener uses local-only final operations that are absent from the public `SubprocessHandle` and `SubprocessTerminalHandle` interfaces: + +- An ordinary handle immediately sends SIGKILL to its detached POSIX process group or runs synchronous `taskkill /PID /T /F` on Windows. +- A terminal handle synchronously signals every captured and currently observable descendant with SIGKILL, kills the PTY root, then rescans once for members that became observable during that boundary. +- The service contains each target's failure and continues with the remaining handles. The callback creates no promise or timer, writes no diagnostic, and does not change the original exit code or error. + +Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subprocess-seam.md) terminate-and-join path: ordinary trees receive TERM, the configured grace, then KILL, and every ordinary or terminal cleanup is awaited to quiescence. The synchronous path requests final termination but does not publish a completion result or claim the OS tree is already gone when the callback returns. Remote providers retain their own sandbox ownership and do not inherit a local Node listener. + +| Host path | Local provider action | Completion evidence | +| --- | --- | --- | +| Normal Cordis disposal | Cooperative termination, bounded escalation, and awaited ordinary/terminal cleanup | Every owned handle reaches quiescence before disposal settles | +| `process.exit()`, default uncaught exception, or default unhandled rejection | Synchronous final signals against the service's current live sets | External observation after the host exits | +| Default termination for an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP`; `SIGKILL`; fatal OOM; `process.abort()`; native crash; or power loss | No in-process action can run | External supervisor, container, or OS ownership is required unless the application installs a signal handler that performs disposal or calls `process.exit()` | + +## Verification + +A parent test starts an isolated TypeScript host through the repository source launcher, waits until exact root and descendant process identities are observable, then allows the host to take each fatal path. Direct exit, default uncaught exception, and default unhandled rejection cover ordinary TERM-resistant trees; direct exit also covers a real terminal root and descendant. The parent asserts the original host exit category and waits for every recorded process to disappear, while failure cleanup targets only recorded identities or the recorded Windows tree. + +Unit evidence pins synchronous POSIX group and Windows taskkill delivery, terminal scans before and after the PTY root kill, repeated finalization, per-target failure containment, normal TERM-to-KILL disposal, live-set retention during pending disposal, and listener removal after disposal. + +## Alternatives considered + +**Rely only on launcher release callbacks.** Rejected because not every entry point supplies one, and a bounded release can still end before the subprocess provider's grace and timers complete. + +**Call the existing asynchronous `terminate()` methods from the `exit` listener.** Rejected because Node does not await exit listeners; promises, timers, output draining, and quiescence polling cannot finish after the callback returns. + +**Add a public raw `forceKill()` operation to subprocess handles.** Rejected because consumers need one cooperative termination contract. Immediate final termination is an implementation responsibility used only by the local service's host-exit owner. + +**Delegate every failure mode to an external supervisor.** Rejected as the only solution because Node exposes a reliable synchronous callback for several common fatal paths and the provider already owns the exact targets. External ownership remains necessary when JavaScript cannot run. + +## Consequences + +Each active local subprocess service contributes one process-global exit listener, removed with the service effect. Fatal exit gives up grace, output draining, and an in-process quiescence proof in exchange for issuing the strongest available local termination before the host disappears. Normal disposal keeps those guarantees and costs unchanged. + +The listener cannot cover failures that do not execute JavaScript, and it cannot discover a terminal descendant that escaped before the provider ever observed it; that separate ownership gap remains tracked by Issue #1726. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md new file mode 100644 index 0000000000..33e13b7a1a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 宿主退出时同步清理受管子进程 + +Status: implemented + +[English](2026-08-11-synchronous-subprocess-exit-cleanup.md) | 中文 + +## Problem + +本地 subprocess provider拥有普通 detached进程树和 terminal session,但此前只能通过异步 Cordis dispose触及它们。致命 launcher可能在 dispose完成前调用 `process.exit()`:[fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md)最多等待两秒,而本地进程可以拥有更长的终止宽限期。Node进入同步退出阶段后,待处理的 Promise与升级 timer不会继续执行,因此忽略 TERM的子进程可能比宿主存活更久,继续占用 CPU、内存或端口。部分 ACP、JSON-RPC和 SDK入口也没有 root release回调。 + +公共 subprocess seam在正常 dispose期间承诺等待完全停稳,这项承诺是正确的。缺陷属于 seam之下另一条最终宿主退出路径,不应削弱正常生命周期,也不应让每个 launcher重复保存进程所有权。 + +## Decision + +`LocalSubprocessService`在自身 Cordis effect中安装一个同步 Node `exit` listener。只有正常 dispose结算后,同一 effect才移除该 listener。异步清理仍在等待时,普通和 terminal handle继续保留在服务已有的存活集合中,因此更短的外层退出上限仍能看到并强制终止它们。等待中的 dispose报告清理失败时,服务会在清空集合并移除 listener前调用同一组同步最终操作。 + +该 listener使用本地实现私有的最终操作;公共 `SubprocessHandle`和 `SubprocessTerminalHandle`接口不包含这些操作: + +- 普通 handle立即向 detached POSIX进程组发送 SIGKILL,或在 Windows同步运行 `taskkill /PID /T /F`。 +- Terminal handle同步向全部已捕获及当前可观察的后代发送 SIGKILL,终止 PTY root,然后再扫描一次并终止在该边界期间变得可观察的成员。 +- 服务分别包含每个目标的失败并继续处理其余 handle。回调不会创建 Promise或 timer,不写诊断,也不改变原始退出码或错误。 + +正常 dispose继续使用[subprocess seam](../architecture/2026-07-26-subprocess-seam.md)的先终止再等待退出路径:普通进程树先接收 TERM,经过配置的宽限期后再接收 KILL,并等待每个普通或 terminal清理达到完全停稳。同步路径只请求最终终止,不发布完成结果,也不声称回调返回时 OS进程树已经消失。远程 provider继续由其 sandbox独立拥有,不继承本地 Node listener。 + +| 宿主路径 | 本地 provider动作 | 完成证据 | +| --- | --- | --- | +| 正常 Cordis dispose | 协作式终止、有界升级,并等待普通/terminal清理 | dispose结算前,每个自有 handle均达到完全停稳 | +| `process.exit()`、默认未捕获异常或默认未处理 rejection | 对服务当前存活集合发送同步最终信号 | 宿主退出后的外部观察 | +| 未安装 handler 时由 `SIGTERM`、`SIGINT` 或 `SIGHUP` 默认终止;`SIGKILL`;fatal OOM;`process.abort()`;native crash;或断电 | 进程内操作无法运行 | 必须由外部 supervisor、容器或 OS 所有权负责;应用安装执行 dispose 或调用 `process.exit()` 的信号 handler 时除外 | + +## Verification + +父测试通过仓库 source launcher启动隔离的 TypeScript宿主,等待精确 root与后代进程身份可观察后,再允许宿主进入各条致命路径。直接退出、默认未捕获异常和默认未处理 rejection覆盖忽略 TERM的普通进程树;直接退出还覆盖真实 terminal root与后代。父测试断言原始宿主退出类别,并等待所有已记录进程消失;失败清理只针对已记录身份或已记录的 Windows进程树。 + +单元证据固定同步 POSIX进程组与 Windows taskkill投递、PTY root终止前后的 terminal扫描、重复最终清理、逐目标失败包含、正常 TERM到 KILL dispose、dispose等待期间保留存活集合,以及 dispose后移除 listener。 + +## Alternatives considered + +**只依赖 launcher release回调。** 拒绝,因为不是每个入口都会提供该回调,而且有界 release仍可能在 subprocess provider的宽限期与 timer完成前结束。 + +**在 `exit` listener中调用现有异步 `terminate()`。** 拒绝,因为 Node不会等待 exit listener;回调返回后,Promise、timer、输出排空与停稳轮询都无法完成。 + +**向公共 subprocess handle增加 raw `forceKill()`操作。** 拒绝,因为消费方只需要一项协作式终止约定。立即最终终止属于实现职责,只由本地服务的宿主退出 owner使用。 + +**把所有故障模式交给外部 supervisor。** 不接受将其作为唯一方案,因为 Node为几条常见致命路径提供可靠的同步回调,而 provider已经拥有精确目标。JavaScript无法运行时仍必须依赖外部所有权。 + +## Consequences + +每个有效的本地 subprocess service都会贡献一个进程全局 exit listener,并随服务 effect移除。致命退出放弃宽限、输出排空与进程内停稳证明,以换取宿主消失前发出本地可用的最强终止操作。正常 dispose的保证与成本保持不变。 + +listener无法覆盖不执行 JavaScript的故障,也无法发现 provider首次观察前已经逃逸的 terminal后代;该独立所有权缺口仍由 Issue #1726跟踪。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml new file mode 100644 index 0000000000..cc3873f137 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md +2026-08-12-onboarding-reads-every-provider.md: 1f247a6c93257c24052f55eb4297ec3c9c3df06d +2026-08-12-onboarding-reads-every-provider.zh.md: fc6e43195a46eaea881f8b4bee3219b5e583b284 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md new file mode 100644 index 0000000000..1f247a6c93 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md @@ -0,0 +1,38 @@ +# Agent Note: First-run readiness reads every provider, and the setup card closes + +Status: implemented + +English | [中文](2026-08-12-onboarding-reads-every-provider.zh.md) + +## Problem + +The first-run step and the Models page both asked one question — is `deepseek-official`'s credential stored? — of a join that describes every provider. Two defects followed from that single reading. + +A user who configured some other provider (a pi-ai gateway, a self-hosted route) and never wanted the official DeepSeek endpoint was taken over by the full-screen credential prompt on every blank session, with a working model already selected in the composer behind it. Nothing they could do short of storing a DeepSeek key would end it, because the step's readiness projection never looked at the row they had configured. + +On the Models page the same reading opened the DeepSeek setup card over them on every visit, and that card could not be closed: it was rendered from row data with no local state a Cancel could flip, so its Cancel button did nothing visible. Worse, it shared the row-editor/add/declare close handler, which unconditionally clears all three of those states — so cancelling the card that owned none of them discarded the add card's draft while staying open itself. + +## Decision + +One predicate answers what both surfaces actually need. `providerUsable(row)` is true when the route is registered with the adapter registry (`entry.active`) and whatever credential its resolved profile names is stored; a profile naming no reference authenticates through the provider's own path, as does a live route with no settings address, so neither owes this page a key. + +`onboardingReadiness` (renamed from `deepSeekReadiness`, which no longer describes what it reads) returns `provider-ready` as soon as any joined row is usable. Only a user with none of those reaches the official DeepSeek lookup, which is unchanged: it is the one route the prompt can offer a key field for. The gate subsumes two diagnostics the old projection carried — `settings-unavailable` and `credential-ref-unavailable` — because both described an active route the new gate now calls usable; the outcome for the user was already identical (the step completed without rendering). + +`needsSetup(row, anyUsable)` takes the same fact, so the setup card is the first-run posture alone. With another provider reachable, DeepSeek is an ordinary row carrying the missing-key dot, one Edit click from the same card. + +Each card kind now owns its own close handler. `closeSetup` records the provider in a component-local `dismissedSetup` set and touches nothing else; `closeEditor` keeps clearing the three states its cards own. Both route the post-save reload through one `announceSaved` helper. Dismissal is viewing state, like the open editor and the add card: a reload restores the first-run posture for a user still in it. + +## Alternatives considered + +- **Deriving readiness from the model catalog (`llm.models`) instead of the join.** It answers "can the user talk to something" most directly, but it costs a per-provider listing round trip on a surface that already holds the join, and a provider whose listing fails transiently would re-open onboarding. +- **Requiring `row.configured` in `providerUsable`.** It reads as the stricter check, and would exclude exactly the routes a deployment mounts through `cordis.yml` without a configurable-provider declaration — live routes serving models that this page cannot configure. Registration, not configurability, is what makes a provider usable. +- **Only adding the dismissal, leaving the card auto-opening.** It fixes the Cancel button and nothing else: a user with a working provider would still be handed the DeepSeek form on every visit to Models, which is the same misreading in a quieter form. +- **Persisting the dismissal to settings.** A durable "do not ask about DeepSeek" flag is a second fact about first-run state that can disagree with the join. The credential itself already ends the posture permanently, and every other card on this page is session-local. + +## Consequences + +Onboarding now ends for reasons the DeepSeek route knows nothing about, so the step's name is the last thing tying it to that adapter; a future step that offers more than one route to configure would replace the prompt, not the readiness projection. The narrowed diagnostic union means an unresolvable `llm-deepseek` settings address is reported as `provider-ready` rather than as its own reason — the user-visible behavior is unchanged, and the Models page remains the diagnostic surface. + +## Testing + +Package tests pin `providerUsable` over the four join states and `onboardingReadiness` over both the new gate and every surviving diagnostic; the section tests cover the first-run posture, the plain-row posture, and the cancel that collapses the setup card while the add card keeps its draft. The `onboarding-usable-provider` web e2e lane replays the whole scenario through the real wire: cancel with both cards open, configure `minimax-cn` instead, reload, and find no takeover — with one aria golden of the dismissed state. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md new file mode 100644 index 0000000000..fc6e43195a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md @@ -0,0 +1,38 @@ +# Agent Note: First-run readiness reads every provider, and the setup card closes + +Status: implemented + +[English](2026-08-12-onboarding-reads-every-provider.md) | 中文 + +## Problem + +首次使用引导步骤与 Models 页都只向一个描述全部提供方的联接快照提出了同一个问题——`deepseek-official` 的凭据存了吗?两个缺陷由这一次读取而来。 + +配置了别的提供方(某个 pi-ai 网关、某条自建路由)、根本不打算用 DeepSeek 官方端点的用户,会在每一个空白会话上被全屏凭据提示接管,而其背后输入框里早已选好了一个可用模型。除了存入一把 DeepSeek 密钥,他们做什么都结束不了它——因为该步骤的就绪投影从不看他们已经配好的那一行。 + +在 Models 页上,同一次读取每次进入都会把 DeepSeek 设置卡片展开在他们面前,而这张卡片关不掉:它由行数据渲染而来,没有任何本地状态可供「取消」翻转,因此那颗取消按钮不产生任何可见效果。更糟的是,它与行内编辑卡/新增卡/自定义声明卡共用同一个关闭回调,而该回调会无条件清空那三个状态——于是取消一张它们一个都不拥有的卡片,反而丢弃了新增卡里的草稿,自己却仍然开着。 + +## Decision + +一个谓词回答两处界面真正需要的事实。`providerUsable(row)` 在路由已注册进适配器注册表(`entry.active`)、且其解析后 profile 所指名的凭据已存储时为真;不指名任何引用的 profile 走提供方自己的认证路径,没有 settings 地址的存活路由亦然,因此二者都不欠这个页面一把密钥。 + +`onboardingReadiness`(原名 `deepSeekReadiness`,该名称已不再描述它读取的内容)只要联接中有任意一行可用,就返回 `provider-ready`。只有二者皆无的用户才会走到官方 DeepSeek 查找,那部分保持不变:它是这条提示唯一能为其提供密钥输入框的路由。这道门槛吸收了旧投影携带的两个诊断——`settings-unavailable` 与 `credential-ref-unavailable`——因为二者描述的都是新门槛现在判为可用的活跃路由;对用户而言结果本就一致(该步骤不渲染直接完成)。 + +`needsSetup(row, anyUsable)` 接受同一个事实,因此设置卡片仅代表首次运行姿态。当另有可触达的提供方时,DeepSeek 就是一行带缺失密钥点的普通行,距离同一张卡片只有一次「编辑」点击。 + +现在每一类卡片各自拥有自己的关闭回调。`closeSetup` 把该提供方记入组件本地的 `dismissedSetup` 集合,别的一概不碰;`closeEditor` 继续清空它那些卡片所拥有的三个状态。两者都经由同一个 `announceSaved` 助手完成保存后的重载。关闭状态属于查看态,与展开的编辑卡和新增卡一样:对仍处于首次运行姿态的用户,重载会恢复该姿态。 + +## Alternatives considered + +- **从模型目录(`llm.models`)而非联接推导就绪状态。** 它最直接地回答「用户有没有能对话的东西」,但会在一个已经持有联接的界面上多花每提供方一次列举往返,而且某个提供方列举的瞬时失败会让引导重新弹出。 +- **在 `providerUsable` 中要求 `row.configured`。** 它读起来更严格,却会恰好排除部署通过 `cordis.yml` 挂载、没有可配置提供方声明的那些路由——它们是正在提供模型、只是这个页面配置不了的存活路由。使一个提供方可用的是注册,不是可配置性。 +- **只加关闭状态,保留卡片自动展开。** 那只修好取消按钮,别的什么都没修:已有可用提供方的用户每次进入 Models 仍会被塞一张 DeepSeek 表单,那是同一个误读的安静版本。 +- **把关闭状态持久化到 settings。** 一个「别再问 DeepSeek」的持久标志,是关于首次运行状态的第二个事实,可能与联接互相矛盾。凭据本身已经永久结束该姿态,而这个页面上其他每一张卡片都是会话内的。 + +## Consequences + +引导现在会因为 DeepSeek 路由一无所知的理由而结束,因此该步骤的名字是最后一处把它和那个适配器绑在一起的东西;未来若有一个步骤能提供不止一条可配置路由,替换掉的会是提示本身,而非就绪投影。收窄后的诊断联合意味着无法解析的 `llm-deepseek` settings 地址会被报为 `provider-ready` 而非它自己的理由——用户可见行为不变,Models 页仍是诊断界面。 + +## Testing + +包内测试针对四种联接状态钉住 `providerUsable`,并针对新门槛与每一个存留的诊断钉住 `onboardingReadiness`;分区测试覆盖首次运行姿态、普通行姿态,以及在新增卡保住草稿的同时折叠设置卡片的那次取消。`onboarding-usable-provider` web e2e 泳道通过真实协议重放整个场景:两张卡片都开着时取消、改配 `minimax-cn`、重载,然后不再出现接管——并附一份关闭后状态的 aria golden。 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 9d13851a0d..231768828d 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md -2026-07-20-dsh-cli-personal-config.md: ed04725e92848bbab550a27ef2f4c021536f765e -2026-07-20-dsh-cli-personal-config.zh.md: cc97987f803f7fb513e94ce0ce079558f5e3dc75 +2026-07-20-dsh-cli-personal-config.md: bc2aff322de01bb9c6beebb1679b2ff9909d1fe3 +2026-07-20-dsh-cli-personal-config.zh.md: 507a7188a4a77d904e3204499290f3ed22abab2c diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index ed04725e92..bc2aff322d 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -6,7 +6,7 @@ English | [中文](2026-07-20-dsh-cli-personal-config.zh.md) ## Problem -A developer's own preferences — which provider and model the TUI uses, personal credentials, a private adapter route — had nowhere to live except edits to committed files. Pointing the TUI demo at a personal Anthropic-proxy Opus route meant patching `examples/tui-agent/cordis.yml` and `.env` in the working tree, which risks committing secrets and repeats per checkout. There was also no installable command: running the agent in an arbitrary project directory required invoking the repo's demo script from the repo root. Loader metadata is static, so "conditional composition uses overlays" (AGENTS.md) — but overlays only existed as committed sibling files, not as a machine-level layer. +A developer's own preferences — which provider and model the TUI uses, personal credentials, a private adapter route — had nowhere to live except edits to committed files. Pointing the TUI demo at a personal Anthropic-proxy Opus route meant patching `examples/tui-agent/cordis.yml` and `.env` in the working tree, which risks committing secrets and repeats per checkout. There was also no installable command: running the agent in an arbitrary project directory required invoking the repo's demo script from the repo root. Loader metadata is static except the entry `disabled` field (see the [loader `disabled` interpolation decision](../architecture/2026-08-11-loader-entry-disabled-interpolation.md)), so "conditional composition uses overlays" (AGENTS.md) — but overlays only existed as committed sibling files, not as a machine-level layer. ## Decision diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index cc97987f80..507a7188a4 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -开发者自己的偏好——TUI 使用哪个提供方和模型、个人凭证、私有的适配器路由——除了改动已提交的文件之外无处安放。要把 TUI 示例指向个人的 Anthropic 代理 Opus 路由,只能在工作区里改 `examples/tui-agent/cordis.yml` 和 `.env`,既有提交密钥的风险,又要在每个 checkout 里重复一遍。也没有可安装的命令:想在任意项目目录里运行这个 agent,必须回到仓库根目录调用示例脚本。Loader 元数据是静态的,所以「条件组合使用 overlay」(AGENTS.md)——但 overlay 此前只以已提交的同级文件形式存在,没有机器级的层。 +开发者自己的偏好——TUI 使用哪个提供方和模型、个人凭证、私有的适配器路由——除了改动已提交的文件之外无处安放。要把 TUI 示例指向个人的 Anthropic 代理 Opus 路由,只能在工作区里改 `examples/tui-agent/cordis.yml` 和 `.env`,既有提交密钥的风险,又要在每个 checkout 里重复一遍。也没有可安装的命令:想在任意项目目录里运行这个 agent,必须回到仓库根目录调用示例脚本。Loader 元数据是静态的——条目 `disabled` 字段除外(见 [loader `disabled` 插值决策](../architecture/2026-08-11-loader-entry-disabled-interpolation.md))——所以「条件组合使用 overlay」(AGENTS.md);但 overlay 此前只以已提交的同级文件形式存在,没有机器级的层。 ## Decision diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml index 5c05f0b86d..bf3d66fcf3 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md -2026-07-23-web-todo-display.md: bb66ef8512badea090b3b22030eef3f43f3b1119 -2026-07-23-web-todo-display.zh.md: 5b3534e20956a4412814a782532d1337c08ff027 +2026-07-23-web-todo-display.md: 3d6720bca80cbcfa4d163d91c0fdffe5e7ed624f +2026-07-23-web-todo-display.zh.md: 692993731d921ed9502dc66d1ca60e1ed0648c0a diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md index bb66ef8512..3d6720bca8 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -33,4 +33,4 @@ The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview` ## Consequences -Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 71) plus `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pin the full chain (row summary and state, dock panel content, collapse round-trip). `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The automation-only ACP bridge deliberately omits todo presentation; the web surfaces render the same event, adding one wire field and no new event type. That field is how cold-load reconstruction stays host-backed: the tail history page carries `todos` — the full-log standing plan (latest `todo/write` with no later `turn/start`), computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan when it still stands and the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, clears on a later `turn/start`, and resets to empty when a tail response carries no projection. +Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 71) plus `packages/client/ui-conversation/tests/todo-panel.client.spec.tsx` pin the full chain (row summary and state, dock panel content, collapse round-trip). `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The automation-only ACP bridge deliberately omits todo presentation; the web surfaces render the same event, adding one wire field and no new event type. That field is how cold-load reconstruction stays host-backed: the tail history page carries `todos` — the full-log standing plan (latest `todo/write` with no later `turn/start`), computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan when it still stands and the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, clears on a later `turn/start`, and resets to empty when a tail response carries no projection. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md index 5b3534e209..692993731d 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -33,4 +33,4 @@ Status: implemented ## 后果 -回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都会自然保持 todos 一致;fx-alpha 第 71 轮的 fixture(测试前置数据)加上 `packages/client/ui-conversation/tests/todo-panel.spec.tsx` 固定整条链(行摘要与状态、dock 面板内容、折叠往返)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。自动化专用的 ACP 桥接刻意不做 todo 呈现;Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。这个由 host 提供的字段正是冷加载重建的依据:history 尾页附带 `todos`——全量 log 上当前有效的计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`),独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时若计划仍然有效且最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,更晚的 `turn/start` 会清空,而尾页响应不带投影时复位为空。 +回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都会自然保持 todos 一致;fx-alpha 第 71 轮的 fixture(测试前置数据)加上 `packages/client/ui-conversation/tests/todo-panel.client.spec.tsx` 固定整条链(行摘要与状态、dock 面板内容、折叠往返)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。自动化专用的 ACP 桥接刻意不做 todo 呈现;Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。这个由 host 提供的字段正是冷加载重建的依据:history 尾页附带 `todos`——全量 log 上当前有效的计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`),独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时若计划仍然有效且最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,更晚的 `turn/start` 会清空,而尾页响应不带投影时复位为空。 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml index 097c0c9f2d..015852b082 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md -2026-07-25-session-list-browsing-and-manual-order.md: 3d2125bdf67a70a1a5bca43c5d5acb09fda178b7 -2026-07-25-session-list-browsing-and-manual-order.zh.md: 161ebd2857073d4dd9cfc2883880cd3e2d91c040 +2026-07-25-session-list-browsing-and-manual-order.md: 52a0fe0c94106cb4178c57e737b1c9a3f458f803 +2026-07-25-session-list-browsing-and-manual-order.zh.md: a6c44579c685479ca460da8e52ea885f20e4776b diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md index 3d2125bdf6..52a0fe0c94 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md @@ -14,7 +14,7 @@ Two existing mechanisms stood in the way. First, the host durably promoted the a ### Flat rows and viewing state -The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode renders peer session rows within each group in the manual order from `WorkspaceView.sessionIds`; In one list combines every session and sorts them strictly newest-first by `updatedAt`. Neither mode projects `parentId` into a list hierarchy; fork lineage remains session data only. [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the complete fork behavior. The mode choice persists in the browser (`dsh.workspace.view`) across reloads. +The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode renders peer session rows within each group in the manual order from `WorkspaceView.sessionIds`; In one list combines every session and sorts them strictly newest-first by `updatedAt`. Neither mode projects `parentId` into a list hierarchy; fork lineage remains session data only. [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the complete fork behavior. The mode choice persists in the browser (`dsh.workspace.view`) across reloads. [Workspace Sidebar Order and Folding](2026-08-11-workspace-sidebar-order-and-folding.md) later added a browser-local recent-update view without changing the Host account's manual-order authority. ### Row interactions @@ -50,7 +50,7 @@ ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, ## Consequences -- Manual order is the sole authority over the workspace account: an order the user arranges is never scrambled by activity; the cost is losing float-to-top-on-activity, whose signal now rides the row status dot and time label. The `WorkspaceView.sessionIds` wire contract is reworded to the manual-order semantics. +- Manual order is the sole authority over the Host workspace account: activity never mutates `WorkspaceView.sessionIds`. A later browser-local recent-update view may promote active rows without changing that account; its separate semantics are defined in [Workspace Sidebar Order and Folding](2026-08-11-workspace-sidebar-order-and-folding.md). - The two-fact shell/region contract funnels every future workspace-domain feature (Delete confirmation, cross-group moves, Ungrouped adoption) into the single ui-workspace package; ui-sidebar no longer evolves with session-list features. - Flat mode supports neither reordering nor a create-in-workspace entry point (switching back to grouped view is required) — an accepted scope reduction. - Wiring session Delete and growing the wire status enum remain future iterations. diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md index 161ebd2857..a6c44579c6 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -14,7 +14,7 @@ Status: implemented ### 平铺行与浏览态 -group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序在各组内展示同级 session 行;In one list 把所有 session 合并后严格按 `updatedAt` 新→旧排序。两种模式都不把 `parentId` 投影成列表层级,fork 谱系只保留为 session 数据;完整 fork 行为由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。 +group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序在各组内展示同级 session 行;In one list 把所有 session 合并后严格按 `updatedAt` 新→旧排序。两种模式都不把 `parentId` 投影成列表层级,fork 谱系只保留为 session 数据;完整 fork 行为由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。[Workspace 侧边栏顺序与折叠](2026-08-11-workspace-sidebar-order-and-folding.md)随后加入浏览器本地的最近更新视图,而未改变 Host 记账的手动顺序权威。 ### 行交互 @@ -50,7 +50,7 @@ ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settin ## Consequences -- 手动序是唯一的 workspace 账本序权威:用户排好的顺序不再被活动打乱;代价是「最近活跃浮到最上」的行为消失,活跃感知转由行内状态点与时间标签承担。`WorkspaceView.sessionIds` 的 wire 约定随之改为手动序措辞。 +- 手动序是 Host workspace 账本的唯一顺序权威:活动绝不改动 `WorkspaceView.sessionIds`。后续加入的浏览器本地最近更新视图可以把活跃行提到最前,但不会改变该账本;其独立语义见 [Workspace 侧边栏顺序与折叠](2026-08-11-workspace-sidebar-order-and-folding.md)。 - 壳/区域两事实约定把 workspace 域的后续功能(Delete 确认、跨组移动、Ungrouped 收编)全部收进 ui-workspace 单包;ui-sidebar 不再随 session 列表功能演进。 - 平铺模式不支持排序与分组入口(建到指定 workspace 需切回分组视图),是拍板接受的范围收窄。 - session Delete 的功能接线与状态枚举扩 wire,留待后续迭代。 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml index c0813607d3..8d8d36e87d 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md -2026-07-25-workspace-ui-product-flow.md: 98e963195126df2ec8291a11b3d9fc7a2baeb0df -2026-07-25-workspace-ui-product-flow.zh.md: 486093be0b8d10c2ae0b8083b305ecad5386351c +2026-07-25-workspace-ui-product-flow.md: 76d279bf2101d7487fe4f5231c7cea4809e166f4 +2026-07-25-workspace-ui-product-flow.zh.md: e15ead7b437d8f2324f7ea51222eb4fcfb4a9e4a diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md index 98e9631951..76d279bf21 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -20,6 +20,7 @@ The Host provides the following GUI wiring on the Workspace entity: | --- | --- | | `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation | | `workspace.create({ path })` | Adopts an existing directory by canonical path; basename-derived display titles may repeat | +| `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` | Moves one Workspace within durable registry order and returns the complete committed order | | `workspace.delete({ workspaceId })` | Removes the Workspace registration while retaining its directory and session logs; its Sessions become Ungrouped | | `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it | | `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session | @@ -49,7 +50,7 @@ On initial entry, the application waits until both the Workspace and Session bas When no Workspace exists, the page creates a frontend Workspace object named `workspace` and a frontend Session that targets it. Neither writes to the Host, and the composer always accepts input; the first send materializes the Workspace, attaches the Session, and sends the message in that order. -Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. +Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the current Session's Workspace, then the most recent Workspace, and enters the blank New Session page when no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. A new Workspace takes its display name from the directory it was created in. Distinct canonical paths may share the same basename-derived title ([identity decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)); the explicit rename operation retains its duplicate-title check. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. @@ -67,11 +68,11 @@ Lost RPC responses, Host frames arriving before completions, and completions arr ### Sidebar and ordering -Workspace groups strictly follow the persistent order returned by the Host. Bootstrap determines the historical order once, explicitly created Workspaces are placed first, and Session activity does not move Workspace groups. +Workspace groups follow the persistent order returned by the Host. Bootstrap determines the historical order once, explicitly created Workspaces are placed first, and `workspace.insertBefore` durably applies user drag order. Session activity does not move Workspace groups. -Within each group, order strictly follows `Workspace.sessionIds`. A newly attached Session is placed first; when a Session later becomes active, the Host moves only that id to the front and persists the change. The Client does not reorder the entire group by time after the Session list arrives, so it never displays one Workspace order and then jumps to another during hydration. +The Host account remains the manual `Workspace.sessionIds` order: a newly attached Session is placed first and activity does not mutate it. The grouped browser can instead select a browser-local recent-update view that promotes a Session when its `updatedAt` advances and remains manually editable. Five Sessions are visible per open Workspace until the user transiently expands the remainder. The durable Workspace reorder and browser-local Session order are defined in [Workspace Sidebar Order and Folding](2026-08-11-workspace-sidebar-order-and-folding.md). -A frontend Session Intent appears as a “New session” row and temporarily counts toward the group's Session total only when it targets a real Workspace. When it targets a Workspace Intent, neither the Workspace nor the Session appears in the sidebar. After the Intent is published, the real row with the same preallocated id takes its place; after refresh, both the Intent row and temporary count disappear. Search mode neither retains nor filters Intent rows. +The current blank Session appears as a “New session” row without a count, time label, or row menu; other blank Sessions remain hidden and eligible for per-Workspace reuse. Search excludes blank rows. Real Sessions that cannot be assigned to any Workspace appear under Ungrouped. Host `session-added` and `workspace-changed` events may arrive in either order; list merging does not depend on frame order. @@ -105,15 +106,15 @@ The Sidebar and conversation empty hero receive standardized actions through slo - Frontend Sessions and Workspaces preserve object identity across materialization; input, errors, focus, and sidebar projections always originate from the object layer. - The first send advances through Workspace, Session, and prompt in order; successful stages are not rolled back, input is not lost before the prompt is accepted, and creation retries use the same SessionId. - Workspace list performs one reentrant bootstrap using only headers; an initialized empty registry does not initialize again after restart, and membership reads validate both the index and canonical cwd. -- The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front. -- A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts. +- The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered by hydration or Session activity, and explicit Workspace drag order survives reconnect. +- The current blank Session can appear as a single New Session row without exposing other reusable blanks or a Session count. - The UI and Host admit distinct same-basename directories as separate Workspaces, while the explicit rename operation rejects duplicate titles; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. - Confirmed Workspace deletion removes only the registration, retains the current Session, directory, files, and session log, and survives reload; package tests pin unary/frame/baseline races and failure rollback. - Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order. ## Consequences -- SessionHeader does not record last-active time, so historical bootstrap can initialize order only by `createdAt`; real Session activity events move individual entries afterward. +- SessionHeader does not record last-active time, so historical bootstrap can initialize the Host manual order only by `createdAt`; the browser's optional recent-update view begins from Session summaries after hydration. - Historical Sessions with a missing cwd, an invalid directory, or a failed realpath remain Ungrouped; this iteration has no manual-adoption entry point. - Refreshing the page discards unmaterialized Workspace and Session Intents and input not yet accepted by the Host; this is the page-local contract. - Explicit Create Workspace writes to disk immediately, so leaving without sending still leaves an empty Workspace. diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md index 486093be0b..e15ead7b43 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -20,6 +20,7 @@ Host 在 Workspace entity 上提供以下 GUI 接线: | --- | --- | | `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id | | `workspace.create({ path })` | 按 canonical path 收编已有目录;由 basename 派生的显示名可以重复 | +| `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` | 在持久注册表顺序内移动一个 Workspace,并返回完整的已提交顺序 | | `workspace.delete({ workspaceId })` | 移除 Workspace 注册记录,同时保留目录和会话日志;相关 Session 进入 Ungrouped | | `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach | | `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session | @@ -49,7 +50,7 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预 完全没有 Workspace 时,页面创建默认名为 `workspace` 的前端 Workspace 对象和指向它的前端 Session。两者不写 Host,composer 始终可输入;首次发送才依次 materialize Workspace、attach Session、发送消息。 -顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 +顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时先使用当前 Session 所属 Workspace,再使用最近 Workspace;没有真实 Workspace 时进入空白 New Session 页面。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 新建 Workspace 的显示名取自其所在目录。不同 canonical path 可以拥有相同的 basename 派生显示名(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md));显式的重命名操作仍保留显示名重名检查。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 @@ -67,11 +68,11 @@ RPC 响应丢失、Host frame 先于 completion 和 completion 先于 Host frame ### Sidebar 与排序 -Workspace 组严格使用 Host 返回的持久顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放在首位;Session 活跃不会移动 Workspace 组。 +Workspace 组使用 Host 返回的持久顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放在首位,`workspace.insertBefore` 则持久应用用户拖拽顺序;Session 活跃不会移动 Workspace 组。 -组内严格使用 `Workspace.sessionIds`。新 attach 的 Session 放在首位,后续某个 Session 活跃时 Host 只前移该 id 并持久化。Client 不在 Session list 到达后按时间整体重排,因此不会先显示一套 Workspace 顺序再因 hydration 瞬间跳动。 +Host 记账保持手动的 `Workspace.sessionIds` 顺序:新 attach 的 Session 放在首位,活动不会改动该顺序。分组浏览器可以改选浏览器本地的最近更新视图;当 Session 的 `updatedAt` 增大时该视图会把它移到首位,同时仍允许手动调整。每个打开的 Workspace 默认显示五条 Session,用户可临时展开其余条目。持久 Workspace 重排序和浏览器本地 Session 顺序见 [Workspace 侧边栏顺序与折叠](2026-08-11-workspace-sidebar-order-and-folding.md)。 -前端 Session Intent 只有在目标是真实 Workspace 时才作为 「New session」 行显示,并临时计入该组 Session 数量;目标是 Workspace Intent 时,Workspace 与 Session 都不进入 sidebar。Intent 发布后由同一预分配 id 对应的真实行接替,刷新后 Intent 行和临时计数一起消失。搜索模式不保存或筛选 Intent 行。 +当前空白 Session 会显示为一条「New session」行,但不显示数量、时间标签或行菜单;其他空白 Session 保持隐藏,并可由对应 Workspace 复用。搜索会排除空白行。 无法归入任何 Workspace 的真实 Session 进入 Ungrouped。Host `session-added` 与 `workspace-changed` 可以任意顺序到达,列表合并不依赖 frame 顺序。 @@ -105,15 +106,15 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe - 前端 Session 与 Workspace 在 materialize 前后保持对象身份,输入、错误、焦点和 sidebar 投影始终来自对象层。 - 首发按 Workspace、Session、提示词顺序推进,各成功阶段不回滚,输入在提示词被接受前不丢失,创建重试使用同一 SessionId。 - Workspace list 只读取 header 完成一次可重入 bootstrap;initialized 的空 registry 重启不重复初始化,成员读取同时校验索引与 canonical cwd。 -- 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 -- 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 +- 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃重排,显式 Workspace 拖拽顺序在重连后仍然保持。 +- 当前空白 Session 可显示为唯一的 New Session 行,同时不暴露其他可复用空白会话,也不显示 Session 数量。 - UI 与 Host 会将 canonical path 不同但 basename 相同的目录接纳为独立 Workspace,而显式的重命名操作会拒绝重复显示名;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 - 经确认的 Workspace 删除只移除注册记录,保留当前 Session、目录、文件和会话日志,并在刷新后保持该状态;包级测试固定一元响应/帧/基线竞态和失败回滚行为。 - keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。 ## Consequences -- SessionHeader 不记录最后活跃时间,历史 bootstrap 只能按 `createdAt` 初始化;此后由真实 Session 活跃事件逐项前移。 +- SessionHeader 不记录最后活跃时间,历史 bootstrap 只能按 `createdAt` 初始化 Host 手动顺序;浏览器可选的最近更新视图在 hydration 后从 Session 摘要开始建立。 - 历史 cwd 缺失、目录无效或 realpath 失败的 Session 留在 Ungrouped;本期没有手动收编入口。 - 页面刷新会丢弃未 materialize 的 Workspace/Session Intent 和尚未被 Host 接受的输入,这是 page-local 约定。 - 显式 Create Workspace 立即落盘,用户不发送就离开也会留下空 Workspace。 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml index 4e2d35175f..6e4ca08c86 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md -2026-07-26-code-dispatch-ui-foundation.md: 4115a1898de7d2cce01346c3f005fcd19c325f4c -2026-07-26-code-dispatch-ui-foundation.zh.md: aeb57b93d781163dd0a4747ac03053c65deda1db +2026-07-26-code-dispatch-ui-foundation.md: 94316e774f231a2f2d5e9bcc8d1a30fd4a2ec733 +2026-07-26-code-dispatch-ui-foundation.zh.md: 2c9ee5b93e20b5c09950e2896a888f90863b8bd0 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md index 4115a1898d..94316e774f 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md @@ -16,7 +16,7 @@ Three changes, one per obstacle: 1. **`run_code` gains a required `description` parameter** (bash's exact contract: active voice, 5-10 words, shown in the UI; whitespace-only rejected at execute). `presentCall` now titles the card with the description and moves the program to `rawInput`. The prompt-side cost is a few tokens per call; the return is that every surface — TUI card, ACP title, web row — gets a human-readable label without parsing TypeScript. 2. **`tool/code-dispatch` logs the sub-call's complete model-facing outcome** — `content: ContentBlock[]` + `isError`, the `tool/result` vocabulary — replacing `resultSummary` and deleting the summarize/cwd-normalization machinery outright. A UI renders a sub-call through the identical code path as a native result, including error text and non-text blocks. The event stays log-only (`deriveMessages()` ignores it): nothing about model context changes. -3. **`DSH_TOOLS_MODE` env var on the `dsh` config tree** (`native`|`code`|`both`; unset keeps the schema default): the `tools` row reads it via `!!js`, and the worker code runtime is mounted unconditionally (Loader metadata is static, so no conditional row exists; a native boot only registers the service — workers spawn per run). This is an explicitly temporary configuration hook: per-session tool-mode selection owned by the web UI is the design goal, and the env var dies when that lands. +3. **`DSH_TOOLS_MODE` env var on the `dsh` config tree** (`native`|`code`|`both`; unset keeps the schema default): the `tools` row reads it via `!!js`, and the worker code runtime is mounted unconditionally (Loader metadata was static when this shipped — no conditional row existed; the later [`disabled` interpolation decision](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) makes one possible but changes nothing here — a native boot only registers the service, workers spawn per run). This is an explicitly temporary configuration hook: per-session tool-mode selection owned by the web UI is the design goal, and the env var dies when that lands. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md index aeb57b93d7..2c9ee5b93e 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md @@ -16,7 +16,7 @@ Status: implemented 1. **`run_code` 新增必填的 `description` 参数**(与 bash 完全相同的约定:主动语态、5-10 个词、展示在 UI 中;仅含空白的取值在执行时被拒绝)。`presentCall` 现在以该 description 作为卡片标题,并把程序文本移入 `rawInput`。提示词侧的成本是每次调用多出几个 token;换来的是每个表面——TUI 卡片、ACP(Agent Client Protocol)标题、Web 行——都无需解析 TypeScript 就能获得可供人阅读的标签。 2. **`tool/code-dispatch` 记录子调用面向模型的完整结果**(`content: ContentBlock[]` 加 `isError`,即 `tool/result` 的词汇),取代 `resultSummary`,并把摘要与 cwd 归一化机制彻底删除。UI 渲染子调用走的代码路径与渲染原生结果完全相同,包括错误文本和非文本块。该事件保持仅日志(`deriveMessages()` 忽略它):模型上下文没有任何变化。 -3. **`dsh` 配置树上的 `DSH_TOOLS_MODE` 环境变量**(`native`|`code`|`both`;未设置时保持 schema 默认值):`tools` 行通过 `!!js` 读取它,worker 代码运行时则无条件挂载(loader 元数据是静态的,因此不存在条件行;native 启动只是注册该服务,worker 要到每次运行时才 spawn)。这是一个明确标注为临时的配置钩子:设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。 +3. **`dsh` 配置树上的 `DSH_TOOLS_MODE` 环境变量**(`native`|`code`|`both`;未设置时保持 schema 默认值):`tools` 行通过 `!!js` 读取它,worker 代码运行时则无条件挂载(本项交付时 loader 元数据仍是静态的,因此不存在条件行;后来的 [`disabled` 插值决策](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) 让条件行成为可能,但此处不变——native 启动只是注册该服务,worker 要到每次运行时才 spawn)。这是一个明确标注为临时的配置钩子:设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml index 9f98597809..e7ed85f4c6 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md -2026-07-26-todo-parallel-in-progress.md: 5664960ce94dfac60d89cb363ea62459dd51f59e -2026-07-26-todo-parallel-in-progress.zh.md: f346f89fcae5e8607c5608bbf7541ada0bae4593 +2026-07-26-todo-parallel-in-progress.md: 3d7b80e94762f8ca24b716c42619bf20d9a13608 +2026-07-26-todo-parallel-in-progress.zh.md: 28e32c949de09b5c0722cd1c7e9d91c1542a6cf7 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md index 5664960ce9..3d7b80e947 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -51,4 +51,4 @@ Two known gaps are deferred. The `summarySuffix` span carries no accessible name ## Consequences -A todo list can now faithfully mirror parallel execution, and every surface renders several active markers at once: the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every snapshot sidecar carrying the todo schema. No count is recorded here: the set grows with every pinning scenario that lands. The operative rule is that a branch changing the tool description must refresh whichever sidecars landed after it branched — including the numbered `tool-schemas..expected.json` files pinning a subagent class, whose schemas the parent scenario does not cover — and `pnpm run test:snapshot:refresh` does it keylessly over the whole corpus. The web fixture's todo sample now runs two items `in_progress`, so both fixture-driven surfaces render a parallel plan. `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pins the row summary and the plan strip over src, the ACP `todo-write` scenario records a three-todo plan with two active, and `apps/web/tests/todo-row.snapshot.ts` pins both surfaces in the assembled application — booted from the built `packages/client/*/lib/client.js` bundles, so it is the one place the keyed registration and the bundled wiring are under test. That last file records `summary`, `suffix`, and the strip's header as separate fields, so folding the `+N` count back into the summary string changes the expected output even though the concatenated text would read the same. +A todo list can now faithfully mirror parallel execution, and every surface renders several active markers at once: the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every snapshot sidecar carrying the todo schema. No count is recorded here: the set grows with every pinning scenario that lands. The operative rule is that a branch changing the tool description must refresh whichever sidecars landed after it branched — including the numbered `tool-schemas..expected.json` files pinning a subagent class, whose schemas the parent scenario does not cover — and `pnpm run test:snapshot:refresh` does it keylessly over the whole corpus. The web fixture's todo sample now runs two items `in_progress`, so both fixture-driven surfaces render a parallel plan. `packages/client/ui-conversation/tests/todo-panel.client.spec.tsx` pins the row summary and the plan strip over src, the ACP `todo-write` scenario records a three-todo plan with two active, and `apps/web/tests/todo-row.snapshot.ts` pins both surfaces in the assembled application — booted from the built `packages/client/*/lib/client.js` bundles, so it is the one place the keyed registration and the bundled wiring are under test. That last file records `summary`, `suffix`, and the strip's header as separate fields, so folding the `+N` count back into the summary string changes the expected output even though the concatenated text would read the same. diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md index f346f89fca..28e32c949d 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -51,4 +51,4 @@ Status: implemented ## 后果 -现在 todo 列表可以忠实反映并行执行,并且每个展示面都能一次渲染多个活跃标记:计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照伴随文件。此处不记录数量:该集合会随每个新落地的 pin 场景增长。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些伴随文件——包括固定 subagent 类工具的编号文件 `tool-schemas..expected.json`,其 schema 不被父场景覆盖——`pnpm run test:snapshot:refresh` 可以无 key 地对整个语料完成刷新。web fixture(测试前置数据)的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划。`packages/client/ui-conversation/tests/todo-panel.spec.tsx` 在 src 上固定工具行摘要与计划横条,ACP(Agent Client Protocol)`todo-write` 场景录制的是三条目、两个活跃的计划,而 `apps/web/tests/todo-row.snapshot.ts` 在组装后的应用中固定这两个面——它从构建产物 `packages/client/*/lib/client.js` 启动,因此是唯一覆盖 keyed 注册与打包接线的地方。该文件把 `summary`、`suffix` 与横条表头记录为独立字段,因此即便拼接后的文本读起来一样,把 `+N` 计数折回摘要字符串也会改变预期输出。 +现在 todo 列表可以忠实反映并行执行,并且每个展示面都能一次渲染多个活跃标记:计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照伴随文件。此处不记录数量:该集合会随每个新落地的 pin 场景增长。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些伴随文件——包括固定 subagent 类工具的编号文件 `tool-schemas..expected.json`,其 schema 不被父场景覆盖——`pnpm run test:snapshot:refresh` 可以无 key 地对整个语料完成刷新。web fixture(测试前置数据)的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划。`packages/client/ui-conversation/tests/todo-panel.client.spec.tsx` 在 src 上固定工具行摘要与计划横条,ACP(Agent Client Protocol)`todo-write` 场景录制的是三条目、两个活跃的计划,而 `apps/web/tests/todo-row.snapshot.ts` 在组装后的应用中固定这两个面——它从构建产物 `packages/client/*/lib/client.js` 启动,因此是唯一覆盖 keyed 注册与打包接线的地方。该文件把 `summary`、`suffix` 与横条表头记录为独立字段,因此即便拼接后的文本读起来一样,把 `+N` 计数折回摘要字符串也会改变预期输出。 diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml index 781c07f8f3..3148aa5836 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md -2026-07-28-web-terminal-card.md: 84e5cdb786944b014fd3a687cfefad6768174006 -2026-07-28-web-terminal-card.zh.md: 58c236bda9c45e13fda0f965f84af24b3c831bb4 +2026-07-28-web-terminal-card.md: ef6da52b4166b61579cf3ef98e760c2f1c42b679 +2026-07-28-web-terminal-card.zh.md: 80c555f227b4e6dd96adcfb666f52e9bb42e2d41 diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md index 84e5cdb786..ef6da52b41 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md @@ -55,9 +55,9 @@ Inline rendering is licensed for the terminal intent alone. A future intent that ## Testing -`packages/client/ui-primitives/tests/ansi.spec.ts` pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the `textDecoration` collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, the cursor replay (redraws leaving a longer frame's tail standing, a trailing backspace erasing nothing, erase-in-line in all three parameter forms, tab stops, wide characters, SGR threading across lines, and a cursor/erase sequence never entering a cell style), and CRLF preservation. Each replay case was checked against a real terminal first. `packages/client/ui-primitives/tests/terminal-block.spec.tsx` pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its `aria-expanded` toggle, the run-state dot across all three reachable states plus its position ahead of the prompt label, the one-row-per-command-line prompt and its single dot on the first row, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus `writeClipboard` directly. +`packages/client/ui-primitives/tests/ansi.client.spec.ts` pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the `textDecoration` collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, the cursor replay (redraws leaving a longer frame's tail standing, a trailing backspace erasing nothing, erase-in-line in all three parameter forms, tab stops, wide characters, SGR threading across lines, and a cursor/erase sequence never entering a cell style), and CRLF preservation. Each replay case was checked against a real terminal first. `packages/client/ui-primitives/tests/terminal-block.client.spec.tsx` pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its `aria-expanded` toggle, the run-state dot across all three reachable states plus its position ahead of the prompt label, the one-row-per-command-line prompt and its single dot on the first row, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus `writeClipboard` directly. -`packages/client/ui-tool/tests/terminal-card.spec.tsx` pins the wiring at every render site: `terminalCardModel`'s derivation and each of its null arms, the result title replacing the pending one, the cwd resolving against the session workspace across all four of its cases, the panel resetting the card's expand state when the selection changes, the chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card and its agreement with its own summary row's state dot, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — `packages/client/ui-tool/src/*` sits on the coverage `exclude` list in `vitest.config.ts`, so a coverage run over this package measures none of these files. +`packages/client/ui-tool/tests/terminal-card.client.spec.tsx` pins the wiring at every render site: `terminalCardModel`'s derivation and each of its null arms, the result title replacing the pending one, the cwd resolving against the session workspace across all four of its cases, the panel resetting the card's expand state when the selection changes, the chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card and its agreement with its own summary row's state dot, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — `packages/client/ui-tool/src/*` sits on the coverage `exclude` list in `vitest.config.ts`, so a coverage run over this package measures none of these files. `apps/web/tests/terminal-card.snapshot.ts` pins the assembled application over the built client bundles: the same render intent at both conversation render sites and in both chat-row shapes, because a bash call reaches a resident card only through the keyed `BashRow` registration and every other terminal-declaring tool name lands on the render-site fallback row, whose body is expand-gated. Fixture turn 65 is named `bash` and turn 60 stays `fx-bash` so one fixture covers both shapes, and turn 60's command is two lines so the built-bundle snapshot pins the per-line prompt and its single dot (`dotsPerPromptRow: [1, 0]`). That terminal turn is ordered BEFORE the todo turn on purpose: the standing plan retires at the next `turn/start`, so appending it after would have emptied the dock's plan strip and taken the todo surfaces' own coverage with it; that turn also carries what turn 60's two prompt rows cannot — SGR runs resolved to `--dsw-*` tokens, output past the chat cap, a nested cwd, and a non-zero exit authored beside the sample. The sample's body deliberately carries NO `[exit code: N]` line: the real bash presenter consumes that marker out of the body precisely because the card shows the exit as its own pill, so leaving it in would pin a frame showing the exit twice — one the product path cannot produce. diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md index 58c236bda9..80c555f227 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md @@ -55,9 +55,9 @@ Web client 却对它视而不见。`packages/client/ui-tool/src/client/tool/mode ## Testing -`packages/client/ui-primitives/tests/ansi.spec.ts` 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 `textDecoration` 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、光标重放(较短重绘让上一帧尾巴留存、末尾退格不擦除任何东西、行内擦除的全部三种参数形式、制表位、宽字符、SGR 跨行延续,以及光标/擦除序列绝不进入单元格样式),以及 CRLF 的保留。每一条重放用例都先对照真实终端核实过。`packages/client/ui-primitives/tests/terminal-block.spec.tsx` 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 `aria-expanded` 开关、运行状态点全部三种可达状态及其位于提示符标签之前的位置、每条命令行一行的提示区及其位于第一行的单枚状态点,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 `writeClipboard` 的直接固定。 +`packages/client/ui-primitives/tests/ansi.client.spec.ts` 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 `textDecoration` 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、光标重放(较短重绘让上一帧尾巴留存、末尾退格不擦除任何东西、行内擦除的全部三种参数形式、制表位、宽字符、SGR 跨行延续,以及光标/擦除序列绝不进入单元格样式),以及 CRLF 的保留。每一条重放用例都先对照真实终端核实过。`packages/client/ui-primitives/tests/terminal-block.client.spec.tsx` 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 `aria-expanded` 开关、运行状态点全部三种可达状态及其位于提示符标签之前的位置、每条命令行一行的提示区及其位于第一行的单枚状态点,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 `writeClipboard` 的直接固定。 -`packages/client/ui-tool/tests/terminal-card.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、结果标题替换待定标题、cwd 针对会话 workspace 解析的全部四种情形、切换选中调用时面板重置卡片展开态、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片及其与自身摘要行状态点的一致性,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-tool/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。 +`packages/client/ui-tool/tests/terminal-card.client.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、结果标题替换待定标题、cwd 针对会话 workspace 解析的全部四种情形、切换选中调用时面板重置卡片展开态、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片及其与自身摘要行状态点的一致性,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-tool/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。 `apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 65 轮名为 `bash`、第 60 轮保持 `fx-bash`,于是一份 fixture 覆盖两种形态,而第 60 轮的命令为两行,使构建产物快照钉住逐行提示区及其单枚状态点(`dotsPerPromptRow: [1, 0]`)。该终端轮有意排在 todo 轮**之前**:站立计划会在下一次 `turn/start` 时退役,若追加在其后就会让 dock 的计划条变空,并连带毁掉 todo 表面自身的覆盖;该轮还承载第 60 轮两个提示行无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及在样本旁另行标注的非零退出码。样本正文有意**不含** `[exit code: N]` 行:真实的 bash presenter 正是因为卡片以徽章单独呈现退出状态,才把该标记从正文中消费掉;若保留它,钉住的将是一帧把退出状态显示两次的画面——而产品路径产不出这一帧。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml index 549145aad0..a37a306181 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-diff-card.md -2026-07-30-web-diff-card.md: d8b0cf32e42af45e16e6f7059c873c384d4a9229 -2026-07-30-web-diff-card.zh.md: 51df03c3f8b79cd8415a15bd30e92e3c152f335e +2026-07-30-web-diff-card.md: 465a6fc9e2fbe61fd1a2e6f11590d01e2553d506 +2026-07-30-web-diff-card.zh.md: d674b1a323f15f52592b51b6188e0b80d1ba977c diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md index d8b0cf32e4..465a6fc9e2 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md @@ -44,9 +44,9 @@ The multi-file arm of `DiffBlock` (one card, several path headers) has no produc ## Testing -`packages/client/ui-primitives/tests/diff-block.spec.tsx` pins the component: the create arm (added-only, no removed side), the edit arm (removed above added), the same-file `⋯` gap versus a new file's own header, the empty-diffs null render, the footer counts and their singular/plural, the head/tail cap with its `aria-expanded` toggle, and the copy control asserting the prefixed diff text on both the accepted and refused clipboard paths. Per-file 100%. +`packages/client/ui-primitives/tests/diff-block.client.spec.tsx` pins the component: the create arm (added-only, no removed side), the edit arm (removed above added), the same-file `⋯` gap versus a new file's own header, the empty-diffs null render, the footer counts and their singular/plural, the head/tail cap with its `aria-expanded` toggle, and the copy control asserting the prefixed diff text on both the accepted and refused clipboard paths. Per-file 100%. -`packages/client/ui-tool/tests/diff-card.spec.tsx` pins the wiring at every render site: `diffCardModel`'s derivation and each of its null arms, the result hunks replacing the call-time diff, a window-truncated call still rendering from the result, the chat row's diff body, `FileMutationRow`'s resident card and its path link opening cwd-resolved through the host, its registration under both `write` and `edit`, and the panel's Output section. +`packages/client/ui-tool/tests/diff-card.client.spec.tsx` pins the wiring at every render site: `diffCardModel`'s derivation and each of its null arms, the result hunks replacing the call-time diff, a window-truncated call still rendering from the result, the chat row's diff body, `FileMutationRow`'s resident card and its path link opening cwd-resolved through the host, its registration under both `write` and `edit`, and the panel's Output section. The fixture (`packages/client/connection/src/client/fixture.ts`) carries three diff turns so a `?fixture` server and the per-package wiring suite exercise all three arms at both render sites: a single-hunk edit (turn 62, keyed `FileMutationRow`), a create/write (turn 63), and a multi-hunk edit (turn 67, the `⋯` gap between two scattered hunks in one file). The built-boot snapshot (`apps/web/tests/built-boot.snapshot.ts`) is a boot-assembly smoke that asserts only that the graph mounts and reaches chat content (`data-sample="bash-global"`); by its own contract it carries no diff-behavior assertions, which the wiring suite owns. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md index 51df03c3f8..d674b1a323 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md @@ -44,9 +44,9 @@ chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX ## Testing -`packages/client/ui-primitives/tests/diff-block.spec.tsx` 钉住组件:新建支路(只有新增、无删除侧)、编辑支路(删除在新增之上)、同文件 `⋯` gap 对比新文件自己的头、空 diffs 的 null 渲染、页脚计数及其单复数、头尾上限及其 `aria-expanded` 切换、以及复制控件在接受与拒绝两条剪贴板路径上断言带前缀的 diff 文本。Per-file 100%。 +`packages/client/ui-primitives/tests/diff-block.client.spec.tsx` 钉住组件:新建支路(只有新增、无删除侧)、编辑支路(删除在新增之上)、同文件 `⋯` gap 对比新文件自己的头、空 diffs 的 null 渲染、页脚计数及其单复数、头尾上限及其 `aria-expanded` 切换、以及复制控件在接受与拒绝两条剪贴板路径上断言带前缀的 diff 文本。Per-file 100%。 -`packages/client/ui-tool/tests/diff-card.spec.tsx` 钉住每个渲染点的接线:`diffCardModel` 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、`FileMutationRow` 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 `write` 与 `edit` 下的注册、以及面板的 Output 区。 +`packages/client/ui-tool/tests/diff-card.client.spec.tsx` 钉住每个渲染点的接线:`diffCardModel` 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、`FileMutationRow` 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 `write` 与 `edit` 下的注册、以及面板的 Output 区。 fixture(`packages/client/connection/src/client/fixture.ts`)携带三个 diff turn,使 `?fixture` 服务与 per-package 接线测试套件在两个渲染点演练全部三个支路:单 hunk 编辑(turn 62,keyed `FileMutationRow`)、新建/写入(turn 63)、多 hunk 编辑(turn 67,一个文件内两处分散 hunk 之间的 `⋯` gap)。built-boot snapshot(`apps/web/tests/built-boot.snapshot.ts`)是启动装配 smoke,只断言图挂载并抵达 chat 内容(`data-sample="bash-global"`);按其自身约定它不带 diff 行为断言,那由接线套件负责。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml index 9e159b5b40..13091086c4 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md -2026-07-30-web-read-card-frontend.md: 1d9b6d7fa0c8b5391d175a53507db88a84234b91 -2026-07-30-web-read-card-frontend.zh.md: 2678d50e851e4b3a079f59c7db6ec08ecc5c4aed +2026-07-30-web-read-card-frontend.md: 98e31d4192f7522f9d0e23bce56372a70f8c50b6 +2026-07-30-web-read-card-frontend.zh.md: 294821ec9f1b35f58c399aa77d21b629557cd059 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md index 1d9b6d7fa0..98e31d4192 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md @@ -40,9 +40,9 @@ A read row in the Web chat now carries the file content resident, a deliberate d ## Testing -`packages/client/ui-primitives/tests/read-block.spec.tsx` pins the primitive and the token path: `highlightLines`' per-line css-variables runs, its trailing-terminator-line drop and the genuinely-blank-final-line case, its `undefined` for an unknown/absent language, and its lazy path (a lazy grammar returns plain on first touch, then highlights after the import registers and the subscriber fires); and `ReadBlock`'s gutter-numbered rows keeping the file's own numbers, the highlighted-vs-plain content arms, the banner (label, language, the count note only when the read is a window), the head/tail height cap with its `aria-expanded` toggle, the copy control writing the window's raw text on both the accepted and refused clipboard paths, and the empty-window arm hiding the copy control. `code-block.spec.tsx` covers `highlightToHtml` including its lazy path over every read-card grammar (each dynamic import thunk touched once). Both `ReadBlock.tsx` and `highlight.ts` (and `CodeBlock.tsx`) hold per-file 100% coverage across the two specs. +`packages/client/ui-primitives/tests/read-block.client.spec.tsx` pins the primitive and the token path: `highlightLines`' per-line css-variables runs, its trailing-terminator-line drop and the genuinely-blank-final-line case, its `undefined` for an unknown/absent language, and its lazy path (a lazy grammar returns plain on first touch, then highlights after the import registers and the subscriber fires); and `ReadBlock`'s gutter-numbered rows keeping the file's own numbers, the highlighted-vs-plain content arms, the banner (label, language, the count note only when the read is a window), the head/tail height cap with its `aria-expanded` toggle, the copy control writing the window's raw text on both the accepted and refused clipboard paths, and the empty-window arm hiding the copy control. `code-block.spec.tsx` covers `highlightToHtml` including its lazy path over every read-card grammar (each dynamic import thunk touched once). Both `ReadBlock.tsx` and `highlight.ts` (and `CodeBlock.tsx`) hold per-file 100% coverage across the two specs. -`packages/client/ui-tool/tests/read-card.spec.tsx` pins the wiring at every render site: `readCardModel`'s derivation and each null arm (running read, no view, generic view, unknown card), the result title replacing the relativized path, the path relativization against the workspace, the copy-not-alias of the frozen line array; the resident card in `GenericToolCard`'s fallback and in the keyed `ReadRow` (plus its path link opening the host, its running/error/stopped states, and its `read`-key registration); and the panel's Output section rendering the read card at full height while keeping the JSON Input section, with the running-read placeholder and non-read flattened-pre arms. That file sits on the coverage `exclude` list (`ui-tool/src/*`), so it is written against no gate pressure. +`packages/client/ui-tool/tests/read-card.client.spec.tsx` pins the wiring at every render site: `readCardModel`'s derivation and each null arm (running read, no view, generic view, unknown card), the result title replacing the relativized path, the path relativization against the workspace, the copy-not-alias of the frozen line array; the resident card in `GenericToolCard`'s fallback and in the keyed `ReadRow` (plus its path link opening the host, its running/error/stopped states, and its `read`-key registration); and the panel's Output section rendering the read card at full height while keeping the JSON Input section, with the running-read placeholder and non-read flattened-pre arms. That file sits on the coverage `exclude` list (`ui-tool/src/*`), so it is written against no gate pressure. The fixture (`packages/client/connection/src/client/fixture.ts`) gains turn 66, a `read` call whose result view is a windowed read (lines starting at file line 41, `totalLines` 180, a `ts` hint), so the built-boot snapshot and a live `?fixture` server show the read card with its gutter numbers, highlighting, and count note. It is named `read` to exercise the keyed `ReadRow`. The turn 64 `run_code` sample's nested read sub-dispatches do not exercise the render-site fallback read card: `session.ts` folds them with `resultView: null`, so they cover only the fallback row's generic row shape, not a read card inside it; the fallback-row read card is pinned by `read-card.spec.tsx`'s `web_fetch` case. Turn 66 is ordered before the todo turn (now 67) for the same reason the terminal sample is: the standing plan retires at the next `turn/start`. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md index 2678d50e85..294821ec9f 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md @@ -40,9 +40,9 @@ Web 聊天里的读取行现在常驻承载文件内容,是相对纯摘要行 ## Testing -`packages/client/ui-primitives/tests/read-block.spec.tsx` 固定 primitive 与 token 路径:`highlightLines` 的逐行 css-variables 运行、它对尾部终止行的丢弃与真正空白末行的情形、它对未知/缺省语言返回 `undefined`、以及它的 lazy 路径(lazy 语法首次触碰返回纯文本,import 注册且订阅者触发后再高亮);还有 `ReadBlock` 的带行号行保留文件自身编号、高亮与纯文本两条内容分支、横幅(标签、语言、仅当读取是窗口时的计数提示)、头/尾高度上限及其 `aria-expanded` 切换、复制控件在接受与拒绝两条剪贴板路径上写入窗口原始文本、以及空窗口分支隐藏复制控件。`code-block.spec.tsx` 覆盖 `highlightToHtml`,含它对每种读取卡片语法的 lazy 路径(每个动态 import thunk 各触碰一次)。`ReadBlock.tsx`、`highlight.ts`(及 `CodeBlock.tsx`)在这两个 spec 上均保持每文件 100% 覆盖。 +`packages/client/ui-primitives/tests/read-block.client.spec.tsx` 固定 primitive 与 token 路径:`highlightLines` 的逐行 css-variables 运行、它对尾部终止行的丢弃与真正空白末行的情形、它对未知/缺省语言返回 `undefined`、以及它的 lazy 路径(lazy 语法首次触碰返回纯文本,import 注册且订阅者触发后再高亮);还有 `ReadBlock` 的带行号行保留文件自身编号、高亮与纯文本两条内容分支、横幅(标签、语言、仅当读取是窗口时的计数提示)、头/尾高度上限及其 `aria-expanded` 切换、复制控件在接受与拒绝两条剪贴板路径上写入窗口原始文本、以及空窗口分支隐藏复制控件。`code-block.spec.tsx` 覆盖 `highlightToHtml`,含它对每种读取卡片语法的 lazy 路径(每个动态 import thunk 各触碰一次)。`ReadBlock.tsx`、`highlight.ts`(及 `CodeBlock.tsx`)在这两个 spec 上均保持每文件 100% 覆盖。 -`packages/client/ui-tool/tests/read-card.spec.tsx` 固定每个渲染点的接线:`readCardModel` 的派生与每条 null 分支(运行中读取、无视图、通用视图、未知卡片)、结果标题替换化简后的路径、路径相对工作区的化简、冻结行数组的复制而非别名;`GenericToolCard` 回退中与 keyed `ReadRow` 中的常驻卡片(外加其路径链接打开宿主、其 running/error/stopped 状态、以及其 `read` 键注册);还有面板 Output 区段以全高渲染读取卡片同时保留 JSON Input 区段,含运行中读取占位与非读取摊平 pre 两条分支。该文件位于覆盖 `exclude` 列表(`ui-tool/src/*`),因此不承受门槛压力。 +`packages/client/ui-tool/tests/read-card.client.spec.tsx` 固定每个渲染点的接线:`readCardModel` 的派生与每条 null 分支(运行中读取、无视图、通用视图、未知卡片)、结果标题替换化简后的路径、路径相对工作区的化简、冻结行数组的复制而非别名;`GenericToolCard` 回退中与 keyed `ReadRow` 中的常驻卡片(外加其路径链接打开宿主、其 running/error/stopped 状态、以及其 `read` 键注册);还有面板 Output 区段以全高渲染读取卡片同时保留 JSON Input 区段,含运行中读取占位与非读取摊平 pre 两条分支。该文件位于覆盖 `exclude` 列表(`ui-tool/src/*`),因此不承受门槛压力。 fixture(`packages/client/connection/src/client/fixture.ts`)增加 turn 66,一次 `read` 调用,其结果视图是窗口读取(行号从文件行 41 起、`totalLines` 180、`ts` 提示),使内置启动快照和实时 `?fixture` 服务器展示带行号、高亮和计数提示的读取卡片。它命名为 `read` 以驱动 keyed `ReadRow`。turn 64 的 `run_code` 样例中的嵌套读取子派发并不驱动渲染点回退读取卡片:`session.ts` 把它们折叠为 `resultView: null`,因此它们只覆盖回退行的通用行形状,而非回退行内的读取卡片;回退行读取卡片由 `read-card.spec.tsx` 的 `web_fetch` 用例钉住。turn 66 排在 todo turn(现为 67)之前,与终端样例同因:常驻计划在下一次 `turn/start` 退场。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml index 37debadbe3..03a676161c 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md -2026-07-30-web-result-card-frontend.md: c048c4f93f72e4a1da5c6426497e9274d65ac266 -2026-07-30-web-result-card-frontend.zh.md: a7090818dc023f857e01e43773adccfba585cc55 +2026-07-30-web-result-card-frontend.md: 6a00e0820af2c4010554df444a7585fb226294bf +2026-07-30-web-result-card-frontend.zh.md: 727466dbbde1a7d06673713f635e16769eda00e0 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md index c048c4f93f..6a00e0820a 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md @@ -36,9 +36,9 @@ The whole-row collapse/expand interaction shared by every resident card (termina ## Testing -`packages/client/ui-primitives/tests/web-block.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the full source list rendering inside one scroll container with no expand control and `
  • ` numbering every source contiguously from 1. +`packages/client/ui-primitives/tests/web-block.client.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the full source list rendering inside one scroll container with no expand control and `
  • ` numbering every source contiguously from 1. -`packages/client/ui-tool/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring boundary: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-tool/src/*`), so a coverage run measures none of it. +`packages/client/ui-tool/tests/web-card.client.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring boundary: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-tool/src/*`), so a coverage run measures none of it. The fixture (`packages/client/connection/src/client/fixture.ts`) adds turns 66 (`web_search`) and 67 (`web_fetch`), authored inline because the client-side fixture cannot import the web tool: turn 66's result view carries an answer and three sources exercising the citation list (a titled source with a snippet and date, a source with no title so its hostname labels the link, and a source with a date but no snippet) with the capped indicator on; turn 67's carries the fetched URL and a 200 status. Both keep a generic pending call view and add the `web` card only at result time, matching the contract's result-only web shape, and are named after the real tools so they hit the keyed `WebRow`. They are ordered before the todo turn (renumbered to 68) for the same reason the terminal turn is: the standing plan retires at the next `turn/start`, so a turn appended after it would empty the dock's plan strip. This drives the built-boot snapshot and a live `?fixture` server. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md index a7090818dc..727466dbbd 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md @@ -36,9 +36,9 @@ Status: implemented ## Testing -`packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及完整 source 列表渲染在单个滚动容器内、无展开控件、`
  • ` 从 1 起为每条 source 连续编号。 +`packages/client/ui-primitives/tests/web-block.client.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及完整 source 列表渲染在单个滚动容器内、无展开控件、`
  • ` 从 1 起为每条 source 连续编号。 -`packages/client/ui-tool/tests/web-card.spec.tsx` 在每个接线边界镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-tool/src/*`),因此覆盖率运行不度量它。 +`packages/client/ui-tool/tests/web-card.client.spec.tsx` 在每个接线边界镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-tool/src/*`),因此覆盖率运行不度量它。 fixture(`packages/client/connection/src/client/fixture.ts`)添加 turn 66(`web_search`)与 67(`web_fetch`),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 `web` 卡片,匹配约定的 result-only web 形状,且以真实工具命名,使其命中键控 `WebRow`。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:待定计划在下一个 `turn/start` 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` 服务。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml index 79bb4d0fb4..a818d7c294 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-search-card.md -2026-07-30-web-search-card.md: 23c89585770151551526223454e98d18716d1a44 -2026-07-30-web-search-card.zh.md: f92bd9f9231d947d4c70e283b462294c030e3f33 +2026-07-30-web-search-card.md: 930a705fc227d7961e9cb1a51b0b04774a62c6a4 +2026-07-30-web-search-card.zh.md: 83904c8f583af6a0ef3b24bbca2336c457644f6c diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md index 23c8958577..930a705fc2 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md @@ -54,9 +54,9 @@ Three sites consume the derivation, mirroring the terminal card's placement exac ## Testing -`packages/client/ui-primitives/tests/search-block.spec.tsx` pins the component at per-file 100%: both kinds, the folded pre-cap total in the summary, the empty arm, per-file collapse/re-expand without touching neighbours, a file header counting as one capped row alongside its matches, the tail slice restoring its owning file header when the cut falls mid-file, the head/tail cap and its expand control across both shapes and the no-tail and default-cap edges, and the copy control writing the whole structured result on the accepted and refused clipboard paths. +`packages/client/ui-primitives/tests/search-block.client.spec.tsx` pins the component at per-file 100%: both kinds, the folded pre-cap total in the summary, the empty arm, per-file collapse/re-expand without touching neighbours, a file header counting as one capped row alongside its matches, the tail slice restoring its owning file header when the cut falls mid-file, the head/tail cap and its expand control across both shapes and the no-tail and default-cap edges, and the copy control writing the whole structured result on the accepted and refused clipboard paths. -`packages/client/ui-tool/tests/search-card.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, the recovery text surfaced only when capped, each null arm (running, no views, generic, terminal, unknown card, an uncompiled `kind`, and a known kind with a missing/malformed shape); the chat row's expand-gated matches and paths bodies through `GenericToolCard` (with the recovery footer) against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its recovery footer, its fallback body for both an errored search and a settled cardless result, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds (with the recovery footer) against the non-search flattened form. `packages/client/ui-tool/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` (three files, twelve rows over the row cap, `truncated` with a spill-recovery footer, so it exercises the head/tail cap and the recovery footer in the assembled snapshot) and a `glob` turn emitting `kind: 'paths'`, both driving the built-boot snapshot and the live `?fixture` server. `apps/web/tests/search-card.snapshot.ts` is the assembled-output check the repo contract asks for: it boots the real built `client.js` bundles through the keyless fixture transport, opens the fixture session, and pins the grep card's assembled shape — kind, truncation summary, the head/tail slice, and its expand control — under `apps/web/tests/snapshots/search-card/`, so a broken SearchRow registration or a dropped card fails a golden the built-boot smoke (boot-only by contract) cannot. +`packages/client/ui-tool/tests/search-card.client.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, the recovery text surfaced only when capped, each null arm (running, no views, generic, terminal, unknown card, an uncompiled `kind`, and a known kind with a missing/malformed shape); the chat row's expand-gated matches and paths bodies through `GenericToolCard` (with the recovery footer) against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its recovery footer, its fallback body for both an errored search and a settled cardless result, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds (with the recovery footer) against the non-search flattened form. `packages/client/ui-tool/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` (three files, twelve rows over the row cap, `truncated` with a spill-recovery footer, so it exercises the head/tail cap and the recovery footer in the assembled snapshot) and a `glob` turn emitting `kind: 'paths'`, both driving the built-boot snapshot and the live `?fixture` server. `apps/web/tests/search-card.snapshot.ts` is the assembled-output check the repo contract asks for: it boots the real built `client.js` bundles through the keyless fixture transport, opens the fixture session, and pins the grep card's assembled shape — kind, truncation summary, the head/tail slice, and its expand control — under `apps/web/tests/snapshots/search-card/`, so a broken SearchRow registration or a dropped card fails a golden the built-boot smoke (boot-only by contract) cannot. ## Related diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md index f92bd9f923..83904c8f58 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md @@ -54,9 +54,9 @@ Status: implemented ## Testing -`packages/client/ui-primitives/tests/search-block.spec.tsx` 以 per-file 100% 覆盖固定组件:两种 kind、折入摘要的截断前总数、空结果分支、逐文件折叠/再展开且不影响邻居、一个文件头与其匹配一起计为一个被截断行、切口落在文件中间时尾部切片恢复其所属文件头、跨两种形态的头/尾上限及其展开控件(含无尾与默认上限的边界),以及复制控件在接受与拒绝的剪贴板路径上写入整个结构化结果。 +`packages/client/ui-primitives/tests/search-block.client.spec.tsx` 以 per-file 100% 覆盖固定组件:两种 kind、折入摘要的截断前总数、空结果分支、逐文件折叠/再展开且不影响邻居、一个文件头与其匹配一起计为一个被截断行、切口落在文件中间时尾部切片恢复其所属文件头、跨两种形态的头/尾上限及其展开控件(含无尾与默认上限的边界),以及复制控件在接受与拒绝的剪贴板路径上写入整个结构化结果。 -`packages/client/ui-tool/tests/search-card.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题、仅在截断时暴露的恢复文本,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片、本版本无法编译的 `kind`、以及一个形态缺失/错误的已知 kind);通过 `GenericToolCard` 的展开门控 matches 与 paths body(含恢复脚注),对照非搜索的 args-JSON body;`SearchRow` 对两种 kind 的常驻卡片、它的恢复脚注、它对出错搜索与已结算无卡片结果两者的 fallback body、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 `grep` 与 `glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind(含恢复脚注),对照非搜索的压平形态。`packages/client/ui-tool/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'` 的 `grep` turn(三个文件、十二行超过行内上限、`truncated` 且带溢出恢复脚注,因此在组装快照里同时演练头/尾上限与恢复脚注)与一个发出 `kind: 'paths'` 的 `glob` turn,两者都驱动 built-boot snapshot 与实时 `?fixture` 服务。`apps/web/tests/search-card.snapshot.ts` 是仓库约定要求的组装输出检查:它通过 keyless fixture 传输启动真实构建的 `client.js` bundle,打开 fixture 会话,并把 grep 卡片的组装形态——kind、截断摘要、头/尾切片及其展开控件——固定在 `apps/web/tests/snapshots/search-card/` 下,因此一个损坏的 SearchRow 注册或被丢弃的卡片会让一个 golden 失败,而 built-boot smoke(按约定只测启动)无法捕获它。 +`packages/client/ui-tool/tests/search-card.client.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题、仅在截断时暴露的恢复文本,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片、本版本无法编译的 `kind`、以及一个形态缺失/错误的已知 kind);通过 `GenericToolCard` 的展开门控 matches 与 paths body(含恢复脚注),对照非搜索的 args-JSON body;`SearchRow` 对两种 kind 的常驻卡片、它的恢复脚注、它对出错搜索与已结算无卡片结果两者的 fallback body、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 `grep` 与 `glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind(含恢复脚注),对照非搜索的压平形态。`packages/client/ui-tool/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'` 的 `grep` turn(三个文件、十二行超过行内上限、`truncated` 且带溢出恢复脚注,因此在组装快照里同时演练头/尾上限与恢复脚注)与一个发出 `kind: 'paths'` 的 `glob` turn,两者都驱动 built-boot snapshot 与实时 `?fixture` 服务。`apps/web/tests/search-card.snapshot.ts` 是仓库约定要求的组装输出检查:它通过 keyless fixture 传输启动真实构建的 `client.js` bundle,打开 fixture 会话,并把 grep 卡片的组装形态——kind、截断摘要、头/尾切片及其展开控件——固定在 `apps/web/tests/snapshots/search-card/` 下,因此一个损坏的 SearchRow 注册或被丢弃的卡片会让一个 golden 失败,而 built-boot smoke(按约定只测启动)无法捕获它。 ## Related diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml index 24219d9ff0..88b9f415d4 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: 4e681b32088954d870df86898e26fe2cae669f14 -2026-08-01-windows-pwsh-default.zh.md: a9d600f8a8e47db49c3733f33091e667e341c6a7 +2026-08-01-windows-pwsh-default.md: c66e289c24d6024b1df53cd60f25c27d46fafc5a +2026-08-01-windows-pwsh-default.zh.md: b2ad45ec96d546d01436a383ed7d8884b978aa31 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md index 4e681b3208..c66e289c24 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -12,9 +12,8 @@ The harness's shipped execution profile is bash-first on every platform. Windows Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, one-shot tasks) get the PowerShell stack by default; POSIX hosts are unchanged. -- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`. It disables the POSIX-only `bash-sandbox`/`tool-bash` rows and inserts `pwsh-sandbox`/`tool-pwsh`. The later [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) filled the win32 runner chain and superseded this note's original unconfined roster: `sandbox`, `sandbox-policy`, `fs-sandbox`, `permission`/`ui-permission`, and `approval` now stay enabled exactly as on POSIX, while the ACL backend truthfully reports its Everyone and hard-link gaps as partial enforcement. -- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack re-enables the bash rows and disables both pwsh rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. -- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` declare `dsh-pwsh-sandbox`/`dsh-tool-pwsh`; the executor's dependency chain supplies `dsh-pwsh-local`, and the base bundle lists every row plugin as a dependency by house style. +- **The base patch gates both shell stacks on its own rows** (the [loader `disabled` interpolation](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) note records the mechanism and the platform-layer fold): `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'` (bash has no Windows runner), and their twins `pwsh-sandbox`/`tool-pwsh` mount only on win32 with the inverted expression — one shared patch file, exactly one shell stack per host. The confined pwsh stack runs over the ACL restricted-token runner, and the permission surface stays exactly as on POSIX (the [Windows ACL restricted-token sandbox](2026-08-08-windows-acl-restricted-token-sandbox.md) note owns that roster). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack or an unconfined pwsh executor overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. The separate `windows.cordis.patch.yml` layer and the launcher's `apps/cli/src/windows-shell.ts` injection are deleted; the layer existed only because entry metadata was static. +- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the pwsh rows. `apps/cli` and `dsh-base` declare `dsh-pwsh-sandbox`/`dsh-tool-pwsh`, and the executor's dependency chain supplies `dsh-pwsh-local`; the base bundle lists every row plugin as a dependency by house style. The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior. @@ -32,13 +31,13 @@ The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches ba ## Consequences -- A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled). +- A Windows host running a shipped `dsh` surface gets the confined `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there. On the Web surface the shell TOOL rows come from the session's preset (the [loader `disabled` interpolation](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) note owns the one-plane mechanism): each shipped preset declares `tool-pwsh` gated by `process.platform !== 'win32'` and its `tool-bash` twin by the inverted expression, so the preset layer exposes exactly one shell tool per host. - Windows commands and fs operations share the sandbox policy, permission switcher, and approval service. The ACL runner confines writes but reports `enforcement: 'partial'`; explicit `danger-full-access` remains the approved bypass rather than the platform default. -- POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows. -- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — disabling `pwsh-sandbox`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. +- POSIX hosts mount the bash stack as before; the pwsh rows sit disabled in their composition, because the one shared patch file lists both stacks and each row gates itself. +- A Windows host that prefers the bash stack (e.g. with WSL/Git-Bash on PATH) overrides the shipped rows through its profile or home `cordis.patch.yml` — disabling `pwsh-sandbox`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. ## Verification -- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, custom-profile skip, missing-patch failure, cold-start dependency closure, and real composed roster; `packages/bundle/base/tests/base.spec.ts` pins that the Windows layer disables only the bash rows, inserts the confined pwsh rows, and leaves sandbox, permission, fs, and approval ownership untouched. -- Keyless: a win32 `dsh --profile --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged. +- Unit: `apps/cli/tests/windows-shell.spec.ts` composes the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the app installation) through the boot's patch algorithm and pins the effective per-platform roster — the win32 pwsh roster, the POSIX bash roster, and the base-only profile — plus the preset-level shell-tool gates (`tool-bash`/`tool-pwsh`) and the cold-start resolution closure; `packages/bundle/base/tests/base.spec.ts` pins the four shell rows' symmetric `!!js` platform gates and that no separate platform patch ships. +- Keyless: a `dsh --profile --dump-config` shows both stacks in the one shared patch layer, with each row's own `disabled` expression deciding the roster at mount. - The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes). diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md index a9d600f8a8..b2ad45ec96 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -12,9 +12,8 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 启动交付 profile(`dsh web`、`dsh --profile headless`、一次性任务)的 Windows 主机默认获得 PowerShell 栈;POSIX 主机不变。 -- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml)。它禁用仅限 POSIX 的 `bash-sandbox`/`tool-bash` 行,并插入 `pwsh-sandbox`/`tool-pwsh`。后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.md)填充了 win32 runner 链,并取代了本笔记最初的不限权清单:`sandbox`、`sandbox-policy`、`fs-sandbox`、`permission`/`ui-permission` 与 `approval` 均与 POSIX 上一样保持启用,而 ACL 后端则如实把 Everyone 与硬链接缺口报告为部分强制执行。 -- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行,并禁用两个 pwsh 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 -- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 声明 `dsh-pwsh-sandbox`/`dsh-tool-pwsh`;执行器的依赖链提供 `dsh-pwsh-local`,按仓库惯例,base bundle 把每个行插件都列为依赖。 +- **base patch 在自身行上按平台门控两个 shell 栈**([loader `disabled` 插值](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) note 记录了该机制与平台层折叠):`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`(bash 没有 Windows runner),它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载——同一份 patch 文件,每个宿主恰好挂载一个 shell 栈。受限 pwsh 栈运行在 ACL 受限令牌 runner 之上,权限面与 POSIX 完全一致([Windows ACL 受限令牌沙箱](2026-08-08-windows-acl-restricted-token-sandbox.md) note 拥有该清单)。覆盖交付默认是组合决策:偏好 bash 栈或不限权 pwsh 执行器的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。独立的 `windows.cordis.patch.yml` 层与启动器的 `apps/cli/src/windows-shell.ts` 注入已删除;该层只因条目元数据是静态的而存在。 +- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到 pwsh 行。`apps/cli` 与 `dsh-base` 声明 `dsh-pwsh-sandbox`/`dsh-tool-pwsh`,执行器的依赖链提供 `dsh-pwsh-local`;按仓库惯例,base bundle 把每个行插件都列为依赖。 pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。 @@ -32,13 +31,13 @@ pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-b ## 后果 -- 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。 +- 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得受限 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`。在 Web 表面,shell 工具行来自会话的预设([loader `disabled` 插值](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) note 拥有 one-plane 机制):每个 shipped 预设声明 `tool-pwsh`(以 `process.platform !== 'win32'` 门控)及其孪生行 `tool-bash`(取反表达式),因此预设层每台宿主恰好暴露一个 shell 工具。 - Windows 命令与 fs 操作共用沙箱策略、权限切换器和 approval 服务。ACL runner 限制写入,但报告 `enforcement: 'partial'`;显式的 `danger-full-access` 仍是获准的绕过方式,而非平台默认。 -- POSIX 主机不变:平台层永不生效,bash 栈仍是通用 `cordis.patch.yml` 的行。 -- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。 +- POSIX 主机如常挂载 bash 栈;pwsh 行以其自身的门控表达式处于禁用状态——同一份共享 patch 文件列出两个栈,每个行自己决定挂载。 +- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付行——禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。 ## 验证 -- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过、缺少 patch 时失败、冷启动依赖闭包和真实组合清单;`packages/bundle/base/tests/base.spec.ts` 固定 Windows 层仅禁用 bash 行、插入受限的 pwsh 行,并且不改变沙箱、权限、fs 与审批的归属。 -- Keyless:win32 上的 `dsh --profile --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行;POSIX 转储(CI Linux)不变。 +- 单元:`apps/cli/tests/windows-shell.spec.ts` 通过启动所用的 patch 算法组合真实交付的 bundle 层(从应用安装解析的 dsh-base + dsh-web-app),固定每个平台的有效清单——win32 pwsh 清单、POSIX bash 清单与 base-only profile——外加预设级 shell 工具门控(`tool-bash`/`tool-pwsh`)与冷启动解析闭包;`packages/bundle/base/tests/base.spec.ts` 固定四个 shell 行的对称 `!!js` 平台门控,并断言不再交付独立的平台 patch。 +- Keyless:`dsh --profile --dump-config` 在同一份共享 patch 层中显示两个栈,每个行以自己的 `disabled` 表达式在挂载时决定清单。 - 真实组合冒烟在 win32 上启动 web profile,pwsh 栈挂载成功(即本笔记描述的确切清单)。 diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml index 484df46a31..d0cb4e3cd9 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md -2026-08-02-web-thinking-tail-scroll.md: 18e94b2e0075bf7099b9b48177de2e274896942a -2026-08-02-web-thinking-tail-scroll.zh.md: 7bafeb0f64f05a41c502aba8f479e391c8b395f0 +2026-08-02-web-thinking-tail-scroll.md: b9aa47a01b4d8e22baddac1b03f52b3524250941 +2026-08-02-web-thinking-tail-scroll.zh.md: 34a0a39f88a9511a1c93d0b21b3ac13903848e98 diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md index 18e94b2e00..b9aa47a01b 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md @@ -28,4 +28,4 @@ The collapsed row now communicates provider cadence through content motion as we ## Testing -`packages/client/ui-conversation/tests/reasoning-row.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable. +`packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable. diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md index 7bafeb0f64..34a0a39f88 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md @@ -28,4 +28,4 @@ Web Think 行在结算与流式 block 中都把 reasoning 首行渲染成折叠 ## 测试 -`packages/client/ui-conversation/tests/reasoning-row.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的 keyless 完整 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要约定仍然稳定。 +`packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的 keyless 完整 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要约定仍然稳定。 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml index 49a14093ef..b77b574232 100644 --- a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md -2026-08-03-web-search-source-scroll.md: f6b118b91d3d2102e11fa7b6d068621e961a7674 -2026-08-03-web-search-source-scroll.zh.md: 58eae594bd18c58f7fb80322f6902abdbba49ba7 +2026-08-03-web-search-source-scroll.md: 3402f519e1974b99e1f5a87dcd53b4d94a1a8374 +2026-08-03-web-search-source-scroll.zh.md: 34ee46c6837cc81740d3f74df1f2c6eae689adf2 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md index f6b118b91d..3402f519e1 100644 --- a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md +++ b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md @@ -36,7 +36,7 @@ Every source the tool returned is always in the DOM, so no source the view carri ## Testing -`packages/client/ui-primitives/tests/web-block.spec.tsx` drops the collapse cases (head/tail slice, expand-on-click, collapsed-tail numbering, expander-out-of-numbering, head-alone, default cap) and adds: a 30-source card renders all 30 `
  • ` with no `[aria-expanded]` and no ` + + ) : null} + {state.status === 'ready' ? ( +
    + +
    +

    {t('catalog')}

    + {filteredEntries.length} +
    + {state.snapshot.entries.length === 0 ?

    {t('empty')}

    : null} + {state.snapshot.entries.length > 0 && filteredEntries.length === 0 + ?

    {t('emptySearch')}

    + : null} + {filteredEntries.length > 0 ? ( +
      + {filteredEntries.map((entry) => { + const status = phaseLabel(entry.fiberPhase, t) + const title = moduleShortName(entry.moduleName) + const open = expanded === entry.entryId + const detailId = `${titleId}-details-${encodeURIComponent(entry.entryId)}` + return ( +
    • + + {open ? ( +
      + {entry.entryId} +
      +
      +
      {t('configuration')}
      +
      {t(entry.enabled ? 'enabledTag' : 'disabledTag')}
      +
      +
      +
      {t('cordis')}
      +
      {status}
      +
      +
      +
      + ) : null} +
    • + ) + })} +
    + ) : null} +
    + ) : null} + + ) +} diff --git a/packages/client/ui-plugins/src/client/index.ts b/packages/client/ui-plugins/src/client/index.ts new file mode 100644 index 0000000000..ccf12ab989 --- /dev/null +++ b/packages/client/ui-plugins/src/client/index.ts @@ -0,0 +1,47 @@ +/** Read-only Host plugin inventory registered into Web Settings. */ + +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import { PluginSettingsSection, type PluginSettingsSectionInjected } from './PluginSettingsSection.tsx' +import { en, zh, type PluginsKey } from './locales.ts' + +export type { PluginSettingsSectionInjected, PluginSettingsSectionProps } from './PluginSettingsSection.tsx' +export type { PluginsKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Read-only Host plugin inventory copy. */ + 'settings.plugins': PluginsKey + } +} + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'settings.plugins' + +/** Services required by the Settings registration and generated Remote face. */ +export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory'] + +/** Register the lazy plugin inventory page below Models in Settings. */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugins: dictionaries') + + const t = ctx.locale.bind(NS) + const list: PluginSettingsSectionInjected['list'] = async () => { + const result = await ctx.remote.pluginInventory.list() + if (!result.ok) { + throw new Error(`pluginInventory.list failed: ${result.error.code}: ${result.error.message}`) + } + return result.value + } + const injected = (): PluginSettingsSectionInjected => ({ list }) + + ctx.slots.inject('settings.section', () => ctx.slots.register({ + name: 'settings.section', + id: 'plugin-inventory', + order: 15, + label: () => t('nav'), + locale: NS, + inject: injected, + }, PluginSettingsSection)) +} diff --git a/packages/client/ui-plugins/src/client/locales.ts b/packages/client/ui-plugins/src/client/locales.ts new file mode 100644 index 0000000000..c505296f38 --- /dev/null +++ b/packages/client/ui-plugins/src/client/locales.ts @@ -0,0 +1,50 @@ +/** Copy dictionaries for the plugin inventory Settings section. */ + +/** Simplified Chinese dictionary and key source of truth. */ +export const zh = { + nav: '插件', + title: '插件', + loading: '正在读取插件…', + error: '暂时无法读取插件。', + retry: '重试', + search: '搜索插件', + catalog: '插件列表', + empty: '暂无插件。', + emptySearch: '没有匹配的插件。', + enabledTag: '已启用', + disabledTag: '已停用', + configuration: '配置状态', + cordis: 'Cordis 状态', + unobserved: '未挂载', + pending: '等待依赖', + loadingPhase: '加载中', + active: '已挂载', + failed: '挂载失败', + unloading: '卸载中', +} satisfies Record + +/** Plugin inventory locale key union. */ +export type PluginsKey = keyof typeof zh + +/** English dictionary checked against the Chinese key set. */ +export const en = { + nav: 'Plugins', + title: 'Plugins', + loading: 'Reading plugins…', + error: 'Plugins are temporarily unavailable.', + retry: 'Retry', + search: 'Search plugins', + catalog: 'Plugin list', + empty: 'No plugins are available.', + emptySearch: 'No matching plugins.', + enabledTag: 'Enabled', + disabledTag: 'Disabled', + configuration: 'Configuration', + cordis: 'Cordis status', + unobserved: 'Not mounted', + pending: 'Waiting for dependencies', + loadingPhase: 'Loading', + active: 'Mounted', + failed: 'Mount failed', + unloading: 'Unloading', +} satisfies Record diff --git a/packages/client/ui-plugins/src/css-modules.d.ts b/packages/client/ui-plugins/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-plugins/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-plugins/src/index.ts b/packages/client/ui-plugins/src/index.ts new file mode 100644 index 0000000000..489544a421 --- /dev/null +++ b/packages/client/ui-plugins/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser implementation exported from `./client`. */ + +/** Host plugin body — no host-side behavior for the plugin settings section. */ +export function apply(): void {} diff --git a/packages/client/ui-plugins/src/invariant.ts b/packages/client/ui-plugins/src/invariant.ts new file mode 100644 index 0000000000..2d001d4312 --- /dev/null +++ b/packages/client/ui-plugins/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-client-ui-plugins/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-plugins' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-plugins-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: this package owns a read-only Settings contribution. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx new file mode 100644 index 0000000000..d9d8a43cd8 --- /dev/null +++ b/packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { Context, Service } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup } from '@testing-library/react' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { apply, inject, NS } from '../src/client/index.ts' +import { PluginSettingsSection } from '../src/client/PluginSettingsSection.tsx' +import type { PluginSettingsSectionInjected } from '../src/client/PluginSettingsSection.tsx' + +usePinnedBrowserLanguages('zh-CN') +afterEach(cleanup) + +const EMPTY = { entries: [] } +type ListResult = + | { readonly ok: true; readonly value: typeof EMPTY } + | { readonly ok: false; readonly error: { readonly code: string; readonly message: string } } + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + class RemoteService extends Service { + constructor(serviceCtx: Context) { + super(serviceCtx, 'remote') + } + } + new RemoteService(ctx) + const list = vi.fn<() => Promise>() + .mockResolvedValue({ ok: true, value: EMPTY }) + ctx.provide('remote.pluginInventory', { list }) + return { ctx, slots: ctx.get('slots') as SlotsService, locale, list } +} + +function declare(slots: SlotsService): () => void { + return slots.register({ + name: 'root', + children: { 'settings.section': { kind: 'list', scope: 'root' } }, + } as never, () => null) +} + +describe('ui-plugins browser plugin', () => { + it('declares only the services used by the Settings Remote contribution', () => { + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory']) + }) + + it('registers a localized section without reading the Remote eagerly', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + + const entry = b.slots.entries('settings.section')[0]! + expect(entry.component).toBe(PluginSettingsSection) + expect(entry.options).toMatchObject({ id: 'plugin-inventory', order: 15 }) + expect(entry.locale).toBe(NS) + expect(resolveSlotLabel(entry.options.label)).toBe('插件') + expect(b.list).not.toHaveBeenCalled() + + const injected = (entry.inject as unknown as () => PluginSettingsSectionInjected)() + await expect(injected.list()).resolves.toEqual(EMPTY) + expect(b.list).toHaveBeenCalledOnce() + b.list.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'unavailable' } }) + await expect(injected.list()).rejects.toThrow('pluginInventory.list failed: REMOTE_ERROR: unavailable') + await b.ctx.fiber.dispose() + }) + + it('follows locale and recovers across late declaration and declarer reload', async () => { + const b = await bench() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries('settings.section')).toHaveLength(0) + + const stop = declare(b.slots) + await vi.waitFor(() => { expect(b.slots.entries('settings.section')).toHaveLength(1) }) + b.locale.setLocale('en') + expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Plugins') + + stop() + expect(b.slots.entries('settings.section')).toHaveLength(0) + declare(b.slots) + await vi.waitFor(() => { + expect(b.slots.entries('settings.section')[0]?.component).toBe(PluginSettingsSection) + }) + + await fiber.dispose() + expect(b.slots.entries('settings.section')).toHaveLength(0) + expect(() => b.locale.register(NS, 'zh', {})).not.toThrow() + await b.ctx.fiber.dispose() + }) +}) diff --git a/packages/client/ui-plugins/tests/components.client.spec.tsx b/packages/client/ui-plugins/tests/components.client.spec.tsx new file mode 100644 index 0000000000..9da8a79b0d --- /dev/null +++ b/packages/client/ui-plugins/tests/components.client.spec.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PluginSettingsSection } from '../src/client/PluginSettingsSection.tsx' +import type { + PluginSettingsSectionInjected, + PluginSettingsSectionProps, +} from '../src/client/PluginSettingsSection.tsx' +import { en, type PluginsKey } from '../src/client/locales.ts' + +afterEach(cleanup) + +type Snapshot = Awaited> +const t = ((key: PluginsKey): string => en[key]) as PluginSettingsSectionProps['t'] +const unusedHook = (() => { throw new Error('unused by plugin inventory') }) as never + +function props(list: PluginSettingsSectionInjected['list']): PluginSettingsSectionProps { + return { + close: vi.fn(), + useSessions: unusedHook, + useWorkspaces: unusedHook, + t, + list, + } +} + +const SNAPSHOT = { + entries: [ + { entryId: '8a1b2c3d', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' }, + { entryId: 'pending', moduleName: 'cordis:pending-name', enabled: true, fiberPhase: 'pending' }, + { entryId: 'loading', moduleName: '@fixture/loading-name', enabled: true, fiberPhase: 'loading' }, + { entryId: 'failed', moduleName: '@fixture/failed-name', enabled: true, fiberPhase: 'failed' }, + { entryId: 'unloading', moduleName: '@fixture/unloading-name', enabled: true, fiberPhase: 'unloading' }, + { entryId: 'disabled-entry', moduleName: '@deepseek-ai/dsh-host-directory-picker-native', enabled: false, fiberPhase: null }, + ], +} as unknown as Snapshot + +describe('PluginSettingsSection', () => { + it('renders searchable two-column-card semantics with dots and tags', async () => { + const deferred = Promise.withResolvers() + const list = vi.fn(() => deferred.promise) + const view = render() + expect(screen.getByText(en.loading)).toBeTruthy() + + await act(async () => { deferred.resolve(SNAPSHOT) }) + expect(list).toHaveBeenCalledOnce() + expect(screen.getByRole('searchbox', { name: en.search })).toBeTruthy() + expect(screen.getByRole('heading', { name: en.catalog })).toBeTruthy() + expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('6') + expect(screen.getAllByRole('listitem')).toHaveLength(6) + expect(screen.getAllByText(en.enabledTag)).toHaveLength(5) + expect(screen.getByText(en.disabledTag)).toBeTruthy() + for (const value of [ + 'Mounted', + 'Waiting for dependencies', + 'Loading', + 'Mount failed', + 'Unloading', + 'Not mounted', + ]) { + expect(screen.getByRole('img', { name: value })).toBeTruthy() + } + const active = screen.getByRole('button', { name: 'hmr, Mounted, Enabled' }) + expect(active.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(active) + expect(active.getAttribute('aria-expanded')).toBe('true') + expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('8a1b2c3d') + expect(screen.getByText(en.configuration)).toBeTruthy() + expect(screen.getByText(en.cordis)).toBeTruthy() + fireEvent.click(active) + expect(view.container.querySelector('[data-loader-entry]')).toBeNull() + + fireEvent.click(active) + fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { + target: { value: 'disabled-entry' }, + }) + expect(view.container.querySelector('[data-loader-entry]')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'directory-picker-native, Not mounted, Disabled' })) + expect(screen.getAllByText(en.disabledTag)).toHaveLength(2) + }) + + it('filters by module name or Loader entry id', async () => { + render( SNAPSHOT)} />) + const search = await screen.findByRole('searchbox', { name: en.search }) + + fireEvent.change(search, { target: { value: 'disabled-entry' } }) + expect(screen.getAllByRole('listitem')).toHaveLength(1) + expect(screen.getByText('directory-picker-native')).toBeTruthy() + + fireEvent.change(search, { target: { value: 'cordis-plugin-hmr' } }) + expect(screen.getAllByRole('listitem')).toHaveLength(1) + expect(screen.getByText('hmr')).toBeTruthy() + + fireEvent.change(search, { target: { value: 'not-a-plugin' } }) + expect(screen.queryAllByRole('listitem')).toHaveLength(0) + expect(screen.getByText(en.emptySearch)).toBeTruthy() + }) + + it('shows a generic failure and retries into the empty state', async () => { + const list = vi.fn() + .mockRejectedValueOnce(new Error('private transport detail')) + .mockResolvedValueOnce({ entries: [] }) + render() + + expect((await screen.findByRole('alert')).textContent).toBe(en.error) + expect(screen.queryByText('private transport detail')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.retry })) + await waitFor(() => { expect(list).toHaveBeenCalledTimes(2) }) + expect(await screen.findByText(en.empty)).toBeTruthy() + }) + + it('contains a synchronous Remote failure and ignores a result after unmount', async () => { + const syncFailure = vi.fn(() => { throw new Error('namespace unavailable') }) as PluginSettingsSectionInjected['list'] + const failed = render() + expect((await screen.findByRole('alert')).textContent).toBe(en.error) + failed.unmount() + + const deferred = Promise.withResolvers() + const pending = render( deferred.promise)} />) + pending.unmount() + await act(async () => { deferred.resolve(SNAPSHOT) }) + + const deferredFailure = Promise.withResolvers() + const pendingFailure = render( deferredFailure.promise)} />) + pendingFailure.unmount() + await act(async () => { deferredFailure.reject(new Error('late failure')) }) + }) +}) diff --git a/packages/client/ui-plugins/tests/invariant.client.spec.ts b/packages/client/ui-plugins/tests/invariant.client.spec.ts new file mode 100644 index 0000000000..df4161cb13 --- /dev/null +++ b/packages/client/ui-plugins/tests/invariant.client.spec.ts @@ -0,0 +1,15 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as PluginsInvariant from '../src/invariant.ts' + +describe('ui-plugins invariant companion', () => { + it('registers the empty installer and keeps the node half inert', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(PluginsInvariant).await()).resolves.toBeDefined() + const { apply } = await import('../src/index.ts') + apply() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/client/ui-plugins/tsconfig.json b/packages/client/ui-plugins/tsconfig.json new file mode 100644 index 0000000000..2019585ff7 --- /dev/null +++ b/packages/client/ui-plugins/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-settings" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-plugins/tsdown.config.ts b/packages/client/ui-plugins/tsdown.config.ts new file mode 100644 index 0000000000..a85ab4569f --- /dev/null +++ b/packages/client/ui-plugins/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-plugins', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 20c1584c1a..7bc0c5aced 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -115,6 +115,15 @@ background: var(--dsw-alias-interactive-bg-hover); } +.denseList .item { + min-height: 34px; + padding-block: 5px; +} + +.denseList .label { + padding-block: 4px; +} + .list.compactList, .submenu.compactList { min-width: 164px; diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index ea7e51b478..46c30b8afb 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -62,6 +62,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * @param props.anchor - the trigger element (rendered in place). * @param props.items - selectable rows and optional separators. * @param props.selectedId - row shown as selected. + * @param props.selectedIds - rows shown as selected when a menu contains independent option groups. * @param props.onSelect - row click callback (not called for disabled rows or submenu parents that only open children). * @param props.onClose - invoked on outside click or Escape. * @param props.align - list alignment against the anchor (default 'start'). @@ -74,6 +75,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * both trigger and list for the pointer grace (default false keeps it open * until outside click/Escape/selection). The grace makes the 4px trigger->list * gap and a brief overshoot survivable; coming back cancels the close. + * @param props.dense - reduce vertical row spacing without changing the standard typography or card width. * @param props.compact - use reduced menu typography and spacing. * @param props.getAnchorRect - portal mode only: supply the anchor rect * directly (e.g. from a host-owned trigger button) instead of measuring the @@ -85,18 +87,20 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * by a hairline; they stay visible while the items above scroll. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, footer, className }: { +export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, getAnchorRect, footer, className }: { open: boolean anchor: ReactNode items: readonly MenuEntry[] footer?: readonly MenuEntry[] selectedId?: string | undefined + selectedIds?: readonly string[] | undefined onSelect: (id: string) => void onClose: () => void align?: 'start' | 'end' side?: 'bottom' | 'top' | 'right' portal?: boolean closeOnPointerLeave?: boolean + dense?: boolean compact?: boolean getAnchorRect?: () => DOMRect | null className?: string @@ -204,6 +208,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align } const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 const subOpen = hasSub && openSubmenuId === entry.id + const selected = entry.id === selectedId || selectedIds?.includes(entry.id) === true return (
    {entry.icon}} {entry.label} {/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */} - {entry.id === selectedId && } + {selected && } {subOpen && entry.submenu !== undefined && (
    @@ -260,7 +265,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align const list = open && (
    ( ) +/** ic_ds_globe_outline_14 — meridian globe (harness-only figma extract). */ +export const IconGlobeOutline14 = ({ size = 14, className }: IconProps) => ( + + + +) + /** ic_ds_settings_outline_14 */ export const IconSettingsOutline14 = ({ size = 14, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/tests/ansi.spec.ts b/packages/client/ui-primitives/tests/ansi.client.spec.ts similarity index 100% rename from packages/client/ui-primitives/tests/ansi.spec.ts rename to packages/client/ui-primitives/tests/ansi.client.spec.ts diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/atoms.spec.tsx rename to packages/client/ui-primitives/tests/atoms.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/code-block.spec.tsx b/packages/client/ui-primitives/tests/code-block.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/code-block.spec.tsx rename to packages/client/ui-primitives/tests/code-block.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/diff-block.spec.tsx b/packages/client/ui-primitives/tests/diff-block.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/diff-block.spec.tsx rename to packages/client/ui-primitives/tests/diff-block.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/hover-card.spec.tsx rename to packages/client/ui-primitives/tests/hover-card.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx similarity index 96% rename from packages/client/ui-primitives/tests/icons.spec.tsx rename to packages/client/ui-primitives/tests/icons.client.spec.tsx index f6560a4cc1..41f8caad47 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -16,8 +16,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full icon set (46 deepsuite + 19 figma extracts + three product glyphs outside those sets)', () => { - expect(iconNames.length).toBe(68) + it('exports the full icon set (46 deepsuite + 20 figma extracts + three product glyphs outside those sets)', () => { + expect(iconNames.length).toBe(69) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { diff --git a/packages/client/ui-primitives/tests/invariant.spec.ts b/packages/client/ui-primitives/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-primitives/tests/invariant.spec.ts rename to packages/client/ui-primitives/tests/invariant.client.spec.ts diff --git a/packages/client/ui-primitives/tests/json-tree.spec.tsx b/packages/client/ui-primitives/tests/json-tree.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/json-tree.spec.tsx rename to packages/client/ui-primitives/tests/json-tree.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/markdown-dom-parity.spec.tsx b/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/markdown-dom-parity.spec.tsx rename to packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx b/packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/markdown-incremental.spec.tsx rename to packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/markdown-plain-text.spec.ts b/packages/client/ui-primitives/tests/markdown-plain-text.client.spec.ts similarity index 100% rename from packages/client/ui-primitives/tests/markdown-plain-text.spec.ts rename to packages/client/ui-primitives/tests/markdown-plain-text.client.spec.ts diff --git a/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx b/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/markdown-render-units.spec.tsx rename to packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/markdown.spec.tsx rename to packages/client/ui-primitives/tests/markdown.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/onboarding-surface.spec.tsx b/packages/client/ui-primitives/tests/onboarding-surface.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/onboarding-surface.spec.tsx rename to packages/client/ui-primitives/tests/onboarding-surface.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/read-block.spec.tsx b/packages/client/ui-primitives/tests/read-block.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/read-block.spec.tsx rename to packages/client/ui-primitives/tests/read-block.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/search-block.spec.tsx b/packages/client/ui-primitives/tests/search-block.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/search-block.spec.tsx rename to packages/client/ui-primitives/tests/search-block.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/state-dot.spec.tsx b/packages/client/ui-primitives/tests/state-dot.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/state-dot.spec.tsx rename to packages/client/ui-primitives/tests/state-dot.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/terminal-block.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/terminal-block.spec.tsx rename to packages/client/ui-primitives/tests/terminal-block.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/toast.spec.tsx b/packages/client/ui-primitives/tests/toast.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/toast.spec.tsx rename to packages/client/ui-primitives/tests/toast.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/tooltip.spec.tsx rename to packages/client/ui-primitives/tests/tooltip.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/web-block.spec.tsx b/packages/client/ui-primitives/tests/web-block.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/web-block.spec.tsx rename to packages/client/ui-primitives/tests/web-block.client.spec.tsx diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json index cb74154ff0..9365417bda 100644 --- a/packages/client/ui-question/package.json +++ b/packages/client/ui-question/package.json @@ -53,19 +53,21 @@ "react": "^18.2.0" }, "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", - "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^" + "@types/react": "~18.3.1" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-question/src/client/contract/slots.ts b/packages/client/ui-question/src/client/contract/slots.ts index e8bada18b0..c37351578e 100644 --- a/packages/client/ui-question/src/client/contract/slots.ts +++ b/packages/client/ui-question/src/client/contract/slots.ts @@ -11,7 +11,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots // entry) into every program that sees this contract, so PropsRuntime resolves. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import type { QuestionResponsePayload } from '@deepseek-ai/dsh-client-connection/client' +import type { QuestionResponsePayload } from '@deepseek-ai/dsh-api-remotes/client' /** The pending question carrier the owner dispatches into the composer slot. */ export type QuestionWait = PendingWait<'question'> diff --git a/packages/client/ui-question/tests/browser-plugin.spec.ts b/packages/client/ui-question/tests/browser-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-question/tests/browser-plugin.spec.ts rename to packages/client/ui-question/tests/browser-plugin.client.spec.ts diff --git a/packages/client/ui-question/tests/node-plugin.spec.ts b/packages/client/ui-question/tests/node-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-question/tests/node-plugin.spec.ts rename to packages/client/ui-question/tests/node-plugin.client.spec.ts diff --git a/packages/client/ui-question/tests/plan-review-panel.spec.tsx b/packages/client/ui-question/tests/plan-review-panel.client.spec.tsx similarity index 99% rename from packages/client/ui-question/tests/plan-review-panel.spec.tsx rename to packages/client/ui-question/tests/plan-review-panel.client.spec.tsx index be22597a04..2abbe85844 100644 --- a/packages/client/ui-question/tests/plan-review-panel.spec.tsx +++ b/packages/client/ui-question/tests/plan-review-panel.client.spec.tsx @@ -9,7 +9,7 @@ import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcReceipt } from '@deepseek-ai/dsh-api-remotes/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { planReviewOf, type QuestionComposerProps, type QuestionWait } from '../src/client/contract/slots.ts' diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.client.spec.tsx similarity index 99% rename from packages/client/ui-question/tests/question-composer.spec.tsx rename to packages/client/ui-question/tests/question-composer.client.spec.tsx index 91475f43e4..9f3fce1ab4 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.client.spec.tsx @@ -5,7 +5,7 @@ import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcReceipt } from '@deepseek-ai/dsh-api-remotes/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts' diff --git a/packages/client/ui-question/tsconfig.json b/packages/client/ui-question/tsconfig.json index a6400f7c84..3913ac0e9c 100644 --- a/packages/client/ui-question/tsconfig.json +++ b/packages/client/ui-question/tsconfig.json @@ -9,10 +9,10 @@ ], "references": [ { - "path": "../../../vendor/cordis" + "path": "../../api/remotes/tsconfig.client.json" }, { - "path": "../connection" + "path": "../../../vendor/cordis" }, { "path": "../locale" diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css index f1bd87e9af..060e8d115b 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css @@ -1,19 +1,20 @@ /* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar - foot trigger row + centered 1080x700 modal panel. The trigger reproduces - the former sidebar foot geometry (49px wide row / 36px rail circle); the + foot trigger row + centered 1080x700 modal panel. The trigger uses the + sidebar's 34px compact row / 36px rail circle rhythm; the panel is a two-column layout — 188px nav rail + content column with a 54px header and the 24px-padded options area. */ -/* Trigger row (former sidebar foot, figma 133:7668): 49px hover pill. */ +/* Trigger row: match the other wide sidebar controls' compact vertical rhythm. */ .trigger { flex: none; display: flex; align-items: center; gap: 8px; - width: 100%; - height: 49px; - margin: 8px 0 0; - padding: 0 2px 0 6px; + width: calc(100% + 8px); + height: 34px; + margin: 4px -4px 4px; + padding: 6px 2px 6px 10px; + box-sizing: border-box; border: none; border-radius: 12px; background: transparent; @@ -22,6 +23,7 @@ color: var(--dsw-alias-label-primary); font-family: inherit; font-size: 14px; + line-height: 22px; } .trigger:hover { @@ -32,7 +34,7 @@ .trigger.rail { width: 36px; height: 36px; - margin: 18px 0 10px; + margin: 8px 0 10px; justify-content: center; gap: 0; padding: 0; diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 1c207efc76..3a868dc3b4 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -8,7 +8,7 @@ * Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: the settings slot declarations plus the ctx.settingsScope Context diff --git a/packages/client/ui-settings-general/src/client/settings-document-store.ts b/packages/client/ui-settings-general/src/client/settings-document-store.ts index eb1d9590b9..dde604d5ae 100644 --- a/packages/client/ui-settings-general/src/client/settings-document-store.ts +++ b/packages/client/ui-settings-general/src/client/settings-document-store.ts @@ -1,6 +1,6 @@ /** State owner for the optional local settings-document action. */ -import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' /** Browser state of the Host-owned settings document. */ diff --git a/packages/client/ui-settings-general/src/client/welcome-store.ts b/packages/client/ui-settings-general/src/client/welcome-store.ts index c95c9e46d8..48f96bcb94 100644 --- a/packages/client/ui-settings-general/src/client/welcome-store.ts +++ b/packages/client/ui-settings-general/src/client/welcome-store.ts @@ -1,6 +1,6 @@ /** Welcome-notice state, durable when the browser may use Host settings. */ -import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts similarity index 100% rename from packages/client/ui-settings-general/tests/apply.spec.ts rename to packages/client/ui-settings-general/tests/apply.client.spec.ts diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx similarity index 100% rename from packages/client/ui-settings-general/tests/components.spec.tsx rename to packages/client/ui-settings-general/tests/components.client.spec.tsx diff --git a/packages/client/ui-settings-general/tests/host.spec.ts b/packages/client/ui-settings-general/tests/host.client.spec.ts similarity index 100% rename from packages/client/ui-settings-general/tests/host.spec.ts rename to packages/client/ui-settings-general/tests/host.client.spec.ts diff --git a/packages/client/ui-settings-general/tests/invariant.spec.ts b/packages/client/ui-settings-general/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-settings-general/tests/invariant.spec.ts rename to packages/client/ui-settings-general/tests/invariant.client.spec.ts diff --git a/packages/client/ui-settings-general/tests/settings-document-store.spec.ts b/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts similarity index 98% rename from packages/client/ui-settings-general/tests/settings-document-store.spec.ts rename to packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts index 9be3cf3252..b514da85da 100644 --- a/packages/client/ui-settings-general/tests/settings-document-store.spec.ts +++ b/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' function response(hasDocument = false): RpcResponse<{ diff --git a/packages/client/ui-settings-general/tests/settings-root.spec.tsx b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx similarity index 100% rename from packages/client/ui-settings-general/tests/settings-root.spec.tsx rename to packages/client/ui-settings-general/tests/settings-root.client.spec.tsx diff --git a/packages/client/ui-settings-general/tests/shell.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts similarity index 100% rename from packages/client/ui-settings-general/tests/shell.spec.ts rename to packages/client/ui-settings-general/tests/shell.client.spec.ts diff --git a/packages/client/ui-settings-general/tests/welcome-notice.spec.tsx b/packages/client/ui-settings-general/tests/welcome-notice.client.spec.tsx similarity index 100% rename from packages/client/ui-settings-general/tests/welcome-notice.spec.tsx rename to packages/client/ui-settings-general/tests/welcome-notice.client.spec.tsx diff --git a/packages/client/ui-settings-general/tests/welcome-store.spec.ts b/packages/client/ui-settings-general/tests/welcome-store.client.spec.ts similarity index 99% rename from packages/client/ui-settings-general/tests/welcome-store.spec.ts rename to packages/client/ui-settings-general/tests/welcome-store.client.spec.ts index 04f2608763..bfa5e16999 100644 --- a/packages/client/ui-settings-general/tests/welcome-store.spec.ts +++ b/packages/client/ui-settings-general/tests/welcome-store.client.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { WelcomeNoticeStore } from '../src/client/welcome-store.ts' import { refreshWelcomeIfLoaded } from '../src/client/welcome-store.ts' import { diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json index a4535df785..3f8200e137 100644 --- a/packages/client/ui-settings-general/tsconfig.json +++ b/packages/client/ui-settings-general/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../ui-slots" }, - { - "path": "../connection" - }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index d1da7f46a2..95575dc666 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -46,7 +46,6 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -58,9 +57,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 84591802c1..241ca86ae9 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -9,7 +9,7 @@ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, IApiClient, SettingsNamespaceView, SettingsPathOpView, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form' import { createSnapshotStore, type SettingsScope, type SettingsScopeSnapshot, @@ -24,7 +24,7 @@ import { // `$on` and its key face without dragging a build artifact in. The runtime // `remote` injection belongs to whoever calls bindSettingsScope: the // subscription is registered on the caller's own context. -import type {} from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-remotes/types' // The forwarded event's own declaration: `$on`'s key face is // `Extract`, so the allowlist alone resolves to diff --git a/packages/client/ui-settings/tests/invariant.spec.ts b/packages/client/ui-settings/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-settings/tests/invariant.spec.ts rename to packages/client/ui-settings/tests/invariant.client.spec.ts diff --git a/packages/client/ui-settings/tests/plugin.spec.ts b/packages/client/ui-settings/tests/plugin.client.spec.ts similarity index 100% rename from packages/client/ui-settings/tests/plugin.spec.ts rename to packages/client/ui-settings/tests/plugin.client.spec.ts diff --git a/packages/client/ui-settings/tests/settings-scope.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts similarity index 99% rename from packages/client/ui-settings/tests/settings-scope.spec.ts rename to packages/client/ui-settings/tests/settings-scope.client.spec.ts index 88baeab83b..3aed7c7042 100644 --- a/packages/client/ui-settings/tests/settings-scope.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { describe, expect, it, vi } from 'vitest' -import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client' import { SettingsScopeController, SettingsScopeService } from '../src/client/settings-scope.ts' diff --git a/packages/client/ui-settings/tsconfig.json b/packages/client/ui-settings/tsconfig.json index 193107b1d2..4fa9f78ab8 100644 --- a/packages/client/ui-settings/tsconfig.json +++ b/packages/client/ui-settings/tsconfig.json @@ -17,14 +17,11 @@ { "path": "../runtime" }, - { - "path": "../connection" - }, { "path": "../schema-form" }, { - "path": "../../api/gateway" + "path": "../../api/remotes/tsconfig.client.json" }, { "path": "../../settings/settings" diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 4660ea5814..562396d88f 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md -README.md: 4eb9eeb73f1f8398eb9d16434996840182ba79a9 -README.zh.md: a9fb927305d0bab5fb4d27adbfdbec90dfa1dd6d +README.md: 9974118f69901de985e012e1b62f95a0bcee64c2 +README.zh.md: 11b0aa142cf62626ab6105e2c405d506e35349b0 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 4eb9eeb73f..9974118f69 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -Sidebar plugin: real Host Workspaces in stable Host order, each containing its `sessionIds` in Workspace order with `parentId` nesting; Sessions outside every Workspace appear in a trailing `Ungrouped` section. Search, state dots, and collapse into the layout-owned 56px rail are presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). +Sidebar shell plugin: the wordmark, New Session action, layout-owned collapse control, scroll-aware region seat, and bottom-pinned Settings seat. [ui-workspace](../ui-workspace/README.md) owns the Workspace and Session browser rendered into `sidebar.workspaces`; this package neither derives its rows nor owns its view preferences. Collapse into the layout-owned 56px rail remains presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). -New Session starts the runtime's page-local frontend Session Intent; a real Workspace's "+" starts one targeted to that Workspace. The Workspace header "+" opens ui-workspace's shared picker, whose selection also targets a frontend Session. A Workspace Intent does not appear in the sidebar. +New Session starts the runtime's page-local frontend Session Intent. The runtime targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. Workspace-specific controls and the shared picker belong to ui-workspace. -`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` and `sidebar.settings` child slots, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state. +`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspaces` and `sidebar.settings` child slots, and injected `startSession` plus sidebar-toggle callbacks. There is no plugin store. Scrollbars in the column are a pointer affordance: the shell rebinds ui-theme's [scrollbar indirection](../ui-theme/README.md) to `transparent` whenever the pointer is outside it, and keeps the thumb drawn for 2s after the pointer leaves, so a list nobody is pointing at carries no bar. The reservation that keeps rows from moving belongs to the scrolling region ([ui-workspace](../ui-workspace/README.md)), so revealing a thumb never reflows. @@ -25,5 +25,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Session state-dot rendering is owned by [ui-workspace](../ui-workspace/README.md)** — no done/error notification sources are available. -- **Group-by supports Workspace only** — Update and Status are not available strategies. +- **Workspace browser behavior is composition-owned** — grouping, ordering, search, and row state belong to [ui-workspace](../ui-workspace/README.md), not this shell. - **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index a9fb927305..11b0aa142c 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -侧边栏插件:真实 Host Workspace 按稳定的 Host 顺序排列;每个 Workspace 按自身顺序包含其 `sessionIds`,并以 `parentId` 嵌套;不属于任何 Workspace 的会话显示在末尾的 `Ungrouped` 分区。搜索、状态点以及折叠到布局拥有的 56px 轨道,都只属于呈现层。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。 +侧边栏外壳插件:负责字标、New Session 操作、布局持有的折叠控件、可感知滚动的区域 seat,以及固定在底部的 Settings seat。[ui-workspace](../ui-workspace/README.md) 持有渲染到 `sidebar.workspaces` 的 Workspace 与 Session 浏览器;本包既不派生其中的行,也不持有其视图偏好。折叠到布局拥有的 56px 轨道仍属于本地呈现行为。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。 -New Session 会启动运行时的页面局部前端 Session Intent;真实 Workspace 的「+」会启动一项以该 Workspace 为目标的 Intent。Workspace 标题栏的「+」打开 ui-workspace 的共享选择器,选择结果同样以一个前端会话为目标。Workspace Intent 不会出现在侧边栏中。 +New Session 会启动运行时的页面局部前端 Session Intent。运行时优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。Workspace 专属控件与共享选择器由 ui-workspace 持有。 -`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` 钩子、已声明的 `sidebar.workspace` 与 `sidebar.settings` 子 slot,以及注入的 `startSession`、`open` 和侧边栏切换回调。这里没有插件 store:`deriveGroups` 消费对象层快照与组件局部的展开/搜索状态。 +`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` 钩子、已声明的 `sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的 `startSession` 与侧边栏切换回调。这里没有插件 store。 栏内的滚动条是一种指针可供性:只要指针不在栏内,外壳就把 ui-theme 的[滚动条间接层](../ui-theme/README.md)重新绑定为 `transparent`;指针离开后滑块再保留 2 秒,因此没人指向的列表不会带着滚动条。避免行位移的空间预留属于滚动区域本身([ui-workspace](../ui-workspace/README.md)),所以显示滑块不会引起重排。 @@ -25,5 +25,5 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work ## 已知限制与暂缓事项 - **Session 状态点渲染由 [ui-workspace](../ui-workspace/README.md) 持有**:没有可用的 done/error 通知数据源。 -- **分组只支持 Workspace**:Update 和 Status 不是可用策略。 +- **Workspace 浏览行为由组合持有**:分组、排序、搜索与行状态都属于 [ui-workspace](../ui-workspace/README.md),不属于此外壳。 - **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 67310853a2..17333b5ccc 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -167,7 +167,7 @@ gap: 6px; height: 38px; padding: 8px 16px; - margin: 0 2px 20px; /* bottom: former headerBlock padBottom 12 + root gap 8 */ + margin: 0 2px 8px; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; @@ -215,16 +215,20 @@ min-height: 0; display: flex; flex-direction: column; + margin-left: -4px; margin-right: calc(-1 * var(--dsh-sidebar-inline-padding)); + padding-left: 4px; overflow: hidden; } .collapsed .regionArea { + margin-left: 0; margin-right: 0; + padding-left: 0; } /* Foot seat: a pure layout socket pinned under the region; the ui-settings - trigger row inside owns its own geometry (49px wide row / 36px rail + trigger row inside owns its own geometry (38px wide row / 36px rail circle) and hover chrome. */ .footArea { flex: none; diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 7b30e4232e..4da4d14eed 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -58,8 +58,8 @@ export interface SidebarSettingsOwnerProps { export type SidebarRootInjected = { /** * Start a New Session: with a workspace, reuse-or-create its blank session - * and open it; without one, clear the selection into the New Session pure - * view state (the conversation.empty seat). + * and open it; without one, inherit the current Session Workspace, then the + * recent Workspace, or clear into the New Session pure view when none exist. */ startSession: (workspaceId?: WorkspaceId) => void /** Toggle the sidebar column through the layout service. */ diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 3d7ed23aa4..a9706c3e99 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -30,7 +30,7 @@ export function apply(ctx: ClientContext): void { const injectProps = (): SidebarRootInjected => ({ // The shell's New Session button rides the runtime's shared action - // (recent-Workspace targeting; explicit Workspace wins for scoped actions). + // (current Session Workspace, then recent Workspace). startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) }, toggleSidebar: () => { ctx.layout.toggleSidebar() }, }) diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap similarity index 100% rename from packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.spec.tsx.snap rename to packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.client.spec.tsx similarity index 100% rename from packages/client/ui-sidebar/tests/apply.spec.tsx rename to packages/client/ui-sidebar/tests/apply.client.spec.tsx diff --git a/packages/client/ui-sidebar/tests/invariant.spec.ts b/packages/client/ui-sidebar/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-sidebar/tests/invariant.spec.ts rename to packages/client/ui-sidebar/tests/invariant.client.spec.ts diff --git a/packages/client/ui-sidebar/tests/pointer-scrollbars.spec.tsx b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx similarity index 100% rename from packages/client/ui-sidebar/tests/pointer-scrollbars.spec.tsx rename to packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx diff --git a/packages/client/ui-sidebar/tests/scrollbar-quiet-styles.spec.ts b/packages/client/ui-sidebar/tests/scrollbar-quiet-styles.client.spec.ts similarity index 100% rename from packages/client/ui-sidebar/tests/scrollbar-quiet-styles.spec.ts rename to packages/client/ui-sidebar/tests/scrollbar-quiet-styles.client.spec.ts diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx similarity index 100% rename from packages/client/ui-sidebar/tests/sidebar-root.spec.tsx rename to packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx diff --git a/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx similarity index 100% rename from packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx rename to packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx diff --git a/packages/client/ui-sidebar/tests/sidebar-styles.spec.ts b/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts similarity index 84% rename from packages/client/ui-sidebar/tests/sidebar-styles.spec.ts rename to packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts index 63721258c9..c4abce1911 100644 --- a/packages/client/ui-sidebar/tests/sidebar-styles.spec.ts +++ b/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts @@ -30,9 +30,13 @@ describe('SidebarRoot.module.css inset', () => { const root = declarations('.root') expect(root?.get('--dsh-sidebar-inline-padding')).toBe('12px') expect(root?.get('padding')).toBe('6px var(--dsh-sidebar-inline-padding)') + expect(declarations('.regionArea')?.get('margin-left')).toBe('-4px') + expect(declarations('.regionArea')?.get('padding-left')).toBe('4px') expect(declarations('.regionArea')?.get('margin-right')).toBe( 'calc(-1 * var(--dsh-sidebar-inline-padding))', ) + expect(declarations('.collapsed .regionArea')?.get('margin-left')).toBe('0') + expect(declarations('.collapsed .regionArea')?.get('padding-left')).toBe('0') expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0') }) }) diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 82f80bccca..e5d11fe0e1 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -51,10 +51,10 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" @@ -65,10 +65,10 @@ "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 5511398234..a1e7527292 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -29,9 +29,8 @@ * This browser half also owns the `skill` keyed toolview: a replay-stable * accent row derived only from each logged call/result slice. */ -import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client' -// Type-only: pulls the forwarded Host-event face and ctx.remote merge. -import type {} from '@deepseek-ai/dsh-api-remotes/client' +// Type-only: the carrier types, the forwarded Host-event face and the ctx.remote merge. +import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-api-remotes/client' import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-skill/tests/browser-plugin.spec.ts rename to packages/client/ui-skill/tests/browser-plugin.client.spec.ts diff --git a/packages/client/ui-skill/tests/skill-row.spec.tsx b/packages/client/ui-skill/tests/skill-row.client.spec.tsx similarity index 100% rename from packages/client/ui-skill/tests/skill-row.spec.tsx rename to packages/client/ui-skill/tests/skill-row.client.spec.tsx diff --git a/packages/client/ui-skill/tsconfig.json b/packages/client/ui-skill/tsconfig.json index b1d9835eee..8ea4ba311a 100644 --- a/packages/client/ui-skill/tsconfig.json +++ b/packages/client/ui-skill/tsconfig.json @@ -9,10 +9,10 @@ ], "references": [ { - "path": "../../../vendor/cordis" + "path": "../../api/remotes/tsconfig.client.json" }, { - "path": "../connection" + "path": "../../../vendor/cordis" }, { "path": "../locale" diff --git a/packages/client/ui-slash/tests/apply.spec.ts b/packages/client/ui-slash/tests/apply.client.spec.ts similarity index 100% rename from packages/client/ui-slash/tests/apply.spec.ts rename to packages/client/ui-slash/tests/apply.client.spec.ts diff --git a/packages/client/ui-slash/tests/core-detect.spec.ts b/packages/client/ui-slash/tests/core-detect.client.spec.ts similarity index 100% rename from packages/client/ui-slash/tests/core-detect.spec.ts rename to packages/client/ui-slash/tests/core-detect.client.spec.ts diff --git a/packages/client/ui-slash/tests/core-menu.spec.ts b/packages/client/ui-slash/tests/core-menu.client.spec.ts similarity index 100% rename from packages/client/ui-slash/tests/core-menu.spec.ts rename to packages/client/ui-slash/tests/core-menu.client.spec.ts diff --git a/packages/client/ui-slash/tests/menu-view.spec.tsx b/packages/client/ui-slash/tests/menu-view.client.spec.tsx similarity index 100% rename from packages/client/ui-slash/tests/menu-view.spec.tsx rename to packages/client/ui-slash/tests/menu-view.client.spec.tsx diff --git a/packages/client/ui-slash/tests/service.spec.ts b/packages/client/ui-slash/tests/service.client.spec.ts similarity index 100% rename from packages/client/ui-slash/tests/service.spec.ts rename to packages/client/ui-slash/tests/service.client.spec.ts diff --git a/packages/client/ui-slots/tests/core.spec.ts b/packages/client/ui-slots/tests/core.client.spec.ts similarity index 100% rename from packages/client/ui-slots/tests/core.spec.ts rename to packages/client/ui-slots/tests/core.client.spec.ts diff --git a/packages/client/ui-slots/tests/dynamic-keys.spec.ts b/packages/client/ui-slots/tests/dynamic-keys.client.spec.ts similarity index 100% rename from packages/client/ui-slots/tests/dynamic-keys.spec.ts rename to packages/client/ui-slots/tests/dynamic-keys.client.spec.ts diff --git a/packages/client/ui-slots/tests/invariant.spec.ts b/packages/client/ui-slots/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-slots/tests/invariant.spec.ts rename to packages/client/ui-slots/tests/invariant.client.spec.ts diff --git a/packages/client/ui-slots/tests/type-chain.spec.tsx b/packages/client/ui-slots/tests/type-chain.client.spec.tsx similarity index 100% rename from packages/client/ui-slots/tests/type-chain.spec.tsx rename to packages/client/ui-slots/tests/type-chain.client.spec.tsx diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-subagent/tests/browser-plugin.spec.ts rename to packages/client/ui-subagent/tests/browser-plugin.client.spec.ts diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx similarity index 100% rename from packages/client/ui-subagent/tests/conversation-ui.spec.tsx rename to packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx diff --git a/packages/client/ui-task/tests/browser-plugin.spec.ts b/packages/client/ui-task/tests/browser-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-task/tests/browser-plugin.spec.ts rename to packages/client/ui-task/tests/browser-plugin.client.spec.ts diff --git a/packages/client/ui-task/tests/task-list-action.spec.tsx b/packages/client/ui-task/tests/task-list-action.client.spec.tsx similarity index 100% rename from packages/client/ui-task/tests/task-list-action.spec.tsx rename to packages/client/ui-task/tests/task-list-action.client.spec.tsx diff --git a/packages/client/ui-theme/tests/appearance-row.spec.tsx b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx similarity index 100% rename from packages/client/ui-theme/tests/appearance-row.spec.tsx rename to packages/client/ui-theme/tests/appearance-row.client.spec.tsx diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/apply.spec.ts rename to packages/client/ui-theme/tests/apply.client.spec.ts diff --git a/packages/client/ui-theme/tests/boot-theme.spec.ts b/packages/client/ui-theme/tests/boot-theme.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/boot-theme.spec.ts rename to packages/client/ui-theme/tests/boot-theme.client.spec.ts diff --git a/packages/client/ui-theme/tests/host.spec.ts b/packages/client/ui-theme/tests/host.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/host.spec.ts rename to packages/client/ui-theme/tests/host.client.spec.ts diff --git a/packages/client/ui-theme/tests/invariant.spec.ts b/packages/client/ui-theme/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/invariant.spec.ts rename to packages/client/ui-theme/tests/invariant.client.spec.ts diff --git a/packages/client/ui-theme/tests/scrollbar-styles.spec.ts b/packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/scrollbar-styles.spec.ts rename to packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts diff --git a/packages/client/ui-theme/tests/settings-store.spec.ts b/packages/client/ui-theme/tests/settings-store.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/settings-store.spec.ts rename to packages/client/ui-theme/tests/settings-store.client.spec.ts diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/theme.spec.ts rename to packages/client/ui-theme/tests/theme.client.spec.ts diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 398831749b..23bcbb3a04 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -48,16 +48,19 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -69,7 +72,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx index 47b17b6fa8..e49189d6d4 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx @@ -23,8 +23,13 @@ import { CONVERSATION_NS as NS } from '../../locale.ts' /** Full row props: the toolview runtime share plus the standard locale seat. */ type SearchRowProps = ToolCallViewProps & PropsLocale<'conversation'> +const SEARCH_TITLES: Record = { + grep: 'Grep', + glob: 'Glob', +} + /** - * Search row: icon + Search · {summary} in the shared ToolRow chrome, with the + * Search row: icon + Grep/Glob · {summary} in the shared ToolRow chrome, with the * completed search's card as the row's collapsed-by-default card body (a capped * search's recovery footer rides below it, inside ToolRow). Registered under * both `grep` and `glob`; the derived model's `kind` decides the card shape. A @@ -40,7 +45,7 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) { variant={model.variant} toolName={toolName} icon={} - title={model.title} + title={SEARCH_TITLES[toolName] ?? model.title} // The result view's replacement title outranks the args-derived summary, // matching the terminal card's description precedence. summary={search?.title ?? model.summary} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx index c0e546f071..3dab222e95 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx @@ -10,7 +10,7 @@ // summary line alone. import type { Context } from '@deepseek-ai/cordis' -import { IconBrowseOutline16, IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconBrowseOutline16, IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' import { webCardModel } from '../models/web-card-model.ts' @@ -35,7 +35,8 @@ const WEB_TITLES: Record = { export function WebRow({ toolName, block, inspect, t }: WebRowProps) { const model = toolRowModel(toolName, block) const web = webCardModel(block) - const icon = toolName === 'web_fetch' ? : + // Web search uses a globe; local grep/glob keep the magnifier family. + const icon = toolName === 'web_fetch' ? : return ( () => {} }) runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx similarity index 99% rename from packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx rename to packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx index fbdfc4b75e..61a15dd283 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx @@ -25,7 +25,7 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts' -import { toolChatSnapshot } from './tool-details-render.tsx' +import { toolChatSnapshot } from './tool-details-render.client.tsx' const SID = 's1' as SessionId diff --git a/packages/client/ui-tool/tests/coverage-tails.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx similarity index 100% rename from packages/client/ui-tool/tests/coverage-tails.spec.tsx rename to packages/client/ui-tool/tests/coverage-tails.client.spec.tsx diff --git a/packages/client/ui-tool/tests/diff-card.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx similarity index 99% rename from packages/client/ui-tool/tests/diff-card.spec.tsx rename to packages/client/ui-tool/tests/diff-card.client.spec.tsx index 1726c81136..6708d3806a 100644 --- a/packages/client/ui-tool/tests/diff-card.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -13,7 +13,7 @@ import { import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' -import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' @@ -22,7 +22,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { FileMutationRow, fileMutationToolview } from '../src/client/tool/toolviews/file-mutation-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' afterEach(cleanup) diff --git a/packages/client/ui-tool/tests/read-card.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx similarity index 99% rename from packages/client/ui-tool/tests/read-card.spec.tsx rename to packages/client/ui-tool/tests/read-card.client.spec.tsx index ae719173d5..6351c5c312 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -18,7 +18,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' -import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/tool/models/read-card-model.ts' import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' @@ -26,7 +26,7 @@ import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/t import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { ReadRow, readToolview } from '../src/client/tool/toolviews/read-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' afterEach(cleanup) diff --git a/packages/client/ui-tool/tests/search-card.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx similarity index 98% rename from packages/client/ui-tool/tests/search-card.spec.tsx rename to packages/client/ui-tool/tests/search-card.client.spec.tsx index 3ff068b3c9..9fc18c1deb 100644 --- a/packages/client/ui-tool/tests/search-card.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -15,7 +15,7 @@ import { import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' -import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' @@ -25,7 +25,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { SearchRow, searchToolview } from '../src/client/tool/toolviews/search-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' /** SearchRow now composes ToolRow, so its props include the locale `t` seat. */ type SearchRowProps = Parameters[0] @@ -246,7 +246,8 @@ describe('SearchRow keyed card', () => { it('collapses to the summary row; expanding reveals the grep card', () => { const view = render() - expect(view.getByText('Search')).toBeTruthy() + expect(view.getByText('Grep')).toBeTruthy() + expect(view.queryByText('Search')).toBeNull() // Collapsed: the card is not in the DOM until the row is expanded. expect(searchKindOf(view.container)).toBeNull() expect(view.queryByText(/const foo = 1/)).toBeNull() @@ -259,6 +260,8 @@ describe('SearchRow keyed card', () => { it('expands to the glob path card', () => { const view = render() + expect(view.getByText('Glob')).toBeTruthy() + expect(view.queryByText('Search')).toBeNull() expect(searchKindOf(view.container)).toBeNull() toggleRow(view) expect(view.getByText('src/a.ts')).toBeTruthy() diff --git a/packages/client/ui-tool/tests/terminal-card.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx similarity index 99% rename from packages/client/ui-tool/tests/terminal-card.spec.tsx rename to packages/client/ui-tool/tests/terminal-card.client.spec.tsx index dd39b8bc88..ad351dfabe 100644 --- a/packages/client/ui-tool/tests/terminal-card.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -13,7 +13,7 @@ import { import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' -import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' @@ -22,7 +22,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' type BashRowProps = Parameters[0] diff --git a/packages/client/ui-tool/tests/todo-row.spec.tsx b/packages/client/ui-tool/tests/todo-row.client.spec.tsx similarity index 100% rename from packages/client/ui-tool/tests/todo-row.spec.tsx rename to packages/client/ui-tool/tests/todo-row.client.spec.tsx diff --git a/packages/client/ui-tool/tests/tool-call-tree.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx similarity index 100% rename from packages/client/ui-tool/tests/tool-call-tree.spec.tsx rename to packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx diff --git a/packages/client/ui-tool/tests/tool-details-render.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx similarity index 100% rename from packages/client/ui-tool/tests/tool-details-render.tsx rename to packages/client/ui-tool/tests/tool-details-render.client.tsx diff --git a/packages/client/ui-tool/tests/tool-row-styles.spec.ts b/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts similarity index 100% rename from packages/client/ui-tool/tests/tool-row-styles.spec.ts rename to packages/client/ui-tool/tests/tool-row-styles.client.spec.ts diff --git a/packages/client/ui-tool/tests/tool-row.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx similarity index 100% rename from packages/client/ui-tool/tests/tool-row.spec.tsx rename to packages/client/ui-tool/tests/tool-row.client.spec.tsx diff --git a/packages/client/ui-tool/tests/toolview-slot.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx similarity index 98% rename from packages/client/ui-tool/tests/toolview-slot.spec.tsx rename to packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index 2ab4ba9b12..4fa5dbeb5d 100644 --- a/packages/client/ui-tool/tests/toolview-slot.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -18,7 +18,7 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '@deepseek-ai/dsh-client-ui-tool/client' import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' -import { toolChatSnapshot } from './tool-details-render.tsx' +import { toolChatSnapshot } from './tool-details-render.client.tsx' const SID = 's1' as SessionId @@ -64,7 +64,7 @@ const LAYOUT_CHILDREN = { */ async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) // ui-theme's Appearance row binds a durable scope through these two. runtime.provide('remote', { $on: () => () => {} }) runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) @@ -200,7 +200,7 @@ describe('keyed toolview hole through the real machinery', () => { describe('registrant declaration injection', () => { it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) // ui-theme's Appearance row binds a durable scope through these two. runtime.provide('remote', { $on: () => () => {} }) runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) diff --git a/packages/client/ui-tool/tests/toolview-type-chain.spec.tsx b/packages/client/ui-tool/tests/toolview-type-chain.client.spec.tsx similarity index 100% rename from packages/client/ui-tool/tests/toolview-type-chain.spec.tsx rename to packages/client/ui-tool/tests/toolview-type-chain.client.spec.tsx diff --git a/packages/client/ui-tool/tests/web-card.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx similarity index 97% rename from packages/client/ui-tool/tests/web-card.spec.tsx rename to packages/client/ui-tool/tests/web-card.client.spec.tsx index 04535a0954..3b5a7ebf32 100644 --- a/packages/client/ui-tool/tests/web-card.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -16,16 +16,17 @@ import { import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' -import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client' +import { IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import { webCardModel } from '../src/client/tool/models/web-card-model.ts' import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { WebRow, webToolview } from '../src/client/tool/toolviews/web-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' @@ -140,9 +141,11 @@ describe('chat row web body', () => { } it('the WebRow collapses to the summary row, expanding to the full search card', () => { + const globe = render().container.querySelector('svg')!.outerHTML const view = render() // Collapsed: the summary row alone, no card in the DOM. expect(view.getByText('Search')).toBeTruthy() + expect(view.container.querySelector('svg')?.outerHTML).toBe(globe) expect(view.queryByText('Titled')).toBeNull() expect(view.container.querySelector('[data-web]')).toBeNull() toggleRow(view) diff --git a/packages/client/ui-tool/tsconfig.json b/packages/client/ui-tool/tsconfig.json index 17516295fb..7183ad66d2 100644 --- a/packages/client/ui-tool/tsconfig.json +++ b/packages/client/ui-tool/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../api/remotes/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-trajectory/tests/cell.spec.tsx b/packages/client/ui-trajectory/tests/cell.client.spec.tsx similarity index 100% rename from packages/client/ui-trajectory/tests/cell.spec.tsx rename to packages/client/ui-trajectory/tests/cell.client.spec.tsx diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts similarity index 94% rename from packages/client/ui-trajectory/tests/client-bundle.spec.ts rename to packages/client/ui-trajectory/tests/client-bundle.client.spec.ts index edb2e3072b..2efedc8d3e 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts @@ -9,6 +9,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { afterEach, describe, expect, it } from 'vitest' import { ConversationEventRegistry, ConversationViewRegistry, SlotsService, @@ -82,9 +83,11 @@ describe('tsdown client artifact', () => { // Paging is session-owned; this registration-only probe never renders the // entry, so the binding stays deliberately empty. The locale plugin backs // the locale-aware view tab label (its settings scope needs a connection - // handle). + // handle and the Host-facing settings/remote seams). ctx.provide('sessions', { binding: () => undefined }) ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = await import('@deepseek-ai/dsh-client-locale/client') ctx.plugin({ inject: [...locale.inject], apply: locale.apply }) const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void }) diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts similarity index 100% rename from packages/client/ui-trajectory/tests/conversation-definitions.spec.ts rename to packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts diff --git a/packages/client/ui-trajectory/tests/export-log.spec.ts b/packages/client/ui-trajectory/tests/export-log.client.spec.ts similarity index 100% rename from packages/client/ui-trajectory/tests/export-log.spec.ts rename to packages/client/ui-trajectory/tests/export-log.client.spec.ts diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx similarity index 100% rename from packages/client/ui-trajectory/tests/layout.spec.tsx rename to packages/client/ui-trajectory/tests/layout.client.spec.tsx diff --git a/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts b/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts similarity index 100% rename from packages/client/ui-trajectory/tests/snapshot-builder.spec.ts rename to packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx similarity index 100% rename from packages/client/ui-trajectory/tests/table.spec.tsx rename to packages/client/ui-trajectory/tests/table.client.spec.tsx diff --git a/packages/client/ui-trajectory/tests/toolbar.spec.tsx b/packages/client/ui-trajectory/tests/toolbar.client.spec.tsx similarity index 100% rename from packages/client/ui-trajectory/tests/toolbar.spec.tsx rename to packages/client/ui-trajectory/tests/toolbar.client.spec.tsx diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx similarity index 100% rename from packages/client/ui-trajectory/tests/views.spec.tsx rename to packages/client/ui-trajectory/tests/views.client.spec.tsx diff --git a/packages/client/ui-trajectory/tests/virtual-rows.spec.ts b/packages/client/ui-trajectory/tests/virtual-rows.client.spec.ts similarity index 100% rename from packages/client/ui-trajectory/tests/virtual-rows.spec.ts rename to packages/client/ui-trajectory/tests/virtual-rows.client.spec.ts diff --git a/packages/client/ui-workflow-run/README.i18n.yaml b/packages/client/ui-workflow-run/README.i18n.yaml index 3d6294e997..6baade6354 100644 --- a/packages/client/ui-workflow-run/README.i18n.yaml +++ b/packages/client/ui-workflow-run/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workflow-run/README.md -README.md: 66539e0c16ac4102f9e1fe881106e6881b36a7d5 -README.zh.md: a803857af24802e8a4645c4d5aca56c04424c85e +README.md: 489715c51759b1efd2da68d3bd3e0f7788ce7ecd +README.zh.md: 326a7ae4e4b8eaad43ca7ad0d22145452af6a734 diff --git a/packages/client/ui-workflow-run/README.md b/packages/client/ui-workflow-run/README.md index 66539e0c16..489715c517 100644 --- a/packages/client/ui-workflow-run/README.md +++ b/packages/client/ui-workflow-run/README.md @@ -12,7 +12,7 @@ Phase groups come only from members that actually started. Exact phase strings s ## Presentation and navigation -The run and each phase have independent disclosure state. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. A running run initially expands; a terminal run loaded from history initially collapses. Local choices survive data updates while the keyed node remains mounted and reset only on a full remount. +The run and each phase derive disclosure control from their current lifecycle facts. The run stays expanded while its own status is running, failed, cancelled, or interrupted, or while any phase contains such a member; each affected phase also stays expanded. Forced-open headers are static expanded rows without button, keyboard, or `aria-expanded` promises. A phase folds once when every member completes, and the run folds once when it and every phase complete. Each clean layer then exposes an ordinary disclosure control whose local choice survives clean rerenders; new activity takes control again, and a remount derives the initial state from current data. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. A member opens a child Session only while every current fact agrees: the member is running, the child id is in the ordinary Session list, the row has `origin: 'subagent'`, its `parentId` is the current Session, and the list row is still running. Underlined member text is the only visible navigation affordance; keyboard focus draws a two-pixel business-primary ring around the name area, while status copy remains `Running`. The component calls only the injected ordinary `sessions.open(id)` action; remote, addressed-only, wrong-parent, or terminal rows remain non-interactive. diff --git a/packages/client/ui-workflow-run/README.zh.md b/packages/client/ui-workflow-run/README.zh.md index a803857af2..326a7ae4e4 100644 --- a/packages/client/ui-workflow-run/README.zh.md +++ b/packages/client/ui-workflow-run/README.zh.md @@ -12,7 +12,7 @@ ## 展示与导航 -运行和每个阶段分别拥有本地 disclosure 状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。运行中记录首次挂载时展开,从历史加载的终态记录首次挂载时折叠。只要 keyed 节点仍挂载,本地选择就在数据更新时保持;只有完整 remount 才重新初始化。 +运行和每个阶段都从当前生命周期事实派生 disclosure 控制。运行自身处于运行中、失败、已取消或已中断,或者任一阶段包含这些状态的成员时,运行保持展开;受影响的阶段也保持展开。强制展开的标题行只是静态展开行,不承诺按钮、键盘操作或 `aria-expanded`。阶段在全部成员完成时折叠一次;运行在自身和全部阶段都完成时折叠一次。每个干净层级随后恢复普通 disclosure 控件,其本地选择在干净状态的 rerender 中保持;新活动会重新取得控制,remount 则从当前数据派生初始状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。 只有所有实时事实同时成立时,成员才可打开子 Session:成员仍在运行、子 id 位于普通 Session 列表、列表行为 `origin: 'subagent'`、`parentId` 等于当前 Session,且列表行仍标记运行。带下划线的成员文字是唯一可见导航提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,右侧状态仍只显示“运行中”。组件只调用注入的普通 `sessions.open(id)`;远程、仅地址化、父级不符或终态的行都不可交互。 diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css index 77145ee06a..fdb54fdde2 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css @@ -14,7 +14,6 @@ padding: 0 8px; border-radius: 8px; background: var(--dsw-alias-bg-module-platform); - cursor: pointer; } .runHeader:focus-visible { @@ -78,7 +77,6 @@ width: 100%; min-width: 0; height: 32px; - cursor: pointer; } .phaseHeader:focus-visible { diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx index fcb36da7a3..c58399c583 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react' +import { useState, type ReactNode } from 'react' import { - DisclosureRow, IconChevronRightOutline14, StateDot, type StateDotState, + DisclosureRow, IconChevronRightOutline14, StateDot, + type DisclosureRowProps, type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { shallowEqual, type SessionId, type SessionListState } from '@deepseek-ai/dsh-client-runtime/client' @@ -62,6 +63,36 @@ function memberCount(count: number, t: WorkflowRunPanelProps['t']): string { return t(count === 1 ? 'run.members.one' : 'run.members.other', { count }) } +function phaseRequiresExpansion(phase: WorkflowRunPhaseData): boolean { + return phase.members.some(member => member.status !== 'completed') +} + +type StatusDisclosureProps = Omit + +/* v8 ignore next -- DisclosureRow requires the callback but cannot invoke it when expandable is false. */ +const forcedOpenToggle = (): void => {} + +function ManualDisclosure(props: StatusDisclosureProps) { + const [open, setOpen] = useState(false) + return ( + { setOpen(value => !value) }} + /> + ) +} + +function StatusDisclosure({ cleanCycleKey, requiresExpansion, ...props }: StatusDisclosureProps & { + /** Remount a clean Phase when its append-only member count changes between batched renders. */ + readonly cleanCycleKey?: number | undefined + readonly requiresExpansion: boolean +}) { + if (!requiresExpansion) return + return +} + function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string { const counts = new Map() for (const member of members) counts.set(member.status, (counts.get(member.status) ?? 0) + 1) @@ -97,21 +128,19 @@ function navigableMembers( return result } -function RunHeader({ count, name, onToggle, open, status, t }: { +function RunHeader({ children, count, name, requiresExpansion, status, t }: { + readonly children: ReactNode readonly count: number readonly name: string - readonly onToggle: () => void - readonly open: boolean + readonly requiresExpansion: boolean readonly status: WorkflowRunStatus readonly t: WorkflowRunPanelProps['t'] }) { return ( - } title={t('run.title', { name })} - open={open} - expandable - onToggle={onToggle} + requiresExpansion={requiresExpansion} expandOnRowClick previewChevron={false} keepContentWhenOpen @@ -128,7 +157,9 @@ function RunHeader({ count, name, onToggle, open, status, t }: { )} - /> + > + {children} + ) } @@ -168,15 +199,12 @@ function PhaseSection({ phase, navigable, openSession, t }: { readonly openSession: WorkflowRunInjected['openSession'] readonly t: WorkflowRunPanelProps['t'] }) { - const [open, setOpen] = useState(false) - const toggle = (): void => { setOpen(value => !value) } return ( - } title={readablePhase(phase.phase, t)} - open={open} - expandable - onToggle={toggle} + cleanCycleKey={phase.members.length} + requiresExpansion={phaseRequiresExpansion(phase)} expandOnRowClick previewChevron={false} keepContentWhenOpen @@ -203,14 +231,15 @@ function PhaseSection({ phase, navigable, openSession, t }: { /> ))}
    - + ) } -/** Render one durable workflow run with independent run and phase disclosure. */ +/** Render one durable workflow run with status-driven run and phase disclosure. */ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) { - const [open, setOpen] = useState(() => node.data.status === 'running') - const memberCount = node.data.phases.reduce((count, phase) => count + phase.members.length, 0) + const totalMembers = node.data.phases.reduce((count, phase) => count + phase.members.length, 0) + const requiresExpansion = node.data.status !== 'completed' + || node.data.phases.some(phaseRequiresExpansion) const navigable = useSessions( sessions => navigableMembers(sessions, node.data.phases, sessionId), shallowEqual, @@ -218,14 +247,12 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t return (
    { setOpen(value => !value) }} - /> - {open && ( + >
    {node.data.phases.length === 0 ? {t('run.empty')} @@ -239,7 +266,7 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t /> ))}
    - )} +
    ) } diff --git a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx similarity index 73% rename from packages/client/ui-workflow-run/tests/workflow-run.spec.tsx rename to packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 196bdf1144..e4fefa5ad7 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -301,90 +301,170 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi } describe('WorkflowRunPanel', () => { - it('defaults running runs open, terminal history closed, and keeps the current choice across data updates', () => { + it('forces running run and phase content open without false disclosure controls', () => { + const view = render() + expect(screen.getByText('worker')).toBeTruthy() + expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull() + expect(screen.queryByRole('button', { name: /Research/ })).toBeNull() + const rows = [...view.container.querySelectorAll('[data-disclosure-row]')] + expect(rows).toHaveLength(2) + for (const row of rows) { + expect(row.getAttribute('role')).toBeNull() + expect(row.getAttribute('tabindex')).toBeNull() + expect(row.getAttribute('aria-expanded')).toBeNull() + expect(row.getAttribute('data-expandable')).toBeNull() + } + }) + + it('folds each clean transition once and preserves review choices until activity returns', () => { const running: WorkflowRunChatData = { name: 'audit', status: 'running', phases: [phase()], } const view = render() - expect(screen.getByText('未分阶段')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: /^audit/ })) - expect(screen.queryByText('未分阶段')).toBeNull() + const phaseCompleted: WorkflowRunChatData = { + ...running, + phases: [phase({ + members: [{ + seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed', + }], + })], + } + view.rerender() + const phaseHeader = screen.getByRole('button', { name: /未分阶段/ }) + expect(phaseHeader.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText('done')).toBeNull() + fireEvent.click(phaseHeader) + expect(screen.getByText('done')).toBeTruthy() - const terminal: WorkflowRunChatData = { ...running, status: 'completed' } - view.rerender() + const completed: WorkflowRunChatData = { ...phaseCompleted, status: 'completed' } + view.rerender() + const runHeader = screen.getByRole('button', { name: /^audit/ }) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') expect(screen.queryByText('未分阶段')).toBeNull() + fireEvent.keyDown(runHeader, { key: 'ArrowDown' }) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + fireEvent.keyDown(runHeader, { key: 'Enter' }) + expect(runHeader.getAttribute('aria-expanded')).toBe('true') + const completedPhase = screen.getByRole('button', { name: /未分阶段/ }) + fireEvent.keyDown(completedPhase, { key: 'Enter' }) + expect(screen.getByText('done')).toBeTruthy() + fireEvent.keyDown(runHeader, { key: ' ' }) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + fireEvent.keyDown(runHeader, { key: ' ' }) + expect(runHeader.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + expect(screen.getByText('done')).toBeTruthy() - cleanup() - render() + const cleanUpdate: WorkflowRunChatData = { + ...completed, + phases: [phase({ + members: [{ + seq: 1, label: 'reviewed', childId: 'child-1' as SessionId, status: 'completed', + }], + })], + } + view.rerender() + expect(screen.getByText('reviewed')).toBeTruthy() + + view.rerender() + expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull() + expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull() + expect(screen.getByText('worker')).toBeTruthy() + view.rerender() + expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('false') expect(screen.queryByText('未分阶段')).toBeNull() }) - it('supports root keyboard disclosure and renders a zero-member running state', () => { - render( { + const firstMember = { + seq: 1, label: 'first', childId: 'child-1' as SessionId, status: 'completed' as const, + } + const phaseClean: WorkflowRunChatData = { + name: 'phase-cycle', status: 'running', + phases: [phase({ members: [firstMember] })], + } + const phaseView = render() + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + expect(screen.getByText('first')).toBeTruthy() + phaseView.rerender() - const header = screen.getByRole('button', { name: /^keyboard/ }) - expect(header.getAttribute('aria-expanded')).toBe('true') - fireEvent.keyDown(header, { key: 'ArrowDown' }) - expect(header.getAttribute('aria-expanded')).toBe('true') - fireEvent.keyDown(header, { key: 'Enter' }) - expect(header.getAttribute('aria-expanded')).toBe('false') - fireEvent.keyDown(header, { key: ' ' }) - expect(header.getAttribute('aria-expanded')).toBe('true') - expect(screen.getByText('Research')).toBeTruthy() - expect(screen.getByText('运行中 1')).toBeTruthy() - const phaseHeader = screen.getByRole('button', { name: /Research/ }) - fireEvent.keyDown(phaseHeader, { key: 'ArrowDown' }) - expect(phaseHeader.getAttribute('aria-expanded')).toBe('false') - fireEvent.keyDown(phaseHeader, { key: 'Enter' }) - expect(phaseHeader.getAttribute('aria-expanded')).toBe('true') - fireEvent.keyDown(phaseHeader, { key: ' ' }) - expect(phaseHeader.getAttribute('aria-expanded')).toBe('false') + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText('first')).toBeNull() + expect(screen.queryByText('second')).toBeNull() + }) - cleanup() - render() + it('derives the zero-member running and completed states from the current run status', () => { + const running: WorkflowRunChatData = { name: 'empty', status: 'running', phases: [] } + const view = render() + expect(screen.queryByRole('button', { name: /^empty/ })).toBeNull() + expect(screen.getByText('没有启动成员')).toBeTruthy() + view.rerender() + const header = screen.getByRole('button', { name: /^empty/ }) + expect(header.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText('没有启动成员')).toBeNull() + fireEvent.click(header) expect(screen.getByText('没有启动成员')).toBeTruthy() }) - it('keeps phase disclosure independent and preserves empty versus absent names', () => { + it.each(['failed', 'cancelled', 'interrupted'] as const)( + 'bubbles a %s member to the run and keeps a matching run outcome open', + (status) => { + const memberView = render() + expect(screen.queryByRole('button', { name: /^member-outcome/ })).toBeNull() + expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull() + expect(screen.getByText(status)).toBeTruthy() + memberView.unmount() + + render() + expect(screen.queryByRole('button', { name: /^run-outcome/ })).toBeNull() + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText('done')).toBeNull() + }, + ) + + it('keeps clean sibling phases independent and preserves empty versus absent names', () => { render() - fireEvent.click(screen.getByRole('button', { name: /空阶段名/ })) - expect(screen.getByText('空成员名')).toBeTruthy() - expect(screen.queryByText('second')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull() + const cleanPhase = screen.getByRole('button', { name: /空阶段名/ }) + expect(cleanPhase.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull() + expect(screen.queryByText('空成员名')).toBeNull() expect(screen.getByText('second')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: /空阶段名/ })) + fireEvent.click(cleanPhase) + expect(screen.getByText('空成员名')).toBeTruthy() + expect(screen.getByText('second')).toBeTruthy() + fireEvent.click(cleanPhase) expect(screen.queryByText('空成员名')).toBeNull() expect(screen.getByText('second')).toBeTruthy() }) - it('covers the Figma completed, failed/cancelled, and interrupted state boards', () => { - const completed: WorkflowRunChatData = { - name: 'repo-audit', status: 'completed', - phases: [phase({ - members: [{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }], - })], - } - const completedView = render() - const completedHeader = screen.getByRole('button', { name: /^repo-audit/ }) - expect(completedHeader.getAttribute('aria-expanded')).toBe('false') - fireEvent.click(completedHeader) - expect(completedHeader.getAttribute('aria-expanded')).toBe('true') - completedView.unmount() - + it('renders mixed and interrupted aggregate status while attention stays visible', () => { const mixed: WorkflowRunChatData = { name: 'repo-audit', status: 'failed', phases: [phase({ @@ -395,8 +475,6 @@ describe('WorkflowRunPanel', () => { })], } const mixedView = render() - fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ })) - fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) expect(screen.getByText('失败 1 · 已取消 1')).toBeTruthy() expect([...mixedView.container.querySelectorAll('[data-member-status]')] .map(row => row.getAttribute('data-member-status'))).toEqual(['failed', 'cancelled']) @@ -404,28 +482,18 @@ describe('WorkflowRunPanel', () => { expect(mixedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1) mixedView.unmount() - const interrupted: WorkflowRunChatData = { + const interruptedView = render() - fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ })) + phases: [phase({ + members: [ + { seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }, + { seq: 2, label: 'interrupted', childId: 'child-2' as SessionId, status: 'interrupted' }, + ], + })], + })} />) expect(screen.getByText('已完成 1 · 已中断 1')).toBeTruthy() expect(interruptedView.container.querySelector('[data-run-status="interrupted"]')).toBeTruthy() - expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1) + expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(2) }) it('opens only a running ordinary-list subagent proven to have this parent', () => { @@ -434,7 +502,6 @@ describe('WorkflowRunPanel', () => { } const openSession = vi.fn() render() - fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) fireEvent.click(screen.getByRole('button', { name: '打开 worker' })) expect(openSession).toHaveBeenCalledWith('child-1') }) @@ -464,7 +531,6 @@ describe('WorkflowRunPanel', () => { })], } render() - fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull() cleanup() }) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 1a1dd057f0..7a50937c41 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: 1ec07bd41e72bb5a26b2cfc3bf90e57e7d92db08 -README.zh.md: 8edd0fed6d3bdefd9df339a8b3d0588533264538 +README.md: 9d7d4d77cc064146f1fdaed615509215c64308fc +README.zh.md: ca35d7cd2e7ff176f4ea40d1e9a3a6d1a7457462 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 1ec07bd41e..9d7d4d77cc 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,9 @@ English | [中文](README.zh.md) Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and add flow. -The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace add/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. +The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. Creating a Session from a Workspace row first opens that group so the new row remains visible when the Session state arrives. Once the Workspace list baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. View options combine grouping with one browser-persisted Session order per account: real Workspaces initialize from `WorkspaceView.sessionIds`, while Ungrouped and the cross-Workspace flat list initialize from recency. **Manual** and **Last updated** apply in either presentation. Entering Last updated performs a complete recency sort and later user prompts or steers promote their Session once, while entering Manual preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags for real Workspaces also update the Host Session account, while Ungrouped and flat-list orders remain browser-local because neither has one Workspace account. Flat rows omit the empty leading status slot because they have no parent hierarchy, but retain it when a Session status is visible. Workspace drag order is Host-durable in either Session order mode. + +Collapsed search is one header action beside the view and add actions. Activating it expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 8edd0fed6d..ca35d7cd2e 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,9 @@ 共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个界面使用同一套 Workspace 菜单和添加流程。 -该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 +该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条 Session,其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。从 Workspace 行创建 Session 时会先打开该分组,使 Session 状态到达后新行保持可见。Workspace 列表基线就绪后,浏览器持久化的展开状态与 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。视图选项把分组方式和每个记账各自的一份浏览器持久化 Session 顺序放在一起:真实 Workspace 从 `WorkspaceView.sessionIds` 初始化,Ungrouped 和跨 Workspace 的单列表则从最近更新时间顺序初始化。**手动排序**和**最近更新**在两种呈现方式下都可用。进入最近更新时会执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入手动排序则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序;真实 Workspace 在手动模式下的拖拽还会更新 Host Session 记账,而 Ungrouped 和单列表因没有单一 Workspace 记账,其顺序始终只保存在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;Session 存在可见状态时仍保留该槽。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。 + +折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index 6c6c44c2c7..a160f2ffd7 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -38,9 +38,8 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Section header: 36px, "Workspaces/Sessions" label + group-by / - new-workspace buttons; the right-anchored new-workspace button is the - row's rail survivor. */ +/* Section header: title, an inline search control, and the two trailing + actions. Expanding search collapses the action cluster and takes its room. */ .sectionHeader { flex: none; display: flex; @@ -48,7 +47,7 @@ justify-content: flex-end; gap: 4px; height: 36px; - padding-left: 12px; + padding-left: 4px; margin-bottom: 4px; box-sizing: border-box; border-radius: 12px; @@ -56,71 +55,118 @@ color: var(--dsw-alias-label-tertiary); } +.root:not(.rail) .sectionHeader { + margin-top: 2px; + margin-right: -4px; +} + .sectionLabel { - flex: 1; + flex: none; + max-width: 45%; min-width: 0; overflow: hidden; white-space: nowrap; line-height: 20px; + opacity: 1; + visibility: visible; + transition: + max-width 180ms var(--ds-ease-in-out), + margin-right 180ms var(--ds-ease-in-out), + opacity 120ms var(--ds-ease-in-out), + transform 180ms var(--ds-ease-in-out), + visibility 0s linear; } -/* Search input: 38px bar, 12px radius (figma 133:7649 geometry, squared-off - corners); rail state renders it as the - region's search control. Upstream binds a dedicated design-system variable - (light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component - token pinned to the static scale mirrors it. */ -.search { - --dsh-search-input-fill: var(--dsw-static-neutral-bluish-75); +.sectionLabelHidden { + max-width: 0; + margin-right: -4px; + opacity: 0; + transform: translateX(-4px); + visibility: hidden; + transition-delay: 0s, 0s, 0s, 0s, 180ms; +} + +.searchSlot { + flex: 1; + max-width: 28px; + min-width: 0; + display: flex; + align-items: center; + margin-left: auto; + padding-left: 0; + box-sizing: border-box; + transition: + max-width 180ms var(--ds-ease-in-out), + padding-left 180ms var(--ds-ease-in-out); +} + +.searchSlotExpanded { + max-width: 100%; + padding-left: 0; +} + +.headerActions { flex: none; display: flex; align-items: center; - gap: 8px; - height: 38px; - margin: 0 2px 12px; - padding: 0 14px; - box-sizing: border-box; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 12px; - background: var(--dsh-search-input-fill); - color: var(--dsw-alias-label-caption); + gap: 4px; + max-width: 60px; + opacity: 1; overflow: hidden; + visibility: visible; + transition: + max-width 180ms var(--ds-ease-in-out), + opacity 120ms var(--ds-ease-in-out), + transform 180ms var(--ds-ease-in-out), + visibility 0s linear; } -:global(body[data-ds-dark-theme]) .search { - --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); +.headerActionsHidden { + max-width: 0; + opacity: 0; + transform: translateX(4px); + visibility: hidden; + pointer-events: none; + transition-delay: 0s, 0s, 0s, 180ms; } -/* The capsule's leading icon: decorative while wide (pointer-events off so - clicks reach the input), the hit target in rail state. */ -.searchButton { +/* Inline search always fills the room between the title and trailing actions; + it grows farther right when the action cluster collapses. */ +.search { flex: none; - display: inline-flex; + display: flex; align-items: center; - justify-content: center; + gap: 0; + width: 100%; + height: 28px; + margin: 0; + padding: 0; + box-sizing: border-box; border: none; border-radius: 50%; - padding: 0; background: transparent; - pointer-events: none; - color: inherit; + cursor: text; + color: var(--dsw-alias-label-secondary); + overflow: hidden; + transition: + width 180ms var(--ds-ease-in-out), + padding 180ms var(--ds-ease-in-out), + border-color 180ms var(--ds-ease-in-out), + background-color 180ms var(--ds-ease-in-out); } -.searchInput { - flex: 1; - min-width: 0; - border: none; - outline: none; +.searchExpanded { + width: calc(100% + 4px); + height: 30px; + margin-inline: -2px; + padding: 0 4px 0 0; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; background: transparent; - font-size: 14px; - line-height: 20px; - color: var(--dsw-alias-label-primary); + color: var(--dsw-alias-label-caption); } -.searchInput::placeholder { - color: var(--dsw-alias-label-tertiary); -} - -.clearButton { +.searchButton { flex: none; display: inline-flex; align-items: center; @@ -132,9 +178,66 @@ padding: 0; background: transparent; cursor: pointer; + color: inherit; +} + +.searchExpanded .searchButton { + width: 28px; + height: 30px; +} + +.searchButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.searchExpanded .searchButton:hover { + background: transparent; +} + +.searchInput { + flex: 1; + width: 0; + min-width: 0; + border: none; + outline: none; + background: transparent; + opacity: 0; + pointer-events: none; + font-size: 13px; + line-height: 18px; + color: var(--dsw-alias-label-primary); + transition: opacity 120ms var(--ds-ease-in-out); +} + +.searchExpanded .searchInput { + margin-left: -2px; + opacity: 1; + pointer-events: auto; +} + +.searchInput::placeholder { + color: var(--dsw-alias-label-tertiary); +} + +.clearButton { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: 50%; + padding: 0; + background: transparent; + cursor: pointer; color: var(--dsw-alias-label-secondary); } +.clearButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + /* Rail variant (own .rail class from the wide owner prop — the region never reads the shell's class names): the two icon controls stack as 36x36 circles matching the shell's rail rhythm. */ @@ -144,6 +247,10 @@ margin-bottom: 12px; } +.rail .headerActions { + max-width: none; +} + .rail .iconButton { width: 36px; height: 36px; @@ -151,6 +258,7 @@ } .rail .search { + width: 36px; height: 36px; padding: 0; margin: 0 0 12px; @@ -162,8 +270,6 @@ .rail .searchButton { width: 36px; height: 36px; - pointer-events: auto; - cursor: pointer; color: var(--dsw-alias-label-primary); } @@ -177,12 +283,18 @@ min-height: 0; display: flex; flex-direction: column; + margin-left: -4px; margin-right: calc(-1 * var(--dsh-session-list-edge-inset)); - overflow: hidden; + padding-left: 4px; + /* The list remains the scroll clip. This seat stays visible so the + absolutely positioned first-boundary marker can occupy the header gap. */ + overflow: visible; } .rail .listArea { + margin-left: 0; margin-right: 0; + padding-left: 0; } /* Relative for the bottom fade overlay. */ @@ -194,14 +306,14 @@ position: relative; } -/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom, +/* Bottom fade: compact overlay pinned to the visible bottom, transparent -> sidebar fill so it tracks the theme. */ .fade { position: absolute; left: 0; right: var(--dsh-session-list-edge-inset); bottom: 0; - height: 72px; + height: 24px; background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill)); pointer-events: none; } @@ -223,15 +335,17 @@ flex: 1; min-height: 0; overflow-y: auto; + margin-left: -4px; margin-right: var(--dsh-session-list-scrollbar-offset); + padding-left: 4px; padding-right: calc( var(--dsh-session-list-edge-inset) - var(--dsh-session-list-scrollbar-width) - var(--dsh-session-list-scrollbar-offset) ); - /* Clears the 72px bottom fade overlay: at scroll end the last row sits + /* Clears the compact bottom fade overlay: at scroll end the last row sits above the gradient instead of under it. */ - padding-bottom: 48px; + padding-bottom: 16px; scrollbar-gutter: stable; } @@ -254,10 +368,84 @@ } /* One workspace section: header row + a compact expanded session run. */ +.groupSection { + position: relative; +} + .groupSection + .groupSection { margin-top: 4px; } +.listTopDropIndicator, +.workspaceDropBefore::before, +.workspaceDropAfter::after { + content: ''; + position: absolute; + z-index: 1; + left: 0; + right: 0; + height: 12px; + background: + linear-gradient( + 55deg, + transparent calc(50% - 1px), + var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px), + transparent calc(50% + 1px) + ) 0 0 / 5px 7px no-repeat, + linear-gradient( + 125deg, + transparent calc(50% - 1px), + var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px), + transparent calc(50% + 1px) + ) 0 5px / 5px 7px no-repeat, + linear-gradient( + var(--dsw-alias-state-business-primary) 0 0 + ) 4px 5px / calc(100% - 4px) 2px no-repeat; + pointer-events: none; +} + +/* The first insertion boundary keeps the same -8px coordinate as every + Workspace boundary, but lives outside the scrolling clip. */ +.listTopDropIndicator { + top: -8px; + left: 0; + right: var(--dsh-session-list-edge-inset); +} + +.listTopDropActive > .workspaceDropBefore:first-child::before { + display: none; +} + +.workspaceDropBefore::before { + top: -8px; +} + +.workspaceDropAfter::after { + bottom: -8px; +} + +.sessionOverflowButton { + width: 100%; + height: 28px; + border: none; + border-radius: 8px; + padding: 0 12px 0 28px; + background: transparent; + cursor: pointer; + text-align: left; + font-size: 12px; + color: var(--dsw-alias-label-tertiary); +} + +.groupSection > .sessionOverflowButton { + margin-top: 0; +} + +.sessionOverflowButton:hover { + background: transparent; + color: var(--dsw-alias-label-secondary); +} + .empty { padding: 16px 12px; color: var(--dsw-alias-label-tertiary); @@ -305,4 +493,12 @@ .wide { animation: none; } + + .search, + .sectionLabel, + .searchSlot, + .searchInput, + .headerActions { + transition: none; + } } diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index a049b3f3d1..e9d6ed192f 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -1,6 +1,6 @@ /** * The workspace/session browsing region filling the sidebar shell's - * `sidebar.workspaces` hole: section header (title + group-by + add + * `sidebar.workspaces` hole: section header (title + view options + add * workspace), search, the grouped tree or flat list, and the workspace * dialogs. Wide state renders the full browser; rail state renders the two * region icons (search / add workspace), each requesting shell expansion @@ -16,12 +16,13 @@ import { IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { - SessionSearchResultItem, WorkspaceId, WorkspaceView, + SessionId, SessionListState, SessionSearchResultItem, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspaceBrowserProps } from './contract/slots.ts' -import type { SessionNode } from './tree.ts' +import type { SessionNode, SessionOrderBy } from './tree.ts' import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts' import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx' +import { FLAT_SESSION_ORDER_KEY } from './stores.ts' import { WorkspacePickFlow } from './WorkspacePicker.tsx' import css from './WorkspaceBrowser.module.css' @@ -34,6 +35,8 @@ const EXPAND_SLIDE_MS = 300 const SEARCH_DEBOUNCE_MS = 250 /** `session.search` wire bound, measured in JavaScript UTF-16 code units. */ const SEARCH_QUERY_MAX_CODE_UNITS = 500 +/** Session rows visible per Workspace before the local overflow control. */ +const COLLAPSED_SESSION_LIMIT = 5 /** Keep controlled input and RPC payload inside the session.search wire contract. */ function sanitizeSearchQuery(value: string): string { @@ -46,15 +49,106 @@ function sanitizeSearchQuery(value: string): string { return withoutNul.slice(0, end) } -/** Immutable membership toggle for the local expansion arrays. */ +/** Immutable membership toggle for the local expand-all array. */ function toggled(list: readonly string[], key: string): string[] { return list.includes(key) ? list.filter(k => k !== key) : [...list, key] } -/** Group-by strategy menu; own open state so it resets with the wide chrome. */ -function GroupByMenu({ groupBy, onPick, t }: { +/** + * Accept the native drag at document level while a row drag is active: row + * hover still owns the insertion marker, and releasing outside the list must + * not be rendered as a rejected drop before dragend commits that last marker. + */ +function useNativeDragAcceptance(active: boolean): void { + useEffect(() => { + if (!active) return + const acceptDrag = (event: DragEvent): void => { + event.preventDefault() + if (event.dataTransfer !== null) event.dataTransfer.dropEffect = 'move' + } + const acceptDrop = (event: DragEvent): void => { event.preventDefault() } + document.addEventListener('dragover', acceptDrag) + document.addEventListener('drop', acceptDrop) + return () => { + document.removeEventListener('dragover', acceptDrag) + document.removeEventListener('drop', acceptDrop) + } + }, [active]) +} + +/** Reconcile a stored view order with the Workspace's current session account. */ +function reconciledSessionOrder(sessionIds: readonly SessionId[], stored: readonly string[] | undefined): SessionId[] { + if (stored === undefined) return [...sessionIds] + const byId = new Map(sessionIds.map(id => [id as string, id])) + const ordered: SessionId[] = [] + const included = new Set() + for (const key of stored) { + const id = byId.get(key) + if (id === undefined || included.has(key)) continue + ordered.push(id) + included.add(key) + } + for (const id of sessionIds) { + if (included.has(id)) continue + ordered.push(id) + } + return ordered +} + +/** Newest update first with stable Session identity as the tie-break. */ +function compareSessionRecency(a: SessionId, b: SessionId, byId: SessionListState['byId']): number { + const aUpdatedAt = byId[a]?.updatedAt ?? Number.NEGATIVE_INFINITY + const bUpdatedAt = byId[b]?.updatedAt ?? Number.NEGATIVE_INFINITY + if (aUpdatedAt !== bUpdatedAt) return bUpdatedAt - aUpdatedAt + return a < b ? -1 : 1 +} + +/** Reconcile one editable order account and apply its activity-promotion policy. */ +function nextSessionOrderAccount({ + sessionIds, previousOrder, previousUpdatedAt, list, orderBy, sortByRecency, +}: { + sessionIds: readonly SessionId[] + previousOrder: readonly string[] | undefined + previousUpdatedAt: Readonly> + list: SessionListState + orderBy: SessionOrderBy + sortByRecency: boolean +}): { order: SessionId[]; updatedAt: Record; changed: boolean } { + let order = reconciledSessionOrder(sessionIds, previousOrder) + if (sortByRecency) { + order.sort((a, b) => compareSessionRecency(a, b, list.byId)) + } else if (orderBy === 'updated') { + const promoted = sessionIds + .filter((id) => { + const session = list.byId[id] + return session !== undefined + && (previousUpdatedAt[id] === undefined || session.updatedAt > previousUpdatedAt[id]) + }) + .sort((a, b) => compareSessionRecency(a, b, list.byId)) + if (promoted.length > 0) { + const promotedIds = new Set(promoted) + order = [...promoted, ...order.filter(id => !promotedIds.has(id))] + } + } + const updatedAt: Record = {} + for (const id of sessionIds) { + const session = list.byId[id] + if (session !== undefined) updatedAt[id] = session.updatedAt + } + const orderChanged = previousOrder === undefined + || order.length !== previousOrder.length + || order.some((id, index) => id !== previousOrder[index]) + const timestampsChanged = Object.keys(updatedAt).length !== Object.keys(previousUpdatedAt).length + || Object.entries(updatedAt).some(([id, timestamp]) => previousUpdatedAt[id] !== timestamp) + return { order, updatedAt, changed: orderChanged || timestampsChanged } +} + +/** Grouping and ordering menu; own open state so it resets with the wide chrome. */ +function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { groupBy: 'workspace' | 'flat' - onPick: (mode: 'workspace' | 'flat') => void + orderBy: SessionOrderBy + onGroupPick: (mode: 'workspace' | 'flat') => void + onOrderPick: (mode: SessionOrderBy) => void t: WorkspaceBrowserProps['t'] }) { const [open, setOpen] = useState(false) @@ -66,23 +160,28 @@ function GroupByMenu({ groupBy, onPick, t }: { { type: 'label' as const, id: 'group-by', text: t('groupBy.label') }, { id: 'workspace', label: t('groupBy.workspace') }, { id: 'flat', label: t('groupBy.flat') }, + { type: 'separator' as const, id: 'order-by-separator' }, + { type: 'label' as const, id: 'order-by', text: t('orderBy.label') }, + { id: 'manual', label: t('orderBy.manual') }, + { id: 'updated', label: t('orderBy.updated') }, ]} - selectedId={groupBy} + selectedIds={[groupBy, orderBy]} onSelect={(id) => { - /* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */ - if (id === 'workspace' || id === 'flat') onPick(id) + if (id === 'workspace' || id === 'flat') onGroupPick(id) + else if (id === 'manual' || id === 'updated') onOrderPick(id) setOpen(false) }} align="end" + dense // Portal: the section header clips overflow, so an in-place list would // be cut off at the header's bounds. portal anchor={( - + + )} +
    + ) + })}
    ) } -/** The flat "In one list" body: every session a top-level row, newest-first. */ -function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick< - SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't' +/** The flat "In one list" body: every session is one draggable top-level row. */ +function FlatList({ + useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, + orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, setRecentSessionOrder, t, +}: Pick< + SessionTreeProps, + | 'useSessions' + | 'open' + | 'forkSession' + | 'onSessionRename' + | 'onSessionArchive' + | 'archivedSessionIds' + | 'orderBy' + | 'recentSessionOrder' + | 'recentSessionUpdatedAt' + | 'syncRecentSessions' + | 'setRecentSessionOrder' + | 't' >) { const list = useSessions(s => s) - const rows = useMemo(() => deriveFlat(list, archivedSessionIds), [list, archivedSessionIds]) + const baseRows = useMemo( + () => deriveFlat(list, archivedSessionIds), + [list, archivedSessionIds], + ) + const sessionIds = useMemo(() => baseRows.map(row => row.id), [baseRows]) + const previousOrderBy = useRef(orderBy) + useEffect(() => { + if (list.phase !== 'ready') return + const previousOrder = recentSessionOrder[FLAT_SESSION_ORDER_KEY] + const previousUpdatedAt = recentSessionUpdatedAt[FLAT_SESSION_ORDER_KEY] ?? {} + const switchedToUpdated = previousOrderBy.current !== 'updated' && orderBy === 'updated' + previousOrderBy.current = orderBy + const next = nextSessionOrderAccount({ + sessionIds, + previousOrder, + previousUpdatedAt, + list, + orderBy, + sortByRecency: orderBy === 'updated' && (previousOrder === undefined || switchedToUpdated), + }) + if (next.changed) { + syncRecentSessions(FLAT_SESSION_ORDER_KEY, next.order.map(id => id as string), next.updatedAt) + } + }, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, sessionIds, syncRecentSessions]) + const rows = useMemo(() => { + const byId = new Map(baseRows.map(row => [row.id, row])) + return reconciledSessionOrder(sessionIds, recentSessionOrder[FLAT_SESSION_ORDER_KEY]) + .flatMap((id) => { + const row = byId.get(id) + return row === undefined ? [] : [row] + }) + }, [baseRows, recentSessionOrder, sessionIds]) + const [drag, setDrag] = useState(null) + const dropCommitted = useRef(false) + useNativeDragAcceptance(drag !== null) + const commitDrag = (activeDrag: DragState, over: NonNullable): void => { + if (dropCommitted.current) return + dropCommitted.current = true + setDrag(null) + const targetIndex = rows.findIndex(row => row.id === over.id) + if (targetIndex === -1) return + const anchor = over.half === 'before' ? over.id : rows[targetIndex + 1]?.id + if (anchor === activeDrag.sessionId) return + const sourceIndex = rows.findIndex(row => row.id === activeDrag.sessionId) + const anchorIndex = anchor === undefined ? rows.length : rows.findIndex(row => row.id === anchor) + if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return + const nextOrder = rows.map(row => row.id).filter(id => id !== activeDrag.sessionId) + const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor) + nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId) + setRecentSessionOrder(FLAT_SESSION_ORDER_KEY, nextOrder.map(id => id as string)) + } const now = Date.now() return (
    @@ -244,19 +620,42 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionAr {rows.length === 0 && (
    {t('empty.none')}
    )} - {rows.map(node => ( - - ))} + {rows.map((node) => { + const active = drag !== null + return ( + { + dropCommitted.current = false + setDrag({ workspaceKey: FLAT_SESSION_ORDER_KEY, sessionId: node.id, over: null }) + }, + active, + marker: active && drag.over?.id === node.id ? drag.over.half : null, + hover: (half) => { + setDrag(current => current === null ? current : { ...current, over: { id: node.id, half } }) + }, + drop: (half) => { + if (drag !== null) commitDrag(drag, { id: node.id, half }) + }, + end: () => { + if (drag?.over !== null && drag?.over !== undefined) commitDrag(drag, drag.over) + else setDrag(null) + dropCommitted.current = false + }, + }} + t={t} + /> + ) + })}
    @@ -352,6 +751,7 @@ export function WorkspaceBrowser({ forkSession, renameWorkspace, deleteWorkspace, + insertWorkspaceBefore, archiveSession, insertSessionBefore, createWorkspace, @@ -362,14 +762,28 @@ export function WorkspaceBrowser({ t, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) + const workspacePhase = useWorkspaces(state => state.phase) const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) // Live occupancy of this surface's directory-flow hole (the same source the // flow reads): a composition without a picking affordance can add nothing. const directoryFlowAvailable = useDirectoryFlow(occupied => occupied) const groupBy = useStore(s => s.groupBy) + const orderBy = useStore(s => s.orderBy) + const workspaceExpansion = useStore(s => s.workspaceExpansion) + const recentSessionOrder = useStore(s => s.recentSessionOrder) + const recentSessionUpdatedAt = useStore(s => s.recentSessionUpdatedAt) + useEffect(() => { + if (workspacePhase !== 'ready') return + actions.retainWorkspaceKeys([ + UNGROUPED_KEY, + FLAT_SESSION_ORDER_KEY, + ...workspaces.map(workspace => workspace.workspaceId as string), + ]) + }, [actions.retainWorkspaceKeys, workspacePhase, workspaces]) // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. const [query, setQuery] = useState('') + const [searchExpanded, setSearchExpanded] = useState(false) const normalizedQuery = sanitizeSearchQuery(query).trim() const [remoteSearch, setRemoteSearch] = useState({ query: '', @@ -377,6 +791,7 @@ export function WorkspaceBrowser({ items: [], hasMore: false, }) + const searchRoot = useRef(null) const searchInput = useRef(null) // Section-header + opens the picker menu (same popover in wide and rail // states; the menu anchors on this button). @@ -397,6 +812,23 @@ export function WorkspaceBrowser({ } }, [wide, searchOnExpand]) + useEffect(() => { + if (!wide || !searchExpanded || searchOnExpand) return + searchInput.current?.focus({ preventScroll: true }) + }, [wide, searchExpanded, searchOnExpand]) + + useEffect(() => { + if (!wide || !searchExpanded) return + const onClick = (event: MouseEvent): void => { + if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return + searchInput.current?.blur() + if (normalizedQuery !== '') return + setSearchExpanded(false) + } + document.addEventListener('click', onClick) + return () => { document.removeEventListener('click', onClick) } + }, [normalizedQuery, wide, searchExpanded]) + useEffect(() => { if (normalizedQuery === '') { setRemoteSearch({ query: '', status: 'idle', items: [], hasMore: false }) @@ -544,29 +976,96 @@ export function WorkspaceBrowser({
    {wide && ( - + {groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')} )} - {wide && { actions.setGroupBy(mode) }} t={t} />} - {/* Adding is the button's one action, so a composition with no - picking affordance has nothing to offer here: the region hides the - button rather than leaving a dead one in the header. */} - {directoryFlowAvailable && ( - - - + + + + { setQuery(sanitizeSearchQuery(e.target.value)) }} + onKeyDown={(e) => { + if (e.key !== 'Escape') return + setQuery('') + setSearchExpanded(false) + }} + /> + {searchExpanded && ( + + )} +
    +
    )} +
    + {wide && ( + { actions.setGroupBy(mode) }} + onOrderPick={(mode) => { actions.setOrderBy(mode) }} + t={t} + /> + )} + {/* Adding is the button's one action, so a composition with no + picking affordance has nothing to offer here: the region hides the + button rather than leaving a dead one in the header. */} + {directoryFlowAvailable && ( + + + + )} +
    {/* Add flow + its error dialog (same package — direct composition). */} - {/* Expanded: the row is a click-to-focus field (the leading icon is - decorative). Rail: the icon is the region's search control. */} -
    { if (wide) searchInput.current?.focus() }}> - + {/* The collapsed rail keeps search as its own 36px control. */} + {!wide &&
    + - {wide && ( - { setQuery(sanitizeSearchQuery(e.target.value)) }} - /> - )} - {wide && query !== '' && ( - - )} -
    +
    } {/* Always-mounted seat keeps the region's flex slot while the list itself is wide-only. */} @@ -644,7 +1124,13 @@ export function WorkspaceBrowser({ ) : ( @@ -654,10 +1140,18 @@ export function WorkspaceBrowser({ onSessionArchive={onSessionArchive} forkSession={forkSession} workspaces={workspaces} + workspaceExpansion={workspaceExpansion} + setWorkspaceExpanded={actions.setWorkspaceExpanded} + recentSessionOrder={recentSessionOrder} + recentSessionUpdatedAt={recentSessionUpdatedAt} + syncRecentSessions={actions.syncRecentSessions} + setRecentSessionOrder={actions.setRecentSessionOrder} archivedSessionIds={archivedSessionIds} startSession={startSession} open={open} + insertWorkspaceBefore={insertWorkspaceBefore} insertSessionBefore={insertSessionBefore} + orderBy={orderBy} t={t} onRenameRequest={(workspaceId, currentTitle) => { setRenameTarget({ workspaceId, currentTitle }) diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index e1c41c9c17..8027a3623a 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -91,9 +91,9 @@ export type DirectoryPickingHooks = { */ export type WorkspaceBrowserInjected = DirectoryPickingInjected & { /** - * Start a New Session in a Workspace: reuse-or-create its blank session - * and open it; with no workspace, clear the selection into the New Session - * pure view state (the conversation.empty seat). + * Start a New Session in a Workspace: reuse-or-create its blank session and + * open it; without an explicit workspace, inherit the current Session + * Workspace, then the recent Workspace, or clear into the New Session view. */ startSession: (workspaceId?: WorkspaceId) => void /** Open a real Session. */ @@ -116,6 +116,11 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & { renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise /** Delete only a Host Workspace registration; directory and Session logs remain. */ deleteWorkspace: (workspaceId: WorkspaceId) => Promise + /** + * Reorder a Workspace in the durable registry display order. + * Omitted anchor appends to the end. + */ + insertWorkspaceBefore: (workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId) => Promise /** * Archive a Session into the registry-global set: hidden from grouping * surfaces, log and accounting slot retained. Archiving the current diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 5f499c4336..6b14243ecf 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -68,8 +68,8 @@ export function apply(ctx: ClientContext): void { const browserFlowSource = flowSource('sidebar.workspaces.directoryFlow') const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow') const browserInjected = (): WorkspaceBrowserInjected => ({ - // Explicit group actions keep their target; unscoped New Session rides - // the runtime's shared action (recent-Workspace projection inside). + // Explicit group actions keep their target; unscoped New Session inherits + // the current Session Workspace before the recent-Workspace fallback. startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) }, open: (sessionId) => { ctx.sessions.open(sessionId) }, searchSessions, @@ -91,6 +91,9 @@ export function apply(ctx: ClientContext): void { }, renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) }, + insertWorkspaceBefore: async (workspaceId, beforeWorkspaceId) => { + await ctx.workspaces.insertBefore(workspaceId, beforeWorkspaceId) + }, archiveSession: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId) }, insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 30fe6bfcc0..c08fd931de 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -10,14 +10,20 @@ export const zh = { 'session.new': '新会话', 'section.workspaces': '工作区', 'section.sessions': '会话', + 'viewOptions.label': '视图选项', 'groupBy.label': '分组方式', 'groupBy.workspace': '按工作区', 'groupBy.flat': '单列表', + 'orderBy.label': '排序方式', + 'orderBy.manual': '手动排序', + 'orderBy.updated': '最近更新', + 'sessions.expand': '展开其余 {n} 个会话', + 'sessions.collapse': '收起', 'empty.none': '暂无会话', 'empty.noMatches': '无匹配结果', 'workspace.add': '添加工作区', 'search.sessions.aria': '搜索会话', - 'search.placeholder': '搜索名称、关键词…', + 'search.placeholder': '搜索会话…', 'search.clear': '清除搜索', 'search.results.aria': '搜索结果', 'search.pending': '正在搜索会话历史…', @@ -73,14 +79,20 @@ export const en = { 'session.new': 'New Session', 'section.workspaces': 'Workspaces', 'section.sessions': 'Sessions', + 'viewOptions.label': 'View options', 'groupBy.label': 'Group by', 'groupBy.workspace': 'WorkSpace', 'groupBy.flat': 'In one list', + 'orderBy.label': 'Order by', + 'orderBy.manual': 'Manual', + 'orderBy.updated': 'Last updated', + 'sessions.expand': 'Show {n} more sessions', + 'sessions.collapse': 'Show less', 'empty.none': 'No sessions yet', 'empty.noMatches': 'No matches', 'workspace.add': 'Add workspace', 'search.sessions.aria': 'Search sessions', - 'search.placeholder': 'Search name, keywords...', + 'search.placeholder': 'Search sessions...', 'search.clear': 'Clear search', 'search.results.aria': 'Search results', 'search.pending': 'Searching session history…', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 612eb3e306..5dc899af45 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -1,5 +1,5 @@ -/* Tree rows (figma Cell set 14:3080): project 54px two-line, session 34px - single-line, radius 8, indent step 22px (16px slot + 6px gap). Hover swaps +/* Tree rows: project 34px, session 32px, radius 8, indent step 22px + (16px slot + 6px gap). Hover swaps are pure CSS: project folder -> chevron + action buttons; session time -> ellipsis button. */ @@ -21,7 +21,7 @@ } .sessionRow.selected { - background: var(--dsw-alias-interactive-bg-active); + background: var(--dsw-alias-interactive-bg-hover); } .searchResultRow { @@ -29,11 +29,11 @@ flex-direction: column; align-items: stretch; width: 100%; - min-height: 62px; + min-height: 48px; box-sizing: border-box; border: none; border-radius: 8px; - padding: 7px 8px; + padding: 4px 8px; background: transparent; cursor: pointer; text-align: left; @@ -45,7 +45,7 @@ } .searchResultRow.selected { - background: var(--dsw-alias-interactive-bg-active); + background: var(--dsw-alias-interactive-bg-hover); } .searchResultHeading { @@ -64,9 +64,16 @@ line-height: 20px; } +.searchResultMeta { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + margin-left: 20px; +} + .searchResultWorkspace, .searchResultSnippet { - margin-left: 20px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -75,21 +82,21 @@ } .searchResultWorkspace { + flex: none; + max-width: 40%; color: var(--dsw-alias-label-tertiary); } .searchResultSnippet { + flex: 1; + min-width: 0; color: var(--dsw-alias-label-secondary); } -/* Two-line row: the leading slot (folder/chevron), title, and trailing - actions all top-align on the 20px first text line (figma cell) — content - is 42px (20 + 2 + 20), so 6px vertical padding centers the block. */ +/* Compact one-line Workspace row after removing the session-count subtitle. */ .projectRow { - height: 54px; - align-items: flex-start; - padding-top: 6px; - padding-bottom: 6px; + height: 34px; + align-items: center; box-sizing: border-box; } @@ -99,7 +106,7 @@ /* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */ .sessionRow { - height: 34px; + height: 32px; gap: 0; /* Mount fade: session rows appear by unfolding a group (or the tree mounting). Stable row keys keep already-visible rows from replaying it. */ @@ -110,6 +117,10 @@ margin: 0 6px 0 4px; } +.flatSessionRowWithoutStatus .title { + margin-left: 0; +} + @keyframes row-in { from { opacity: 0; } } @@ -133,11 +144,11 @@ white-space: nowrap; } - .folderActive { color: var(--dsw-alias-state-business-primary); } + /* Project leading slot: folder by default, expand arrow on row hover. */ .projectRow .chevron { display: none; } .projectRow:hover .chevron { display: inline-flex; } @@ -233,14 +244,46 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Drag reorder insert line (workspace-group session rows): 2px accent above or - below the hovered row, drawn with box-shadow so no layout shift. */ -.sessionRow.dropBefore { - box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary); +/* Session drag insert marker: a leading chevron and 2px rule between rows, + absolutely positioned so it neither resembles a row border nor changes layout. */ +.sessionRow.dropBefore, +.sessionRow.dropAfter { + position: relative; } -.sessionRow.dropAfter { - box-shadow: 0 2px 0 0 var(--dsw-alias-state-business-primary); +.sessionRow.dropBefore::before, +.sessionRow.dropAfter::after { + content: ''; + position: absolute; + z-index: 1; + left: 0; + right: 4px; + height: 12px; + background: + linear-gradient( + 55deg, + transparent calc(50% - 1px), + var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px), + transparent calc(50% + 1px) + ) 0 0 / 5px 7px no-repeat, + linear-gradient( + 125deg, + transparent calc(50% - 1px), + var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px), + transparent calc(50% + 1px) + ) 0 5px / 5px 7px no-repeat, + linear-gradient( + var(--dsw-alias-state-business-primary) 0 0 + ) 4px 5px / calc(100% - 4px) 2px no-repeat; + pointer-events: none; +} + +.sessionRow.dropBefore::before { + top: -7px; +} + +.sessionRow.dropAfter::after { + bottom: -7px; } /* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */ diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 71c0b05af5..481e0f0e47 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -67,29 +67,60 @@ function WorkspaceHoverContent({ label, cwd, createdAt, t }: { } /** - * Project (workspace) header row: 54px, folder + title + session count; + * Row drag wiring supplied by the tree owner. `drop` reports the half of the + * row where the pointer released so the owner can resolve an insert anchor. + */ +export interface RowDragProps { + /** Start dragging this row. */ + start: () => void + /** A compatible row drag is in flight. */ + active: boolean + /** Current marker on this row: insert line above, below, or none. */ + marker: 'before' | 'after' | null + /** Report the hovered half while a compatible drag passes over this row. */ + hover: (half: 'before' | 'after') => void + drop: (half: 'before' | 'after') => void + end: () => void +} + +/** Drag lifecycle owned by a workspace row; its enclosing group owns hit testing. */ +interface WorkspaceRowDragProps { + start: () => void + end: () => void +} + +/** Pointer-position half of a row (insert line above or below). */ +function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { + const rect = e.currentTarget.getBoundingClientRect() + return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' +} + +/** + * Project (workspace) header row: folder + title; * hover reveals the chevron and create button, and dwelling on a real * Workspace shows its hover card (the ungrouped bucket has none). * `containsCurrent` arrives on the node (derivation fact, no renderer scan). * @param props.group - derived group node. * @param props.onToggle - expand/collapse the group. * @param props.onCreate - start a frontend Session inside this Workspace. + * @param props.drag - optional workspace-row drag wiring. * @param props.t - the browser root's locale seat. * @returns the row element. */ -export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { +export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: { group: GroupNode onToggle: () => void onCreate: () => void /** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */ actions?: { rename: () => void; delete: () => void } | undefined + /** Present only for real Workspace rows in the grouped view. */ + drag?: WorkspaceRowDragProps | undefined t: RowTranslate }) { const row = group // The ungrouped bucket has no workspace title: its label is dictionary copy. const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label const active = group.expanded && group.containsCurrent - const count = t(row.sessionCount === 1 ? 'sessions.count.one' : 'sessions.count.other', { n: row.sessionCount }) const [menuOpen, setMenuOpen] = useState(false) const workspaceMenuItems = [ { id: 'rename', label: t('rename'), icon: }, @@ -101,6 +132,15 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { role="treeitem" aria-expanded={row.expanded} onClick={onToggle} + draggable={drag !== undefined} + onDragStart={drag === undefined + ? undefined + : (e) => { + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', row.key) + drag.start() + }} + onDragEnd={drag?.end} > {row.expanded ? : } @@ -110,7 +150,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { {label} - {count} {actions !== undefined && ( @@ -220,6 +259,18 @@ function sessionStatuses( return [{ state: 'done', label: t('status.idle') }] } +/** Primary status dot plus every status's screen-reader label, shared by the search and session rows. */ +function SessionStatusDots({ statuses }: { statuses: readonly [SessionStatus, ...SessionStatus[]] }) { + return ( + <> + + {statuses.map(status => ( + {status.label} + ))} + + ) +} + /** Hover-card body: full title, relative time, and every relevant live status. */ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) { const statuses = sessionStatuses(node, t) @@ -239,24 +290,6 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; ) } -/** - * Session-row drag wiring supplied by the group owner (workspace groups only). - * `drop` reports the half of the row the pointer released on: 'before' - * inserts above this row, 'after' below it (the owner resolves the anchor). - */ -export interface RowDragProps { - /** Start dragging this row. */ - start: () => void - /** A drag from the same group is in flight (rows show insert markers). */ - active: boolean - /** Current marker on this row: insert line above, below, or none. */ - marker: 'before' | 'after' | null - /** Report the hovered half while a same-group drag passes over this row. */ - hover: (half: 'before' | 'after') => void - drop: (half: 'before' | 'after') => void - end: () => void -} - /** * One flat search result: title, Workspace context, and optional content * excerpt. Search navigation opens the session only; it does not address an @@ -287,30 +320,21 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { {(primaryStatus.state !== 'done' || result.completed) && ( - <> - - {statuses.map(status => ( - {status.label} - ))} - + )} {result.title} - {result.workspace} - {result.snippet !== undefined && ( - {result.snippet} - )} + + {result.workspace} + {result.snippet !== undefined && ( + {result.snippet} + )} + ) } -/** Pointer-position half of a row (insert line above or below). */ -function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { - const rect = e.currentTarget.getBoundingClientRect() - return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' -} - /** * One top-level 34px session row: status dot (pending user interaction outranks * own or descendant activity), title, relative time, and the row actions menu. @@ -322,10 +346,11 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | * @param props.onFork - fork a session at its last completed turn. * @param props.onArchive - archive a session by id. * @param props.drag - optional draggable-row wiring. + * @param props.flat - omit the empty status slot in the hierarchy-free flat list. * @param props.t - the browser root's locale seat. * @returns the session row. */ -export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: { +export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, flat = false, t }: { node: SessionNode currentId: string | undefined now: number @@ -338,6 +363,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork onArchive: (id: SessionNode['id']) => void /** Present only on draggable rows (workspace-group sessions outside search). */ drag?: RowDragProps | undefined + /** The row is rendered without a parent Workspace header. */ + flat?: boolean | undefined t: RowTranslate }) { const row = node @@ -345,6 +372,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork const selected = node.id === currentId const statuses = sessionStatuses(node, t) const primaryStatus = statuses[0] + const showStatus = primaryStatus.state !== 'done' || row.completed const [menuOpen, setMenuOpen] = useState(false) // Archive hides the row through the registry-global archive set and never // touches the session log, so it is not styled as destructive and needs no @@ -360,6 +388,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
    { e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', node.id) drag.start() }} onDragEnd={drag?.end} @@ -392,16 +422,11 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork {/* Pending interaction and own or descendant activity outrank the finished-but-unviewed reminder, which returns after activity stops and is cleared by opening the session. */} - - {(primaryStatus.state !== 'done' || row.completed) && ( - <> - - {statuses.map(status => ( - {status.label} - ))} - - )} - + {(!flat || showStatus) && ( + + {showStatus && } + + )} {title} {/* A blank New Session row is a provisional placeholder: nothing has happened in it yet, so a "now" timestamp and the row verbs diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts index ed89d80d9e..4df6fd6fc3 100644 --- a/packages/client/ui-workspace/src/client/stores.ts +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -7,11 +7,25 @@ */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' +/** Browser-local order account for the hierarchy-free flat Session list. */ +export const FLAT_SESSION_ORDER_KEY = '__flat_session_order__' + /** Session-list grouping mode: workspace sections or one flat recency list. */ export type WorkspaceGroupBy = 'workspace' | 'flat' +/** Session order: user-arranged only, or user-arranged plus activity promotion. */ +export type WorkspaceOrderBy = 'manual' | 'updated' -/** Workspace browser viewing state (grouping mode only; transient UI facts stay component-local). */ -type WorkspaceViewState = { groupBy: WorkspaceGroupBy } +/** Workspace browser viewing state persisted across surface remounts and reloads. */ +type WorkspaceViewState = { + groupBy: WorkspaceGroupBy + orderBy: WorkspaceOrderBy + /** Explicit zero-or-five-session state keyed by Workspace group identity. */ + workspaceExpansion: Record + /** Shared editable order per Workspace group plus the browser-local flat-list account. */ + recentSessionOrder: Record + /** Last observed update timestamps per order account for one-time promotion events. */ + recentSessionUpdatedAt: Record> +} /** * Annotation twin of the actions literal below (the export needs a declared @@ -19,6 +33,16 @@ type WorkspaceViewState = { groupBy: WorkspaceGroupBy } */ type WorkspaceViewActions = { setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void + setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void + setWorkspaceExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void + retainWorkspaceKeys: (draft: WorkspaceViewState, workspaceKeys: readonly string[]) => void + syncRecentSessions: ( + draft: WorkspaceViewState, + workspaceKey: string, + order: string[], + updatedAt: Record, + ) => void + setRecentSessionOrder: (draft: WorkspaceViewState, workspaceKey: string, order: string[]) => void } /** @@ -27,10 +51,37 @@ type WorkspaceViewActions = { */ export function createWorkspaceViewStore(): EngineStoreHandle { return defineStore({ - init: (): WorkspaceViewState => ({ groupBy: 'workspace' }), - persist: 'dsh.workspace.view', + init: (): WorkspaceViewState => ({ + groupBy: 'workspace', + orderBy: 'manual', + workspaceExpansion: {}, + recentSessionOrder: {}, + recentSessionUpdatedAt: {}, + }), + persist: 'dsh.workspace.view.v4', actions: { setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode }, + setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode }, + setWorkspaceExpanded: (d, key: string, expanded: boolean) => { d.workspaceExpansion[key] = expanded }, + retainWorkspaceKeys: (d, workspaceKeys: readonly string[]) => { + const retained = new Set(workspaceKeys) + d.workspaceExpansion = Object.fromEntries( + Object.entries(d.workspaceExpansion).filter(([key]) => retained.has(key)), + ) + d.recentSessionOrder = Object.fromEntries( + Object.entries(d.recentSessionOrder).filter(([key]) => retained.has(key)), + ) + d.recentSessionUpdatedAt = Object.fromEntries( + Object.entries(d.recentSessionUpdatedAt).filter(([key]) => retained.has(key)), + ) + }, + syncRecentSessions: (d, workspaceKey: string, order: string[], updatedAt: Record) => { + d.recentSessionOrder[workspaceKey] = order + d.recentSessionUpdatedAt[workspaceKey] = updatedAt + }, + setRecentSessionOrder: (d, workspaceKey: string, order: string[]) => { + d.recentSessionOrder[workspaceKey] = order + }, }, }) } diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 008ab687f7..e9cbf8ed71 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -32,6 +32,9 @@ export interface SessionNode { updatedAt: number } +/** Session order selected by the Workspace browser. */ +export type SessionOrderBy = 'manual' | 'updated' + /** One workspace group section: header row facts + visible top-level session rows. */ export interface GroupNode { /** Group key: the workspace id or {@link UNGROUPED_KEY}. */ @@ -75,6 +78,8 @@ export interface SearchResultSet { /** Viewing state consumed by the derivation. */ export interface TreeView { expandedProjects: readonly string[] + /** Browser-local order for Sessions without a backing Workspace account. */ + ungroupedOrder?: readonly string[] } interface Group { @@ -136,21 +141,41 @@ function buildGroup( order: 'account' | 'recency', ): Group { const sessions = [...members] - // Workspace order is workspace.sessionIds; only Ungrouped lacks an account - // order and therefore falls back to recency. + // Real Workspace order comes from sessionIds. Ungrouped falls back to + // recency until the browser supplies its persisted local order. if (order === 'recency') sessions.sort(byRecency) return { key, workspaceId, cwd, createdAt, label, sessions } } +/** Apply a stored Ungrouped order and append newly loose Sessions by recency. */ +function orderedUngrouped(members: readonly SessionSummary[], stored: readonly string[]): SessionSummary[] { + const byId = new Map(members.map(session => [session.id as string, session])) + const included = new Set() + const ordered: SessionSummary[] = [] + for (const key of stored) { + const session = byId.get(key) + if (session === undefined || included.has(key)) continue + ordered.push(session) + included.add(key) + } + for (const session of [...members].sort(byRecency)) { + if (included.has(session.id)) continue + ordered.push(session) + } + return ordered +} + /** * Group Sessions by Host Workspace: one group per entity in stable Host * order, with members resolved from sessionIds in their stored order. Sessions - * outside every Workspace trail in the recency-ordered Ungrouped bucket. + * outside every Workspace trail in the browser-local Ungrouped order, which + * falls back to recency before that order is initialized. */ function groupByWorkspace( list: SessionListState, workspaces: readonly WorkspaceView[], archived: ReadonlySet, + ungroupedOrder: readonly string[] | undefined, ): Group[] { const groups: Group[] = [] const accounted = new Set() @@ -173,7 +198,15 @@ function groupByWorkspace( .filter((s): s is SessionSummary => s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived)) if (stray.length > 0) { - groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency')) + groups.push(buildGroup( + UNGROUPED_KEY, + undefined, + undefined, + undefined, + UNGROUPED_LABEL, + ungroupedOrder === undefined ? stray : orderedUngrouped(stray, ungroupedOrder), + ungroupedOrder === undefined ? 'recency' : 'account', + )) } return groups } @@ -197,8 +230,8 @@ function sessionNode( /** * Derive the workspace browser groups with every session as a top-level row. * - * Every group shows; sessions populate under expanded groups, preserving - * Host account order. Blank sessions are excluded except for the selected + * Every group shows; sessions populate under expanded groups in the selected + * local order. Blank sessions are excluded except for the selected * provisional New Session row; archived sessions are excluded everywhere. * Content search lives outside this derivation * (see {@link deriveSearchResults}). @@ -222,7 +255,7 @@ export function deriveGroups( : (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined) ?? UNGROUPED_KEY const groups: GroupNode[] = [] - for (const g of groupByWorkspace(list, workspaces, archived)) { + for (const g of groupByWorkspace(list, workspaces, archived, view.ungroupedOrder)) { const expanded = expandedProjects.has(g.key) groups.push({ key: g.key, @@ -248,7 +281,10 @@ export function deriveGroups( * @param archivedSessionIds - registry-global archive set. * @returns flat rows in render order. */ -export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] { +export function deriveFlat( + list: SessionListState, + archivedSessionIds: readonly SessionId[], +): SessionNode[] { const archived = new Set(archivedSessionIds) const descendants = indexSubagentDescendants(list.byId) const rows: SessionSummary[] = [] diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts similarity index 100% rename from packages/client/ui-workspace/tests/apply.spec.ts rename to packages/client/ui-workspace/tests/apply.client.spec.ts diff --git a/packages/client/ui-workspace/tests/browser-styles.spec.ts b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts similarity index 53% rename from packages/client/ui-workspace/tests/browser-styles.spec.ts rename to packages/client/ui-workspace/tests/browser-styles.client.spec.ts index 4165971bff..d66baef917 100644 --- a/packages/client/ui-workspace/tests/browser-styles.spec.ts +++ b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8') +const rowsCss = readFileSync(fileURLToPath(new URL('../src/client/rows/Rows.module.css', import.meta.url)), 'utf8') /** * Declarations of one selector rule, keyed by property with whitespace collapsed. @@ -15,21 +16,23 @@ const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.m * @param selector - one exact selector, including a leading dot for local classes. * @returns the rule's declarations, or undefined when no such rule exists. */ -function declarations(selector: string): Map | undefined { - const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ') +function declarationsFrom(source: string, selector: string): Map | undefined { + const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, ' ') + const found = new Map() for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue - const found = new Map() for (const part of body.split(';')) { const colon = part.indexOf(':') if (colon === -1) continue found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' ')) } - return found } - return undefined + return found.size === 0 ? undefined : found } +const declarations = (selector: string): Map | undefined => declarationsFrom(css, selector) +const rowDeclarations = (selector: string): Map | undefined => declarationsFrom(rowsCss, selector) + describe('WorkspaceBrowser.module.css list', () => { const root = declarations('.root') const listArea = declarations('.listArea') @@ -45,9 +48,13 @@ describe('WorkspaceBrowser.module.css list', () => { expect(root?.get('--dsh-session-list-scrollbar-width')).toBe('8px') expect(root?.get('--dsh-session-list-scrollbar-offset')).toBe('2px') expect(root?.get('padding-right')).toBe('var(--dsh-session-list-edge-inset)') + expect(listArea?.get('margin-left')).toBe('-4px') + expect(listArea?.get('padding-left')).toBe('4px') expect(listArea?.get('margin-right')).toBe('calc(-1 * var(--dsh-session-list-edge-inset))') expect(declarations('.fade')?.get('right')).toBe('var(--dsh-session-list-edge-inset)') expect(list?.get('margin-right')).toBe('var(--dsh-session-list-scrollbar-offset)') + expect(list?.get('margin-left')).toBe('-4px') + expect(list?.get('padding-left')).toBe('4px') expect(list?.get('padding-right')).toBe([ 'calc(', 'var(--dsh-session-list-edge-inset)', @@ -68,4 +75,36 @@ describe('WorkspaceBrowser.module.css list', () => { expect(declarations('.groupSection > * + *')?.get('margin-top')).toBe('2px') expect(declarations('.groupSection + .groupSection')?.get('margin-top')).toBe('4px') }) + + it('draws drag targets as a leading chevron joined to the insertion line', () => { + const listTopMarker = declarations('.listTopDropIndicator') + const workspaceMarker = declarations('.workspaceDropBefore::before') + const sessionMarker = rowDeclarations('.sessionRow.dropBefore::before') + expect(listTopMarker?.get('top')).toBe('-8px') + expect(listTopMarker?.get('left')).toBe('0') + expect(workspaceMarker?.get('left')).toBe('0') + expect(sessionMarker?.get('left')).toBe('0') + for (const marker of [listTopMarker, workspaceMarker, sessionMarker]) { + expect(marker?.get('height')).toBe('12px') + expect(marker?.get('background')).not.toContain('radial-gradient') + expect(marker?.get('background')).toContain('55deg') + expect(marker?.get('background')).toContain('125deg') + expect(marker?.get('background')).toContain('calc(50% - 1px) calc(50% + 1px)') + expect(marker?.get('background')).toContain('0 0 / 5px 7px') + expect(marker?.get('background')).toContain('0 5px / 5px 7px') + expect(marker?.get('background')).toContain('4px 5px / calc(100% - 4px) 2px') + } + }) + + it('keeps the compact fade, overflow control, search field, and row heights', () => { + expect(declarations('.fade')?.get('height')).toBe('24px') + expect(declarations('.sessionOverflowButton')?.get('height')).toBe('28px') + expect(declarations('.searchExpanded')?.get('height')).toBe('30px') + expect(rowDeclarations('.projectRow')?.get('height')).toBe('34px') + expect(rowDeclarations('.sessionRow')?.get('height')).toBe('32px') + expect(rowDeclarations('.flatSessionRowWithoutStatus .title')?.get('margin-left')).toBe('0') + expect(rowDeclarations('.searchResultRow')?.get('min-height')).toBe('48px') + expect(rowDeclarations('.sessionRow.selected')?.get('background')) + .toBe('var(--dsw-alias-interactive-bg-hover)') + }) }) diff --git a/packages/client/ui-workspace/tests/invariant.spec.ts b/packages/client/ui-workspace/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-workspace/tests/invariant.spec.ts rename to packages/client/ui-workspace/tests/invariant.client.spec.ts diff --git a/packages/client/ui-workspace/tests/rename-assembly.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx similarity index 100% rename from packages/client/ui-workspace/tests/rename-assembly.spec.tsx rename to packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx similarity index 96% rename from packages/client/ui-workspace/tests/rows.spec.tsx rename to packages/client/ui-workspace/tests/rows.client.spec.tsx index 7e0971cf72..c7a153ff5f 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -46,7 +46,7 @@ function installClipboard(writeText: (text: string) => Promise): () => voi } } -const dataTransfer = { effectAllowed: '', dropEffect: '' } +const dataTransfer = { effectAllowed: '', dropEffect: '', setData: vi.fn() } /** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void { @@ -57,6 +57,21 @@ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): } describe('workspace browser rows', () => { + it('omits only an empty leading status slot in the hierarchy-free flat list', () => { + const idle: SessionNode = { + id: sid('flat'), title: 'Flat Session', blank: false, running: false, + runningSubagentCount: 0, completed: false, updatedAt: 0, + } + const view = render() + const title = screen.getByText('Flat Session') + expect(title.previousElementSibling).toBeNull() + + view.rerender() + expect(screen.getByText('Flat Session').previousElementSibling?.querySelector('[data-state="ongoing"]')).toBeTruthy() + }) + it('renders a selected content-search row and opens only its session', () => { const onOpen = vi.fn() const result: SearchResultNode = { @@ -105,7 +120,6 @@ describe('workspace browser rows', () => { } render() - expect(screen.getByText('1 个会话')).toBeTruthy() expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true') fireEvent.click(screen.getByRole('button', { name: '在“Project”中新建会话' })) expect(onCreate).toHaveBeenCalledOnce() diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts similarity index 89% rename from packages/client/ui-workspace/tests/tree.spec.ts rename to packages/client/ui-workspace/tests/tree.client.spec.ts index 5a6d145e36..3ad1468af5 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -11,7 +11,8 @@ import { createWorkspaceViewStore } from '../src/client/stores.ts' const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({ - id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }), + id: sid(id), displayTitle: id, running: false, blank: false, + updatedAt, ...(cwd === undefined ? {} : { cwd }), }) const list = (...items: SessionSummary[]): SessionListState => ({ ids: items.map(item => item.id), @@ -23,8 +24,9 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView workspaceId: wid(id), path: `/projects/${id}`, title, sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }) -const view = (expandedProjects: readonly string[] = []) => ({ +const view = (expandedProjects: readonly string[] = [], ungroupedOrder?: readonly string[]) => ({ expandedProjects, + ...(ungroupedOrder === undefined ? {} : { ungroupedOrder }), }) const noArchive: readonly SessionId[] = [] const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid) @@ -53,6 +55,19 @@ describe('deriveGroups', () => { expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')]) }) + it('applies stored Ungrouped order and appends new loose Sessions by recency', () => { + const sessions = list(summary('one', 3), summary('two', 2), summary('new', 4)) + const groups = deriveGroups( + sessions, + [], + noArchive, + view([UNGROUPED_KEY], ['two', 'stale', 'two']), + ) + expect(groups[0]!.sessions.map(session => session.id)).toEqual([ + sid('two'), sid('new'), sid('one'), + ]) + }) + it('shows only the current blank session in its Workspace count and tree', () => { const currentBlank = { ...summary('current-blank', 5), blank: true } const staleBlank = { ...summary('stale-blank', 4), blank: true } @@ -377,11 +392,38 @@ describe('deriveSearchResults', () => { }) describe('createWorkspaceViewStore', () => { - it('defaults to workspace grouping; setGroupBy is the sole mutation', () => { + it('stores grouping, ordering, Workspace expansion, and recent-session view order', () => { const store = createWorkspaceViewStore().create() expect(store.getSnapshot().groupBy).toBe('workspace') + expect(store.getSnapshot().orderBy).toBe('manual') store.actions.setGroupBy('flat') + store.actions.setOrderBy('updated') + store.actions.setWorkspaceExpanded('alpha', true) + store.actions.syncRecentSessions('alpha', ['two', 'one'], { one: 1, two: 2 }) + store.actions.setRecentSessionOrder('alpha', ['one', 'two']) expect(store.getSnapshot().groupBy).toBe('flat') + expect(store.getSnapshot()).toMatchObject({ + orderBy: 'updated', + workspaceExpansion: { alpha: true }, + recentSessionOrder: { alpha: ['one', 'two'] }, + recentSessionUpdatedAt: { alpha: { one: 1, two: 2 } }, + }) + }) + + it('removes view state outside the retained Workspace key set', () => { + const store = createWorkspaceViewStore().create() + store.actions.setWorkspaceExpanded('', true) + store.actions.setWorkspaceExpanded('alpha', true) + store.actions.setWorkspaceExpanded('deleted', true) + store.actions.syncRecentSessions('alpha', ['alpha-session'], { 'alpha-session': 2 }) + store.actions.syncRecentSessions('deleted', ['deleted-session'], { 'deleted-session': 1 }) + + store.actions.retainWorkspaceKeys(['', 'alpha']) + + const snapshot = store.getSnapshot() + expect(snapshot.workspaceExpansion).toEqual({ '': true, alpha: true }) + expect(snapshot.recentSessionOrder).toEqual({ alpha: ['alpha-session'] }) + expect(snapshot.recentSessionUpdatedAt).toEqual({ alpha: { 'alpha-session': 2 } }) }) }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx similarity index 66% rename from packages/client/ui-workspace/tests/workspace-browser.spec.tsx rename to packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 918a3f6c0e..c39e7c37c4 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -8,7 +8,8 @@ import type { import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts' -import { createWorkspaceViewStore } from '../src/client/stores.ts' +import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts' +import { UNGROUPED_KEY } from '../src/client/tree.ts' import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' import { zh } from '../src/client/locales.ts' @@ -53,6 +54,10 @@ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): fireEvent(row, event) } +function dragData(): Pick { + return { effectAllowed: 'uninitialized', dropEffect: 'none', setData: vi.fn() } +} + function mount(overrides: Partial = {}) { const store = createWorkspaceViewStore().create() const props: WorkspaceBrowserProps = { @@ -71,6 +76,7 @@ function mount(overrides: Partial = {}) { renameWorkspace: vi.fn(async () => {}), deleteWorkspace: vi.fn(async () => {}), archiveSession: vi.fn(async () => {}), + insertWorkspaceBefore: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), @@ -89,6 +95,28 @@ function rerender(b: ReturnType, overrides: Partial { + it('prunes deleted Workspace view state only after the Workspace baseline is ready', async () => { + const pending = { + ...workspaceState([]), + phase: 'pending' as const, + state: 'loading' as const, + baselinesReady: false, + } + const b = mount({ useWorkspaces: hook(pending) }) + act(() => { + b.store.actions.setWorkspaceExpanded('deleted', true) + b.store.actions.syncRecentSessions('deleted', ['session'], { session: 1 }) + }) + expect(b.store.getSnapshot().workspaceExpansion).toEqual({ deleted: true }) + + rerender(b, { useWorkspaces: hook(workspaceState([])) }) + await waitFor(() => { + expect(b.store.getSnapshot().workspaceExpansion).toEqual({}) + expect(b.store.getSnapshot().recentSessionOrder).toEqual({ [UNGROUPED_KEY]: [] }) + expect(b.store.getSnapshot().recentSessionUpdatedAt).toEqual({ [UNGROUPED_KEY]: {} }) + }) + }) + it('renders the grouped tree by default and switches to the flat list via Group by', () => { const sessions = sessionState([summary('alpha-s', 2), summary('beta-s', 1)]) const b = mount({ @@ -100,8 +128,14 @@ describe('WorkspaceBrowser', () => { // Sessions hidden while their group is folded. expect(screen.queryByText('alpha-s')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: '分组方式' })) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label + expect(screen.getByRole('separator')).toBeTruthy() + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([ + '按工作区', '单列表', '手动排序', '最近更新', + ]) + expect(screen.getByRole('menuitem', { name: '按工作区' }).querySelector('svg')).toBeTruthy() + expect(screen.getByRole('menuitem', { name: '手动排序' }).querySelector('svg')).toBeTruthy() fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) // Store-driven flip: title changes, rows flatten newest-first, headers gone. expect(b.store.getSnapshot().groupBy).toBe('flat') @@ -111,18 +145,73 @@ describe('WorkspaceBrowser', () => { expect(screen.getByText('beta-s')).toBeTruthy() // Back to workspace grouping through the same menu. - fireEvent.click(screen.getByRole('button', { name: '分组方式' })) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + expect(screen.getByRole('menuitem', { name: '手动排序' }).hasAttribute('disabled')).toBe(false) fireEvent.click(screen.getByRole('menuitem', { name: '按工作区' })) expect(b.store.getSnapshot().groupBy).toBe('workspace') expect(screen.getByText('工作区')).toBeTruthy() // Escape closes the menu without picking. - fireEvent.click(screen.getByRole('button', { name: '分组方式' })) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) fireEvent.keyDown(document, { key: 'Escape' }) expect(screen.queryByRole('menu')).toBeNull() expect(b.store.getSnapshot().groupBy).toBe('workspace') }) + it('persists flat-list drag order locally and applies Last updated within that account', async () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) + const workspaces = workspaceState([ + workspace('alpha', ['one']), + workspace('beta', ['two']), + ]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaces), + insertSessionBefore, + }) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionOrder[FLAT_SESSION_ORDER_KEY]) + .toEqual(['one', 'two', 'three']) + }) + + const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + const three = screen.getByText('three').closest('[role="treeitem"]') as HTMLElement + three.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, + x: 0, y: 150, toJSON: () => ({}), + }) + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(three, 'drop', 180) + expect(b.store.getSnapshot().recentSessionOrder[FLAT_SESSION_ORDER_KEY]) + .toEqual(['two', 'three', 'one']) + expect(insertSessionBefore).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionOrder[FLAT_SESSION_ORDER_KEY]) + .toEqual(['one', 'two', 'three']) + }) + + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '手动排序' })) + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(three, 'drop', 180) + b.view.unmount() + + const restored = mount({ useSessions: hook(sessions), useWorkspaces: hook(workspaces) }) + expect(restored.store.getSnapshot().groupBy).toBe('flat') + expect(restored.store.getSnapshot().orderBy).toBe('manual') + expect(screen.getAllByRole('treeitem').map(row => row.textContent)).toEqual([ + expect.stringContaining('two'), + expect.stringContaining('three'), + expect.stringContaining('one'), + ]) + }) + it('expands a group on click and opens a session row', () => { const open = vi.fn() mount({ @@ -138,6 +227,93 @@ describe('WorkspaceBrowser', () => { expect(screen.queryByText('alpha-s')).toBeNull() }) + it('shows five sessions by default and clears transient show-all when the Workspace collapses', () => { + const items = Array.from({ length: 7 }, (_, index) => summary(`session-${index + 1}`, 7 - index)) + const b = mount({ + useSessions: hook(sessionState(items)), + useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), + }) + fireEvent.click(screen.getByText('alpha')) + for (const item of items.slice(0, 5)) expect(screen.getByText(item.displayTitle)).toBeTruthy() + expect(screen.queryByText('session-6')).toBeNull() + expect(screen.queryByText('session-7')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: '展开其余 2 个会话' })) + expect(screen.getByText('session-6')).toBeTruthy() + expect(screen.getByText('session-7')).toBeTruthy() + expect(screen.getByRole('button', { name: '收起' })).toBeTruthy() + + fireEvent.click(screen.getByText('alpha')) + expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: false }) + fireEvent.click(screen.getByText('alpha')) + expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: true }) + expect(screen.queryByText('session-6')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy() + }) + + it('shares one editable order across modes and promotes only while Last updated is active', async () => { + const initial = sessionState([summary('one', 3), summary('two', 2)]) + const b = mount({ + useSessions: hook(initial), + useWorkspaces: hook(workspaceState([workspace('alpha', ['two', 'one'])])), + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) + await waitFor(() => { + const rows = screen.getAllByRole('treeitem').slice(1) + expect(rows[0]?.textContent).toContain('one') + expect(rows[1]?.textContent).toContain('two') + }) + + const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + }) + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(two, 'drop', 180) + expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one']) + + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '手动排序' })) + expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two') + + // User activity updates the timestamp baseline in Manual mode without + // changing the shared visual order. + const updated = sessionState([summary('one', 4), summary('two', 2)]) + rerender(b, { useSessions: hook(updated) }) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionUpdatedAt.alpha).toEqual({ one: 4, two: 2 }) + }) + expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one']) + expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two') + + // Entering Last updated performs one complete recency sort. + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['one', 'two']) + expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('one') + }) + + // A later user activity timestamp promotes that Session once while the + // mode remains active. + const promoted = sessionState([summary('one', 4), summary('two', 5)]) + rerender(b, { useSessions: hook(promoted) }) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one']) + expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two') + }) + + b.view.unmount() + const restored = mount({ + useSessions: hook(promoted), + useWorkspaces: hook(workspaceState([workspace('alpha', ['two', 'one'])])), + }) + expect(restored.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one']) + expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two') + }) + it('archives a session from the row menu and hides archived rows in both modes', async () => { const archiveSession = vi.fn(async () => {}) const b = mount({ @@ -150,11 +326,10 @@ describe('WorkspaceBrowser', () => { fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' })) expect(archiveSession).toHaveBeenCalledWith(sid('gone-s')) - // The archive-set echo hides the row in grouped mode (count included) and flat mode. + // The archive-set echo hides the row in grouped and flat modes. rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])], [sid('gone-s')])) }) expect(screen.queryByText('gone-s')).toBeNull() - expect(screen.getByText('1 个会话')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: '分组方式' })) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) expect(screen.getByText('kept-s')).toBeTruthy() expect(screen.queryByText('gone-s')).toBeNull() @@ -195,16 +370,20 @@ describe('WorkspaceBrowser', () => { expect(screen.getByText('child-s').closest('[role="treeitem"]')?.getAttribute('draggable')).toBe('true') }) - it('auto-expands the selected session group and starts a session from the group +', () => { + it('expands the target group before starting a session from its +', () => { const startSession = vi.fn() - mount({ - useSessions: hook(sessionState([summary('alpha-s', 1)], { current: sid('alpha-s') })), + const b = mount({ + useSessions: hook(sessionState([summary('alpha-s', 1)])), useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])), startSession, }) - // The current-group effect expanded the owning group without a click. - expect(screen.getByText('alpha-s')).toBeTruthy() + startSession.mockImplementation(() => { + expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: true }) + }) + expect(screen.queryByText('alpha-s')).toBeNull() fireEvent.click(screen.getByRole('button', { name: '在“alpha”中新建会话' })) + expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: true }) + expect(screen.getByText('alpha-s')).toBeTruthy() expect(startSession).toHaveBeenCalledWith(wid('alpha')) }) @@ -253,7 +432,6 @@ describe('WorkspaceBrowser', () => { expect(screen.getByText('新会话')).toBeTruthy() expect(screen.queryByText('alpha-blank')).toBeNull() expect(screen.queryByText('beta-blank')).toBeNull() - expect(screen.getByText('1 个会话')).toBeTruthy() rerender(b, { useSessions: hook({ ...sessions, current: staleBlank.id }) }) expect(screen.getAllByText('新会话')).toHaveLength(1) @@ -262,9 +440,9 @@ describe('WorkspaceBrowser', () => { expect(screen.getAllByText('新会话')).toHaveLength(1) // Search excludes blank rows entirely — neither the canonical stored // title nor the localized display label participates in matching. - fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'new session' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'new session' } }) expect(screen.queryByText('新会话')).toBeNull() - fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: '新会话' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: '新会话' } }) expect(screen.queryByText('新会话')).toBeNull() }) @@ -279,7 +457,8 @@ describe('WorkspaceBrowser', () => { useSessions: hook(sessions), useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])), }) - const input = screen.getByPlaceholderText('搜索名称、关键词…') + fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) + const input = screen.getByPlaceholderText('搜索会话…') fireEvent.change(input, { target: { value: 'needle' } }) const resultTree = screen.getByRole('tree', { name: '搜索结果' }) expect(screen.getByText('Needle row')).toBeTruthy() @@ -302,6 +481,27 @@ describe('WorkspaceBrowser', () => { } }) + it('collapses an empty search on outside click but keeps a non-empty query expanded', () => { + mount() + const search = screen.getByRole('button', { name: '搜索会话' }) + fireEvent.click(search) + expect(search.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(document.body) + expect(search.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(search) + const input = screen.getByPlaceholderText('搜索会话…') + fireEvent.change(input, { target: { value: ' ' } }) + fireEvent.click(document.body) + expect(search.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(search) + fireEvent.change(input, { target: { value: 'kept' } }) + fireEvent.click(document.body) + expect(search.getAttribute('aria-expanded')).toBe('true') + expect(input.value).toBe('kept') + }) + it('adds Host content hits with context, shows the result bound, and opens without clearing the query', async () => { vi.useFakeTimers() try { @@ -320,7 +520,7 @@ describe('WorkspaceBrowser', () => { open, searchSessions, }) - const input = screen.getByPlaceholderText('搜索名称、关键词…') + const input = screen.getByPlaceholderText('搜索会话…') fireEvent.change(input, { target: { value: 'waterfall token' } }) expect(screen.getByText('正在搜索会话历史…')).toBeTruthy() expect(screen.queryByText('Research notes')).toBeNull() @@ -345,7 +545,7 @@ describe('WorkspaceBrowser', () => { try { const searchSessions = vi.fn(async () => ({ items: [], hasMore: false })) mount({ searchSessions }) - const input = screen.getByPlaceholderText('搜索名称、关键词…') + const input = screen.getByPlaceholderText('搜索会话…') expect(input.maxLength).toBe(500) fireEvent.change(input, { target: { value: 'y'.repeat(501) } }) expect(input.value).toBe('y'.repeat(500)) @@ -376,7 +576,7 @@ describe('WorkspaceBrowser', () => { useWorkspaces: hook(workspaceState([workspace('alpha', ['local-hit'])])), searchSessions, }) - fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { + fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'needle' }, }) expect(screen.getByText('Needle title')).toBeTruthy() @@ -413,7 +613,7 @@ describe('WorkspaceBrowser', () => { ])), searchSessions, }) - const input = screen.getByPlaceholderText('搜索名称、关键词…') + const input = screen.getByPlaceholderText('搜索会话…') fireEvent.change(input, { target: { value: 'first' } }) await act(async () => { await vi.advanceTimersByTimeAsync(250) }) const firstSignal = searchSessions.mock.calls[0]?.[1] as AbortSignal @@ -447,7 +647,7 @@ describe('WorkspaceBrowser', () => { ? first : Promise.resolve({ items: [], hasMore: false })) mount({ searchSessions }) - const input = screen.getByPlaceholderText('搜索名称、关键词…') + const input = screen.getByPlaceholderText('搜索会话…') fireEvent.change(input, { target: { value: 'first' } }) await act(async () => { await vi.advanceTimersByTimeAsync(250) }) @@ -470,7 +670,7 @@ describe('WorkspaceBrowser', () => { b.store.actions.setGroupBy('flat') rerender(b, {}) expect(screen.getByText('暂无会话')).toBeTruthy() - fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'x' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'x' } }) expect(screen.getByText('正在搜索会话历史…')).toBeTruthy() await act(async () => { await vi.advanceTimersByTimeAsync(250) }) expect(screen.getByText('无匹配会话')).toBeTruthy() @@ -486,12 +686,12 @@ describe('WorkspaceBrowser', () => { const b = mount({ wide: false, expandSidebar }) // No wide chrome in rail state. expect(screen.queryByText('工作区')).toBeNull() - expect(screen.queryByPlaceholderText('搜索名称、关键词…')).toBeNull() + expect(screen.queryByPlaceholderText('搜索会话…')).toBeNull() fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) expect(expandSidebar).toHaveBeenCalledTimes(1) // The wide flip mounts the input and focuses it after the slide. rerender(b, { wide: true }) - const input = screen.getByPlaceholderText('搜索名称、关键词…') + const input = screen.getByPlaceholderText('搜索会话…') act(() => { vi.advanceTimersByTime(300) }) expect(document.activeElement).toBe(input) // Wide search button is decorative (tabIndex -1, no expand call). @@ -524,6 +724,84 @@ describe('WorkspaceBrowser', () => { expect(screen.getByText('alpha')).toBeTruthy() }) + it('uses the full expanded Workspace section when resolving a Workspace drop half', () => { + const insertWorkspaceBefore = vi.fn(async () => {}) + const sessions = sessionState(Array.from({ length: 5 }, (_, index) => summary(`beta-${index}`, index))) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([ + workspace('alpha', []), + workspace('beta', sessions.ids), + workspace('tail', []), + ])), + insertWorkspaceBefore, + }) + fireEvent.click(screen.getByText('beta')) + const source = screen.getByText('tail').closest('[role="treeitem"]') as HTMLElement + let targetSection = screen.getByText('beta').closest('[role="treeitem"]')?.parentElement as HTMLElement + while (targetSection.parentElement?.getAttribute('role') !== 'tree') { + targetSection = targetSection.parentElement as HTMLElement + } + targetSection.getBoundingClientRect = () => ({ + top: 100, bottom: 300, left: 0, right: 200, width: 200, height: 200, x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(source, { dataTransfer: dragData() }) + // y=190 is below the header row but still in the top half of the whole + // expanded section, so the target is before beta rather than after it. + fireDrag(targetSection, 'drop', 190) + expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta')) + }) + + it('draws the first Workspace insertion boundary on the scroll container', () => { + mount({ + useWorkspaces: hook(workspaceState([ + workspace('alpha', []), + workspace('beta', []), + ])), + }) + const source = screen.getByText('beta').closest('[role="treeitem"]') as HTMLElement + let firstSection = screen.getByText('alpha').closest('[role="treeitem"]')?.parentElement as HTMLElement + while (firstSection.parentElement?.getAttribute('role') !== 'tree') { + firstSection = firstSection.parentElement as HTMLElement + } + firstSection.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(source, { dataTransfer: dragData() }) + fireDrag(firstSection, 'dragOver', 105) + expect(firstSection.parentElement?.className).toContain('listTopDropActive') + const marker = firstSection.parentElement?.previousElementSibling + expect(marker?.className).toContain('listTopDropIndicator') + }) + + it('accepts a document-level drop and commits the last Workspace marker on drag end', () => { + const insertWorkspaceBefore = vi.fn(async () => {}) + mount({ + useWorkspaces: hook(workspaceState([ + workspace('alpha', []), + workspace('beta', []), + workspace('tail', []), + ])), + insertWorkspaceBefore, + }) + const source = screen.getByText('tail').closest('[role="treeitem"]') as HTMLElement + let target = screen.getByText('beta').closest('[role="treeitem"]')?.parentElement as HTMLElement + while (target.parentElement?.getAttribute('role') !== 'tree') { + target = target.parentElement as HTMLElement + } + target.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(source, { dataTransfer: dragData() }) + fireDrag(target, 'dragOver', 105) + const outsideDrop = createEvent.drop(document.body) + Object.defineProperty(outsideDrop, 'dataTransfer', { value: dragData() }) + fireEvent(document.body, outsideDrop) + expect(outsideDrop.defaultPrevented).toBe(true) + fireEvent.dragEnd(source) + expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta')) + }) + it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => { const insertSessionBefore = vi.fn(async () => {}) const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) @@ -538,7 +816,7 @@ describe('WorkspaceBrowser', () => { three.getBoundingClientRect = () => ({ top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}), }) - const dataTransfer = { effectAllowed: '', dropEffect: '' } + const dataTransfer = dragData() fireEvent.dragStart(one, { dataTransfer }) // Drop on the top half of "three": insert one before three. fireDrag(three, 'dragOver', 205) @@ -559,6 +837,55 @@ describe('WorkspaceBrowser', () => { expect(insertSessionBefore).toHaveBeenCalledTimes(1) }) + it('persists Ungrouped drag order in both modes without writing a Host Workspace account', async () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('未分组')) + + const dragAfter = (sourceTitle: string, targetTitle: string): void => { + const source = screen.getByText(sourceTitle).closest('[role="treeitem"]') as HTMLElement + const target = screen.getByText(targetTitle).closest('[role="treeitem"]') as HTMLElement + target.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + }) + fireEvent.dragStart(source, { dataTransfer: dragData() }) + fireDrag(target, 'drop', 180) + } + + dragAfter('one', 'three') + expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['two', 'three', 'one']) + dragAfter('two', 'one') + expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['three', 'one', 'two']) + expect(insertSessionBefore).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['one', 'two', 'three']) + }) + dragAfter('one', 'three') + expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['two', 'three', 'one']) + expect(insertSessionBefore).not.toHaveBeenCalled() + + b.view.unmount() + const restored = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([])), + insertSessionBefore, + }) + expect(restored.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['two', 'three', 'one']) + expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([ + expect.stringContaining('two'), + expect.stringContaining('three'), + expect.stringContaining('one'), + ]) + }) + it('still sends the reorder when the dragged row left the group mid-drag', () => { const insertSessionBefore = vi.fn(async () => {}) const sessions = sessionState([summary('one', 2), summary('two', 1)]) @@ -569,7 +896,7 @@ describe('WorkspaceBrowser', () => { }) fireEvent.click(screen.getByText('alpha')) const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement - fireEvent.dragStart(one, { dataTransfer: { effectAllowed: '', dropEffect: '' } }) + fireEvent.dragStart(one, { dataTransfer: dragData() }) // The host dropped "one" from the workspace account while the drag is in // flight: the source index is gone but the drop still resolves its anchor. rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['two'])])) }) @@ -594,7 +921,7 @@ describe('WorkspaceBrowser', () => { two.getBoundingClientRect = () => ({ top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), }) - const dataTransfer = { effectAllowed: '', dropEffect: '' } + const dataTransfer = dragData() fireEvent.dragStart(one, { dataTransfer }) fireEvent.dragEnd(one) // The drag ended: rows no longer accept drops. @@ -608,6 +935,28 @@ describe('WorkspaceBrowser', () => { expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined) }) + it('accepts a document-level drop and commits the last Session marker on drag end', () => { + const insertSessionBefore = vi.fn(async () => {}) + mount({ + useSessions: hook(sessionState([summary('one', 2), summary('two', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + }) + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(two, 'dragOver', 180) + const outsideDrop = createEvent.drop(document.body) + Object.defineProperty(outsideDrop, 'dataTransfer', { value: dragData() }) + fireEvent(document.body, outsideDrop) + expect(outsideDrop.defaultPrevented).toBe(true) + fireEvent.dragEnd(one) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined) + }) + it('logs and keeps the order when the reorder call rejects', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { @@ -623,7 +972,7 @@ describe('WorkspaceBrowser', () => { two.getBoundingClientRect = () => ({ top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), }) - const dataTransfer = { effectAllowed: '', dropEffect: '' } + const dataTransfer = dragData() fireEvent.dragStart(one, { dataTransfer }) fireDrag(two, 'drop', 180) await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) }) @@ -778,7 +1127,7 @@ describe('WorkspaceBrowser', () => { useSessions: hook(sessions), useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])), }) - fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'needle' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'needle' } }) const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement expect(row.hasAttribute('draggable')).toBe(false) }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx similarity index 100% rename from packages/client/ui-workspace/tests/workspace-picker.spec.tsx rename to packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx diff --git a/packages/client/web-react/tests/bind.spec.tsx b/packages/client/web-react/tests/bind.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/bind.spec.tsx rename to packages/client/web-react/tests/bind.client.spec.tsx diff --git a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx b/packages/client/web-react/tests/scoped-slots-real-core.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/scoped-slots-real-core.spec.tsx rename to packages/client/web-react/tests/scoped-slots-real-core.client.spec.tsx diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/scoped-slots.spec.tsx rename to packages/client/web-react/tests/scoped-slots.client.spec.tsx diff --git a/packages/client/web-react/tests/session-provider.spec.tsx b/packages/client/web-react/tests/session-provider.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/session-provider.spec.tsx rename to packages/client/web-react/tests/session-provider.client.spec.tsx diff --git a/packages/client/web-react/tests/stale-authorization.spec.tsx b/packages/client/web-react/tests/stale-authorization.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/stale-authorization.spec.tsx rename to packages/client/web-react/tests/stale-authorization.client.spec.tsx diff --git a/packages/client/web-react/tests/use-invoke.spec.tsx b/packages/client/web-react/tests/use-invoke.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/use-invoke.spec.tsx rename to packages/client/web-react/tests/use-invoke.client.spec.tsx diff --git a/packages/client/web-react/tests/use-projection.spec.tsx b/packages/client/web-react/tests/use-projection.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/use-projection.spec.tsx rename to packages/client/web-react/tests/use-projection.client.spec.tsx diff --git a/packages/client/web/tests/app-root.spec.tsx b/packages/client/web/tests/app-root.client.spec.tsx similarity index 100% rename from packages/client/web/tests/app-root.spec.tsx rename to packages/client/web/tests/app-root.client.spec.tsx diff --git a/packages/client/web/tests/app-shell.spec.tsx b/packages/client/web/tests/app-shell.client.spec.tsx similarity index 100% rename from packages/client/web/tests/app-shell.spec.tsx rename to packages/client/web/tests/app-shell.client.spec.tsx diff --git a/packages/client/web/tests/app.spec.tsx b/packages/client/web/tests/app.client.spec.tsx similarity index 100% rename from packages/client/web/tests/app.spec.tsx rename to packages/client/web/tests/app.client.spec.tsx diff --git a/packages/client/web/tests/base-styles.spec.ts b/packages/client/web/tests/base-styles.client.spec.ts similarity index 100% rename from packages/client/web/tests/base-styles.spec.ts rename to packages/client/web/tests/base-styles.client.spec.ts diff --git a/packages/client/web/tests/document-title.spec.tsx b/packages/client/web/tests/document-title.client.spec.tsx similarity index 100% rename from packages/client/web/tests/document-title.spec.tsx rename to packages/client/web/tests/document-title.client.spec.tsx diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json index 53f85868c2..acc7fc8fae 100644 --- a/packages/feedback/message-feedback/package.json +++ b/packages/feedback/message-feedback/package.json @@ -45,9 +45,7 @@ "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts", - "lib/typert.remote-client.d.ts.map", - "src" + "lib/typert.remote-client.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index 8a13ec7ceb..e5e57b6b07 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -49,9 +49,7 @@ "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts", - "lib/typert.remote-client.d.ts.map", - "src" + "lib/typert.remote-client.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index 3eb8fe7eb8..84e471c7fb 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/README.md -README.md: 926cb0b6b87a8ee76cb2dab745a31f620f4e7f5c -README.zh.md: 7ef057ee56e56ddc2baa7092ccbe44fb161b7448 +README.md: 1c3b6ab3192fe35a5532183414e45d1b02325e57 +README.zh.md: a062d5fce055e3266953993d532a86bec1375377 diff --git a/packages/host/README.md b/packages/host/README.md index 926cb0b6b8..1c3b6ab319 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -13,6 +13,7 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and | [`directory-picker-native/`](directory-picker-native/README.md) | Native directory-picker backend and browser interaction | registers `ctx.directoryPicker` | | [`directory-picker-browse/`](directory-picker-browse/README.md) | In-app directory-browser backend and interaction | registers `ctx.directoryPicker` | | [`directory-picker-auto/`](directory-picker-auto/README.md) | Host-adaptive picker composition | mounts a backend | +| [`plugin-inventory/`](plugin-inventory/README.md) | Read-only projection of current Loader entries | Remote `pluginInventory/list` | `apiproxy` remains transport-independent; [`client/connection`](../client/connection/README.md) supplies the browser/HTTP carrier. Picker implementations replace one another behind the shared seam. diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 7ef057ee56..a062d5fce0 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -13,6 +13,7 @@ dsh Web GUI 的宿主侧:所有客户端形态共享的 API 网关,以及承 | [`directory-picker-native/`](directory-picker-native/README.md) | 原生目录选择器后端和浏览器交互 | 注册 `ctx.directoryPicker` | | [`directory-picker-browse/`](directory-picker-browse/README.md) | 应用内目录浏览器后端和交互 | 注册 `ctx.directoryPicker` | | [`directory-picker-auto/`](directory-picker-auto/README.md) | 宿主自适应选择器组合 | 挂载一个后端 | +| [`plugin-inventory/`](plugin-inventory/README.md) | 当前 Loader 条目的只读投影 | Remote `pluginInventory/list` | `apiproxy` 保持传输无关;[`client/connection`](../client/connection/README.md) 提供浏览器/HTTP 载体。选择器实现可在共享 seam 后互相替换。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 1345dbcbcd..a0e3b413c1 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 541ebdb7a6802286b9b698575136534486387a8c -README.zh.md: 595ef03c24873272fa78ad67289b264458083614 +README.md: 5915d20b176ed6eccdb2c939bdf58b0a122271c5 +README.zh.md: e1128323c45c8388562582de25cf8c68d936fac0 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 541ebdb7a6..5915d20b17 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -42,7 +42,7 @@ Pending queued input is a live control-plane contract, not conversation history. Background tasks ride the same live-push posture. When `ctx.tasks` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/tasks` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `TaskView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames. -Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` commits one registry-order move and answers the complete order; a pure reorder emits `host/workspace-order-changed` with that complete order, while unknown sources or anchors return `workspace-not-found`. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 595ef03c24..e1128323c4 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -42,7 +42,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 后台任务沿用同一种实时推送姿态。当组合中有 `ctx.tasks` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/tasks` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `TaskView` 丢弃 `ownerSession`、`reported` 和 `outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` 提交一次注册表顺序移动并应答完整顺序;单纯重排序会通过 `host/workspace-order-changed` 推送同一份完整顺序,而未知来源或锚点返回 `workspace-not-found`。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 8bca4156a3..c9567256db 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -25,7 +25,7 @@ import { isUserInvocable } from '@deepseek-ai/dsh-skill' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, - WorkspaceMoveInvalidError, WorkspaceUnknownSessionError, + WorkspaceMoveInvalidError, WorkspaceOrderInvalidError, WorkspaceUnknownSessionError, } from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import { @@ -67,7 +67,7 @@ import type {} from '@deepseek-ai/dsh-session-projection-cache' // GoalError narrows domain rejections to their stable codes at the wire boundary. import { GoalError } from '@deepseek-ai/dsh-goal' import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal' -// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`. +// Type-only edges: resolve the command-change stream and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-skill' // The settings/credentials seams: brand guards run at this wire boundary; the @@ -2758,6 +2758,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return ok(request, { deleted: true as const }) }, + async insertBefore(request) { + const { workspaceId, beforeWorkspaceId } = request.payload + try { + const workspaceIds = await ctx.workspace.insertBefore( + brandWorkspaceId(workspaceId), + beforeWorkspaceId === undefined ? undefined : brandWorkspaceId(beforeWorkspaceId), + ) + return ok(request, { workspaceIds: [...workspaceIds] }) + } catch (error: unknown) { + if (!(error instanceof WorkspaceOrderInvalidError)) throw error + return workspaceNotFound(request, error.workspaceId) + } + }, + async insertSessionBefore(request) { const { payload } = request const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) @@ -2889,49 +2903,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, - commands: { - // Both methods address one session's agent. agentFor resumes on miss - // and fences every subagent-owned identity with `agent-busy`; the - // api/commands.ts module contract owns that fence's wording, so this - // comment only notes the routing shape: clients send a sessionId for a - // published session, and resume restores an existing entity. - async list(request) { - // Missing service = the deployment omitted dsh-commands from its - // composition, not an empty catalog: fail loud instead of serving []. - const commands = ctx.get('commands') - if (commands === undefined) { - return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} }) - } - const found = await agentFor(request.payload.sessionId) - if ('error' in found) return err(request, found.error) - return ok(request, { commands: commands.list(found.agent) }) - }, - - async execute(request, signal) { - const commands = ctx.get('commands') - if (commands === undefined) { - return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} }) - } - const { sessionId, line } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - try { - // Pure admission: the executor's durable command/run + command/done - // pair (broadcast on the mux stream) carries the outcome; the - // response reports whether the line resolved to a handler, plus the - // minted pairing id so the issuing client can correlate its request - // with the flow node the lifecycle events produce. - const execution = await commands.execute(found.agent, line, signal) - return ok(request, execution === undefined - ? { matched: false } - : { matched: true, commandId: execution.commandId }) - } catch (error: unknown) { - if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} }) - return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} }) - } - }, - }, - goals: { // Mutations only — the read side is the 'goal' session projection. // Every verb resolves the session's agent (agentFor: implicit cold @@ -3455,9 +3426,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro host(_request, signal) { const queue = new FrameQueue>() + const committedWorkspaces = ctx.workspace.list() const committedWorkspaceIds = new Set( - ctx.workspace.list().map(workspace => String(workspace.id)), + committedWorkspaces.map(workspace => String(workspace.id)), ) + let committedWorkspaceOrder = committedWorkspaces.map(workspace => workspace.id) // Frame-dedup baseline, same posture as committedWorkspaceIds: the // stream opens against the current set; workspace.list re-baselines // reconnecting clients, so only later changes need frames. @@ -3488,6 +3461,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (change.table === '') { if (change.operation !== 'put') return const state = workspaceDomainState.parse(change.value) + const orderChanged = state.workspaceIds.length === committedWorkspaceOrder.length + && state.workspaceIds.every(workspaceId => committedWorkspaceIds.has(String(workspaceId))) + && state.workspaceIds.some((workspaceId, index) => workspaceId !== committedWorkspaceOrder[index]) for (const workspaceId of state.workspaceIds) { if (committedWorkspaceIds.has(workspaceId)) continue const workspace = ctx.workspace.get(workspaceId) @@ -3497,6 +3473,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro committedWorkspaceIds.add(workspaceId) queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) })) } + committedWorkspaceOrder = [...state.workspaceIds] + if (orderChanged) { + queue.push(frame({ + type: 'host/workspace-order-changed', + workspaceIds: [...state.workspaceIds], + })) + } if (state.archivedSessionIds.length !== archivedSessionIds.length || state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) { archivedSessionIds = state.archivedSessionIds diff --git a/packages/host/apiproxy/src/api/commands.schema.ts b/packages/host/apiproxy/src/api/commands.schema.ts deleted file mode 100644 index c135c82e5a..0000000000 --- a/packages/host/apiproxy/src/api/commands.schema.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * commands domain zod schemas (names derived from map keys: commandListRequestSchema / - * commandListValueSchema / commandExecuteRequestSchema / commandExecuteValueSchema). - */ - -import { z } from 'zod' -import type { CommandId } from '@deepseek-ai/dsh-commands/brand' -import type { RequestPayload, ResponseValue } from './rpc-map.ts' -import type { Wire } from './rpc.schema.ts' -import { sessionIdSchema } from './sessions.schema.ts' -import type { CommandDescriptor } from './commands.ts' - -/** CommandDescriptor row of command.list. */ -export const commandDescriptorSchema = z.object({ - name: z.string().min(1), - description: z.string(), - input: z.object({ hint: z.string() }).optional(), -}) satisfies z.ZodType> - -/** command.list request payload. */ -export const commandListRequestSchema = z.object({ - sessionId: sessionIdSchema, -}) satisfies z.ZodType>> - -/** command.list response value. */ -export const commandListValueSchema = z.object({ - commands: z.array(commandDescriptorSchema), -}) satisfies z.ZodType>> - -/** command.execute request payload. */ -export const commandExecuteRequestSchema = z.object({ - sessionId: sessionIdSchema, - line: z.string(), -}) satisfies z.ZodType>> - -/** CommandId: one brand cast after schema validation (the only cast point in this domain). */ -export const commandIdSchema = z.string().min(1) as unknown as z.ZodType - -/** command.execute response value: pure admission — outcomes ride the logged - * lifecycle events; commandId (present exactly when matched) correlates with them. */ -export const commandExecuteValueSchema = z.object({ - matched: z.boolean(), - commandId: commandIdSchema.optional(), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/commands.ts b/packages/host/apiproxy/src/api/commands.ts deleted file mode 100644 index 984a184093..0000000000 --- a/packages/host/apiproxy/src/api/commands.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * commands domain contract: the web catalog/dispatch face of the host command - * registry (`ctx.commands`). Both methods address an ordinary session's Agent - * via `sessionId`, resuming it when cold. Session-backed subagents reject with - * `agent-busy` and retain their dedicated continuation owner. - */ - -import type { CommandId } from '@deepseek-ai/dsh-commands/brand' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { RpcRequest, RpcResponse } from './rpc.ts' - -/** - * Handler-free command view served to clients. Wire mirror of the host - * registry descriptor (which stays host-side with its cordis dependencies); - * no source field — the host descriptor has none. - */ -export interface CommandDescriptor { - /** Lowercase command name without the leading slash. */ - readonly name: string - /** Human-readable summary used in discovery UI. */ - readonly description: string - /** Optional free-form input hint advertised to capable clients. */ - readonly input?: { readonly hint: string } -} - -/** Command-domain unary methods (the map keys command.* of RpcMethodMap). */ -export interface CommandsApi { - /** - * Lists the addressed agent's effective command catalog (name-sorted, - * globals plus its scoped shadows). Session-backed subagents reject with - * `agent-busy`. - */ - list(request: RpcRequest<{ sessionId: SessionId }>): Promise> - - /** - * Parses and executes one slash-command line against the addressed agent - * without sending it to the model — pure admission semantics. matched=false - * when syntax or name does not resolve (the client falls back to its - * default sink). The handler's outcome does NOT ride the response: the host - * executor durably logs the lifecycle (`command/run`/`command/done`), which - * broadcasts on the mux stream and renders as a persistent flow node. - * `commandId` is present exactly when matched — the minted lifecycle - * pairing id, letting the issuing client correlate this acknowledgment - * with that flow node. The signal rides beside the request, never on the - * wire: the fetch carrier's request signal cancels the running handler. - * Session-backed subagents reject with `agent-busy` before dispatch. - */ - execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal): - Promise> -} diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index c8ddf99e8d..8b88186582 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -82,6 +82,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }), z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }), z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), + z.object({ type: z.literal('host/workspace-order-changed'), workspaceIds: z.array(workspaceIdSchema) }), z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }), // args stays wide, the same posture as session/projection's value: the frame // arrives from JSON.parse, so every element is already a JSON value, and the diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 901379b181..beba99b595 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -119,7 +119,8 @@ export type MuxFrame = * workspace mutation (create/attach/order change — the client upserts, while * `workspace.list` provides the reconnect baseline); workspace-removed is the * committed registration-deletion increment and never implies directory or - * session-log deletion; archived-sessions-changed pushes the full registry + * session-log deletion; workspace-order-changed pushes the complete durable + * registry order after a reorder; archived-sessions-changed pushes the full registry * archive set after every durable change (same full-snapshot posture as * workspace-changed — `workspace.list` re-baselines it on reconnect). */ @@ -138,6 +139,7 @@ export type HostFrame = | { type: 'host/agent-error'; sessionId: SessionId; message: string } | { type: 'host/workspace-changed'; workspace: WorkspaceView } | { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] } + | { type: 'host/workspace-order-changed'; workspaceIds: WorkspaceView['workspaceId'][] } | { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] } /** * One allowlisted host cordis event forwarded verbatim. The allowlist is diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 5f72192969..7c7b43d650 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -7,7 +7,6 @@ import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' -import type { CommandsApi } from './commands.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { SubagentsApi } from './subagents.ts' @@ -25,7 +24,6 @@ export interface ApiProxy { subagents: SubagentsApi host: HostApi workspace: WorkspaceApi - commands: CommandsApi skills: SkillsApi agentPresets: AgentPresetsApi events: EventsApi @@ -52,7 +50,6 @@ export type { } from './subagents.ts' export type { TaskView } from './tasks.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' -export type { CommandsApi, CommandDescriptor } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts' export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index ca7231e774..80dede1799 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -7,7 +7,6 @@ import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' -import type { CommandsApi } from './commands.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { GoalsApi } from './goals.ts' @@ -48,10 +47,9 @@ export interface RpcMethodMap { 'workspace.create': WorkspaceApi['create'] 'workspace.rename': WorkspaceApi['rename'] 'workspace.delete': WorkspaceApi['delete'] + 'workspace.insertBefore': WorkspaceApi['insertBefore'] 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] 'workspace.archiveSession': WorkspaceApi['archiveSession'] - 'command.list': CommandsApi['list'] - 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] 'agentPreset.list': AgentPresetsApi['list'] 'agentPreset.select': AgentPresetsApi['select'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index b508e25f93..177f0ffd29 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -91,6 +91,9 @@ export function rpcResultSchema(value: z.ZodType): z.ZodUnion /** ServerRequest full form (payload stays wide). */ @@ -119,7 +122,7 @@ export const serverRequestSchema = z.object({ export const clientResponseSchema = z.object({ type: z.literal('client-response'), rpcId: rpcIdSchema, - result: rpcResultSchema(z.unknown()), + result: rpcResultSchema(z.unknown().optional()), }) as unknown as z.ZodType /** Wire full-form union (discriminated by type). */ diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index 5ad5a0b96b..b57305141c 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -66,6 +66,17 @@ export const workspaceDeleteValueSchema = z.object({ deleted: z.literal(true), }) satisfies z.ZodType>> +/** workspace.insertBefore request payload (anchor omitted = append to end). */ +export const workspaceInsertBeforeRequestSchema = z.object({ + workspaceId: workspaceIdSchema, + beforeWorkspaceId: workspaceIdSchema.optional(), +}) satisfies z.ZodType>> + +/** workspace.insertBefore response value: the complete durable display order. */ +export const workspaceInsertBeforeValueSchema = z.object({ + workspaceIds: z.array(workspaceIdSchema), +}) satisfies z.ZodType>> + /** workspace.insertSessionBefore request payload (anchor omitted = append to end). */ export const workspaceInsertSessionBeforeRequestSchema = z.object({ workspaceId: workspaceIdSchema, diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index 64feb27f80..d36d0c406e 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -73,6 +73,15 @@ export interface WorkspaceApi { delete(request: RpcRequest<{ workspaceId: WorkspaceId }>): Promise> + /** + * Moves one Workspace within the registry display order, + * DOM-insertBefore-like. An omitted anchor appends to the end. + */ + insertBefore(request: RpcRequest<{ + workspaceId: WorkspaceId + beforeWorkspaceId?: WorkspaceId + }>): Promise> + /** * Moves an accounted session within its workspace's manual order, * DOM-insertBefore-like: with `beforeSessionId` the session is inserted diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 3c74ad04ac..70e3ece58f 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -35,11 +35,11 @@ import { workspaceArchiveSessionValueSchema, workspaceCreateValueSchema, workspaceDeleteValueSchema, + workspaceInsertBeforeValueSchema, workspaceInsertSessionBeforeValueSchema, workspaceListValueSchema, workspaceRenameValueSchema, } from '../api/workspace.schema.ts' -import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' import { agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, @@ -117,13 +117,10 @@ export interface IApiClient { create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise>> rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise>> delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise>> + insertBefore(payload: RequestPayload<'workspace.insertBefore'>, signal?: AbortSignal): Promise>> insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>> archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise>> } - commands: { - list(payload: RequestPayload<'command.list'>, signal?: AbortSignal): Promise>> - execute(payload: RequestPayload<'command.execute'>, signal?: AbortSignal): Promise>> - } skills: { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> } @@ -198,10 +195,9 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('workspace.create', payload, signal), rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal), delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal), + insertBefore: (payload, signal) => this.callUnary('workspace.insertBefore', payload, signal), insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal), } - readonly commands: IApiClient['commands'] = { - list: (payload, signal) => this.callUnary('command.list', payload, signal), - // Command handlers are user-driven operations and may legitimately exceed - // the transport health deadline. Caller/connection aborts remain. - execute: (payload, signal) => this.callUnary( - 'command.execute', payload, signal, 'caller-signal-only', - ), - } - readonly skills: IApiClient['skills'] = { list: (payload, signal) => this.callUnary('skill.list', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 1e902f059e..4e8800348a 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -38,11 +38,11 @@ import { workspaceArchiveSessionRequestSchema, workspaceCreateRequestSchema, workspaceDeleteRequestSchema, + workspaceInsertBeforeRequestSchema, workspaceInsertSessionBeforeRequestSchema, workspaceListRequestSchema, workspaceRenameRequestSchema, } from '../api/workspace.schema.ts' -import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' import { agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema, @@ -113,10 +113,9 @@ const UNARY_ROUTES: UnaryRoutes = { 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) }, + 'workspace.insertBefore': { schema: workspaceInsertBeforeRequestSchema, invoke: (api, r) => api.workspace.insertBefore(r) }, 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, 'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) }, - 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, - 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, 'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) }, 'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index ca0cf0329b..43b4c5d5b0 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -76,7 +76,6 @@ export class ApiProxyService extends Service implements ApiProxy { readonly subagents: ApiProxy['subagents'] readonly workspace: ApiProxy['workspace'] readonly host: ApiProxy['host'] - readonly commands: ApiProxy['commands'] readonly goals: ApiProxy['goals'] readonly skills: ApiProxy['skills'] readonly agentPresets: ApiProxy['agentPresets'] @@ -102,7 +101,6 @@ export class ApiProxyService extends Service implements ApiProxy { this.subagents = api.subagents this.workspace = api.workspace this.host = api.host - this.commands = api.commands this.goals = api.goals this.skills = api.skills this.agentPresets = api.agentPresets diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts deleted file mode 100644 index 9abe116d31..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ /dev/null @@ -1,427 +0,0 @@ -import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm' -/** - * Command/skill RPC handlers and the two new frames over createApiProxy: - * command.list serves the addressed agent's effective catalog (missing - * registry = loud internal error), command.execute dispatches through the - * registry with the carrier signal, skill.list resolves cwd from the session - * header (never via the Agent registry), the host stream broadcasts - * commands-changed, and the mux stream carries live queued frames plus the - * open-time queue snapshot. - */ - -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' -import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import CommandService from '@deepseek-ai/dsh-commands' -import SkillService from '@deepseek-ai/dsh-skill' -import type { HostFrame } from '../src/api/index.ts' -import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' -import { RpcId } from '../src/api/rpc.ts' -import { assertJsonArgs, createApiProxy } from '../src/api-proxy.ts' - -const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' } - -function request

    (payload: P): RpcRequest

    { - return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } -} -let nextRpc = 1 - -function expectOk(response: RpcResponse): T { - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - return response.result.value -} - -function expectErr(response: RpcResponse): { code: string; message: string } { - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - return response.result.error -} - -/** Composition floor for the command/skill paths (no LLM, no persistence). */ -async function harness(options: { commands?: boolean; skills?: boolean } = {}): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: '' }) - await ctx.plugin(ToolRegistry) - await ctx.plugin(UserInteractionService) - await ctx.plugin(AgentRegistry) - if (options.skills !== false) await ctx.plugin(SkillService, {}) - if (options.commands !== false) await ctx.plugin(CommandService) - // Host-stream opener reads the committed-workspace baseline; the stub - // suffices here — the real workspace composition is api-proxy-workspace.spec's. - ctx.provide('workspace', { list: () => [] } as never) - return ctx -} - -/** Register a live structural agent stub (api-proxy-view precedent: only id/session/status/ctx are read). */ -function stubAgent(ctx: Context, sessionId?: SessionId): Agent { - const session = ctx.sessions.create(sessionId) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) - const agent = { - id: session.id, - session, - inbox, - status: 'idle', - ctx, - } as Agent - ctx.agents.register(agent) - return agent -} - -/** Drain `count` frames from a stream, then abort it. */ -async function collect(iterable: AsyncIterable>, count: number, abort: AbortController): Promise { - const frames: F[] = [] - for await (const frame of iterable) { - frames.push(frame.payload) - if (frames.length >= count) abort.abort() - } - return frames -} - -/** Read the next payload from an open stream. */ -async function nextFrame(iterator: AsyncIterator>): Promise { - const result = await iterator.next() - if (result.done) throw new Error('stream ended') - return result.value.payload -} - -describe('command.list', () => { - it('serves the addressed agent\'s name-sorted catalog', async () => { - const ctx = await harness() - ctx.commands.register({ name: 'zeta', description: 'z', handler: () => ({ kind: 'success' }) }) - ctx.commands.register({ name: 'alpha', description: 'a', input: { hint: '' }, handler: () => ({ kind: 'success' }) }) - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const value = expectOk(await api.commands.list(request({ sessionId: agent.id }))) - expect(value.commands).toEqual([ - { name: 'alpha', description: 'a', input: { hint: '' } }, - { name: 'zeta', description: 'z' }, - ]) - }) - - it('fails loud with internal when the command registry is not mounted', async () => { - const ctx = await harness({ commands: false }) - const api = createApiProxy(ctx, DEFAULTS) - const error = expectErr(await api.commands.list(request({ sessionId: 's' as SessionId }))) - expect(error.code).toBe('internal') - expect(error.message).toContain('command registry') - }) -}) - -describe('command.execute', () => { - it('executes a known command against the addressed agent and detaches the result', async () => { - const ctx = await harness() - let received: string | undefined - ctx.commands.register({ - name: 'goal', - description: 'set goal', - handler: (invocation) => { - received = invocation.rawInput - return { kind: 'success', text: `goal:${invocation.agent.id}` } - }, - }) - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const value = expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/goal ship it' }), new AbortController().signal)) - expect(value).toMatchObject({ matched: true }) - expect(value.commandId).toBeTruthy() - expect(received).toBe(' ship it') - // Pure admission on the wire: the outcome rides the durably logged - // lifecycle pair instead of the response. - const lifecycle = agent.session.events.filter(e => e.type === 'command/run' || e.type === 'command/done') - expect(lifecycle).toMatchObject([ - { type: 'command/run', data: { commandId: value.commandId, name: 'goal', args: ' ship it' } }, - { type: 'command/done', data: { commandId: value.commandId, kind: 'success', text: `goal:${agent.id}` } }, - ]) - }) - - it('returns matched:false when syntax or name does not resolve', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const signal = new AbortController().signal - expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/unknown' }), signal))).toEqual({ matched: false }) - expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: 'not a command' }), signal))).toEqual({ matched: false }) - }) - - it('maps a session miss to session-not-found and a registry gap to internal', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const missing = expectErr(await api.commands.execute( - request({ sessionId: 'session-nope' as SessionId, line: '/x' }), new AbortController().signal)) - expect(missing.code).toBe('internal') // no persistence configured: resume fails loud past the gate - - const bare = await harness({ commands: false }) - const bareApi = createApiProxy(bare, DEFAULTS) - expect(expectErr(await bareApi.commands.execute( - request({ sessionId: 's' as SessionId, line: '/x' }), new AbortController().signal)).code).toBe('internal') - }) - - it('reports an aborted handler as cancelled and a throwing handler as internal', async () => { - const ctx = await harness() - ctx.commands.register({ - name: 'hang', - description: 'never settles on its own', - handler: () => new Promise(() => { /* settled only by abort */ }), - }) - ctx.commands.register({ - name: 'boom', - description: 'throws', - handler: () => { throw new Error('kaboom') }, - }) - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - - const controller = new AbortController() - const pending = api.commands.execute(request({ sessionId: agent.id, line: '/hang' }), controller.signal) - controller.abort() - expect(expectErr(await pending).code).toBe('cancelled') - - const thrown = expectErr(await api.commands.execute(request({ sessionId: agent.id, line: '/boom' }), new AbortController().signal)) - expect(thrown.code).toBe('internal') - expect(thrown.message).toContain('kaboom') - }) -}) - -describe('skill.list', () => { - it('lists skills for the session cwd taken from the header', async () => { - const ctx = await harness() - const seenCwds: (string | undefined)[] = [] - ctx.skills.registerProvider(() => ({ - name: 'probe', - list: (options) => { - seenCwds.push(options.cwd) - return Promise.resolve([ - { - name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', - invocation: { modelInvocable: true, userInvocable: true }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - { - name: 'user-only', description: 'User-only', - invocation: { modelInvocable: false, userInvocable: true }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - { - name: 'model-only', description: 'Model-only', - invocation: { modelInvocable: true, userInvocable: false }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - { - name: 'trusted-only', description: 'Trusted-only', - invocation: { modelInvocable: false, userInvocable: false }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - ]) - }, - get: () => Promise.resolve(undefined), - })) - const api = createApiProxy(ctx, DEFAULTS) - // No agent is registered for this session: header resolution must not - // touch (or resume through) the Agent registry. - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) - const value = expectOk(await api.skills.list(request({ sessionId: session.id }))) - expect(value.skills).toEqual([ - { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true }, - { name: 'user-only', description: 'User-only', modelInvocable: false }, - ]) - expect(seenCwds).toEqual(['/proj']) - expect(ctx.agents.get(session.id)).toBeUndefined() - }) - - it('fails loud on an unattached session id (business error, no resume attempt)', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const error = expectErr(await api.skills.list(request({ sessionId: 'session-cold' as SessionId }))) - expect(error.code).toBe('session-not-found') - }) - - it('fails loud with internal when the skill registry is not mounted', async () => { - const ctx = await harness({ skills: false }) - const api = createApiProxy(ctx, DEFAULTS) - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) - const error = expectErr(await api.skills.list(request({ sessionId: session.id }))) - expect(error.code).toBe('internal') - expect(error.message).toContain('skill registry is absent') - }) - - it('folds a provider failure into internal', async () => { - const ctx = await harness() - ctx.skills.registerProvider(() => ({ - name: 'broken', - list: () => Promise.reject(new Error('directory exploded')), - get: () => Promise.resolve(undefined), - })) - const api = createApiProxy(ctx, DEFAULTS) - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) - const response = await api.skills.list(request({ sessionId: session.id })) - // dsh-skill contains one provider's failure (logs and serves the rest), so - // this surfaces as an empty ok catalog rather than an error. - const value = expectOk(response) - expect(value.skills).toEqual([]) - }) -}) - -describe('forwarded commands/change frame', () => { - it('broadcasts on registry change', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const abort = new AbortController() - const stream = api.events.host({ rpcId: RpcId('t-host'), payload: {} }, abort.signal) - const collected = collect(stream, 1, abort) - ctx.commands.register({ name: 'late', description: 'l', handler: () => ({ kind: 'success' }) }) - // Verbatim forwarding: the wire name is the host's own event name and - // `args` is its argument list (empty for this pure invalidation). - expect(await collected).toEqual([{ type: 'host/remote-event', event: 'commands/change', args: [] }]) - }) - - // The guard belongs to the forwarding boundary, so it is tested there rather - // than through a malformed `ctx.emit`: every currently allowlisted event has a - // statically JSON-safe payload, so no type-legal emit can reach the rejection - // branch. These cases stand in for a future allowlist entry whose payload the - // wire cannot carry — a composition mistake that must fail loud. - describe('assertJsonArgs', () => { - it('passes a JSON-safe argument list through unchanged', () => { - const args = ['llm-deepseek', 7, null, { nested: ['ok'] }] - expect(assertJsonArgs('settings/document-updated', args)).toEqual(args) - expect(assertJsonArgs('commands/change', [])).toEqual([]) - }) - - it('names the offending event and argument position when a payload is not lossless JSON', () => { - expect(() => assertJsonArgs('credentials/updated', [1n])) - .toThrow('forwarded host event "credentials/updated" argument 0 is not lossless JSON data') - expect(() => assertJsonArgs('settings/document-updated', ['ns', () => {}])) - .toThrow('forwarded host event "settings/document-updated" argument 1 is not lossless JSON data') - }) - }) -}) - -/** Build one frozen inbox message. */ -function inboxMessage(id: string, text: string, rpcId?: string): UserMessage { - return freezeMessage({ - id: MessageId(id), - role: 'user', - content: [{ type: 'text' as const, text }], - source: rpcId === undefined ? { kind: 'user' as const } : { kind: 'user' as const, rpcId: RpcId(rpcId) }, - }) -} - -describe('session.updateQueue', () => { - it('splices a queued message and reports a lost claim race', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const present = inboxMessage('present', 'before') - agent.inbox.splice('next-turn', 0, 0, [present]) - const api = createApiProxy(ctx, DEFAULTS) - - const applied = await api.sessions.updateQueue({ - rpcId: RpcId('q-apply'), - payload: { - sessionId: agent.id, - itemId: MessageId('present'), - action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] }, - }, - }) - expect(expectOk(applied)).toEqual({ accepted: true }) - const missing = await api.sessions.updateQueue({ - rpcId: RpcId('q-missing'), - payload: { - sessionId: agent.id, - itemId: MessageId('claimed'), - action: { kind: 'remove' }, - }, - }) - expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' }) - expect(agent.inbox.nextTurn[0]).toMatchObject({ - id: 'present', - content: [{ type: 'text', text: 'edited' }], - }) - }) - - it('rejects a stale occurrence without resuming a cold agent', async () => { - const ctx = await harness() - const resume = vi.spyOn(ctx.agents, 'resume') - const api = createApiProxy(ctx, DEFAULTS) - const response = await api.sessions.updateQueue({ - rpcId: RpcId('q-cold'), - payload: { - sessionId: 'cold-session' as SessionId, - itemId: MessageId('stale-item'), - action: { kind: 'remove' }, - }, - }) - - expect(expectErr(response)).toMatchObject({ code: 'queue-item-not-found' }) - expect(resume).not.toHaveBeenCalled() - }) -}) - -describe('session/queue frames', () => { - it('publishes authoritative inbox snapshots without duplicating message identity', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const queued = inboxMessage('m-1', 'queued prompt') - const edited = inboxMessage('m-1', 'edited prompt') - const steering = inboxMessage('m-2', 'steering prompt') - agent.inbox.splice('next-turn', 0, 0, [queued]) - agent.inbox.splice('next-step', 0, 0, [steering]) - - const abort = new AbortController() - const iterator = api.events.mux({ - rpcId: RpcId('t-mux-baseline'), - payload: {}, - }, abort.signal)[Symbol.asyncIterator]() - const frames = [ - await nextFrame(iterator), - await nextFrame(iterator), - ] - agent.inbox.splice('next-turn', 0, 1, [edited]) - frames.push(await nextFrame(iterator), await nextFrame(iterator)) - const injected = freezeMessage({ - id: MessageId('m-3'), - role: 'user', - content: [{ type: 'text' as const, text: 'injected context' }], - source: { kind: 'plugin' as const, plugin: 'approval' }, - }) - agent.inbox.splice('next-step', 0, 0, [injected]) - frames.push(await nextFrame(iterator), await nextFrame(iterator)) - abort.abort() - await iterator.return?.() - - expect(frames.filter(frame => frame.type === 'session/queue')).toEqual([ - { - type: 'session/queue', - sessionId: agent.id, - items: [ - { id: queued.id, placement: 'queued', message: queued }, - { id: steering.id, placement: 'steering', message: steering }, - ], - }, - { - type: 'session/queue', - sessionId: agent.id, - items: [ - { id: edited.id, placement: 'queued', message: edited }, - { id: steering.id, placement: 'steering', message: steering }, - ], - }, - { - type: 'session/queue', - sessionId: agent.id, - items: [ - { id: edited.id, placement: 'queued', message: edited }, - { id: injected.id, placement: 'context', message: injected }, - { id: steering.id, placement: 'steering', message: steering }, - ], - }, - ]) - }) -}) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 7c3c39e9e1..4c2506c395 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -319,6 +319,50 @@ describe('workspace.create', () => { }) }) +describe('workspace.insertBefore', () => { + it('commits the complete order, streams one order frame, and maps unknown ids', async () => { + const { api, ctx, root } = await harness() + const first = expectOk(await api.workspace.create(request({ path: stageDir(root, 'first') }))).workspace + const second = expectOk(await api.workspace.create(request({ path: stageDir(root, 'second') }))).workspace + const third = expectOk(await api.workspace.create(request({ path: stageDir(root, 'third') }))).workspace + + const abort = new AbortController() + const listWorkspaces = vi.spyOn(ctx.workspace, 'list') + const stream: AsyncIterator> = + api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() + expect(listWorkspaces).toHaveBeenCalledTimes(1) + const changed = nextHostFrame(stream) + const reordered = expectOk(await api.workspace.insertBefore(request({ + workspaceId: first.workspaceId, + beforeWorkspaceId: second.workspaceId, + }))) + expect(reordered.workspaceIds).toEqual([third.workspaceId, first.workspaceId, second.workspaceId]) + expect(await changed).toMatchObject({ + payload: { + type: 'host/workspace-order-changed', + workspaceIds: [third.workspaceId, first.workspaceId, second.workspaceId], + }, + }) + expect(expectOk(await api.workspace.list(request({}))).items.map(item => item.workspaceId)) + .toEqual(reordered.workspaceIds) + + const missingSource = await api.workspace.insertBefore(request({ + workspaceId: 'missing' as WorkspaceId, + })) + expect(missingSource.result).toMatchObject({ + ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'missing' } }, + }) + const missingAnchor = await api.workspace.insertBefore(request({ + workspaceId: first.workspaceId, + beforeWorkspaceId: 'missing-anchor' as WorkspaceId, + })) + expect(missingAnchor.result).toMatchObject({ + ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'missing-anchor' } }, + }) + abort.abort() + }) +}) + describe('session creation and Workspace membership', () => { it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => { const { api, ctx, root } = await harness() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index fdee096348..4130d8f210 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -21,7 +21,6 @@ function scriptedApi(overrides: { sessions?: Partial subagents?: Partial host?: Partial - commands?: Partial skills?: Partial agentPresets?: Partial events?: Partial @@ -86,14 +85,10 @@ function scriptedApi(overrides: { create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }), rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), delete: r => ok(r, { deleted: true as const }), + insertBefore: r => ok(r, { workspaceIds: [r.payload.workspaceId] }), insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }), }, - commands: { - list: r => ok(r, { commands: [] }), - execute: r => ok(r, { matched: false }), - ...overrides.commands, - }, skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, agentPresets: { list: r => ok(r, { presets: [], authorable: false, hasDocument: false }), @@ -224,7 +219,7 @@ describe('unary round trip', () => { expect(response.result).toEqual({ ok: true, value: { sessionId: 's-child' } }) }) - it('routes workspace rename, delete, and insertSessionBefore through the wire', async () => { + it('routes workspace rename, delete, and ordering through the wire', async () => { const api = scriptedApi() const c = client(api) const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' }) @@ -233,6 +228,11 @@ describe('unary round trip', () => { expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } }) const deleted = await c.workspace.delete({ workspaceId: 'w1' as never }) expect(deleted.result).toEqual({ ok: true, value: { deleted: true } }) + const workspaceOrder = await c.workspace.insertBefore({ + workspaceId: 'w1' as never, + beforeWorkspaceId: 'w2' as never, + }) + expect(workspaceOrder.result).toEqual({ ok: true, value: { workspaceIds: ['w1'] } }) const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') }) expect(anchored.result.ok).toBe(true) const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') }) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 1888cf602a..2000f708ba 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -1,4 +1,3 @@ -import { CommandId } from '@deepseek-ai/dsh-commands/brand' import { describe, expect, it, vi } from 'vitest' import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts' import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts' @@ -180,6 +179,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async delete(request) { return { rpcId: request.rpcId, result: { ok: true, value: { deleted: true as const } } } }, + async insertBefore(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { workspaceIds: [request.payload.workspaceId] } } } + }, async insertSessionBefore(request) { return { rpcId: request.rpcId, @@ -190,25 +192,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { archivedSessionIds: [request.payload.sessionId] } } } }, }, - commands: { - async list(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } } } - }, - async execute(request, signal) { - if (request.payload.line === '/hang') { - // Cooperative hang: settles only through the carrier signal (sticky - // abort checked first — listeners never fire retroactively). - if (!signal.aborted) { - await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) - } - return { rpcId: request.rpcId, result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } } } - } - if (request.payload.line.startsWith('/plan')) { - return { rpcId: request.rpcId, result: { ok: true, value: { matched: true, commandId: CommandId('cmd-x') } } } - } - return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } } - }, - }, agentPresets: { list(request: RpcRequest<{}>) { return Promise.resolve({ @@ -441,19 +424,13 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(response.result).toEqual({ ok: true, value: { opened: true } }) }) - it('round-trips command.list / command.execute / skill.list through the wire form', async () => { + it('round-trips skill.list through the wire form', async () => { const c = client() - const list = await c.commands.list({ sessionId: 's' as never }) - expect(list.result).toEqual({ ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } }) - const hit = await c.commands.execute({ sessionId: 's' as never, line: '/plan off' }) - expect(hit.result).toEqual({ ok: true, value: { matched: true, commandId: 'cmd-x' } }) - const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' }) - expect(miss.result).toEqual({ ok: true, value: { matched: false } }) const skills = await c.skills.list({ sessionId: 's' as never }) expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } }) }) - it('lets command.execute finish after the 30-second default unary deadline', async () => { + it('lets host.pickDirectory finish after the 30-second default unary deadline', async () => { vi.useFakeTimers() const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => { const controller = new AbortController() @@ -464,16 +441,13 @@ describe('unary round trip (handler ⇄ client, no network)', () => { }) try { const api = fakeApi() - api.commands.execute = async (request) => { + api.host.pickDirectory = async (request) => { await new Promise(resolve => setTimeout(resolve, 30_001)) - return { - rpcId: request.rpcId, - result: { ok: true, value: { matched: true, commandId: CommandId('cmd-slow') } }, - } + return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/slow' } } } } - const execution = client(api).commands.execute({ sessionId: 's' as never, line: '/slow' }) + const execution = client(api).host.pickDirectory({}) const assertion = expect(execution).resolves.toMatchObject({ - result: { ok: true, value: { matched: true, commandId: 'cmd-slow' } }, + result: { ok: true, value: { path: '/tmp/slow' } }, }) await Promise.all([ @@ -509,10 +483,10 @@ describe('unary round trip (handler ⇄ client, no network)', () => { })).result).toEqual({ ok: true, value: { accepted: true } }) }) - it('keeps caller and connection aborts on command.execute', async () => { + it('keeps caller and connection aborts on a deadline-exempt unary', async () => { const api = fakeApi() const started = Promise.withResolvers() - api.commands.execute = async (request, signal) => { + api.host.pickDirectory = async (request, signal) => { started.resolve(signal) if (!signal.aborted) { await new Promise((resolve) => { @@ -525,10 +499,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { } } const controller = new AbortController() - const execution = client(api).commands.execute( - { sessionId: 's' as never, line: '/hang' }, - controller.signal, - ) + const execution = client(api).host.pickDirectory({}, controller.signal) const handlerSignal = await started.promise controller.abort(new Error('connection closed')) @@ -537,20 +508,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(handlerSignal.aborted).toBe(true) }) - it('propagates the carrier Request signal into command.execute', async () => { - const handler = toFetchHandler(fakeApi()) - const controller = new AbortController() - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-sig', method: 'command.execute', payload: { sessionId: 's', line: '/hang' } }) - // The fake's /hang settles only when the invoke-level signal aborts: a - // completed response with the cancelled error proves req.signal reached it. - const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal })) - controller.abort() - const response = await pending - const parsed = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } } - expect(parsed.rpcId).toBe('r-sig') - expect(parsed.result.error?.code).toBe('cancelled') - }) - it('propagates the carrier Request signal into session.search', async () => { const handler = toFetchHandler(fakeApi()) const controller = new AbortController() diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 9a8f9ecc55..b78a03de07 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -23,14 +23,11 @@ import { workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema, workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceDeleteRequestSchema, workspaceDeleteValueSchema, + workspaceInsertBeforeRequestSchema, workspaceInsertBeforeValueSchema, workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, workspaceListRequestSchema, workspaceListValueSchema, workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema, } from '../src/api/workspace.schema.ts' -import { - commandDescriptorSchema, commandExecuteRequestSchema, commandExecuteValueSchema, - commandListRequestSchema, commandListValueSchema, -} from '../src/api/commands.schema.ts' import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' import { agentPresetEntrySchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, @@ -80,6 +77,8 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid') + // The credentials producer still emits this code, so the branch has to stay. + expect(rpcErrorSchema.parse({ code: 'credential-rejected', message: 'm', details: { ref: 'r' } }).code).toBe('credential-rejected') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -115,9 +114,14 @@ describe('wire full-form schemas', () => { expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow() }) - it('rejects a quadrant missing its members', () => { + it('rejects a quadrant missing its members but accepts a valueless success result', () => { expect(() => clientRequestSchema.parse({ type: 'client-request', rpcId: 'r1' })).toThrow() - expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: { ok: true } })).toThrow() + expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1' })).toThrow() + expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: {} })).toThrow() + // A void business result carries no value field; the endpoint's own second + // parse is what requires a value for methods that return data. + expect(serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: { ok: true } }).rpcId) + .toBe('r1') }) }) @@ -364,6 +368,13 @@ describe('workspace domain schemas', () => { expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow() }) + it('insertSessionBefore accepts an anchored and an anchorless move', () => { + expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2') + expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined() + expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow() + expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') + }) + it('create requires a path', () => { expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p') expect(() => workspaceCreateRequestSchema.parse({})).toThrow() @@ -385,43 +396,15 @@ describe('workspace domain schemas', () => { expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow() }) - it('insertSessionBefore accepts an anchored and an anchorless move', () => { - expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2') - expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined() - expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow() - expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') - }) -}) - -describe('commands domain schemas', () => { - it('validates the catalog request/value pair', () => { - expect(commandListRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') - // The wire is session-addressed only: a sessionId-less payload fails. - expect(() => commandListRequestSchema.parse({})).toThrow() - expect(commandListValueSchema.parse({ commands: [] }).commands).toEqual([]) - const value = commandListValueSchema.parse({ commands: [ - { name: 'plan', description: 'Toggle plan mode' }, - { name: 'goal', description: 'Set the goal', input: { hint: '' } }, - ] }) - expect(value.commands[1]?.input?.hint).toBe('') - expect(commandDescriptorSchema.parse({ name: 'x', description: 'd' }).input).toBeUndefined() - expect(() => commandDescriptorSchema.parse({ name: '', description: 'd' })).toThrow() - expect(() => commandDescriptorSchema.parse({ name: 'x', description: 'd', input: {} })).toThrow() - }) - - it('validates the execute request/value pair with both matched branches', () => { - expect(commandExecuteRequestSchema.parse({ sessionId: 's1', line: '/plan off' }).line).toBe('/plan off') - // Both members are mandatory: dropping either fails the parse. - expect(() => commandExecuteRequestSchema.parse({ line: '/compact' })).toThrow() - expect(() => commandExecuteRequestSchema.parse({ sessionId: 's1' })).toThrow() - expect(commandExecuteValueSchema.parse({ matched: false })).toEqual({ matched: false }) - // Pure admission: matched plus the optional lifecycle pairing id - // (outcomes ride the logged lifecycle events, never this response). - expect(commandExecuteValueSchema.parse({ matched: true, commandId: 'cmd-1' })) - .toEqual({ matched: true, commandId: 'cmd-1' }) - expect(commandExecuteValueSchema.parse({ matched: true })).toEqual({ matched: true }) - expect(() => commandExecuteValueSchema.parse({ matched: true, commandId: '' })).toThrow() - expect(() => commandExecuteValueSchema.parse({})).toThrow() + it('insertBefore accepts an anchored or anchorless Workspace move and returns the complete order', () => { + expect(workspaceInsertBeforeRequestSchema.parse({ + workspaceId: 'w1', beforeWorkspaceId: 'w2', + }).beforeWorkspaceId).toBe('w2') + expect(workspaceInsertBeforeRequestSchema.parse({ workspaceId: 'w1' }).beforeWorkspaceId) + .toBeUndefined() + expect(() => workspaceInsertBeforeRequestSchema.parse({ beforeWorkspaceId: 'w2' })).toThrow() + expect(workspaceInsertBeforeValueSchema.parse({ workspaceIds: ['w2', 'w1'] }).workspaceIds) + .toEqual(['w2', 'w1']) }) }) diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index 572c1045c8..0c791b79a2 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -32,21 +32,25 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-client-ui-directory-picker": "workspace:^", + "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-client-ui-directory-picker": "workspace:^", + "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^" } } diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index 91343463fd..4f1ee77b98 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -1,12 +1,13 @@ /** * Adaptive chooser of the directory-picker seam: resolves the host's * situation once at boot (bind host, SSH launch, display session, Linux - * chooser binary) and mounts the matching dual-face backend — `-native` or - * `-browse` — as a real Loader entry in the in-memory root tree. Because the - * backend arrives as an ordinary entry, its browser half is discovered - * exactly as a config-row's would be, so the seam's one-row-swaps-both-faces - * invariant holds for the resolved choice; pinning an interaction remains - * composing that backend row directly instead of this one. + * chooser binary) and mounts the matching interaction — `native` or `browse` + * — as real Loader entries in the in-memory root tree. Each interaction is a + * pair: the Host backend serving the seam capability and the client surface + * occupying ui-workspace's directory-flow holes. Both arrive as ordinary + * entries, so the surface is discovered exactly as a config-row's would be + * and one resolved choice still swaps both faces; pinning an interaction + * remains composing that pair directly instead of this row. * @module @deepseek-ai/dsh-host-directory-picker-auto */ @@ -28,7 +29,7 @@ export const name = 'directory-picker-auto' export const inject = ['httpServer', 'loader'] /** - * Backend package per resolved kind — fixed composition vocabulary, not a + * Host backend package per resolved kind — fixed composition vocabulary, not a * tunable. Exported because the reference is a runtime string the static * config gate cannot see in a yml row: `verify-cordis-config` requires every * app composing this chooser to declare both values as dependencies. @@ -39,10 +40,23 @@ export const BACKEND_PACKAGES: Record = { } /** - * Resolve the backend from one boot-time sample and mount it as a Loader - * entry; the effect's disposer removes the entry and joins the backend - * fiber's teardown, so unloading this plugin returns only after both faces - * of the mounted backend (and their dependents) quiesced. + * Client surface package per resolved kind, mounted with its backend so one + * resolved interaction still composes both faces. Declared as dependencies by + * every composing app for the same reason as {@link BACKEND_PACKAGES}. Only the + * specifier is referenced here — the packages belong to the Client program, so + * no import of them exists on this side and knip needs them ignored for this + * workspace. + */ +export const SURFACE_PACKAGES: Record = { + native: '@deepseek-ai/dsh-client-ui-directory-picker-native', + browse: '@deepseek-ai/dsh-client-ui-directory-picker', +} + +/** + * Resolve the interaction from one boot-time sample and mount its backend and + * surface as Loader entries; the effect's disposer removes both entries and + * joins their fibers' teardown, so unloading this plugin returns only after + * both faces of the mounted interaction (and their dependents) quiesced. * @param ctx - cordis context carrying the injected `httpServer` and `loader`. */ export async function apply(ctx: Context): Promise { @@ -54,16 +68,31 @@ export async function apply(ctx: Context): Promise { }) await ctx.effect(async () => { // Root-tree create: the Loader root is in-memory (write() is a no-op), so - // the mounted row can never be persisted back into a config file. - const id = await ctx.loader.create({ name: BACKEND_PACKAGES[backend] }) - return async () => { - // Tree teardown (group.stop) can have removed the entry already; - // nothing is left to unmount or await then. - const entry = ctx.loader.store[id] - if (entry === undefined) return - // remove() disposes the entry transactionally, so the chooser's unload - // signals completion only after the backend quiesced. - await ctx.loader.remove(id) + // the mounted rows can never be persisted back into a config file. The + // backend lands first: the surface's browser half drives the capability + // the backend registers. + const ids: string[] = [] + const unmount = async () => { + for (const id of [...ids].reverse()) { + // Tree teardown (group.stop) can have removed the entry already; + // nothing is left to unmount or await then. + if (ctx.loader.store[id] === undefined) continue + // remove() disposes the entry transactionally, so the chooser's unload + // signals completion only after that face quiesced. + await ctx.loader.remove(id) + } } - }, 'directory-picker-auto: backend entry') + try { + for (const name of [BACKEND_PACKAGES[backend], SURFACE_PACKAGES[backend]]) { + ids.push(await ctx.loader.create({ name })) + } + } catch (cause) { + // Setup owns the entries it created until it returns the disposer: leaving + // the backend mounted would make a retry collide with its own + // directoryPicker registration. + await unmount() + throw cause + } + return unmount + }, 'directory-picker-auto: interaction entries') } diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index ed9bfd1e44..4e19936300 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -1,10 +1,10 @@ /** * REAL-composition coverage: a test-only cordis.yml booted through the * vendored Loader mounts the webserver row plus the adaptive chooser, and the - * assertions observe the durable outcome — which backend entry the chooser - * mounted into the Loader store, the capability the seam then serves, and - * that disposing the chooser removes the mounted entry again (HMR safety), - * joining the backend's own teardown before the disposer settles. + * assertions observe the durable outcome — which backend and surface entries + * the chooser mounted into the Loader store, the capability the seam then + * serves, and that disposing the chooser removes both mounted entries again + * (HMR safety), joining the backend's own teardown before the disposer settles. */ import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs' @@ -48,6 +48,23 @@ vi.mock('node:fs/promises', async (importOriginal) => { const AUTO = '@deepseek-ai/dsh-host-directory-picker-auto' const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native' const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse' +const NATIVE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker-native' +const BROWSE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker' + +/** + * Loader-visible stand-in for a client surface package: the surfaces belong to + * the Client program and publish browser entry points only, so a Host-face spec + * can neither name them in a static import nor resolve them from source. What + * the chooser owns is the mounting decision, which every case observes through + * the Loader store; the surface's own browser contributions belong to the + * assembled web coverage. + * + * @param name Surface package specifier the chooser mounts. + * @returns A function-plugin module the Loader can mount under that specifier. + */ +function surfaceModule(name: string): unknown { + return { name, apply: () => undefined } +} let root: string | undefined let fakeBin: string | undefined @@ -71,7 +88,10 @@ afterEach(async () => { }) /** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */ -async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx: Context; configPath: string }> { +async function loadComposition( + bindHost: '127.0.0.1' | '0.0.0.0', + options: { failSurface?: boolean } = {}, +): Promise<{ ctx: Context; configPath: string }> { root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-')) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ @@ -92,10 +112,15 @@ async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx [AUTO, DirectoryPickerAuto], [NATIVE, NativeDirectoryPicker], [BROWSE, BrowseDirectoryPicker], + [NATIVE_SURFACE, surfaceModule(NATIVE_SURFACE)], + [BROWSE_SURFACE, surfaceModule(BROWSE_SURFACE)], ]) context.loader.internal = { version: 'v2', async import(specifier: string) { + if (options.failSurface === true && (specifier === NATIVE_SURFACE || specifier === BROWSE_SURFACE)) { + throw new Error(`surface import failed: ${specifier}`) + } if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) return modules.get(specifier) }, @@ -142,7 +167,9 @@ describe('real Loader composition', () => { .map(entry => entry.options.name) expect(unloaded).toEqual([]) expect(entryNames(ctx)).toContain(NATIVE) + expect(entryNames(ctx)).toContain(NATIVE_SURFACE) expect(entryNames(ctx)).not.toContain(BROWSE) + expect(entryNames(ctx)).not.toContain(BROWSE_SURFACE) const picker = ctx.get('directoryPicker') as DirectoryPicker expect(picker.capability().kind).toBe('native') // The mounted row lives in the Loader's in-memory root tree only — the @@ -155,6 +182,7 @@ describe('real Loader composition', () => { const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! await autoEntry.fiber!.dispose() expect(entryNames(ctx)).not.toContain(NATIVE) + expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE) expect(ctx.get('directoryPicker')).toBeUndefined() // Self-disposing an include-tree entry persists `disabled: true` (loader // behavior, not the chooser's); await that debounced write so it cannot @@ -170,7 +198,9 @@ describe('real Loader composition', () => { const { ctx } = await loadComposition('127.0.0.1') expect(entryNames(ctx)).toContain(BROWSE) + expect(entryNames(ctx)).toContain(BROWSE_SURFACE) expect(entryNames(ctx)).not.toContain(NATIVE) + expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE) const picker = ctx.get('directoryPicker') as DirectoryPicker expect(picker.capability().kind).toBe('browse') }) @@ -180,7 +210,20 @@ describe('real Loader composition', () => { const { ctx } = await loadComposition('0.0.0.0') expect(entryNames(ctx)).toContain(BROWSE) + expect(entryNames(ctx)).toContain(BROWSE_SURFACE) expect(entryNames(ctx)).not.toContain(NATIVE) + expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE) + }) + + it('unmounts the backend when the surface entry fails to load', { timeout: 60_000 }, async () => { + stubAttendedHost() + await expect(loadComposition('127.0.0.1', { failSurface: true })).rejects.toThrow(/surface import failed/) + + // Setup owns both entries until it returns its disposer, so a failed surface + // must take the mounted backend with it: otherwise a retry collides with the + // directoryPicker registration this backend already made. + expect(entryNames(context!)).not.toContain(NATIVE) + expect(context!.get('directoryPicker')).toBeUndefined() }) it('tolerates the mounted entry being removed by the tree before the chooser unloads', { timeout: 60_000 }, async () => { @@ -193,6 +236,7 @@ describe('real Loader composition', () => { renameControl.remainingFailures = 1 await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow() expect(entryNames(ctx)).not.toContain(NATIVE) + expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE) // Same self-dispose persistence as above: let the write land before teardown. await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true') expect(renameControl.injectedFailures).toBe(1) diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index f134a00dbf..dbad0318a6 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -22,55 +22,25 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, - "./client": { - "types": "./lib/types/client/index.d.ts", - "default": "./lib/client.js" - }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", - "lib/client.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-host-directory-picker": "workspace:^", - "clsx": "^2.0.0", "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" - }, - "dsh": { - "client": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-workspace", - "@deepseek-ai/dsh-client-locale" - ], - "platform": "web" - } + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/directory-picker-browse/tsconfig.json b/packages/host/directory-picker-browse/tsconfig.json index 00dcdf8fde..b6a0f96d7e 100644 --- a/packages/host/directory-picker-browse/tsconfig.json +++ b/packages/host/directory-picker-browse/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.base.client.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "lib/types", @@ -16,21 +16,6 @@ }, { "path": "../../support/invariants" - }, - { - "path": "../../client/ui-slots" - }, - { - "path": "../../client/ui-primitives" - }, - { - "path": "../../client/locale" - }, - { - "path": "../../client/runtime" - }, - { - "path": "../../client/ui-workspace" } ] } diff --git a/packages/host/directory-picker-browse/tsdown.config.ts b/packages/host/directory-picker-browse/tsdown.config.ts index 4b2be38c3d..388cecbcb5 100644 --- a/packages/host/directory-picker-browse/tsdown.config.ts +++ b/packages/host/directory-picker-browse/tsdown.config.ts @@ -1,3 +1,15 @@ -import { clientBundle } from '../../client/tsdown.client.ts' +import { defineConfig } from 'tsdown' -export default clientBundle('@deepseek-ai/dsh-host-directory-picker-browse', ['lib/types/index.js', 'lib/types/invariant.js']) +/** Node-only backend: listing and creation primitives over the host filesystem. */ +export default defineConfig([ + { + entry: ['lib/types/index.js', 'lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index 4465553e07..fbc7ae16d5 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -22,10 +22,6 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, - "./client": { - "types": "./lib/types/client/index.d.ts", - "default": "./lib/client.js" - }, "./worker": { "types": "./lib/types/win32-dialog-worker.d.ts", "default": "./lib/worker.cjs" @@ -37,7 +33,6 @@ "lib/index.js", "lib/invariant.js", "lib/worker.cjs", - "lib/client.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", @@ -47,30 +42,12 @@ "koffi": "^3.1.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0", "tsx": "^4.19.2" - }, - "dsh": { - "client": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-workspace" - ], - "platform": "web" - } } } diff --git a/packages/host/directory-picker-native/tsconfig.json b/packages/host/directory-picker-native/tsconfig.json index 395595e836..6962312bd1 100644 --- a/packages/host/directory-picker-native/tsconfig.json +++ b/packages/host/directory-picker-native/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.base.client.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "lib/types", @@ -19,15 +19,6 @@ }, { "path": "../../util/native-command" - }, - { - "path": "../../client/ui-slots" - }, - { - "path": "../../client/runtime" - }, - { - "path": "../../client/ui-workspace" } ] } diff --git a/packages/host/directory-picker-native/tsdown.config.ts b/packages/host/directory-picker-native/tsdown.config.ts index 6d02727f4e..13a7f74070 100644 --- a/packages/host/directory-picker-native/tsdown.config.ts +++ b/packages/host/directory-picker-native/tsdown.config.ts @@ -1,23 +1,31 @@ -import { clientBundle } from '../../client/tsdown.client.ts' +import { defineConfig } from 'tsdown' -// The Win32 dialog worker builds as its own CJS entry (mirroring -// dsh-workflow-workerthread's worker): path-loaded by the driver, inlining -// the dialog logic while koffi stays an external native require. -export default clientBundle( - '@deepseek-ai/dsh-host-directory-picker-native', - ['lib/types/index.js', 'lib/types/invariant.js'], +/** + * Node-only backend. The Win32 dialog worker builds as its own CJS entry + * (mirroring dsh-workflow-workerthread's worker): path-loaded by the driver, + * inlining the dialog logic while koffi stays an external native require. + */ +export default defineConfig([ { - companions: [{ - // The artifact is lib/worker.cjs (the ./worker export the workspace - // constraint keys on), bundled from the descriptive source entry. - entry: { worker: 'lib/types/win32-dialog-worker.js' }, - outDir: 'lib', - format: ['cjs'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - }], + entry: ['lib/types/index.js', 'lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, }, -) + { + // The artifact is lib/worker.cjs (the ./worker export the workspace + // constraint keys on), bundled from the descriptive source entry. + entry: { worker: 'lib/types/win32-dialog-worker.js' }, + outDir: 'lib', + format: ['cjs'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/docs/user/guide/quickstart.i18n.yaml b/packages/host/plugin-inventory/README.i18n.yaml similarity index 55% rename from docs/user/guide/quickstart.i18n.yaml rename to packages/host/plugin-inventory/README.i18n.yaml index 0cca002d4f..e9fc3f9a09 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/packages/host/plugin-inventory/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/user/guide/quickstart.md -quickstart.md: e93e5a430f0cb345728581cd6fa3175ffd20b7d1 -quickstart.zh.md: 69cde830bb802ef19cc1204685395b957a0e02e3 +# pnpm run verify-translation-pairing --write packages/host/plugin-inventory/README.md +README.md: 23fbf07d7900ecc881f81b5da3f8cbe6a45669de +README.zh.md: 87058cde595b83e980b8f3cec4192e6099b8d9ea diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md new file mode 100644 index 0000000000..23fbf07d79 --- /dev/null +++ b/packages/host/plugin-inventory/README.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-host-plugin-inventory + +English | [中文](README.zh.md) + +Read-only Host projection of the current Cordis Loader tree. `PluginInventoryService` registers the `pluginInventory` service and publishes one generated direct Remote, `pluginInventory/list`. Every call reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, module specifier, effective enablement, and current root Fiber phase. + +The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, event stream, or mutation path. Its public payload types live under `./types`, and TypeRT generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`. + +The service is Remote-only and deliberately declares no same-process Cordis `Context` merge. Client packages consume it through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation. + +## Model Experience + +None, as this Host-only inventory projection registers no prompt, tool, message, or provider request. + +#### KV Cache effect + +None; this package never assembles model input. + +## Known Limitations and Deferred Work + +- **Point-in-time state only** — the result contains no durable failure history or subscription; a missing root Fiber is reported as `null`, regardless of why no live root exists. +- **No provenance or mutation** — the service does not identify which bundle, profile, or override introduced an entry, and it cannot enable, disable, add, or remove plugins. diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md new file mode 100644 index 0000000000..87058cde59 --- /dev/null +++ b/packages/host/plugin-inventory/README.zh.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-host-plugin-inventory + +[English](README.md) | 中文 + +当前 Cordis Loader 树的只读 Host 投影。`PluginInventoryService` 注册 `pluginInventory` 服务,并发布一个由 TypeRT 生成的直接 Remote:`pluginInventory/list`。每次调用都直接读取 `ctx.loader.entries()`,跳过结构性的 group 行,再按 Loader 顺序返回其余条目,并且只包含 Loader 条目 id、模块标识、有效启用状态与当前根 Fiber 阶段。 + +阶段为 `pending`、`loading`、`active`、`failed` 或 `unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下:Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型、事件流或修改路径。公开 payload 类型位于 `./types`,TypeRT 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产物。 + +该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.md) 组合消费它,而不导入 Host 实现。 + +## 模型体验 + +无,因为这个仅限 Host 的清单投影不注册提示词、工具、消息或提供方请求。 + +#### KV Cache 影响 + +无;本包从不组装模型输入。 + +## 已知限制与暂缓事项 + +- **仅表示调用当下** —— 结果不包含持久的失败历史或订阅;只要不存在存活的根 Fiber,就会报告 `null`,而不区分其原因。 +- **无来源与修改能力** —— 服务不识别条目由哪个 bundle、profile 或 override 引入,也不能启用、停用、添加或移除插件。 diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json new file mode 100644 index 0000000000..ac51ce4aa8 --- /dev/null +++ b/packages/host/plugin-inventory/package.json @@ -0,0 +1,68 @@ +{ + "name": "@deepseek-ai/dsh-host-plugin-inventory", + "description": "Read-only Remote projection of current Cordis Loader plugin state", + "version": "0.0.1-rc.2", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/host/plugin-inventory" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "BSD-3-Clause", + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts new file mode 100644 index 0000000000..5bc4db936a --- /dev/null +++ b/packages/host/plugin-inventory/src/index.ts @@ -0,0 +1,72 @@ +/** Read-only projection of the current Cordis Loader plugin entries. */ + +import type { Context, FiberState } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/cordis-plugin-loader' +import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta' +// TypeRT-generated ./typert and ./remote artifacts import Zod at runtime. +import type {} from 'zod' +import type { + PluginEntryId, + PluginFiberPhase, + PluginInventoryEntry, + PluginInventorySnapshot, +} from './types.ts' + +export type * from './types.ts' + +/** Brand an existing Loader-tree entry id at the owning boundary. */ +function pluginEntryId(value: string): PluginEntryId { + return value as PluginEntryId +} + +/** Runtime mirror: FiberState is a cross-package const enum. */ +const FIBER_STATE = { + PENDING: 0 as FiberState.PENDING, + LOADING: 1 as FiberState.LOADING, + ACTIVE: 2 as FiberState.ACTIVE, + FAILED: 3 as FiberState.FAILED, + DISPOSED: 4 as FiberState.DISPOSED, + UNLOADING: 5 as FiberState.UNLOADING, +} as const + +/** Complete public projection of Cordis Fiber states. */ +const FIBER_PHASE = { + [FIBER_STATE.PENDING]: 'pending', + [FIBER_STATE.LOADING]: 'loading', + [FIBER_STATE.ACTIVE]: 'active', + [FIBER_STATE.FAILED]: 'failed', + [FIBER_STATE.DISPOSED]: null, + [FIBER_STATE.UNLOADING]: 'unloading', +} as const satisfies Record + +/** Remote-only service exposing the Loader's current non-group entry state. */ +export class PluginInventoryService extends GatewayService { + static inject = ['loader'] + + constructor(ctx: Context) { + super(ctx, 'pluginInventory') + } + + /** + * Read the Loader directly on every call. Cordis's internal plugin/status + * events already maintain Entry.fiber and Fiber.state, so a second cache + * would only add another lifecycle truth to keep synchronized. + * @returns Current non-group Loader entries in Loader order. + */ + @Remote('list') + list(): PluginInventorySnapshot { + const entries: PluginInventoryEntry[] = [] + for (const entry of this.ctx.loader.entries()) { + if (entry.options.group) continue + entries.push({ + entryId: pluginEntryId(entry.id), + moduleName: entry.options.name, + enabled: !entry.disabled, + fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state], + }) + } + return { entries } + } +} + +export default PluginInventoryService diff --git a/packages/host/plugin-inventory/src/invariant.ts b/packages/host/plugin-inventory/src/invariant.ts new file mode 100644 index 0000000000..34acc058aa --- /dev/null +++ b/packages/host/plugin-inventory/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-host-plugin-inventory/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-host-plugin-inventory' + +/** Cordis companion plugin name. */ +export const name = 'host-plugin-inventory-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: every snapshot is projected directly from Loader-owned state. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts new file mode 100644 index 0000000000..f5678fc3c2 --- /dev/null +++ b/packages/host/plugin-inventory/src/types.ts @@ -0,0 +1,28 @@ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Stable Loader-tree identity of one configured plugin entry. */ +export type PluginEntryId = Branded<'PluginEntryId'> + +/** Lifecycle state of an entry's root Fiber, or null when it has no live root Fiber. */ +export type PluginFiberPhase = + | 'pending' + | 'loading' + | 'active' + | 'failed' + | 'unloading' + | null + +/** One non-group Loader entry exposed to trusted clients. */ +export interface PluginInventoryEntry { + readonly entryId: PluginEntryId + /** Exact module specifier imported by the Loader entry. */ + readonly moduleName: string + /** Effective Loader enablement, including disabled ancestor groups. */ + readonly enabled: boolean + readonly fiberPhase: PluginFiberPhase +} + +/** Point-in-time inventory returned by the plugin inventory Remote. */ +export interface PluginInventorySnapshot { + readonly entries: readonly PluginInventoryEntry[] +} diff --git a/packages/host/plugin-inventory/tests/invariant.spec.ts b/packages/host/plugin-inventory/tests/invariant.spec.ts new file mode 100644 index 0000000000..d7e3b99fd8 --- /dev/null +++ b/packages/host/plugin-inventory/tests/invariant.spec.ts @@ -0,0 +1,16 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as PluginInventoryInvariant from '../src/invariant.ts' + +describe('plugin-inventory invariant companion', () => { + it('registers the package-owned empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + const fiber = ctx.plugin(PluginInventoryInvariant) + await expect(fiber.await()).resolves.toBeDefined() + await fiber.dispose() + await expect(ctx.plugin(PluginInventoryInvariant).await()).resolves.toBeDefined() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts new file mode 100644 index 0000000000..e979d34306 --- /dev/null +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context, type Plugin } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import { remoteMethods } from '@deepseek-ai/dsh-type-meta' +import PluginInventoryService from '../src/index.ts' + +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +const activePlugin: Plugin.Function = () => {} +const pendingPlugin: Plugin.Object = { + inject: ['neverReady'], + apply() {}, +} + +async function harness(): Promise<{ + ctx: Context + inventory: PluginInventoryService +}> { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Loader) + ctx.loader.builtins.active = activePlugin + ctx.loader.builtins.pending = pendingPlugin + await ctx.plugin(PluginInventoryService) + const inventory = ctx.get('pluginInventory') as PluginInventoryService + return { ctx, inventory } +} + +describe('PluginInventoryService', () => { + it('publishes one direct list method under the pluginInventory namespace', async () => { + const { inventory } = await harness() + expect(inventory.typertGateway).toMatchObject({ + serviceKey: 'pluginInventory', + namespace: 'pluginInventory', + }) + expect(remoteMethods(inventory)).toEqual([ + { method: 'list', invocation: { kind: 'direct' } }, + ]) + }) + + it('projects current non-group Loader entries without a second cache', async () => { + const { ctx, inventory } = await harness() + const activeId = await ctx.loader.create({ name: 'cordis:active' }) + const pendingId = await ctx.loader.create({ name: 'cordis:pending' }) + const disabledId = await ctx.loader.create({ + name: 'cordis:not-installed', + disabled: true, + }) + await ctx.loader.create({ name: 'cordis:active', group: true }) + + expect(inventory.list()).toEqual({ + entries: [ + { + entryId: activeId, + moduleName: 'cordis:active', + enabled: true, + fiberPhase: 'active', + }, + { + entryId: pendingId, + moduleName: 'cordis:pending', + enabled: true, + fiberPhase: 'pending', + }, + { + entryId: disabledId, + moduleName: 'cordis:not-installed', + enabled: false, + fiberPhase: null, + }, + ], + }) + + await ctx.loader.update(activeId, { disabled: true }) + expect(inventory.list().entries.find(entry => entry.entryId === activeId)).toEqual({ + entryId: activeId, + moduleName: 'cordis:active', + enabled: false, + fiberPhase: null, + }) + + await ctx.loader.remove(pendingId) + expect(inventory.list().entries.some(entry => entry.entryId === pendingId)).toBe(false) + }) +}) diff --git a/packages/host/plugin-inventory/tsconfig.json b/packages/host/plugin-inventory/tsconfig.json new file mode 100644 index 0000000000..524783f8b8 --- /dev/null +++ b/packages/host/plugin-inventory/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 3865fbd2ae..59ce5dcebe 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -30,6 +30,14 @@ "types": "./lib/types/brand.d.ts", "default": "./lib/types/brand.js" }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -37,7 +45,11 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { @@ -46,14 +58,19 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "dependencies": { + "zod": "^4.4.3" + }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index 56b5d4da14..fb281cc543 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -3,26 +3,27 @@ * @module @deepseek-ai/dsh-commands */ -import { Context, Service } from '@deepseek-ai/cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' +import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta' import { CommandId } from './brand.ts' +import type { + CommandDescriptor, + CommandExecution, + CommandInputDescriptor, + CommandResult, +} from './types.ts' export { CommandId } from './brand.ts' -export type { CommandSource, CommandSourceMap } from './types.ts' +export type * from './types.ts' export const name = 'commands' const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u -/** Immutable metadata for a command's optional unstructured input. */ -export interface CommandInputDescriptor { - /** Placeholder shown before the user supplies free-form input. */ - readonly hint: string -} - /** Invocation passed to one registered command handler. */ export interface CommandInvocation { /** Pairing id already written to this invocation's `command/run` event. */ @@ -35,29 +36,6 @@ export interface CommandInvocation { readonly signal: AbortSignal } -/** Expected command outcome rendered directly by the dispatching UI. */ -export type CommandResult = - | { - readonly kind: 'success' - readonly text?: string - /** Earlier authoritative domain event that owns a richer presentation. */ - readonly sourceEventSeq?: number - } - | { readonly kind: 'error'; readonly text: string } - -/** - * One settled command execution: the handler's normalized result plus the - * lifecycle pairing id minted for its `command/run`/`command/done` records, - * so a dispatching surface can correlate the RPC-level acknowledgment with - * the flow node those events produce. - */ -export interface CommandExecution { - /** Pairing id carried by this execution's lifecycle events. */ - readonly commandId: CommandId - /** The handler's normalized outcome. */ - readonly result: CommandResult -} - /** Plugin-owned command registration. */ export interface CommandDefinition { /** Lowercase command name without the leading slash. */ @@ -76,16 +54,6 @@ export interface CommandDefinition { readonly handler: (invocation: CommandInvocation) => CommandResult | Promise } -/** Handler-free immutable command view returned to UI adapters. */ -export interface CommandDescriptor { - /** Lowercase command name without the leading slash. */ - readonly name: string - /** Human-readable summary used in discovery UI. */ - readonly description: string - /** Optional free-form input hint advertised to capable clients. */ - readonly input?: CommandInputDescriptor -} - /** Syntactically valid slash command before registry resolution. */ export interface ParsedCommand { /** Lowercase command name without the leading slash. */ @@ -254,7 +222,7 @@ function normalizeResult(command: string, value: unknown): CommandResult { * registered through a command-injected child of an agent context shadow * globals for that agent. */ -export class CommandService extends Service { +export class CommandService extends GatewayService { private readonly layers = new ScopedLayers( scope => new CommandLayer(scope), () => { this.notifyChange() }, @@ -288,6 +256,7 @@ export class CommandService extends Service { * @param agent - exact receiving agent and scoped-layer key. * @returns name-sorted descriptors after scoped shadowing. */ + @Remote list(agent: Agent): readonly CommandDescriptor[] { return Object.freeze([...this.view(agent).values()] .map(command => command.descriptor) @@ -324,6 +293,7 @@ export class CommandService extends Service { * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. */ + @Remote async execute( agent: Agent, line: string, diff --git a/packages/interaction/commands/src/types.ts b/packages/interaction/commands/src/types.ts index 27309355d4..32f1dbcc43 100644 --- a/packages/interaction/commands/src/types.ts +++ b/packages/interaction/commands/src/types.ts @@ -9,6 +9,45 @@ import type { CommandId } from './brand.ts' +/** Immutable metadata for a command's optional unstructured input. */ +export interface CommandInputDescriptor { + /** Placeholder shown before the user supplies free-form input. */ + readonly hint: string +} + +/** Expected command outcome rendered directly by the dispatching UI. */ +export type CommandResult = + | { + readonly kind: 'success' + readonly text?: string + /** Earlier authoritative domain event that owns a richer presentation. */ + readonly sourceEventSeq?: number + } + | { readonly kind: 'error'; readonly text: string } + +/** + * One settled command execution: the handler's normalized result plus the + * lifecycle pairing id minted for its `command/run`/`command/done` records, + * so a dispatching surface can correlate the Remote acknowledgment with the + * flow node those events produce. + */ +export interface CommandExecution { + /** Pairing id carried by this execution's lifecycle events. */ + readonly commandId: CommandId + /** The handler's normalized outcome. */ + readonly result: CommandResult +} + +/** Handler-free immutable command view returned to UI adapters. */ +export interface CommandDescriptor { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor +} + /** * Producer record for one command invocation (the `command/run` event's * source slot). Merge-extensible sum type mirroring `MessageSourceMap`'s diff --git a/packages/interaction/commands/tsconfig.json b/packages/interaction/commands/tsconfig.json index 901c76a377..128d7d9147 100644 --- a/packages/interaction/commands/tsconfig.json +++ b/packages/interaction/commands/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../typert/type-meta" } ] } diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 559144f6df..d63dd3fe2a 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: 28b9a31ed41e5fc41e38d6b0349c5bd9cbaeed9d -README.zh.md: 1d8d1481c20005b9e7fed5341aa26dca63dcd815 +README.md: 63bed95d192e6aeff6f484b63bdde711df0f1967 +README.zh.md: 505cb017a3a2439a11e0f3ed1c7a950893f5e7de diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 28b9a31ed4..63bed95d19 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -18,7 +18,8 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built. - `ctx.agentPresets.recompose(agentCtx, id): Promise` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`. - `ctx.agentPresets.standingKeyFor(id?): Promise` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`. -- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all. +- `ctx.agentPresets.roots: readonly PresetRoot[]` The roots this roster scans — every configured root in order, then the derived harness-home root. Not `config.roots`: read this to answer whether a roster is composed at all, so one derivation decides it. +- `ctx.agentPresets.authorable: boolean` Whether any of those roots has `user` trust, and therefore whether a preset can be created at all. - `ctx.agentPresets.read(id): Promise` One preset's composition text, exactly as stored. - `ctx.agentPresets.copy(from, id, name?): Promise` Create a locally authored preset by copying an existing one's whole directory — the only authoring write. No composition text crosses this seam, so a copy is exactly as loadable as its source; the copied metadata keeps the source's description but never its name or roster order, and `name` (or the id fallback) is what distinguishes the rows. - `ctx.agentPresets.remove(id): Promise` Delete a locally authored preset; joined sessions keep their standing mount. Clears the user default when it named the preset just deleted: storing a default that does not exist yet is deliberate, but one this call removed will never be supplied again and would fail every session created without an explicit pick. @@ -86,9 +87,20 @@ Every read failure degrades to no metadata — absent, malformed, wrongly typed, |---|---|---| | `default` | required | Preset id mounted when a caller names none | | `roots` | `[]` | Scanned directories in precedence order; each supplies `path` (a leading `~` expands) and `trust` (defaults to `user`) | +| `includeUserRoot` | `true` | Append `/.agent-presets` as a `user` root, after every configured root | An absent root supplies no presets rather than failing: the user root does not exist until the first locally authored preset, and naming a default no root supplies already fails loud at resolution. +### The writable root is this package's, the shipped root is the app's + +`/.agent-presets` is where a person's own presets live, the way `/skills` is where their own skills live ([`dsh-skill-local`](../../skill/skill-local/README.md)), so the roster derives it rather than waiting for a deployment to remember it — a launcher that configures nothing still finds and authors presets. It is appended AFTER every configured root, which keeps an earlier root winning a duplicate id: a shipped `standard` still shadows a home directory that claimed the name, and `copy()` refuses that id rather than landing a preset nothing would resolve. + +The roots are resolved once, when the service is constructed. A root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw. + +`includeUserRoot: false` mounts a roster over `roots` alone. A deployment that confines presets to its own directories needs it, and so does any test pinning an exact roster — otherwise the machine's real `` decides what the roster contains. + +The SHIPPED root stays an assembly fact: it sits beside the installed app's own config, a path only that app can resolve. + ### The default preset is a user setting When a settings provider is composed, this plugin registers the `agent-presets` namespace with `config.default` as its composition base, so the user document layers over the deployment's engineering default: @@ -132,6 +144,7 @@ Prefix-stable for the life of an agent: a composition is installed once, before ## Known Limitations and Deferred Work +- **A preset outside the writable root is discoverable but not deletable** — `remove()` refuses anything that does not live under the FIRST `user` root, so a deployment that configures its own writable root while leaving `includeUserRoot` on lists the harness-home presets, mounts them, and then answers "it does not live under the writable preset root" for every delete. The roster carries one writable root by design; a deployment that wants only its own sets `includeUserRoot: false`. - **A preset cannot be changed once a session has produced anything** — `recompose` re-links a BLANK session's parent scope to another standing mount, and only a blank one: switching a composition that already ran would strand tools the model has called. Changing the default affects only sessions created afterwards. - **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts. - **A superseded generation is never reclaimed** — sessions already joined keep the generation they run on, and the roster holds no join count that could tell when the last one left, so the whole subtree stays mounted until the process ends. The cost is per generation rather than per session, but it is not free: `dsh-skill-local` watches its roots by default, so each edit-then-create cycle adds a live watcher set. Bounded by how often compositions are edited — which the settings-page authoring flow makes a per-save event rather than a per-deploy one. Reclaiming one needs a joined-agent count on the standing mount; see the `TODO` at `ensureStanding`. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 1d8d1481c2..505cb017a3 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -18,7 +18,8 @@ - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。 - `ctx.agentPresets.recompose(agentCtx, id): Promise` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。 - `ctx.agentPresets.standingKeyFor(id?): Promise` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。 -- `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。 +- `ctx.agentPresets.roots: readonly PresetRoot[]` 本 roster 实际扫描的根目录——全部已配置根目录按序在前,随后是推导出的 harness home 根目录。它不是 `config.roots`:判断「是否已组装 roster」应读它,从而由同一处推导决定。 +- `ctx.agentPresets.authorable: boolean` 上述根目录中是否有任一具备 `user` 信任级别,因而 preset 是否可创建。 - `ctx.agentPresets.read(id): Promise` 某个 preset 的组装文本,与存储内容逐字一致。 - `ctx.agentPresets.copy(from, id, name?): Promise` 通过整目录复制一个既有 preset 来创建本地创作的 preset——唯一的创作写入。组装文本不经过这道接缝,因此副本与其来源同等可加载;复制出的元数据保留来源的描述、但绝不保留其名称与 roster 排序,`name`(或回退到 id)才是区分两行的依据。 - `ctx.agentPresets.remove(id): Promise` 删除一个本地创作的 preset;已加入的会话保留其常驻挂载。若用户默认值恰好指向刚删除的 preset 则一并清除:存一个尚不存在的默认值是刻意的,但本次删除的这个再也不会有人提供,留着会让所有未显式指定的新会话无法启动。 @@ -86,9 +87,20 @@ description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agen |---|---|---| | `default` | 必填 | 调用方未指定时挂载的 preset id | | `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) | +| `includeUserRoot` | `true` | 在全部已配置根目录之后,追加 `/.agent-presets` 作为 `user` 根目录 | 根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。 +### 可写根目录属于本包,随附根目录属于 app + +`/.agent-presets` 是个人自有 preset 的所在,正如 `/skills` 是其自有 skill 的所在([`dsh-skill-local`](../../skill/skill-local/README.md)),因此 roster 自行推导它,而不等某个部署记得配置——一个什么都没配的启动器同样能发现并创作 preset。它追加在全部已配置根目录**之后**,从而保持靠前的根目录赢得重复 id:随附的 `standard` 仍然遮蔽一个占用该名字的家目录目录,而 `copy()` 会拒绝该 id,不会落下一个无人解析得到的 preset。 + +根目录在服务构造时解析一次。若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。 + +`includeUserRoot: false` 使 roster 只覆盖 `roots`。把 preset 限制在自有目录内的部署需要它,任何钉住确切 roster 的测试同样需要——否则将由这台机器真实的 `` 决定 roster 的内容。 + +随附根目录仍然是装配事实:它位于已安装 app 自身配置的旁边,那个路径只有该 app 能解析。 + ### 默认 preset 是一项用户设置 当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `config.default` 作为其组装 base,因此用户文档会层叠覆盖部署方的工程默认值: @@ -132,6 +144,7 @@ Indirectly, through the plugins a standing composition registers, which own ever ## Known Limitations and Deferred Work +- **位于可写根目录之外的 preset 可被发现却无法删除** —— `remove()` 拒绝任何不在**第一个** `user` 根目录下的 preset,因此一个既配置了自有可写根、又保留 `includeUserRoot` 的部署,会列出并挂载 harness home 下的 preset,却对每次删除回答「它不在可写 preset 根目录之下」。roster 按设计只有一个可写根;只想要自有根的部署应设置 `includeUserRoot: false`。 - **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。 - **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。 - **被替代的代际永不回收** —— 已加入的会话保持其运行所在的代际,而名单没有加入计数可以判断最后一个何时离开,因此整棵子树一直挂到进程结束。代价按代际计而非按会话计,但并非为零:`dsh-skill-local` 默认监听自己的根目录,因此每一轮「编辑后建会话」都会新增一套活的 watcher。上限取决于组装被编辑的频率——而设置页的编写流程把这件事从「每次部署」变成了「每次保存」。要回收就需要给常驻挂载加上已加入 agent 的计数;见 `ensureStanding` 处的 `TODO`。 diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts index 4ab3f67e50..51e1f30c95 100644 --- a/packages/preset/agent-presets/src/discovery.ts +++ b/packages/preset/agent-presets/src/discovery.ts @@ -25,6 +25,21 @@ import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts' /** The composition file that makes a directory a preset. */ export const COMPOSITION_FILE = 'agent.cordis.yml' +/** + * Harness-home directory holding locally authored presets. + * + * This package owns the writable root the way `dsh-skill-local` owns + * `/skills`. An app must assemble the SHIPPED root, whose path only + * the installed app can resolve; where a person's own presets go is the same + * place in every deployment that does not say otherwise, so a launcher that + * forgets to configure one still finds them. + * + * Package-internal on purpose: no consumer outside this package addresses the + * directory by name, and a test that imported it could not catch this value + * being wrong — the expected segment is spelled out where it is asserted. + */ +export const USER_PRESET_DIR = '.agent-presets' + /** * Why `rows` cannot be an entry list, or undefined when it can. * diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 3da5e3b5c9..a98eb92dd4 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -28,11 +28,12 @@ import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type // Type-only: resolves the `agent/created` lifecycle event this service watches. import type {} from '@deepseek-ai/dsh-agent' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' -import { discoverPresets } from './discovery.ts' +import { dshHomePath } from '@deepseek-ai/dsh-paths' +import { discoverPresets, USER_PRESET_DIR } from './discovery.ts' import { copyComposition, deleteComposition, readComposition } from './authoring.ts' import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts' import { PresetExistsError } from './authoring.ts' -import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './preset.ts' +import { PresetMountError, UnknownPresetError, type AgentPreset, type Config, type PresetRoot } from './preset.ts' import type {} from './types.ts' /** Settings namespace carrying the user's chosen default preset. */ @@ -88,8 +89,21 @@ export class AgentPresets extends Service { path: z.string().required(), trust: z.union(['system', 'user'] as const).default('user'), })).default([]), + includeUserRoot: z.boolean().default(true), }) as z + /** + * The roots discovery and authoring actually scan: every configured root in + * order, then the harness-home user root unless `includeUserRoot` is false. + * + * Derived once, because a root set that changed between `list()` and the + * `copy()` acting on its answer would author into a directory the caller + * never saw. Appending rather than prepending keeps an earlier configured + * root winning a duplicate id, so a shipped preset still shadows a + * locally authored directory that claimed its name. + */ + private readonly resolvedRoots: readonly PresetRoot[] + /** * The user layer over `config.default`, present only while a settings * provider is composed. Held rather than snapshotted so a hot-reloaded @@ -116,6 +130,9 @@ export class AgentPresets extends Service { constructor(ctx: Context, public config: Config) { super(ctx, 'agentPresets') this.selfCtx = ctx + this.resolvedRoots = config.includeUserRoot + ? [...config.roots, { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }] + : [...config.roots] // Deliberately not `installSettingsSection`: that helper exists to re-judge // what a consumer DERIVED from the source — memoized resolutions, // registration-level facts — across attach, detach, and change. Nothing @@ -147,7 +164,7 @@ export class AgentPresets extends Service { // does that today — the Web surface mounts in `setup` and children join // through `composeFrom` before publication. ctx.on('agent/created', ({ agent }) => { - if (this.config.roots.length === 0) return + if (this.resolvedRoots.length === 0) return if (this.composedPreset(agent.ctx) !== undefined) return ctx.logger.warn( `agent "${agent.id}" was published without joining an agent preset; ` @@ -180,7 +197,7 @@ export class AgentPresets extends Service { * @returns the presets, first-root-wins per id. */ async list(): Promise { - return await discoverPresets(this.config.roots) + return await discoverPresets(this.resolvedRoots) } /** @@ -320,9 +337,19 @@ export class AgentPresets extends Service { return standingMountFor(agentCtx)?.presetId } - /** Whether this deployment configures a root locally authored presets go to. */ + /** + * The roots this roster scans, which is not `config.roots`: it is every + * configured root in order, then the harness-home user root unless + * `includeUserRoot` is false. Read this — not the config field — to answer + * whether a roster is composed at all, so one derivation decides it. + */ + get roots(): readonly PresetRoot[] { + return this.resolvedRoots + } + + /** Whether this deployment has a root locally authored presets go to. */ get authorable(): boolean { - return this.config.roots.some(root => root.trust === 'user') + return this.resolvedRoots.some(root => root.trust === 'user') } /** @@ -358,7 +385,7 @@ export class AgentPresets extends Service { if ((await this.list()).some(preset => preset.id === id)) { throw new PresetExistsError(id) } - await copyComposition(this.config.roots, source, id, name) + await copyComposition(this.resolvedRoots, source, id, name) // A settled mount under this id can only be stale (its preset was deleted // from disk outside `remove`); the new preset must not inherit it. Every // session already joined keeps the generation it runs on regardless. @@ -371,7 +398,7 @@ export class AgentPresets extends Service { * @throws when the preset is unknown or ships with the deployment. */ async remove(id: string): Promise { - await deleteComposition(this.config.roots, await this.resolve(id)) + await deleteComposition(this.resolvedRoots, await this.resolve(id)) // Sessions on the deleted preset keep their standing mount; only new // sessions see the roster without it. this.standing.delete(id) diff --git a/packages/preset/agent-presets/src/invariant.ts b/packages/preset/agent-presets/src/invariant.ts index e9240a0b2d..81cfd834ab 100644 --- a/packages/preset/agent-presets/src/invariant.ts +++ b/packages/preset/agent-presets/src/invariant.ts @@ -60,7 +60,7 @@ const install: InvariantInstaller = (ctx, fail) => { ctx.on('system-prompt/assemble', (_assembly, context, next) => { const presets = ctx.get('agentPresets') const agent = context.agent - if (presets !== undefined && presets.config.roots.length > 0 + if (presets !== undefined && presets.roots.length > 0 && agent !== undefined && presets.composedPreset(agent.ctx) === undefined) { fail( `agent "${agent.id}" addressed a model without joining any agent preset while a roster is ` diff --git a/packages/preset/agent-presets/src/preset.ts b/packages/preset/agent-presets/src/preset.ts index b2b48ea6ea..554348cdd6 100644 --- a/packages/preset/agent-presets/src/preset.ts +++ b/packages/preset/agent-presets/src/preset.ts @@ -54,6 +54,11 @@ export interface Config { default: string /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ roots: PresetRoot[] + /** + * Append the harness home's `USER_PRESET_DIR` as a `user` root, after every + * configured root. False mounts a roster over `roots` alone. + */ + includeUserRoot: boolean } /** diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts index df69a792d5..8086996111 100644 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -52,6 +52,10 @@ beforeEach(async () => { { path: join(FIXTURES, 'system'), trust: 'system' as const }, { path: userRoot, trust: 'user' as const }, ], + // Every roster in this file pins its own roots: the derived harness-home + // root would add the developer's real presets to what these assertions + // count, and `copy` would write into it. + includeUserRoot: false, }) }) @@ -199,6 +203,7 @@ describe('a deployment with more than one user root', () => { { path: userRoot, trust: 'user' as const }, { path: second, trust: 'user' as const }, ], + includeUserRoot: false, }) // Writes go to the first user root, so a preset discovered from a later @@ -219,6 +224,7 @@ describe('a deployment with no writable root', () => { await readOnly.plugin(AgentPresets, { default: 'standard', roots: [{ path: join(FIXTURES, 'system'), trust: 'system' as const }], + includeUserRoot: false, }) expect(readOnly.agentPresets.authorable).toBe(false) @@ -240,6 +246,7 @@ describe('a user root that does not exist yet', () => { { path: join(FIXTURES, 'system'), trust: 'system' as const }, { path: absent, trust: 'user' as const }, ], + includeUserRoot: false, }) await fresh.agentPresets.copy('standard', 'mine') diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index 709ee5ba00..f73f77a53b 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -11,7 +11,7 @@ import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import InvariantService from '@deepseek-ai/dsh-invariants' import { describe, expect, it } from 'vitest' -import AgentPresets, { livePresetMounts } from '@deepseek-ai/dsh-agent-presets' +import AgentPresets, { livePresetMounts, type Config } from '@deepseek-ai/dsh-agent-presets' import * as AgentPresetsInvariant from '@deepseek-ai/dsh-agent-presets/invariant' const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') @@ -20,7 +20,7 @@ const ROOTS = [ { path: join(FIXTURES, 'user'), trust: 'user' as const }, ] -async function harness(): Promise { +async function harness(roster: Partial = {}): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) @@ -31,7 +31,7 @@ async function harness(): Promise { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeUserRoot: false, ...roster }) await ctx.plugin(InvariantService) await ctx.plugin(AgentPresetsInvariant) return ctx @@ -97,6 +97,28 @@ describe('agent-presets invariants', () => { .rejects.toThrow(/without joining any agent preset/) }) + it('rejects one just the same when the derived home root is the whole roster', async () => { + // The shape this plugin defaults to: an app configures nothing and the + // roster is the harness home alone. A roster is a roster however its roots + // were resolved, so the fail-loud half must not go quiet here — it read + // `config.roots` once, which is empty in exactly this case. + const ctx = await harness({ roots: [], includeUserRoot: true }) + const handle = await ctx.agents.create({ sessionId: SessionId('inv-derived-only') }) + + await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))) + .rejects.toThrow(/without joining any agent preset/) + }) + + it('stays silent for a composition that opted out of every root', async () => { + // `includeUserRoot: false` with no configured roots is a deployment that + // mounts the roster but keeps its agents on the host plane; there is no + // roster to join, so an unjoined agent is not a violation. + const ctx = await harness({ roots: [], includeUserRoot: false }) + const handle = await ctx.agents.create({ sessionId: SessionId('inv-no-roster') }) + + await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))).resolves.toBeDefined() + }) + it('admits a joined agent, a scopeless read, and a standing-key read', async () => { const ctx = await harness() const handle = await ctx.agents.create({ diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 92a080a930..8901770434 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -38,7 +38,7 @@ const ROOTS = [ * @param roster - roster config, defaulting to the fixture roots. * @returns the booted context. */ -async function harness(roster: Config = { default: 'standard', roots: ROOTS }): Promise { +async function harness(roster: Config = { default: 'standard', roots: ROOTS, includeUserRoot: false }): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) @@ -94,7 +94,7 @@ describe('composing an agent from a preset', () => { join(presetDir, COMPOSITION_FILE), `- id: only\n name: ${plugin}\n config:\n tool: absolute\n`, ) - const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }] }) + const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }], includeUserRoot: false }) const imported = vi.spyOn(scoped.loader.internal!, 'import') await agentOn(scoped, 'sess-absolute-plugin') @@ -347,7 +347,7 @@ describe('composing from a broken preset', () => { const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-')) await mkdir(join(root, 'damaged')) await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition) - return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }] }) + return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) } it('refuses the mount up front with the discovery-reported reason', async () => { @@ -380,7 +380,7 @@ describe('a roster with nothing in it', () => { it('says so instead of naming an empty list of candidates', async () => { const bare = new Context() await bare.plugin(Loader) - await bare.plugin(AgentPresets, { default: 'standard', roots: [] }) + await bare.plugin(AgentPresets, { default: 'standard', roots: [], includeUserRoot: false }) await expect(bare.agentPresets.resolve()) .rejects.toThrow(/preset "standard" not found \(available: none\)/) @@ -418,7 +418,7 @@ describe('the preset file is an input, never a persistence target', () => { await scoped.plugin(ToolRegistry) await scoped.plugin(AgentRegistry) await scoped.plugin(AgentLoop, { agents: [] }) - await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }] }) + await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) await scoped.agents.create({ sessionId: SessionId('sess-self-dispose'), @@ -528,11 +528,13 @@ describe('replacing a composition', () => { expect(warnings).toEqual([]) }) - it('says nothing when the deployment configures no roster at all', async () => { + it('says nothing when the composition opts out of every root', async () => { // Presets are optional: every surface except the Web bundle keeps its // model-facing rows in the host plane, so an agent with a chain of one is - // exactly right there and the diagnostic must stay silent. - const rosterless = await harness({ default: 'standard', roots: [] }) + // exactly right there and the diagnostic must stay silent. Opting out is + // what makes this rosterless — empty `roots` alone would still derive the + // harness-home root, which is a roster like any other. + const rosterless = await harness({ default: 'standard', roots: [], includeUserRoot: false }) const warnings: string[] = [] rosterless.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof rosterless.logger.warn @@ -581,7 +583,7 @@ describe('replacing a composition', () => { await scoped.plugin(ToolRegistry) await scoped.plugin(AgentRegistry) await scoped.plugin(AgentLoop, { agents: [] }) - await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }] }) + await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) const handle = await scoped.agents.create({ sessionId: SessionId('sess-restore-gone'), setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx, 'first'), @@ -621,7 +623,7 @@ describe('editing a composition file', () => { await mkdir(join(root, id)) const path = join(root, id, COMPOSITION_FILE) await writeFile(path, rowFor('before')) - const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }] }) + const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) return { scoped, path } } diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index ef75eb8b78..49f1636a6c 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -49,7 +49,7 @@ async function harness( await ctx.plugin(AgentLoop, { agents: [] }) const settingsFiber = ctx.plugin(SettingsLocal, { path: settingsFile, watch: false }) await settingsFiber - await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots] }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots], includeUserRoot: false }) return { ctx, settingsFile, settingsFiber } } diff --git a/packages/preset/agent-presets/tests/user-root.spec.ts b/packages/preset/agent-presets/tests/user-root.spec.ts new file mode 100644 index 0000000000..98c8123d1d --- /dev/null +++ b/packages/preset/agent-presets/tests/user-root.spec.ts @@ -0,0 +1,131 @@ +/** + * The writable root is this package's own, not an assembly fact each app must + * remember: a roster configured with only a `system` root still discovers and + * authors into `/.agent-presets`, the way `dsh-skill-local` owns + * `/skills`. `includeUserRoot: false` is how a deployment — or a test + * pinning an exact roster — opts out. + * + * `$DSH_HOME` is repointed per test because the derived root is resolved in the + * constructor: the plugin must be mounted while the environment names the + * temporary home, or it would reach the developer's real one. + */ + +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import AgentPresets, { COMPOSITION_FILE, type Config } from '@deepseek-ai/dsh-agent-presets' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const SYSTEM_ROOT = join(FIXTURES, 'system') +/** Spelled out rather than imported: the convention is what these tests assert. */ +const USER_ROOT_SEGMENT = '.agent-presets' +const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n' + +let home: string +let previousHome: string | undefined + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dsh-preset-home-')) + previousHome = process.env.DSH_HOME + process.env.DSH_HOME = home +}) + +afterEach(() => { + if (previousHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = previousHome +}) + +/** Boot a roster over the fixture system root, with the derived root left to the plugin. */ +async function roster(config: Partial = {}): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(AgentPresets, { + default: 'standard', + roots: [{ path: SYSTEM_ROOT, trust: 'system' as const }], + includeUserRoot: true, + ...config, + }) + return ctx +} + +/** Hand-place a preset directory under the harness home's preset root. */ +async function seedHomePreset(id: string): Promise { + await mkdir(join(home, USER_ROOT_SEGMENT, id), { recursive: true }) + await writeFile(join(home, USER_ROOT_SEGMENT, id, COMPOSITION_FILE), VALID) +} + +describe('the harness-home preset root', () => { + it('is what a roster gets when config names no roots at all', () => { + // The schema default is the contract an app relies on by saying nothing; + // every other case here passes the field explicitly. The cast stands for + // the untyped document the Loader hands the schema, which is where a + // composition that omits the key actually comes from. + const parsed = AgentPresets.Config({ default: 'standard' } as unknown as Config) + + expect(parsed).toMatchObject({ includeUserRoot: true, roots: [] }) + }) + + it('is discovered without any app configuring it', async () => { + await seedHomePreset('mine') + const ctx = await roster() + + const listed = await ctx.agentPresets.list() + + expect(listed.find(preset => preset.id === 'mine')).toMatchObject({ trust: 'user' }) + expect((await ctx.agentPresets.resolve('mine')).path) + .toBe(join(home, USER_ROOT_SEGMENT, 'mine', COMPOSITION_FILE)) + }) + + it('makes a roster with only a system root authorable, and receives the copy', async () => { + const ctx = await roster() + + expect(ctx.agentPresets.authorable).toBe(true) + await ctx.agentPresets.copy('standard', 'copied') + + expect(existsSync(join(home, USER_ROOT_SEGMENT, 'copied', COMPOSITION_FILE))).toBe(true) + }) + + it('sorts after every configured root, so a shipped id still shadows a home directory', async () => { + // `standard` exists in the fixture system root; claiming the name at home + // must not take it over, because `copy` refuses an id any root supplies + // and a session resolving `standard` must reach the shipped composition. + await seedHomePreset('standard') + const ctx = await roster() + + expect((await ctx.agentPresets.resolve('standard')).trust).toBe('system') + await expect(ctx.agentPresets.copy('standard', 'standard')).rejects.toThrow(/already exists/) + }) + + it('is absent under includeUserRoot: false, which leaves the roster unauthorable', async () => { + await seedHomePreset('mine') + const ctx = await roster({ includeUserRoot: false }) + + expect((await ctx.agentPresets.list()).map(preset => preset.id)).not.toContain('mine') + expect(ctx.agentPresets.authorable).toBe(false) + await expect(ctx.agentPresets.copy('standard', 'mine')) + .rejects.toThrow(/no user-writable preset root/) + }) + + it('yields to a configured user root for authoring, which writableRoot takes first', async () => { + const explicit = await mkdtemp(join(tmpdir(), 'dsh-preset-explicit-')) + const ctx = await roster({ + roots: [ + { path: SYSTEM_ROOT, trust: 'system' as const }, + { path: explicit, trust: 'user' as const }, + ], + }) + + await ctx.agentPresets.copy('standard', 'copied') + + expect(existsSync(join(explicit, 'copied', COMPOSITION_FILE))).toBe(true) + expect(existsSync(join(home, USER_ROOT_SEGMENT, 'copied'))).toBe(false) + }) +}) diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 1586df3947..389f23a011 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -32,6 +32,16 @@ async function setup(config: Config = {}, internals: LocalSandboxProvider['inter return { ctx, sandbox } } +/** + * A path inside a fresh temp dir where no file is written, pinning the + * built-entry `existsSync` check to false. Without it the resolution depends on + * whether the checkout has run `build:lib:host`, which emits + * `sandbox-windows-acl/lib/runner.js`. + */ +function absentRunnerEntry(): string { + return join(mkdtempSync(join(tmpdir(), 'dsh-absent-acl-entry-')), 'runner.js') +} + /** Write an executable fake `landlock-run` that answers `--probe` with `report`. */ function fakeLauncher(report = 'landlock: fully enforced'): string { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-')) @@ -395,15 +405,31 @@ describe('the windows-acl probe (runner invocation contract)', () => { }) it('runs the REAL default probe against the resolved runner invocation when none is injected', async () => { - // The default probe spawns the exact runner argv confine would use — the - // runner source through tsx on a lib-less checkout. The windows-acl - // runner cannot init off win32, so the probe reads unusable and the walk - // falls through to the injected bwrap verdict on every host. + // No entry injected: this covers the production resolution through + // import.meta.resolve. Which arm of the existsSync check it takes depends + // on whether the checkout has run build:lib:host (which emits + // sandbox-windows-acl/lib/runner.js), so this asserts only what holds + // either way — the runner cannot init off win32, so the probe reads + // unusable and the walk falls through to the injected bwrap verdict. const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeBwrap: () => true }) const confined = sandbox.confine(['true'], RO) expect(confined.argv[0]).toBe('bwrap') }, 30_000) + it('falls back to the runner source through tsx when the built entry is absent', async () => { + // The absent entry pins the source-through-tsx arm regardless of build + // state: on a checkout where build:lib:host has run, the real resolution + // above takes the built-entry arm instead and would leave this uncovered. + const { sandbox } = await setup({}, { + chain: ['windows-acl', 'bwrap'], + probeWindowsAcl: () => true, + windowsAclRunnerEntry: absentRunnerEntry(), + }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv.slice(0, 3)).toEqual([process.execPath, '--import', 'tsx/esm']) + expect(confined.argv[3]).toMatch(/runner\.ts$/) + }) + it('reads an empty runner invocation as unusable (the probe\'s empty-argv guard)', async () => { // windowsAclRunnerInvocation always yields [node, ...] in product; an // override returning [] exercises the default probe's empty-argv guard. diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 40861d6ab5..6170c9a196 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -323,7 +323,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Register a global or calling-agent-scoped command.\n * @param definition - discovery metadata and direct UI handler.\n * @returns the exact effect disposer that unregisters this definition.\n */', }, { - signature: 'list(agent: Agent): readonly CommandDescriptor[]', + signature: '@Remote list(agent: Agent): readonly CommandDescriptor[]', jsDoc: '/**\n * List the effective immutable command descriptors for one agent.\n * @param agent - exact receiving agent and scoped-layer key.\n * @returns name-sorted descriptors after scoped shadowing.\n */', }, { @@ -331,7 +331,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Resolve one effective command definition.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param name - command name without a slash.\n * @returns the scoped shadow or global definition.\n */', }, { - signature: 'async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise', + signature: '@Remote async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise', jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n *\n * A resolved command\'s lifecycle is logged: `command/run` is appended\n * before the handler is invoked and `command/done` after settlement (a\n * thrown or aborted handler settles as `kind: \'error\'`). Both are direct\n * log-only appends — no turn wraps them, and persistence drains them at\n * ordinary checkpoints. Admission misses (syntax or unknown name) log\n * nothing — they never entered a handler. A `command/run` append failure\n * fails the execution loud; a `command/done` append failure on the\n * handler-failure path is contained so the handler\'s own error stays the\n * reported failure.\n *\n * @param agent - exact receiving agent.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns the settled execution (result + lifecycle pairing id), or\n * `undefined` when syntax or name does not resolve.\n */', }, ], @@ -1384,6 +1384,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'delete(id: WorkspaceId): Promise', jsDoc: '/**\n * Delete one workspace registration while retaining its directory and every\n * session log. The durable order is updated before the table deletion; a\n * failed table write restores the prior order and keeps the entity\n * published. Unknown ids are an idempotent no-op for domain callers.\n * @param id - Workspace registration to remove.\n * @returns `true` when a record was deleted, `false` when it was unknown.\n */', }, + { + signature: 'insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise', + jsDoc: '/**\n * Move one workspace within the durable display order, DOM-insertBefore-like.\n * With an anchor it lands before that workspace; without one it appends.\n * @param id - Workspace to move.\n * @param beforeId - Workspace anchor; omitted appends.\n * @returns the complete committed workspace order.\n */', + }, { signature: 'archiveSession(sessionId: SessionId): Promise', jsDoc: '/**\n * Archive one session durably. The session must exist (live or in session\n * persistence); its workspace accounting — or lack of one — is irrelevant.\n * An already archived id resolves without writing.\n * @param sessionId - The session to archive.\n * @returns resolution after durability.\n */', @@ -2266,7 +2270,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvocationParameterDescriptor', - declaration: 'export interface InvocationParameterDescriptor {\n readonly name: string;\n readonly wire: string;\n readonly source: \'json\' | \'lookup\';\n readonly lookup?: string;\n readonly codec: TypeRTCodec;\n}', + declaration: 'export interface InvocationParameterDescriptor {\n readonly name: string;\n readonly wire: string;\n readonly source: \'json\' | \'lookup\';\n readonly lookup?: string;\n readonly codec: TypeRTCodec;\n readonly acceptsUndefined?: true;\n}', }, { name: 'InvocationSourceLocation', diff --git a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts index 28306f0dcc..b4c5d5736e 100644 --- a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts @@ -40,7 +40,7 @@ async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; ctx.loader.builtins.include = Include await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS }) + await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeUserRoot: false }) const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')]) ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 68b8ff7d50..60d25bcc1b 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md -README.md: 2817e02861db6caad89cad258d14a90c34afcbaf -README.zh.md: 251b994a35e8bd7c84827957a548a1f352583ac6 +README.md: bf0af8779f0cc3e2c20382db40be4715814e78f4 +README.zh.md: 64f58f63fd32e1fa45f7642a59b01cf204ba92c3 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 2817e02861..bf0af8779f 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -12,7 +12,8 @@ Local Service provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. - **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. -- **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement. +- **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes. +- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). ## Model Experience @@ -27,6 +28,7 @@ No direct invalidation; the named consumers own any request-prefix changes. - **Windows tree support is best-effort** — termination routes through `taskkill /PID /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary. - **Terminal process inspection is Linux/macOS only** — the terminal primitive fails when its inspector has no supported platform implementation; Linux exact probes cover x64 and arm64, while macOS uses `ps` snapshots. - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor. +- **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event. The default OS disposition for an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP` bypasses that event; an application covers those signals only by installing a handler that performs normal disposal or calls `process.exit()`. `SIGKILL`, fatal OOM, `process.abort()`, native crashes, power loss, and any failure that cannot run JavaScript require an external supervisor, container init, or equivalent OS owner. - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed vars is noted future work. - **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 251b994a35..64f58f63fd 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -12,7 +12,8 @@ - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该能力入口被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 - **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 -- **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。 +- **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。 +- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md)。 ## 模型体验 @@ -27,6 +28,7 @@ - **Windows 进程树支持仅为尽力而为**:终止经由 `taskkill /PID /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。 - **终端进程检查仅支持 Linux/macOS**:检查器没有受支持的平台实现时,终端原语会失败;Linux 精确探针覆盖 x64 与 arm64,macOS 则使用 `ps` 快照。 - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。 +- **进程内清理要求退出阶段仍能执行 JavaScript**:直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件。未安装 handler 时,`SIGTERM`、`SIGINT` 或 `SIGHUP` 的默认 OS 处置不会发出该事件;应用只有安装执行正常 dispose 或调用 `process.exit()` 的 handler 才能覆盖这些信号。`SIGKILL`、fatal OOM、`process.abort()`、native crash、断电,以及任何无法运行 JavaScript 的故障,都需要外部 supervisor、容器 init 或等价的 OS 所有者负责。 - **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 - **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index bc67b9369c..bd041db5f6 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -46,6 +46,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 5242986b3b..751653e8e8 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -1,7 +1,8 @@ /** * Local Service provider for the subprocess capability seam. Each spawn is a detached - * process tree with the spec's per-stream stdio dispositions; disposal - * terminates and joins live trees. It has no config: every disposition and + * process tree with the spec's per-stream stdio dispositions. Normal disposal + * terminates and joins live trees; Node's synchronous exit phase force-stops + * any trees the service still owns. It has no config: every disposition and * limit arrives on the spec, so the deployment-varying choices stay with the * caller's config (the bash executor's, the LSP host's, …). * @module @deepseek-ai/dsh-subprocess-local @@ -21,7 +22,7 @@ import type { SubprocessTerminalSpawnSpec, } from '@deepseek-ai/dsh-subprocess' import { childEnv, spawnSubprocess } from './spawn.ts' -import type { SpawnInternals } from './spawn.ts' +import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts' import { createProcessInspector } from './process-inspector.ts' import type { ProcessInspector } from './process-inspector.ts' import { LocalTerminalHandle } from './terminal.ts' @@ -30,13 +31,14 @@ import { LocalTerminalHandle } from './terminal.ts' * Local subprocess service: detached process trees, Node-shaped stdio * dispositions (raw pipes, inherit, bounded tail-keep collection with spill * files), credential-scrubbed environment, and tree-scoped signalling with - * SIGTERM→grace→SIGKILL escalation. + * SIGTERM→grace→SIGKILL escalation, plus synchronous final termination during + * JavaScript-observable host exit. */ export class LocalSubprocessService extends SubprocessService { - /** Live handles retained only so disposal can terminate and join them. */ - private live = new Set() - /** Live terminal sessions retained through whole-session quiescence. */ - private terminals = new Set() + /** Live handles retained for normal disposal and synchronous host-exit finalization. */ + private live = new Set() + /** Live terminals retained through normal quiescence or host-exit finalization. */ + private terminals = new Set() /** Test hook: spill and platform knobs forwarded to spawnSubprocess. */ internals: SpawnInternals = {} /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */ @@ -44,30 +46,61 @@ export class LocalSubprocessService extends SubprocessService { constructor(ctx: Context) { super(ctx) - ctx.effect(() => async () => { - // Terminate (escalating), then await WHOLE-TREE exit — not just the - // direct child's settlement — so even a TERM-trapping descendant cannot - // outlive the fiber. - const pending: Promise[] = [] - for (const handle of this.live) { - handle.terminate() - // Spawn-failure rejections already settled and left the live set. - pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit())) + ctx.effect(() => { + const onHostExit = (): void => { this.terminateForHostExit() } + process.prependListener('exit', onHostExit) + return async () => { + try { + await this.disposeManagedProcesses() + } finally { + process.off('exit', onHostExit) + } } - for (const terminal of this.terminals) { - pending.push(terminal.terminate()) - } - this.live.clear() - this.terminals.clear() - const outcomes = await Promise.allSettled(pending) - const failures = outcomes.flatMap(outcome => outcome.status === 'rejected' - ? [outcome.reason as unknown] - : []) - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'local subprocess teardown failed') }, 'local subprocess teardown') } + private terminateForHostExit(): void { + for (const handle of this.live) { + try { + handle.terminateForHostExit() + } catch (_ordinaryTreeTerminationFailed) { + // Host exit cannot await or report one target; continue with the rest. + } + } + for (const terminal of this.terminals) { + try { + terminal.terminateForHostExit() + } catch (_terminalTerminationFailed) { + // One terminal must not prevent final termination of another target. + } + } + } + + private async disposeManagedProcesses(): Promise { + // Terminate (escalating), then await WHOLE-TREE exit — not just the + // direct child's settlement — so even a TERM-trapping descendant cannot + // outlive the fiber. Keep both sets authoritative while these waits are + // pending so a shorter process-level exit bound can still force-kill them. + const pending: Promise[] = [] + for (const handle of this.live) { + handle.terminate() + // Spawn-failure rejections already settled and left the live set. + pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit())) + } + for (const terminal of this.terminals) { + pending.push(terminal.terminate()) + } + const outcomes = await Promise.allSettled(pending) + const failures = outcomes.flatMap(outcome => outcome.status === 'rejected' + ? [outcome.reason as unknown] + : []) + if (failures.length > 0) this.terminateForHostExit() + this.live.clear() + this.terminals.clear() + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'local subprocess teardown failed') + } + async resolveExecutable( command: string, env?: Readonly>, diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 5b977cacc1..433ba01791 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -58,6 +58,16 @@ export interface SpawnInternals { linuxProcessGroupHasLiveMembers?: (processGroupId: number) => boolean | undefined } +/** + * Local-only synchronous final termination used by the owning service during + * host exit and as the last fallback after failed normal disposal. It is + * intentionally absent from the public subprocess seam. + */ +export interface LocalSubprocessHandle extends SubprocessHandle { + /** Force-terminate the current tree synchronously without starting timers or waits. */ + terminateForHostExit(): void +} + /** * Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an * awaited teardown must keep the event loop alive until the tree really @@ -313,7 +323,7 @@ function signalTree( * @returns live subprocess handle. * @throws when `graceMs` cannot be represented by one Node timer. */ -export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle { +export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): LocalSubprocessHandle { if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0 || spec.graceMs > MAX_TIMER_DELAY_MS) { throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) } @@ -442,6 +452,10 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs) } + const terminateForHostExit = (): void => { + kill('SIGKILL') + } + // The caller owns timeout classification; this layer only reacts to abort. const onAbort = (): void => { terminate() } spec.signal?.addEventListener('abort', onAbort, { once: true }) @@ -523,6 +537,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter }, done, terminate, + terminateForHostExit, waitForExit, } } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 11d13a405a..6d818c8a7f 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -110,6 +110,33 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { return cleanup } + /** + * Force-terminate the observable session synchronously during Node's exit + * event. This does not claim quiescence and does not replace terminate(). + */ + terminateForHostExit(): void { + this.forceStopDescendants() + this.forceStopShell() + this.forceStopDescendants() + } + + private forceStopShell(): void { + if (this.exited) return + if (this.rootIdentity !== undefined) { + try { + this.inspector.signalProcess(this.rootIdentity, 'SIGKILL') + } catch (_rootExitedDuringHostExit) { + // Exact identity signalling contains both exit races and PID reuse. + } + return + } + try { + this.terminal.kill('SIGKILL') + } catch (_unidentifiedShellExitedDuringHostExit) { + // Without a captured identity, node-pty is the only root kill primitive. + } + } + private survivors(members: ProcessIdentity[]): ProcessIdentity[] { return members.filter(member => this.inspector.isAlive(member)) } @@ -152,6 +179,16 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { } } + private forceStopDescendants(): void { + let members = this.trackedDescendants + try { + members = this.descendants() + } catch (_processTableUnavailableDuringHostExit) { + // Preserve already-captured identities when a final process-table scan fails. + } + this.signalMembers(members, 'SIGKILL') + } + private unionMembers(...groups: ProcessIdentity[][]): ProcessIdentity[] { const members: ProcessIdentity[] = [] const seen = new Set() diff --git a/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts b/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts new file mode 100644 index 0000000000..31d26b9e39 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts @@ -0,0 +1,16 @@ +import { spawn } from 'node:child_process' +import { writeFile } from 'node:fs/promises' + +const [statePath] = process.argv.slice(2) +if (statePath === undefined) throw new Error('usage: managed-tree.ts ') + +process.on('SIGTERM', () => {}) +process.on('SIGHUP', () => {}) +const descendant = spawn(process.execPath, [ + '-e', + 'process.on("SIGTERM",()=>{});process.on("SIGHUP",()=>{});setInterval(()=>{},60_000)', +], { stdio: 'ignore' }) +if (descendant.pid === undefined) throw new Error('managed descendant did not publish a pid') + +await writeFile(statePath, JSON.stringify({ root: process.pid, descendant: descendant.pid })) +setInterval(() => {}, 60_000) diff --git a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts new file mode 100644 index 0000000000..83b4664cae --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts @@ -0,0 +1,79 @@ +import { access, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' + +const [kind, trigger, root] = process.argv.slice(2) +if ((kind !== 'ordinary' && kind !== 'terminal') + || (trigger !== 'direct' && trigger !== 'uncaught-exception' + && trigger !== 'unhandled-rejection' && trigger !== 'dispose') + || root === undefined) { + throw new Error('usage: process-exit-host.ts ') +} + +const treeState = join(root, 'tree.json') +const ready = join(root, 'ready') +const proceed = join(root, 'proceed') +const managedTree = fileURLToPath(new URL('./managed-tree.ts', import.meta.url)) + +async function waitForFile(path: string): Promise { + for (;;) { + try { + await access(path) + return + } catch (_notReady) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + } +} + +const listenersBefore = process.listenerCount('exit') +const ctx = new Context() +const fiber = await ctx.plugin(LocalSubprocessService) +const listenersAfterLoad = process.listenerCount('exit') +if (kind === 'ordinary') { + ctx.subprocess.spawn({ + argv: [process.execPath, managedTree, treeState], + cwd: process.cwd(), + stdio: { + stdin: 'ignore', + stdout: { maxBytes: 1024 }, + stderr: { maxBytes: 1024 }, + }, + graceMs: trigger === 'dispose' ? 100 : 30_000, + }) +} else { + await ctx.subprocess.spawnTerminal({ + argv: [process.execPath, managedTree, treeState], + cwd: process.cwd(), + rows: 24, + cols: 80, + graceMs: 30_000, + }) +} + +await waitForFile(treeState) +const published = JSON.parse(await readFile(treeState, 'utf8')) as { root?: unknown; descendant?: unknown } +if (!Number.isSafeInteger(published.root) || !Number.isSafeInteger(published.descendant)) { + throw new Error('managed tree published invalid process ids') +} +await writeFile(ready, 'ready') +await waitForFile(proceed) + +if (trigger === 'dispose') { + await fiber.dispose() + await writeFile(join(root, 'dispose.json'), JSON.stringify({ + listenersBefore, + listenersAfterLoad, + listenersAfterDispose: process.listenerCount('exit'), + })) +} else if (trigger === 'direct') { + process.exit(23) +} else if (trigger === 'uncaught-exception') { + setImmediate(() => { throw new Error('host-exit-uncaught-exception') }) + await new Promise(() => {}) +} else { + void Promise.reject(new Error('host-exit-unhandled-rejection')) + await new Promise(() => {}) +} diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index e3131543f4..412f55a49c 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -21,6 +21,94 @@ function spec(command: string, overrides: Partial = {}): Su } describe('LocalSubprocessService', () => { + it('places the host-exit finalizer before listeners that predate the service', async () => { + const baseline = new Set(process.listeners('exit')) + const prior = vi.fn() + process.on('exit', prior) + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + try { + const listeners = process.listeners('exit') + const finalizer = listeners.find(candidate => !baseline.has(candidate) && candidate !== prior) + expect(finalizer).toBeTypeOf('function') + expect(listeners.indexOf(finalizer!)).toBeLessThan(listeners.indexOf(prior)) + } finally { + process.off('exit', prior) + await fiber.dispose() + } + }) + + it('keeps the host-exit finalizer active until normal disposal reaches quiescence', async () => { + const before = new Set(process.listeners('exit')) + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + const listener = process.listeners('exit').find(candidate => !before.has(candidate)) + expect(listener).toBeTypeOf('function') + + let finishExit!: () => void + const exited = new Promise((resolve) => { finishExit = resolve }) + const terminate = vi.fn() + const terminateForHostExit = vi.fn() + const live = (ctx.subprocess as unknown as { + live: Set<{ + done: Promise<{ exitCode: number; signal: null }> + terminate(): void + terminateForHostExit(): void + waitForExit(): Promise + }> + }).live + live.add({ + done: Promise.resolve({ exitCode: 0, signal: null }), + terminate, + terminateForHostExit, + waitForExit: async () => { await exited; return true }, + }) + + let disposed = false + const disposing = fiber.dispose().then(() => { disposed = true }) + await new Promise(resolve => setImmediate(resolve)) + expect(disposed).toBe(false) + expect(live.size).toBe(1) + listener?.(0) + expect(terminate).toHaveBeenCalledOnce() + expect(terminateForHostExit).toHaveBeenCalledOnce() + + finishExit() + await disposing + expect(live.size).toBe(0) + expect(process.listeners('exit')).not.toContain(listener) + }) + + it('contains each host-exit termination failure and continues with the other targets', async () => { + const before = new Set(process.listeners('exit')) + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + const listener = process.listeners('exit').find(candidate => !before.has(candidate)) + expect(listener).toBeTypeOf('function') + const ordinaryFailure = vi.fn(() => { throw new Error('ordinary failed') }) + const ordinarySuccess = vi.fn() + const terminalFailure = vi.fn(() => { throw new Error('terminal failed') }) + const terminalSuccess = vi.fn() + const service = ctx.subprocess as unknown as { + live: Set<{ terminateForHostExit(): void }> + terminals: Set<{ terminateForHostExit(): void }> + } + service.live.add({ terminateForHostExit: ordinaryFailure }) + service.live.add({ terminateForHostExit: ordinarySuccess }) + service.terminals.add({ terminateForHostExit: terminalFailure }) + service.terminals.add({ terminateForHostExit: terminalSuccess }) + + expect(() => { listener?.(0) }).not.toThrow() + expect(ordinaryFailure).toHaveBeenCalledOnce() + expect(ordinarySuccess).toHaveBeenCalledOnce() + expect(terminalFailure).toHaveBeenCalledOnce() + expect(terminalSuccess).toHaveBeenCalledOnce() + + service.live.clear() + service.terminals.clear() + await fiber.dispose() + }) + it('resolves absolute and PATH executables and honors lookup cancellation', async () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessService) @@ -177,6 +265,30 @@ describe('LocalSubprocessService', () => { expect(disposalErrors).toEqual([failure]) }) + it('force-terminates remaining targets before releasing a failed disposal', async () => { + const before = new Set(process.listeners('exit')) + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + const listener = process.listeners('exit').find(candidate => !before.has(candidate)) + expect(listener).toBeTypeOf('function') + const failure = new Error('cleanup failed') + const terminateForHostExit = vi.fn(() => { + expect(process.listeners('exit')).toContain(listener) + }) + const terminal = { + terminate: vi.fn(async () => { throw failure }), + terminateForHostExit, + } + const terminals = (ctx.subprocess as unknown as { terminals: Set }).terminals + terminals.add(terminal) + + await fiber.dispose() + + expect(terminateForHostExit).toHaveBeenCalledOnce() + expect(terminals.size).toBe(0) + expect(process.listeners('exit')).not.toContain(listener) + }) + it('releases a terminal after top-level exit reaches quiescence', async () => { let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined const inspector = { diff --git a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts new file mode 100644 index 0000000000..217338fa1e --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts @@ -0,0 +1,173 @@ +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 { execa } from 'execa' +import { describe, expect, it, vi } from 'vitest' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { createProcessInspector } from '../src/process-inspector.ts' +import type { ProcessIdentity, ProcessInspector } from '../src/process-inspector.ts' +import { taskkillProcessTree } from '../src/spawn.ts' + +type ExitTrigger = 'direct' | 'uncaught-exception' | 'unhandled-rejection' | 'dispose' +type ManagedKind = 'ordinary' | 'terminal' +interface TreeState { root: number; descendant: number } + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const hostScript = fileURLToPath(new URL('./fixtures/process-exit-host.ts', import.meta.url)) +const scenarioTimeoutMs = 30_000 + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false + throw error + } +} + +async function readTree(path: string): Promise { + return vi.waitFor(async () => { + const text = await readFile(path, 'utf8') + const state = JSON.parse(text) as Partial + if (!Number.isSafeInteger(state.root) || !Number.isSafeInteger(state.descendant) + || (state.root ?? 0) <= 0 || (state.descendant ?? 0) <= 0 || state.root === state.descendant) { + throw new Error(`invalid managed-tree state: ${text}`) + } + return state as TreeState + }, { interval: 10, timeout: scenarioTimeoutMs }) +} + +async function captureIdentities(inspector: ProcessInspector, state: TreeState): Promise { + return vi.waitFor(() => { + const expected = new Set([state.root, state.descendant]) + const identities = inspector.processTree(state.root).filter(identity => expected.has(identity.pid)) + if (identities.length !== expected.size) throw new Error('managed tree is not fully observable yet') + return identities + }, { interval: 10, timeout: scenarioTimeoutMs }) +} + +async function waitForGone(state: TreeState): Promise { + await Promise.all([state.root, state.descendant].map(pid => vi.waitFor(() => { + if (processExists(pid)) throw new Error(`managed pid ${pid} is still alive`) + }, { interval: 25, timeout: 10_000 }))) +} + +function cleanupTree(state: TreeState | undefined, identities: ProcessIdentity[]): void { + if (state === undefined) return + if (process.platform === 'win32') { + taskkillProcessTree(state.root) + for (const pid of [state.descendant, state.root]) { + try { + process.kill(pid, 'SIGKILL') + } catch (_alreadyGone) { + // The exact recorded process already exited. + } + } + return + } + const inspector = createProcessInspector() + for (const identity of identities) { + try { + inspector.signalProcess(identity, 'SIGKILL') + } catch (_alreadyGone) { + // Exact start identity prevents PID-reuse cleanup from reaching another process. + } + } + if (identities.length === 0) { + for (const pid of [state.descendant, state.root]) { + try { + process.kill(pid, 'SIGKILL') + } catch (_alreadyGone) { + // The scenario failed before process identities became observable. + } + } + } +} + +async function runScenario(kind: ManagedKind, trigger: ExitTrigger) { + const root = await mkdtemp(join(tmpdir(), `dsh-subprocess-host-exit-${kind}-${trigger}-`)) + const launch = resolveExampleLaunch({ + srcBin: hostScript, + mode: 'src', + tsconfigPath: join(repoRoot, 'tsconfig.json'), + configArgs: [kind, trigger, root], + }) + const child = execa(launch.command, launch.args, { + cwd: repoRoot, + env: launch.env, + stdin: 'ignore', + reject: false, + timeout: scenarioTimeoutMs, + }) + let state: TreeState | undefined + let identities: ProcessIdentity[] = [] + let settled = false + let treeGone = false + try { + state = await readTree(join(root, 'tree.json')) + await vi.waitFor(() => readFile(join(root, 'ready'), 'utf8'), { + interval: 10, + timeout: scenarioTimeoutMs, + }) + if (process.platform !== 'win32') identities = await captureIdentities(createProcessInspector(), state) + await writeFile(join(root, 'proceed'), 'proceed') + const outcome = await child + settled = true + await waitForGone(state) + treeGone = true + const disposeCounts = trigger === 'dispose' + ? JSON.parse(await readFile(join(root, 'dispose.json'), 'utf8')) as { + listenersBefore: number + listenersAfterLoad: number + listenersAfterDispose: number + } + : undefined + return { outcome, disposeCounts } + } finally { + if (!settled) { + child.kill('SIGKILL') + await child.catch(() => {}) + } + if (!treeGone) { + cleanupTree(state, identities) + if (state !== undefined) await waitForGone(state).catch(() => {}) + } + await rm(root, { recursive: true, force: true }) + } +} + +describe('synchronous cleanup on host exit', () => { + it.each([ + { trigger: 'direct' as const, expectedCode: 23, diagnostic: undefined }, + { trigger: 'uncaught-exception' as const, expectedCode: 1, diagnostic: 'host-exit-uncaught-exception' }, + { trigger: 'unhandled-rejection' as const, expectedCode: 1, diagnostic: 'host-exit-unhandled-rejection' }, + ])('removes an ordinary managed tree after $trigger', { timeout: 45_000 }, async ({ + trigger, + expectedCode, + diagnostic, + }) => { + const { outcome } = await runScenario('ordinary', trigger) + expect(outcome.exitCode).toBe(expectedCode) + expect(outcome.signal).toBeUndefined() + if (diagnostic !== undefined) expect(outcome.stderr).toContain(diagnostic) + }) + + it.skipIf(process.platform === 'win32')( + 'removes a terminal root and descendant after direct exit', + { timeout: 45_000 }, + async () => { + const { outcome } = await runScenario('terminal', 'direct') + expect(outcome.exitCode).toBe(23) + expect(outcome.signal).toBeUndefined() + }, + ) + + it('preserves normal terminate-and-join disposal and removes the exit listener', { timeout: 45_000 }, async () => { + const { outcome, disposeCounts } = await runScenario('ordinary', 'dispose') + expect(outcome.exitCode).toBe(0) + expect(disposeCounts?.listenersAfterLoad).toBe((disposeCounts?.listenersBefore ?? 0) + 1) + expect(disposeCounts?.listenersAfterDispose).toBe(disposeCounts?.listenersBefore) + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 4cffde6432..568a331a28 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -582,9 +582,28 @@ describe('stdio dispositions', () => { }) describe('windows tree semantics (injected platform)', () => { + it('host-exit termination routes through taskkill immediately', async () => { + const killed: number[] = [] + const running = spawnSubprocess(spec('exec sleep 60', { graceMs: 60_000 }), { + spillDir, + platform: 'win32', + taskkill: (pid) => { + killed.push(pid) + try { + process.kill(pid, 'SIGKILL') + } catch { + // Already gone — matches taskkill's tolerated not-found status. + } + }, + }) + running.terminateForHostExit() + await running.done + expect(killed).toEqual([running.pid]) + }) + it('terminate routes through taskkill by root pid', async () => { const killed: number[] = [] - const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), { + const running = spawnSubprocess(spec('exec sleep 60', { graceMs: 100 }), { spillDir, platform: 'win32', taskkill: (pid) => { @@ -631,6 +650,23 @@ describe('waitForExit', () => { }) }) +describe('synchronous host-exit termination', () => { + it('force-kills the current process tree without waiting for the normal grace', async () => { + const running = spawnSubprocess(spec('trap "" TERM; sleep 60', { graceMs: 60_000 })) + running.terminateForHostExit() + await expect(running.done).resolves.toMatchObject({ exitCode: null, signal: 'SIGKILL' }) + await expect(running.waitForExit()).resolves.toBe(true) + + const kill = vi.spyOn(process, 'kill') + try { + running.terminateForHostExit() + expect(kill).not.toHaveBeenCalled() + } finally { + kill.mockRestore() + } + }) +}) + describe('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => { it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => { // The leader spawns a TERM-trapping helper with all stdio detached from diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index 79501c7dc4..4bfd9f1025 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -74,6 +74,7 @@ class FakeInspector implements ProcessInspector { } signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') { if (this.throwProcess) throw new Error('process raced') + if (!this.isAlive(identity)) return this.processes.push([identity.pid, signal]) if (this.removeOnSignal) this.alive.delete(identity.pid) } @@ -82,6 +83,85 @@ class FakeInspector implements ProcessInspector { afterEach(() => { vi.useRealTimers() }) describe('LocalTerminalHandle', () => { + it('force-kills descendants around the shell during synchronous host exit', () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const first = { pid: 124, started: 'first' } + const late = { pid: 125, started: 'late' } + inspector.members = [first] + inspector.alive.add(pty.pid) + inspector.alive.add(first.pid) + const signalProcess = inspector.signalProcess.bind(inspector) + inspector.signalProcess = (identity, signal) => { + signalProcess(identity, signal) + if (identity.pid === pty.pid) { + inspector.members = [first, late] + inspector.alive.add(late.pid) + } + } + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + + handle.terminateForHostExit() + expect(inspector.processes).toEqual([ + [first.pid, 'SIGKILL'], + [pty.pid, 'SIGKILL'], + [late.pid, 'SIGKILL'], + ]) + expect(pty.kills).toEqual([]) + + pty.emitExit() + handle.terminateForHostExit() + expect(pty.kills).toEqual([]) + }) + + it('uses captured identities and contains shell races when final inspection fails', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const captured = { pid: 124, started: 'captured' } + inspector.members = [captured] + inspector.alive.add(pty.pid) + inspector.alive.add(captured.pid) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + await handle.inspectForeground() + inspector.processTree = () => { throw new Error('process table unavailable') } + inspector.throwProcess = true + + expect(() => { handle.terminateForHostExit() }).not.toThrow() + expect(inspector.processes).toEqual([]) + expect(pty.kills).toEqual([]) + }) + + it('uses node-pty only when the shell start identity was unavailable', () => { + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.root = undefined + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + + handle.terminateForHostExit() + expect(pty.kills).toEqual(['SIGKILL']) + + const racingPty = new FakePty() + const racingInspector = new FakeInspector() + racingInspector.root = undefined + racingPty.throwKill = true + const racingHandle = new LocalTerminalHandle(racingPty.asPty(), racingInspector, 10) + expect(() => { racingHandle.terminateForHostExit() }).not.toThrow() + }) + + it('does not signal a recycled terminal root before its delayed exit callback', () => { + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.alive.add(pty.pid) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + inspector.root = { pid: pty.pid, started: 'recycled' } + inspector.isAlive = identity => identity.started === 'recycled' + + handle.terminateForHostExit() + + expect(inspector.processes).toEqual([]) + expect(pty.kills).toEqual([]) + }) + it('bridges terminal bytes, foreground control, and signalled exit facts', async () => { const pty = new FakePty() const inspector = new FakeInspector() diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 358edceb66..c922232fe7 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -983,8 +983,8 @@ class FaceAnalyzer { } if (parameter.dotDotDotToken !== undefined) this.fail(parameter, 'Remote parameters cannot be rest parameters') if (parameter.initializer !== undefined) this.fail(parameter, 'Remote parameters cannot have default values') - if (parameter.questionToken !== undefined) this.fail(parameter, 'Remote parameters cannot be optional') if (parameter.name.text === 'this') this.fail(parameter, 'Remote methods cannot declare an explicit this parameter') + const optional = parameter.questionToken !== undefined const authoredType = this.requiredType(parameter, parameter.type, 'parameter') const cancellationName = parameter.name.text === 'signal' const cancellationType = this.isGlobalAbortSignal(authoredType) @@ -1002,6 +1002,7 @@ class FaceAnalyzer { const lookup = hostSymbol === undefined ? undefined : lookupByHost.get(this.symbolId(hostSymbol)) let modeled: InvocationParameterModel if (lookup !== undefined) { + if (optional) this.fail(parameter, `lookup parameter for ${lookup.key} cannot be optional`) if (parameter.name.text !== lookup.key) { this.fail(parameter, `lookup parameter for ${lookup.key} must also be named ${lookup.key}`) } @@ -1025,10 +1026,13 @@ class FaceAnalyzer { name: parameter.name.text, wire: parameter.name.text, source: 'json', + ...optional ? { optional: true as const } : {}, boundary: this.remoteBoundary( authoredType, `${registration.name}#${binding.namespace}/${exportedMethod}:${parameter.name.text}`, false, + 'undefined', + optional, ), } } @@ -1095,6 +1099,7 @@ class FaceAnalyzer { resultType, `${registration.name}#${binding.namespace}/${exportedMethod}:result`, false, + 'undefined-or-void', ), location: this.location(method.name), } @@ -1336,9 +1341,18 @@ class FaceAnalyzer { authoredType: ts.TypeNode, fallbackTypeSymbol: string, requireNamed: boolean, + topLevelAbsence: 'reject' | 'undefined' | 'undefined-or-void' = 'reject', + optional = false, ): RemoteBoundaryModel { const type = this.convertType(authoredType) - const codecType = this.resolvedRemoteCodecType(authoredType) + const declaredType = this.checker.getTypeFromTypeNode(authoredType) + // An optional parameter's authored node carries no `undefined`; the codec + // still has to accept the omitted wire field the consumer sends. + const resolvedType = optional + ? this.checker.getNullableType(declaredType, ts.TypeFlags.Undefined) + : declaredType + const codecType = this.resolvedRemoteCodecType(authoredType, resolvedType, topLevelAbsence) + const acceptsUndefined = topLevelAbsence !== 'reject' && this.includesRemoteAbsence(resolvedType) const rootSymbol = this.namedWorkspaceType(authoredType) const imports = new Map() const visit = (node: ts.Node): void => { @@ -1365,6 +1379,7 @@ class FaceAnalyzer { return { type, codecType, + acceptsUndefined, typeSymbol: `${imported.specifier}#${imported.name}`, imports: [...imports.values()].sort((left, right) => left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)), @@ -1374,6 +1389,7 @@ class FaceAnalyzer { return { type, codecType, + acceptsUndefined, typeSymbol: fallbackTypeSymbol, imports: [...imports.values()].sort((left, right) => left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)), @@ -1387,9 +1403,18 @@ class FaceAnalyzer { * validated without teaching the compiler-independent emitter TypeScript's * type evaluator. */ - private resolvedRemoteCodecType(authoredType: ts.TypeNode): TypeNodeId { - const resolvedType = this.checker.getTypeFromTypeNode(authoredType) - this.assertRemoteJsonType(resolvedType, authoredType, new Set(), false) + private resolvedRemoteCodecType( + authoredType: ts.TypeNode, + resolvedType: ts.Type, + topLevelAbsence: 'reject' | 'undefined' | 'undefined-or-void', + ): TypeNodeId { + this.assertRemoteJsonType( + resolvedType, + authoredType, + new Set(), + topLevelAbsence !== 'reject', + topLevelAbsence === 'undefined-or-void', + ) const completed = new Map() const active = new Map() const recursiveDeclarations = new Map() @@ -1554,9 +1579,11 @@ class FaceAnalyzer { site: ts.TypeNode, active: Set, allowUndefined: boolean, + allowVoid: boolean, ): void { const flags = type.flags if ((flags & ts.TypeFlags.Undefined) !== 0 && allowUndefined) return + if ((flags & ts.TypeFlags.Void) !== 0 && allowVoid) return if ((flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) { this.fail(site, `Remote boundary contains unconstrained ${this.checker.typeToString(type)} data`) } @@ -1569,13 +1596,15 @@ class FaceAnalyzer { | ts.TypeFlags.Null | ts.TypeFlags.Never)) !== 0) return if (type.isUnion()) { - for (const member of type.types) this.assertRemoteJsonType(member, site, active, allowUndefined) + for (const member of type.types) { + this.assertRemoteJsonType(member, site, active, allowUndefined, allowVoid) + } return } if (type.isIntersection()) { const material = type.types.filter(member => !this.isRemotePhantomConstraint(member)) if (material.length === 0) this.fail(site, 'Remote boundary contains a symbol-only object') - for (const member of material) this.assertRemoteJsonType(member, site, active, false) + for (const member of material) this.assertRemoteJsonType(member, site, active, false, false) return } if ((flags & ts.TypeFlags.TypeParameter) !== 0) { @@ -1606,6 +1635,7 @@ class FaceAnalyzer { site, active, (elementFlags & ts.ElementFlags.Optional) !== 0, + false, ) }) return @@ -1613,7 +1643,7 @@ class FaceAnalyzer { if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) { const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number) if (element === undefined) this.fail(site, 'Remote boundary array has no element type') - this.assertRemoteJsonType(element, site, active, false) + this.assertRemoteJsonType(element, site, active, false, false) return } const properties = this.checker.getPropertiesOfType(type) @@ -1628,19 +1658,25 @@ class FaceAnalyzer { site, active, (property.flags & ts.SymbolFlags.Optional) !== 0, + false, ) } for (const info of this.checker.getIndexInfosOfType(type)) { if ((info.keyType.flags & ts.TypeFlags.ESSymbolLike) !== 0) { this.fail(site, 'Remote boundary contains a symbol index signature') } - this.assertRemoteJsonType(info.type, site, active, false) + this.assertRemoteJsonType(info.type, site, active, false, false) } } finally { active.delete(type) } } + private includesRemoteAbsence(type: ts.Type): boolean { + if ((type.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0) return true + return type.isUnion() && type.types.some(member => this.includesRemoteAbsence(member)) + } + private isRemotePhantomConstraint(type: ts.Type): boolean { if ((type.flags & ts.TypeFlags.Unknown) !== 0) return true if ((type.flags & ts.TypeFlags.Any) !== 0 || (type.flags & ts.TypeFlags.Object) === 0) return false diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index cbed0047c3..03dcc30d36 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -308,6 +308,7 @@ export class FaceModelEmitter { lines.push(` wire: ${quote(parameter.wire)},`) lines.push(` source: ${quote(parameter.source)},`) if (parameter.lookup !== undefined) lines.push(` lookup: ${quote(parameter.lookup)},`) + if (parameter.boundary.acceptsUndefined) lines.push(' acceptsUndefined: true,') lines.push(` codec: ${indent(strictCodec( parameter.boundary, schemas.boundary(parameterBoundaryKey(invocation, index)), @@ -342,6 +343,7 @@ export class FaceModelEmitter { const lines = [ '/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */', 'import type {', + ' RemoteResult,', ' TypeRTRemoteContribution,', '} from \'@deepseek-ai/dsh-type-meta\'', ] @@ -462,10 +464,13 @@ export class FaceModelEmitter { ): string { const parameters = invocation.parameters.filter(parameter => !scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter => - `${safeIdentifier(parameter.wire)}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) + `${safeIdentifier(parameter.wire)}${parameter.optional === true ? '?' : ''}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal') const result = this.renderer.renderType(invocation.result.type, referenceNames) - return `(${parameters.join(', ')}) => Promise<${result}>` + // The Client Remote face delivers the carrier's outcome, so every generated + // consumer signature resolves to a result the caller reads instead of a + // value it must guard with its own try/catch. + return `(${parameters.join(', ')}) => Promise>` } } diff --git a/packages/typert/generator/src/model.ts b/packages/typert/generator/src/model.ts index 81bc6a91a1..40eb31dde8 100644 --- a/packages/typert/generator/src/model.ts +++ b/packages/typert/generator/src/model.ts @@ -107,6 +107,8 @@ export interface RemoteBoundaryModel { readonly type: TypeNodeId /** Checker-resolved projection used only to emit the runtime codec. */ readonly codecType: TypeNodeId + /** Whether the authored top-level boundary explicitly accepts `undefined`. */ + readonly acceptsUndefined: boolean readonly typeSymbol: string readonly imports: readonly RemoteTypeImportModel[] } @@ -117,6 +119,8 @@ export interface InvocationParameterModel { readonly wire: string readonly source: 'json' | 'lookup' readonly lookup?: string + /** Authored as an optional parameter, so consumers may omit the wire field. */ + readonly optional?: true readonly boundary: RemoteBoundaryModel } diff --git a/packages/typert/generator/src/workspace.ts b/packages/typert/generator/src/workspace.ts index 4a303c4bd4..94d1d937e4 100644 --- a/packages/typert/generator/src/workspace.ts +++ b/packages/typert/generator/src/workspace.ts @@ -98,10 +98,12 @@ export class WorkspaceTypertGenerator { const remoteActual = manifest.exports !== null && typeof manifest.exports === 'object' ? (manifest.exports as Record)['./remote'] : undefined + // The declaration map is emitted beside these two but never published: it + // serves editor navigation in the workspace, where the package link + // resolves its source. const remoteFiles = [ 'lib/typert.remote-client.js', 'lib/typert.remote-client.d.ts', - 'lib/typert.remote-client.d.ts.map', ] if (artifact.remote === undefined) { if (remoteActual !== undefined || remoteFiles.some(file => files.includes(file))) { diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json index b7e0631a0a..bfd57762df 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json @@ -18,7 +18,6 @@ "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts", - "lib/typert.remote-client.d.ts.map" + "lib/typert.remote-client.d.ts" ] } diff --git a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts index 707dc84ce9..7fbc5bd656 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts @@ -13,6 +13,16 @@ declare module '@deepseek-ai/dsh-type-meta' { export interface TypeRTRemoteMap {} export interface TypeRTRemoteScopeMap {} + export interface RemoteFailure { + readonly code: string + readonly message: string + readonly details: object + } + + export type RemoteResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: RemoteFailure } + export type TypeRTRemoteNamespace = { [Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}` ? Method diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 3faef9c8be..443d5997c3 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -23,6 +23,7 @@ interface RuntimeDescriptor { readonly cancellation?: { readonly parameter: 'signal' } readonly parameters: readonly { readonly wire: string + readonly acceptsUndefined?: true readonly codec: { readonly schema: RuntimeSchema } }[] readonly result: { readonly schema: RuntimeSchema } @@ -113,15 +114,15 @@ describe('Remote model generation', { timeout: 60_000 }, () => { expect(artifact?.js).toContain('invocations: [') expect(artifact?.remote?.dts).toContain( - "'goals/create': (agentId: AgentId, request: CreateGoalRequest, signal?: AbortSignal) => Promise", + "'goals/create': (agentId: AgentId, request: CreateGoalRequest, signal?: AbortSignal) => Promise>", ) expect(artifact?.remote?.dts).toContain('interface TypeRTRemoteNamespace$676f616c73 {\n create:') expect(artifact?.remote?.dts).toContain("'goals': TypeRTRemoteNamespace$676f616c73") expect(artifact?.remote?.dts).toContain( - "'agent:goals/create': (request: CreateGoalRequest, signal?: AbortSignal) => Promise", + "'agent:goals/create': (request: CreateGoalRequest, signal?: AbortSignal) => Promise>", ) expect(artifact?.remote?.dts).toContain( - "'agent:goals/rename': (request: RenameGoalRequest) => Promise", + "'agent:goals/rename': (request: RenameGoalRequest) => Promise>", ) const remoteJs = artifact?.remote?.js @@ -146,6 +147,57 @@ describe('Remote model generation', { timeout: 60_000 }, () => { assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap) }) + it('projects authored optionality and absence onto consumers and codecs', async () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace( + '\n}\n\nexport type {', + ` + + @Remote + maybe(value: string | undefined): string | undefined { + return value + } + + @Remote + labelled(id: string, label?: string): string { + return label ?? id + } + + @Remote + clear(): void {} +} + +export type {`, + )) + + const [artifact] = new WorkspaceTypertGenerator(root).generate() + expect(artifact?.remote?.dts).toContain( + "'goals/maybe': (value: string | undefined) => Promise>", + ) + expect(artifact?.remote?.dts).toContain("'goals/clear': () => Promise>") + // An explicit `T | undefined` stays a required argument; only authored + // optionality lets a consumer omit the field. + expect(artifact?.remote?.dts).not.toContain('value?: string') + expect(artifact?.remote?.dts).toContain("'goals/labelled': (id: string, label?: string) => Promise>") + + const remoteJs = artifact?.remote?.js + if (remoteJs === undefined) throw new Error('undefined Remote fixture emitted no Host-for-Client JavaScript') + const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`) + const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule + const maybe = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/maybe')) + const clear = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/clear')) + expect(maybe?.parameters[0]?.acceptsUndefined).toBe(true) + expect(maybe?.parameters[0]?.codec.schema.safeParse(undefined).success).toBe(true) + expect(maybe?.result.schema.safeParse(undefined).success).toBe(true) + expect(clear?.result.schema.safeParse(undefined).success).toBe(true) + expect(clear?.result.schema.safeParse(null).success).toBe(false) + const labelled = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/labelled')) + expect(labelled?.parameters[0]?.acceptsUndefined).toBeUndefined() + expect(labelled?.parameters[1]?.acceptsUndefined).toBe(true) + expect(labelled?.parameters[1]?.codec.schema.safeParse(undefined).success).toBe(true) + expect(labelled?.parameters[1]?.codec.schema.safeParse(7).success).toBe(false) + }) + it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => { const root = copyFixture() editFile(root, 'packages/remote/src/types.ts', source => `${source} @@ -204,7 +256,7 @@ export type GenericResult = { const [artifact] = new WorkspaceTypertGenerator(root).generate() expect(artifact?.remote?.dts).toContain( - "'goals/dispatch': (request: GenericRequest) => Promise", + "'goals/dispatch': (request: GenericRequest) => Promise>", ) const remoteJs = artifact?.remote?.js if (remoteJs === undefined) throw new Error('generic Remote fixture emitted no Host-for-Client JavaScript') @@ -254,7 +306,7 @@ export interface BoxPayload { const [artifact] = new WorkspaceTypertGenerator(root).generate() expect(artifact?.remote?.dts).toMatch(/import type \{ [^}]*Box[^}]*BoxPayload[^}]* \} from '@fixture\/remote\/types'/) - expect(artifact?.remote?.dts).toContain('box: (request: Box) => Promise>') + expect(artifact?.remote?.dts).toContain('box: (request: Box) => Promise>>') assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) }) @@ -274,7 +326,7 @@ export interface BoxPayload { )) const [artifact] = new WorkspaceTypertGenerator(root).generate() - expect(artifact?.remote?.dts).toContain("'create-goal': (request: CreateGoalRequest) => Promise") + expect(artifact?.remote?.dts).toContain("'create-goal': (request: CreateGoalRequest) => Promise>") assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) }) @@ -436,9 +488,9 @@ export interface ClientMarker { message: 'Remote parameters cannot have default values', }, { - name: 'optional parameter', - edit: (source: string) => source.replace('request: CreateGoalRequest', 'request?: CreateGoalRequest'), - message: 'Remote parameters cannot be optional', + name: 'optional lookup parameter', + edit: (source: string) => source.replace('agent: Agent,', 'agent?: Agent,'), + message: 'lookup parameter for agent cannot be optional', }, { name: 'wrong cancellation type', @@ -584,6 +636,7 @@ function assertRemoteConsumerTypechecks( const consumerSource = ` import remote from '@fixture/remote/remote' import type { + RemoteResult, TypeRTRemoteContribution, TypeRTRemoteScopeMap, TypeRTRemoteMap, @@ -595,12 +648,12 @@ const contribution: TypeRTRemoteContribution = remote declare const create: TypeRTRemoteMap['goals/create'] declare const createScoped: TypeRTRemoteScopeMap['agent:goals/create'] declare const rename: TypeRTRemoteScopeMap['agent:goals/rename'] -const created: Promise = create('agent-1', { title: 'ship' }) -const cancellable: Promise = create('agent-1', { title: 'ship' }, new AbortController().signal) -const createdScoped: Promise = createScoped({ title: 'ship' }) -const renamed: Promise = rename({ ref: 'goal-1', title: 'land' }) +const created: Promise> = create('agent-1', { title: 'ship' }) +const cancellable: Promise> = create('agent-1', { title: 'ship' }, new AbortController().signal) +const createdScoped: Promise> = createScoped({ title: 'ship' }) +const renamed: Promise> = rename({ ref: 'goal-1', title: 'land' }) declare const ctx: { remote: TypeRTRemoteNamespaceMap } -const navigated: Promise = ctx.remote.goals.create('agent-1', { title: 'navigate' }) +const navigated: Promise> = ctx.remote.goals.create('agent-1', { title: 'navigate' }) void contribution void created void cancellable diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index b8bab3a121..06e2161a1e 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -653,6 +653,9 @@ function validateInvocation(descriptor: InvocationDescriptor): void { } wires.add(parameter.wire) if (parameter.source === 'lookup') { + if (parameter.acceptsUndefined !== undefined) { + throw new Error(`typert: invocation "${descriptor.id}" lookup parameter "${parameter.name}" cannot accept undefined`) + } if (parameter.lookup === undefined) { throw new Error(`typert: invocation "${descriptor.id}" lookup parameter "${parameter.name}" has no lookup key`) } diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 1bc4b65cb1..69e6497c2e 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -508,6 +508,17 @@ describe('TypertRegistry', () => { ...invocation(), parameters: [{ name: 'agent', wire: 'agentId', source: 'lookup', codec: { mode: 'src-json' } }], }, 'has no lookup key'], + [{ + ...invocation(), + parameters: [{ + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'fixture', + acceptsUndefined: true, + codec: { mode: 'src-json' }, + }], + }, 'cannot accept undefined'], [{ ...invocation(), parameters: [{ diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 3becc67c83..7bbf3f107a 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -41,6 +41,8 @@ export type { InvocationDescriptor, InvocationParameterDescriptor, InvocationSourceLocation, + RemoteFailure, + RemoteResult, TypeRTClientRemote, TypeRTClientContextBinder, TypeRTCodec, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index c08fb209ee..9fffb8a468 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -39,6 +39,28 @@ export interface TypeRTContextMap {} /** Merge-extensible direct Remote method signatures generated for consumers. */ export interface TypeRTRemoteMap {} +/** + * One Remote call's failure as the carrier reported it. `code` stays open here: + * the closed RPC code union belongs to the carrier package, which already + * depends on this one, so naming it would invert that edge. + */ +export interface RemoteFailure { + readonly code: string + readonly message: string + readonly details: object +} + +/** + * What every generated Remote method resolves to. The Remote face itself folds + * carrier failures into the error branch, so no consumer wraps a call to + * recover one; only assembly faults (arity, an unmounted method, a missing + * Context binder) still reject. + * @template T - the Host method's business result. + */ +export type RemoteResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: RemoteFailure } + /** Merge-extensible scoped Remote method signatures generated for consumers. */ export interface TypeRTRemoteScopeMap {} @@ -136,6 +158,8 @@ export interface InvocationParameterDescriptor { readonly lookup?: string /** Boundary codec for the wire representation. */ readonly codec: TypeRTCodec + /** Missing wire fields decode to `undefined` only for an explicitly declared `T | undefined`. */ + readonly acceptsUndefined?: true } /** Source position retained for diagnostics from generated definitions. */ diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index 63d5ce0e8e..caeadcc3d8 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md -README.md: 057765e38de9cc700210eb8edeb1ddc7ffc861ff -README.zh.md: 7416875dbf2ee1652f6e1fa1663144d7407a1ae7 +README.md: 4f7e2925ca7572dc3cc32c2a294bd1f40b243254 +README.zh.md: 2f4f38dea881b2c8a2bb135c8f7b1b3c88b9190a diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index 057765e38d..4f7e2925ca 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -10,9 +10,10 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n - `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; different paths may share a display title. - `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it. +- `ctx.workspace.insertBefore(id, before?)` — moves a registered Workspace within durable registry order, DOM-insertBefore-like: before the anchor, or appended when the anchor is omitted. A source or anchor absent from the registry rejects without writing; a self-anchor or move to the current position resolves without writing. The returned id list is the complete committed order. - `ctx.workspace.delete(id)` — removes only the Workspace registration, its durable order entry, and its session account. Unknown ids return `false`; a removed record returns `true`. The directory, user files, live Sessions, and persisted session logs are never touched, so those Sessions become Ungrouped. A table-write failure restores the prior order and published entity. - `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry. -- `Workspace.insertSessionBefore(id, before?)` — moves an accounted session within the manual order, DOM-insertBefore-like: before the anchor, or appended when the anchor is omitted. A session or anchor absent from the account rejects without writing; a move to the current position resolves without writing. Workspace order never changes. +- `Workspace.insertSessionBefore(id, before?)` — moves an accounted session within the manual order, DOM-insertBefore-like: before the anchor, or appended when the anchor is omitted. A session or anchor absent from the account rejects without writing; a move to the current position resolves without writing. Registry Workspace order never changes. - `ctx.workspace.archiveSession(id)` / `archivedSessionIds` — the registry-global archive set, layered over workspace accounting: an archived session disappears from grouping surfaces but keeps its session log and its `sessionIds` slot, so a future unarchive restores its position. Archiving accepts any live or persisted session (accounted or Ungrouped), resolves without writing for an already archived id, and rejects an unknown id. State written before the field existed parses with an empty set. - `Workspace.sessionIds` — synchronous id-plus-canonical-cwd membership projection in durable candidate order. Missing headers, invalid cwd values, and mismatches are filtered; the next workspace mutation prunes them. A medium indexing one session under two workspaces, claiming one path from two records, or diverging from durable workspace order rejects at startup. - `Workspace.status()` — uncached directory check, `'ok' | 'missing-dir'`; a missing directory never mutates the record. diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index 7416875dbf..2f4f38dea8 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -10,9 +10,10 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领 - `ctx.workspace.create(path, title?)`:规范化 `path` 时使用 `fs.realpath`,拒绝不存在或非目录的路径,每个规范路径最多创建一条记录,并将新记录前置到持久 workspace 顺序。对同一路径重复调用会返回现有 workspace,且不改变其标题;不同路径可以共用显示标题。 - `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它采用相同的 `realpath` 规范化方式,并会拒绝缺失路径,而不是创建路径。 +- `ctx.workspace.insertBefore(id, before?)`:在持久注册表顺序内移动一个已注册 Workspace,语义类似 DOM 的 insertBefore:插到锚点之前,省略锚点则追加到末尾。来源或锚点不在注册表中时拒绝且不写入;以自身为锚点或移动到当前位置时直接完成且不写入。返回的 id 列表是完整的已提交顺序。 - `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话归属记录。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、活跃会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。 - `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。 -- `Workspace.insertSessionBefore(id, before?)`:在手动顺序内移动一个已记账的会话,语义类似 DOM 的 insertBefore:插到锚点之前,省略锚点则追加到末尾。会话或锚点不在记账中时拒绝且不写入;移动到当前位置时直接完成且不写入。Workspace 顺序绝不改变。 +- `Workspace.insertSessionBefore(id, before?)`:在手动顺序内移动一个已记账的会话,语义类似 DOM 的 insertBefore:插到锚点之前,省略锚点则追加到末尾。会话或锚点不在记账中时拒绝且不写入;移动到当前位置时直接完成且不写入。注册表中的 Workspace 顺序绝不改变。 - `ctx.workspace.archiveSession(id)`/`archivedSessionIds`:覆盖在 workspace 记账之上的注册表级全局归档集合:被归档的会话从各分组视图中消失,但其会话日志和 `sessionIds` 席位保持不变,未来取消归档时可恢复原位置。归档接受任何实时或已持久化的会话(无论已记账还是 Ungrouped),对已归档的 id 直接完成而不写入,并拒绝未知 id。在该字段出现之前写入的状态解析为一个空集合。 - `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、用两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。 - `Workspace.status()`:未缓存的目录检查,返回 `'ok' | 'missing-dir'`;目录缺失绝不会改动记录。 diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index d972085939..5d1f3296d8 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -52,6 +52,17 @@ export class WorkspaceUnknownSessionError extends Error { } } +/** A workspace reorder named a source or anchor absent from the durable registry order. */ +export class WorkspaceOrderInvalidError extends Error { + /** + * @param workspaceId - Missing source or anchor id. + */ + constructor(readonly workspaceId: WorkspaceId) { + super(`cannot reorder unknown workspace '${workspaceId}'`) + this.name = 'WorkspaceOrderInvalidError' + } +} + declare module '@deepseek-ai/cordis' { interface Context { @@ -189,6 +200,30 @@ export class WorkspaceRegistry extends Service { return this.enqueueOperation(() => this.deleteKnown(id)) } + /** + * Move one workspace within the durable display order, DOM-insertBefore-like. + * With an anchor it lands before that workspace; without one it appends. + * @param id - Workspace to move. + * @param beforeId - Workspace anchor; omitted appends. + * @returns the complete committed workspace order. + */ + insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise { + return this.enqueueOperation(async () => { + const state = this.requireState() + if (!state.workspaceIds.includes(id)) throw new WorkspaceOrderInvalidError(id) + if (beforeId !== undefined && !state.workspaceIds.includes(beforeId)) { + throw new WorkspaceOrderInvalidError(beforeId) + } + if (beforeId === id) return state.workspaceIds + const without = state.workspaceIds.filter(workspaceId => workspaceId !== id) + const at = beforeId === undefined ? without.length : without.indexOf(beforeId) + const workspaceIds = [...without.slice(0, at), id, ...without.slice(at)] + if (sameIds(workspaceIds, state.workspaceIds)) return state.workspaceIds + await this.setState({ ...state, workspaceIds }) + return workspaceIds + }) + } + /** * The registry-global archive set: sessions hidden from every grouping * surface. Archiving never touches workspace accounting — an archived diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 3c4b6185fb..c04980eecd 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -10,7 +10,11 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' -import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError } from '../src/index.ts' +import WorkspaceRegistry, { + WorkspaceId, + WorkspaceMoveInvalidError, + WorkspaceOrderInvalidError, +} from '../src/index.ts' import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' const DOMAIN_VERSION = 2 @@ -568,6 +572,49 @@ describe('WorkspaceRegistry create and lookup', () => { }) }) +describe('Workspace registry ordering', () => { + it('moves a workspace before an anchor or to the end and restores that order after restart', async () => { + const firstDir = await makeDir('order-first') + const secondDir = await makeDir('order-second') + const thirdDir = await makeDir('order-third') + const result = await harness() + const first = await result.registry.create(firstDir) + const second = await result.registry.create(secondDir) + const third = await result.registry.create(thirdDir) + expect(result.registry.list().map(item => item.id)).toEqual([third.id, second.id, first.id]) + + await expect(result.registry.insertBefore(first.id, second.id)) + .resolves.toEqual([third.id, first.id, second.id]) + await expect(result.registry.insertBefore(third.id)) + .resolves.toEqual([first.id, second.id, third.id]) + expect(storedState(result.pool).workspaceIds).toEqual([first.id, second.id, third.id]) + + const restarted = await harness({ pool: result.pool }) + expect(restarted.registry.list().map(item => item.id)).toEqual([first.id, second.id, third.id]) + }) + + it('keeps self-anchored and already-positioned moves write-free and rejects unknown ids', async () => { + const firstDir = await makeDir('order-noop-first') + const secondDir = await makeDir('order-noop-second') + const result = await harness() + const first = await result.registry.create(firstDir) + const second = await result.registry.create(secondDir) + const written = result.changes.length + + await result.registry.insertBefore(second.id, second.id) + await result.registry.insertBefore(second.id, first.id) + await result.registry.insertBefore(first.id) + expect(result.changes).toHaveLength(written) + expect(result.registry.list().map(item => item.id)).toEqual([second.id, first.id]) + + await expect(result.registry.insertBefore(WorkspaceId('missing'))) + .rejects.toBeInstanceOf(WorkspaceOrderInvalidError) + await expect(result.registry.insertBefore(second.id, WorkspaceId('missing-anchor'))) + .rejects.toMatchObject({ workspaceId: 'missing-anchor' }) + expect(result.changes).toHaveLength(written) + }) +}) + describe('Workspace session ordering', () => { it('prepends new attaches and keeps repeat attach idempotent', async () => { const dir = await makeDir('attach-order') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66df6ad754..28a20e6dc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -846,6 +846,9 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal + '@deepseek-ai/dsh-host-plugin-inventory': + specifier: workspace:^ + version: link:../../host/plugin-inventory '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1566,6 +1569,12 @@ importers: '@deepseek-ai/dsh-client-ui-deliverables': specifier: workspace:^ version: link:../../client/ui-deliverables + '@deepseek-ai/dsh-client-ui-directory-picker': + specifier: workspace:^ + version: link:../../client/ui-directory-picker + '@deepseek-ai/dsh-client-ui-directory-picker-native': + specifier: workspace:^ + version: link:../../client/ui-directory-picker-native '@deepseek-ai/dsh-client-ui-goal': specifier: workspace:^ version: link:../../client/ui-goal @@ -1587,6 +1596,9 @@ importers: '@deepseek-ai/dsh-client-ui-plugin-config': specifier: workspace:^ version: link:../../client/ui-plugin-config + '@deepseek-ai/dsh-client-ui-plugins': + specifier: workspace:^ + version: link:../../client/ui-plugins '@deepseek-ai/dsh-client-ui-question': specifier: workspace:^ version: link:../../client/ui-question @@ -1650,6 +1662,9 @@ importers: '@deepseek-ai/dsh-host-directory-picker-native': specifier: workspace:^ version: link:../../host/directory-picker-native + '@deepseek-ai/dsh-host-plugin-inventory': + specifier: workspace:^ + version: link:../../host/plugin-inventory '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver @@ -1862,9 +1877,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-gateway': + '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ - version: link:../../api/gateway + version: link:../../api/remotes '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2049,6 +2064,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2190,6 +2208,79 @@ importers: specifier: ~18.3.1 version: 18.3.31 + packages/client/ui-directory-picker: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + + packages/client/ui-directory-picker-native: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-goal: devDependencies: '@deepseek-ai/cordis': @@ -2417,9 +2508,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-connection': + '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ - version: link:../connection + version: link:../../api/remotes '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2500,6 +2591,48 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-plugins: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-primitives: dependencies: '@shikijs/langs': @@ -2606,6 +2739,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2626,19 +2762,17 @@ importers: version: 18.3.31 packages/client/ui-settings: + dependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-gateway': - specifier: workspace:^ - version: link:../../api/gateway '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -2988,6 +3122,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -4777,6 +4914,12 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-client-ui-directory-picker': + specifier: workspace:^ + version: link:../../client/ui-directory-picker + '@deepseek-ai/dsh-client-ui-directory-picker-native': + specifier: workspace:^ + version: link:../../client/ui-directory-picker-native '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../directory-picker @@ -4801,40 +4944,13 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../../client/locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../client/test-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../../client/ui-primitives - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../../client/ui-slots - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../../client/ui-workspace '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 packages/host/directory-picker-native: dependencies: @@ -4851,24 +4967,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../../client/ui-slots - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../../client/ui-workspace '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 tsx: specifier: ^4.19.2 version: 4.22.4 @@ -4892,6 +4993,28 @@ importers: specifier: workspace:^ version: link:../../support/invariants + packages/host/plugin-inventory: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + packages/host/webserver: dependencies: '@deepseek-ai/schemastery': @@ -4906,6 +5029,10 @@ importers: version: link:../../support/invariants packages/interaction/commands: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -4925,6 +5052,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta packages/interaction/permission: dependencies: @@ -7152,6 +7282,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../subprocess diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index c341c32cea..d5570fba11 100644 --- a/python/development.i18n.yaml +++ b/python/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/development.md -development.md: 9614c06436ab6863a5e1b2ff83fbe605552dc13b -development.zh.md: 1c646ca39735b85a5d380768fe215c92532be7e7 +development.md: 31dc254b58c05c2a19c7c4cd5dc1e53207517902 +development.zh.md: dbb85a0cffc5e06c7ca781b2b01f6993395204e3 diff --git a/python/development.md b/python/development.md index 9614c06436..31dc254b58 100644 --- a/python/development.md +++ b/python/development.md @@ -58,4 +58,12 @@ python scripts/build-python-release.py --package runtime --platform macos-arm64 pip install --find-links dist-python deepseek-harness-sdk=="$version" ``` -The runtime distribution is wheel-only. The release pipeline publishes three platform wheels with the pure SDK wheel: Linux x64, Linux arm64, and macOS arm64. A `python-vX.Y.Z` tag is accepted only when it matches the repository version. +The runtime distribution is wheel-only. The release pipeline publishes three platform wheels with the pure SDK wheel: Linux x64, Linux arm64, and macOS 14 or newer on arm64. A `python-v` tag is accepted only when it matches the repository version; prerelease repository versions such as `0.0.1-rc.1` use their normalized PEP 440 spelling, such as `0.0.1rc1`, inside wheel filenames and metadata. + +## Validate a release candidate + +Label a pull request `python-release-dry-run`, or manually run the GitHub `Release (Python)` workflow with `publish=false`, to build all four wheels, install the Linux release set on Python 3.10 and 3.14, check exact filenames and metadata, enforce PyPI's default per-file size limit, and retain one aggregate artifact with SHA-256 hashes. Both paths have no registry credentials; a pull request run cannot enter either publication job. + +Public publication runs from the private automation repository; package metadata points to the separate read-only public source mirror, which does not run release Actions. The private repository defines the repository variable `PYPI_PUBLISHER_REPOSITORY` as its own `owner/name` and keeps `PUBLIC_PYPI_RELEASE_ENABLED=false` except during an intentional release. + +Separate runtime and SDK jobs let an SDK upload failure resume without resending immutable runtime files. They accept `publish=true` only when the workflow runs from the configured publisher repository at the matching `python-v*` tag and the protected `pypi-runtime` and `pypi` environments approve the runtime and SDK jobs, respectively. PyPI Trusted Publishing still supplies short-lived OIDC credentials, but public attestations are disabled because they would disclose the private publisher identity. diff --git a/python/development.zh.md b/python/development.zh.md index 1c646ca397..dbb85a0cff 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -58,4 +58,12 @@ python scripts/build-python-release.py --package runtime --platform macos-arm64 pip install --find-links dist-python deepseek-harness-sdk=="$version" ``` -运行时分发包仅提供 wheel 包。发布流水线会连同纯 SDK wheel 包一起发布三个平台 wheel 包:Linux x64、Linux arm64 和 macOS arm64。只有与仓库版本匹配时,才接受 `python-vX.Y.Z` 标签。 +运行时分发包仅提供 wheel 包。发布流水线会连同纯 SDK wheel 包一起发布三个平台 wheel 包:Linux x64、Linux arm64 和 macOS 14 或更高版本的 arm64。只有与仓库版本匹配时,才接受 `python-v` 标签;`0.0.1-rc.1` 之类的仓库预发布版本在 wheel 包文件名和元数据中使用规范化的 PEP 440 写法,例如 `0.0.1rc1`。 + +## 验证候选发行版 + +为拉取请求添加 `python-release-dry-run` 标签,或手动运行 GitHub 的 `Release (Python)` 工作流并设置 `publish=false`,即可构建全部四个 wheel 包,在 Python 3.10 和 3.14 上安装 Linux 发行集合,检查精确文件名和元数据,执行 PyPI 默认单文件大小限制,并保留一份带 SHA-256 哈希的汇总产物。两条路径都没有注册表凭据,拉取请求运行无法进入任何发布作业。 + +公开发布从私有自动化仓库运行;包元数据指向独立的只读公开源码镜像,该镜像不运行发布 Actions。私有仓库把仓库变量 `PYPI_PUBLISHER_REPOSITORY` 定义为自身的 `owner/name`,并且只在有意发布期间把 `PUBLIC_PYPI_RELEASE_ENABLED` 从 `false` 改为 `true`。 + +独立的运行时与 SDK 作业使 SDK 上传失败后可以继续执行,而无需重新发送不可变的运行时文件。只有工作流从配置的发布仓库、匹配的 `python-v*` 标签运行,且受保护的 `pypi-runtime` 和 `pypi` 环境分别批准运行时与 SDK 作业时,才接受 `publish=true`。PyPI Trusted Publishing 仍会提供短期 OIDC 凭据,但公开 attestation 会披露私有发布仓库身份,因此将其禁用。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 4814c98ead..330586f6c3 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk-runtime/README.md -README.md: fa2fc83a88212f6ff163e1a5f86246bfac37cc1f -README.zh.md: 5b82f33cfe1413e4fb6ceded04d9b6feca4c94ca +README.md: 71dedf4cb8064d55bd64b32008b452158a1b154f +README.zh.md: 83c99ed33b2a4ffe00bcb3fe670be455664bfa18 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index fa2fc83a88..71dedf4cb8 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -1,6 +1,6 @@ # DeepSeek Harness Runtime Wheel -English | [中文](README.zh.md) +English | [中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.zh.md) Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, module `deepseek_harness_runtime`): it locates the bundled runtime binaries the `deepseek-harness-sdk` client spawns, and ships the default configuration behind zero-config runs. @@ -11,11 +11,11 @@ Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injecte - **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. - **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. -Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. +Both carriers hold the same content, defined once: the [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. -Each wheel contains exactly one runtime executable. The macOS wheel also contains its matching native spawn helper; a missing sidecar makes that installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools. Linux wheels contain no spawn helper because `node-pty` uses the staged `pty.node` addon directly. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it. +Each wheel contains exactly one runtime executable. The macOS wheel also contains its matching native spawn helper; a missing sidecar makes that installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools. Linux wheels contain no spawn helper because `node-pty` uses the staged `pty.node` addon directly. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_14_0_arm64`; the macOS tag conservatively matches the bundled Node 24 executable's macOS 13.5 deployment target. This package's `platforms.json` owns the fixed tag and executable-name pairs used by both the repository release builder and the isolated build hook. The build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-v` release tag must match it. ## Resolution API @@ -26,4 +26,4 @@ Each wheel contains exactly one runtime executable. The macOS wheel also contain ## Zero-config design -The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving interface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-jsonrpc`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` with the JSON-RPC serving entry, agent core, a preloaded DeepSeek adapter, JSONL persistence, the explicitly composed semantic checkpoint policy, local bash, and a local filesystem provider for bounded workspace-instruction loading. The persistence backend owns durable storage while the separate policy selects request-, tool-dispatch-, and completed-step checkpoints. The adapter reads `DEEPSEEK_API_KEY` and `DEEPSEEK_BASE_URL`, while persistence, bash, and the filesystem provider use `DSH_SESSION_ROOT` and `DSH_CWD` with manual-run fallbacks. When the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](../sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime. +The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving interface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-jsonrpc`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` with the JSON-RPC serving entry, agent core, a preloaded DeepSeek adapter, JSONL persistence, the explicitly composed semantic checkpoint policy, local bash, and a local filesystem provider for bounded workspace-instruction loading. The persistence backend owns durable storage while the separate policy selects request-, tool-dispatch-, and completed-step checkpoints. The adapter reads `DEEPSEEK_API_KEY` and `DEEPSEEK_BASE_URL`, while persistence, bash, and the filesystem provider use `DSH_SESSION_ROOT` and `DSH_CWD` with manual-run fallbacks. When the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 5b82f33cfe..83c99ed33b 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -1,6 +1,6 @@ # DeepSeek Harness 运行时 wheel 包 -[English](README.md) | 中文 +[English](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.md) | 中文 Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness-sdk` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。 @@ -11,11 +11,11 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, - **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **node(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 -两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 +两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 node 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 -每个 wheel 包只包含一个运行时可执行文件。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少伴随文件意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、不存在运行时文件、存在多个运行时文件、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。 +每个 wheel 包只包含一个运行时可执行文件。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少伴随文件意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_14_0_arm64`;macOS 标签保守匹配内置 Node 24 可执行文件的 macOS 13.5 部署目标。本包的 `platforms.json` 统一定义仓库发行构建器与隔离构建钩子使用的固定标签和可执行文件名。构建钩子会拒绝 `py3-none-any`、不存在运行时文件、存在多个运行时文件、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-v` 发布标签必须与其匹配。 ## 解析 API @@ -26,4 +26,4 @@ exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deep ## 零配置设计 -运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一强制语义是运行时设计的一部分,本包不会弱化它。bin(`dsh-jsonrpc-agent`)只启动配置里列出的插件;对外服务接口(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它,启动出的 agent(智能体)就没有对外通道。本包检入的 `runtime/cordis.yml` 包含 JSON-RPC 服务条目、agent 核心、预载的 DeepSeek 适配器、JSONL 持久化、显式组合的语义检查点策略、本地 bash,以及用于有界加载工作区指令的本地文件系统提供方。持久化后端负责持久存储,独立的策略则选择请求、工具分发和已完成步骤的检查点。DeepSeek 适配器读取 `DEEPSEEK_API_KEY` 与 `DEEPSEEK_BASE_URL`,持久化、bash 和文件系统提供方则使用 `DSH_SESSION_ROOT` 和 `DSH_CWD`,并为手动运行提供回退值。调用方未使用任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](../sdk/README.md))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。 +运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一强制语义是运行时设计的一部分,本包不会弱化它。bin(`dsh-jsonrpc-agent`)只启动配置里列出的插件;对外服务接口(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它,启动出的 agent(智能体)就没有对外通道。本包检入的 `runtime/cordis.yml` 包含 JSON-RPC 服务条目、agent 核心、预载的 DeepSeek 适配器、JSONL 持久化、显式组合的语义检查点策略、本地 bash,以及用于有界加载工作区指令的本地文件系统提供方。持久化后端负责持久存储,独立的策略则选择请求、工具分发和已完成步骤的检查点。DeepSeek 适配器读取 `DEEPSEEK_API_KEY` 与 `DEEPSEEK_BASE_URL`,持久化、bash 和文件系统提供方则使用 `DSH_SESSION_ROOT` 和 `DSH_CWD`,并为手动运行提供回退值。调用方未使用任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.md))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index b0f9a79550..400d9d585b 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import os import platform import stat @@ -8,11 +9,30 @@ from pathlib import Path from hatchling.builders.hooks.plugin.interface import BuildHookInterface -_PLATFORMS = { - "linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"), - "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), - "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), -} +def _load_platforms() -> dict[str, tuple[str, str]]: + """Load and validate the platform manifest inside an isolated wheel build.""" + path = Path(__file__).with_name("platforms.json") + try: + payload = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as error: + raise RuntimeError(f"could not read runtime platform manifest from {path}") from error + if not isinstance(payload, dict) or not payload: + raise RuntimeError(f"{path} must contain a non-empty platform object") + platforms: dict[str, tuple[str, str]] = {} + for name, raw in payload.items(): + if ( + not isinstance(name, str) + or not isinstance(raw, dict) + or set(raw) != {"tag", "executable"} + or not isinstance(raw["tag"], str) + or not isinstance(raw["executable"], str) + ): + raise RuntimeError(f"{path} platform entries must contain string tag and executable fields") + platforms[name] = (raw["tag"], raw["executable"]) + return platforms + + +_PLATFORMS = _load_platforms() def _host_platform_tag() -> str: diff --git a/python/sdk-runtime/platforms.json b/python/sdk-runtime/platforms.json new file mode 100644 index 0000000000..069378e8cb --- /dev/null +++ b/python/sdk-runtime/platforms.json @@ -0,0 +1,14 @@ +{ + "linux-x64": { + "tag": "manylinux_2_28_x86_64", + "executable": "dsh-jsonrpc-agent-pkg-linux-x64" + }, + "linux-arm64": { + "tag": "manylinux_2_28_aarch64", + "executable": "dsh-jsonrpc-agent-pkg-linux-arm64" + }, + "macos-arm64": { + "tag": "macosx_14_0_arm64", + "executable": "dsh-jsonrpc-agent-pkg-macos-arm64" + } +} diff --git a/python/sdk-runtime/pyproject.toml b/python/sdk-runtime/pyproject.toml index 6db7595297..9481d33889 100644 --- a/python/sdk-runtime/pyproject.toml +++ b/python/sdk-runtime/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling>=1.30.1"] +requires = ["hatchling==1.30.1"] build-backend = "hatchling.build" [project] @@ -8,7 +8,14 @@ version = "0.0.0.dev0" description = "Pinned DeepSeek Harness runtime for the Python SDK" readme = "README.md" requires-python = ">=3.10" -license = { text = "BSD-3-Clause" } +license = "BSD-3-Clause" +authors = [{ name = "DeepSeek" }] + +[project.urls] +Homepage = "https://github.com/deepseek-ai/deepseek-harness" +Documentation = "https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.md" +Issues = "https://github.com/deepseek-ai/deepseek-harness/issues" +Source = "https://github.com/deepseek-ai/deepseek-harness" # Include the injected executable and default config; exclude the dev-only node # closure from wheels and sdists. diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index e13a2ccd31..42aa01ec6e 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk/README.md -README.md: 8cf366c27c8a604391ea85e298ba725e9987d428 -README.zh.md: a9258ce9aee9bce973107b49114d4ed6e81441e4 +README.md: 70b9d6391644d10ee7d5c29ce122632786e3bbcc +README.zh.md: 1d1a23576cc8029dacbb2df0e3d1d9fc2ce27426 diff --git a/python/sdk/README.md b/python/sdk/README.md index 8cf366c27c..70b9d63916 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -1,6 +1,6 @@ # DeepSeek Harness Python SDK -English | [中文](README.zh.md) +English | [中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.zh.md) Python subprocess SDK for driving DeepSeek Harness over JSON-RPC stdio. The runtime inherits normal DeepSeek Harness environment variables such as @@ -40,12 +40,12 @@ with DeepSeekHarness( `provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. `max_tokens` is an optional positive per-request output-token cap for the root agent and its in-process descendants; omission leaves the provider default in control. Compaction summaries keep the separate limit configured by their compaction plugin. The bundled default composition registers `deepseek-official`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog. -The [Python SDK tutorial](../../docs/user/guide/python-sdk.md) uses a complete standalone Cordis file to demonstrate installation, direct SDK usage, and runs without the Web UI. +The [Python SDK tutorial](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/python-sdk.md) provides an ordered installation and first-run path without the Web UI. The [`jsonrpc-agent` example](https://github.com/deepseek-ai/deepseek-harness/blob/master/examples/jsonrpc-agent/README.md) owns the complete standalone Cordis file used there. `Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications, session_root)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end` in the interval, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the runtime protocol and raises `SdkProtocolError`. Both result fields describe the owned interval rather than an output or ending causally assigned to the prompt. Steering, injected context, and other queued work may contribute before idle. `HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `RunResult.events` contains root-session events only, so descendant messages cannot replace the root response. The low-level `session_prompt()` returns the queued `MessageId` immediately; callers that bypass `Session.run()` own any later activity boundary themselves. -The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin`, `bridge_bin`, or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. +The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin`, `bridge_bin`, or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. `cwd` and `runtime_cwd` are resolved to absolute paths before subprocess launch, environment injection, and the wire handshake. The public API exposes only applied options: deployment persona and persistence belong in `cordis.yml`, while `session_root` remains the high-level convenience that sets `DSH_SESSION_ROOT`. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index a9258ce9ae..1d1a23576c 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -1,6 +1,6 @@ # DeepSeek Harness Python SDK -[English](README.md) | 中文 +[English](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.md) | 中文 通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接使用真实模型端点,也可以把这些变量指向本地代理。 @@ -37,12 +37,12 @@ with DeepSeekHarness( `provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。`max_tokens` 是可选的正整数,用于限制根 agent(智能体)及其进程内后代每次请求的输出 token;省略时由提供方默认值控制。压缩摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek-official`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。 -[Python SDK 教程](../../docs/user/guide/python-sdk.md)使用完整的独立 Cordis 文件演示安装方式、直接调用 SDK,以及在不使用 Web UI 的情况下运行 agent。 +[Python SDK 教程](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/python-sdk.md)提供不使用 Web UI 的顺序安装与首次运行路径。[`jsonrpc-agent` 示例](https://github.com/deepseek-ai/deepseek-harness/blob/master/examples/jsonrpc-agent/README.md)归属该教程使用的完整独立 Cordis 文件。 `Session.run()` 拥有一个从提示词进入持久 inbox 时开始、到整个 agent 下一次进入空闲状态为止的活动区间,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications, session_root)`。`final_response` 是该区间内根会话最后提交的助手文本。`finish_reason` 是该区间内根会话最后一个 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反运行时协议,并会抛出 `SdkProtocolError`。两个结果字段描述的都是自有活动区间,而不是因果上归属于该提示词的输出或结束原因。steering(中途引导)、注入的上下文和其他排队工作都可能在进入空闲状态前参与其中。 `HarnessClient` 会在运行时进程的生命周期内保留已发现的 subagent(子 agent)祖先关系。每次执行 `Session.run()` 时,`RunResult.notifications` 与 `on_notification` 会按协议传输顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期事件与会话事件。`RunResult.events` 只包含根会话事件,因此后代消息不会覆盖根会话回复。底层 `session_prompt()` 会立即返回已排队消息的 `MessageId`;绕过 `Session.run()` 的调用方必须自行负责后续的活动边界。 -同样的行为也可以通过 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此底层客户端的默认启动也具有此行为:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值视为缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin`、`bridge_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 `node` 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 +同样的行为也可以通过 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此底层客户端的默认启动也具有此行为:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值视为缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin`、`bridge_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 `node` 闭包)及其获取方式见 [sdk-runtime README](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.md)。 `cwd` 与 `runtime_cwd` 会在启动子进程、注入环境变量和协议握手前解析为绝对路径。公开 API 只暴露真正生效的选项:部署的角色设定与持久化配置归 `cordis.yml` 管理,而 `session_root` 继续作为设置 `DSH_SESSION_ROOT` 的高层便捷选项。 diff --git a/python/sdk/pyproject.toml b/python/sdk/pyproject.toml index 48ffbf2499..246e44ee26 100644 --- a/python/sdk/pyproject.toml +++ b/python/sdk/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling>=1.30.1"] +requires = ["hatchling==1.30.1"] build-backend = "hatchling.build" [project] @@ -8,12 +8,19 @@ version = "0.0.0.dev0" description = "Python SDK for DeepSeek Harness" readme = "README.md" requires-python = ">=3.10" -license = { text = "BSD-3-Clause" } +license = "BSD-3-Clause" +authors = [{ name = "DeepSeek" }] dependencies = [ - "pydantic>=2.12", + "pydantic>=2.12,<3", "deepseek-harness-runtime-bin==0.0.0.dev0", ] +[project.urls] +Homepage = "https://github.com/deepseek-ai/deepseek-harness" +Documentation = "https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/python-sdk.md" +Issues = "https://github.com/deepseek-ai/deepseek-harness/issues" +Source = "https://github.com/deepseek-ai/deepseek-harness" + [dependency-groups] test = ["pytest>=8.0"] diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 629ddf901f..5442c7e144 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -269,7 +269,11 @@ class HarnessClient: if remaining <= 0: with self._lock: self._responses.pop(request_id, None) - raise TimeoutError(f"{method} timed out waiting for DeepSeek Harness runtime") + diagnostics = self._runtime_diagnostics() + suffix = f"\n{diagnostics}" if diagnostics else "" + raise TimeoutError( + f"{method} timed out waiting for DeepSeek Harness runtime{suffix}" + ) wait_timeout = remaining if wait_timeout is None else min(wait_timeout, remaining) try: item = waiter.get(timeout=wait_timeout) @@ -393,6 +397,11 @@ class HarnessClient: self._requests.put(exc) def _runtime_closed_error(self, reason: str) -> TransportClosedError: + diagnostics = self._runtime_diagnostics() + return TransportClosedError(f"{reason}\n{diagnostics}" if diagnostics else reason) + + def _runtime_diagnostics(self) -> str: + """Return available subprocess state for transport failures and timeouts.""" proc = self._proc if ( proc is not None @@ -403,14 +412,14 @@ class HarnessClient: ): self._stderr_thread.join(timeout=0.1) - parts = [reason] + parts: list[str] = [] if proc is not None: exit_code = proc.poll() if exit_code is not None: parts.append(f"exit code: {exit_code}") if self._stderr_lines: parts.append("stderr tail:\n" + "\n".join(self._stderr_lines)) - return TransportClosedError("\n".join(parts)) + return "\n".join(parts) def _default_launch_args(self) -> tuple[str, ...]: if self.config.runtime_bin is not None: diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 3d341492d8..51c9dacb31 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -721,8 +721,10 @@ def test_client_request_times_out_when_bridge_does_not_respond(tmp_path: Path) - script = tmp_path / "fake_bridge.py" script.write_text( """ +import sys import time +print("bridge is still starting", file=sys.stderr, flush=True) time.sleep(60) """.strip() ) @@ -736,8 +738,9 @@ time.sleep(60) start = time.monotonic() try: client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") - except TimeoutError: + except TimeoutError as exc: assert time.monotonic() - start < 2 + assert "bridge is still starting" in str(exc) else: raise AssertionError("initialize should time out") diff --git a/python/sdk/tests/test_macos_deployment_target.py b/python/sdk/tests/test_macos_deployment_target.py new file mode 100644 index 0000000000..8e68089a6e --- /dev/null +++ b/python/sdk/tests/test_macos_deployment_target.py @@ -0,0 +1,37 @@ +"""Tests for macOS runtime wheel deployment-target validation.""" + +from __future__ import annotations + +import runpy +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "scripts" / "check-macos-deployment-target.py" +checker = SimpleNamespace(**runpy.run_path(str(SCRIPT))) + + +def test_otool_parser_uses_the_newest_macho_slice() -> None: + output = """ + cmd LC_BUILD_VERSION + minos 11.0 + cmd LC_BUILD_VERSION + minos 13.5 + """ + + assert checker.parse_otool_deployment_target(output) == (13, 5) + + +def test_otool_parser_requires_a_deployment_target() -> None: + with pytest.raises(ValueError, match="contains no LC_BUILD_VERSION"): + checker.parse_otool_deployment_target("Load command 0\n") + + +def test_wheel_tag_rejects_a_newer_executable_target() -> None: + checker.ensure_compatible(Path("runtime"), (13, 5), "macosx_14_0_arm64") + + with pytest.raises(RuntimeError, match="requires macOS 14.1"): + checker.ensure_compatible(Path("spawn-helper"), (14, 1), "macosx_14_0_arm64") diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index c66de01a3e..3bff2df3f9 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -57,6 +57,18 @@ def test_pep440_version_spells_a_prerelease_the_python_way() -> None: build_python_release.pep440_version("1.2.3-nightly") +def test_macos_wheel_tag_does_not_claim_unsupported_node_platforms() -> None: + assert build_python_release.PLATFORMS["macos-arm64"][0] == "macosx_14_0_arm64" + + +def test_platform_manifest_rejects_incomplete_entries(tmp_path: Path) -> None: + manifest = tmp_path / "platforms.json" + manifest.write_text('{"macos-arm64":{"tag":"macosx_14_0_arm64"}}\n') + + with pytest.raises(ValueError, match="tag and executable fields"): + build_python_release.load_platforms(manifest) + + def test_stage_sdk_keeps_distribution_module_and_runtime_pin_distinct(tmp_path: Path) -> None: destination = tmp_path / "staging" @@ -66,6 +78,8 @@ def test_stage_sdk_keeps_distribution_module_and_runtime_pin_distinct(tmp_path: assert 'name = "deepseek-harness-sdk"' in pyproject assert 'version = "1.2.3"' in pyproject assert '"deepseek-harness-runtime-bin==1.2.3"' in pyproject + assert 'license-files = ["LICENSE"]' in pyproject + assert (destination / "LICENSE").read_bytes() == (ROOT / "LICENSE").read_bytes() assert (destination / "src" / "deepseek_harness" / "__init__.py").is_file() @@ -88,3 +102,12 @@ def test_stage_runtime_copies_platform_payload( runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" assert {path.name: path.read_bytes() for path in runtime_dir.glob("dsh-jsonrpc-agent-pkg-*")} == expected + pyproject = (destination / "pyproject.toml").read_text() + assert 'license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]' in pyproject + assert (destination / "platforms.json").read_bytes() == ( + ROOT / "python" / "sdk-runtime" / "platforms.json" + ).read_bytes() + assert (destination / "LICENSE").read_bytes() == (ROOT / "LICENSE").read_bytes() + assert (destination / "THIRD_PARTY_NOTICES.md").read_bytes() == ( + ROOT / "THIRD_PARTY_NOTICES.md" + ).read_bytes() diff --git a/python/sdk/tests/test_smoke_model.py b/python/sdk/tests/test_smoke_model.py new file mode 100644 index 0000000000..d4be72032d --- /dev/null +++ b/python/sdk/tests/test_smoke_model.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import runpy +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[3] +SMOKE = runpy.run_path(ROOT / "scripts" / "smoke-python-runtime.py") + + +@pytest.mark.parametrize( + ("prompt_name", "expected"), + [ + ("SNAPSHOT_DIRECT_CHILD_PROMPT", "DIRECT_CHILD_OK"), + ("SNAPSHOT_WORKFLOW_CHILD_PROMPT", "WORKFLOW_CHILD_OK"), + ], +) +def test_child_prompt_precedes_runtime_context(prompt_name: str, expected: str) -> None: + chunks = SMOKE["completion_chunks"]({ + "messages": [ + {"role": "user", "content": SMOKE[prompt_name]}, + {"role": "user", "content": "Current runtime context"}, + ], + }) + + assert any( + choice.get("delta", {}).get("content") == expected + for chunk in chunks + for choice in chunk.get("choices", []) + ) diff --git a/python/sdk/uv.lock b/python/sdk/uv.lock index e2a62a9fe0..c6715ebdd8 100644 --- a/python/sdk/uv.lock +++ b/python/sdk/uv.lock @@ -42,7 +42,7 @@ test = [ [package.metadata] requires-dist = [ { name = "deepseek-harness-runtime-bin", editable = "../sdk-runtime" }, - { name = "pydantic", specifier = ">=2.12" }, + { name = "pydantic", specifier = ">=2.12,<3" }, ] [package.metadata.requires-dev] diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index d326222efa..755dc46c44 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -19,11 +19,32 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SDK_DISTRIBUTION = "deepseek-harness-sdk" RUNTIME_DISTRIBUTION = "deepseek-harness-runtime-bin" -PLATFORMS = { - "linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"), - "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), - "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), -} +PLATFORM_MANIFEST = ROOT / "python" / "sdk-runtime" / "platforms.json" + + +def load_platforms(path: Path = PLATFORM_MANIFEST) -> dict[str, tuple[str, str]]: + """Load the release platform tag and executable pairs from the build manifest.""" + try: + payload = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as error: + raise ValueError(f"could not read runtime platform manifest from {path}") from error + if not isinstance(payload, dict) or not payload: + raise ValueError(f"{path} must contain a non-empty platform object") + platforms: dict[str, tuple[str, str]] = {} + for name, raw in payload.items(): + if ( + not isinstance(name, str) + or not isinstance(raw, dict) + or set(raw) != {"tag", "executable"} + or not isinstance(raw["tag"], str) + or not isinstance(raw["executable"], str) + ): + raise ValueError(f"{path} platform entries must contain string tag and executable fields") + platforms[name] = (raw["tag"], raw["executable"]) + return platforms + + +PLATFORMS = load_platforms() def runtime_suffixes(executable_name: str) -> tuple[str, ...]: @@ -35,7 +56,7 @@ def main() -> None: parser.add_argument("--package", choices=("sdk", "runtime"), required=True) parser.add_argument( "--tag", - help="optional python-vX.Y.Z release tag; it must match package.json", + help="optional python-v release tag; it must match package.json", ) parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--platform", choices=tuple(PLATFORMS)) @@ -146,8 +167,29 @@ def rewrite_version(pyproject: Path, version: str) -> None: pyproject.write_text(text) +def stage_license_files(destination: Path, *, include_notices: bool) -> None: + """Copy legal files and declare them as wheel license payloads.""" + shutil.copy2(ROOT / "LICENSE", destination / "LICENSE") + license_files = '["LICENSE"]' + if include_notices: + shutil.copy2(ROOT / "THIRD_PARTY_NOTICES.md", destination / "THIRD_PARTY_NOTICES.md") + license_files = '["LICENSE", "THIRD_PARTY_NOTICES.md"]' + pyproject = destination / "pyproject.toml" + text, count = re.subn( + r'^(license = "[^"]+")$', + rf"\1\nlicense-files = {license_files}", + pyproject.read_text(), + count=1, + flags=re.MULTILINE, + ) + if count != 1: + raise RuntimeError(f"could not declare license files in {pyproject}") + pyproject.write_text(text) + + def stage_sdk(destination: Path, version: str) -> None: copy_package(ROOT / "python" / "sdk", destination) + stage_license_files(destination, include_notices=False) pyproject = destination / "pyproject.toml" rewrite_version(pyproject, version) text, count = re.subn( @@ -163,6 +205,7 @@ def stage_sdk(destination: Path, version: str) -> None: def stage_runtime(destination: Path, version: str, executable: Path, executable_name: str) -> None: copy_package(ROOT / "python" / "sdk-runtime", destination) + stage_license_files(destination, include_notices=True) rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" runtime_dir.mkdir(parents=True, exist_ok=True) @@ -191,6 +234,16 @@ def verify_wheel( raise RuntimeError( f"{wheel} has distribution name {metadata.get('Name')}, expected {expected_distribution}" ) + if metadata.get("License-Expression") != "BSD-3-Clause": + raise RuntimeError( + f"{wheel} has license expression {metadata.get('License-Expression')}, expected BSD-3-Clause" + ) + expected_license_files = ["LICENSE"] if package == "sdk" else ["LICENSE", "THIRD_PARTY_NOTICES.md"] + license_files = [Path(name).name for name in metadata.get_all("License-File") or []] + if license_files != expected_license_files: + raise RuntimeError( + f"{wheel} has license files {license_files}, expected {expected_license_files}" + ) runtime_files = [ name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name ] diff --git a/scripts/check-macos-deployment-target.py b/scripts/check-macos-deployment-target.py new file mode 100644 index 0000000000..633a3d7ced --- /dev/null +++ b/scripts/check-macos-deployment-target.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Reject runtime executables that require newer macOS than their wheel tag.""" + +from __future__ import annotations + +import argparse +import re +import runpy +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +RELEASE = runpy.run_path(str(ROOT / "scripts" / "build-python-release.py")) +MACOS_PLATFORM_TAG = RELEASE["PLATFORMS"]["macos-arm64"][0] + + +def parse_version(value: str) -> tuple[int, ...]: + """Parse a dot-separated numeric deployment version.""" + if re.fullmatch(r"\d+(?:\.\d+)*", value) is None: + raise ValueError(f"invalid macOS deployment version: {value!r}") + return tuple(int(part) for part in value.split(".")) + + +def claimed_version(platform_tag: str) -> tuple[int, ...]: + """Return the minimum macOS version encoded by a wheel platform tag.""" + match = re.fullmatch(r"macosx_(\d+)_(\d+)_arm64", platform_tag) + if match is None: + raise ValueError(f"unsupported macOS wheel platform tag: {platform_tag!r}") + return int(match.group(1)), int(match.group(2)) + + +def parse_otool_deployment_target(output: str) -> tuple[int, ...]: + """Return the newest deployment target from one or more Mach-O slices.""" + versions = [ + parse_version(match.group(1)) + for match in re.finditer(r"^\s*minos\s+(\d+(?:\.\d+)*)\s*$", output, re.MULTILINE) + ] + if not versions: + raise ValueError("otool output contains no LC_BUILD_VERSION deployment target") + return max(versions) + + +def deployment_target(executable: Path) -> tuple[int, ...]: + """Read one Mach-O executable's deployment target with ``otool``.""" + if not executable.is_file(): + raise FileNotFoundError(f"runtime executable does not exist: {executable}") + result = subprocess.run( + ["otool", "-l", str(executable)], + check=True, + capture_output=True, + text=True, + ) + try: + return parse_otool_deployment_target(result.stdout) + except ValueError as error: + raise ValueError(f"{executable}: {error}") from error + + +def ensure_compatible( + executable: Path, actual: tuple[int, ...], platform_tag: str +) -> None: + """Reject an executable whose deployment target exceeds its wheel claim.""" + claimed = claimed_version(platform_tag) + width = max(len(actual), len(claimed)) + padded_actual = actual + (0,) * (width - len(actual)) + padded_claimed = claimed + (0,) * (width - len(claimed)) + if padded_actual > padded_claimed: + rendered = ".".join(str(part) for part in actual) + raise RuntimeError( + f"{executable} requires macOS {rendered} but the wheel claims {platform_tag}" + ) + + +def validate_deployment_targets( + executables: list[Path], platform_tag: str = MACOS_PLATFORM_TAG +) -> list[tuple[Path, tuple[int, ...]]]: + """Validate every executable and return its measured deployment target.""" + measured = [(executable, deployment_target(executable)) for executable in executables] + for executable, actual in measured: + ensure_compatible(executable, actual, platform_tag) + return measured + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("executables", type=Path, nargs="+") + args = parser.parse_args() + for executable, version in validate_deployment_targets(args.executables): + rendered = ".".join(str(part) for part in version) + print(f"{executable}: macOS {rendered} <= {MACOS_PLATFORM_TAG}") + + +if __name__ == "__main__": + main() diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 0036dc2929..263781ce5a 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -53,7 +53,9 @@ const releaseMemberDirectory = /^(?:packages\/[^/]+\/[^/]+|apps\/[^/]+|vendor\/[ const localArtifactDirs = new Set(['node_modules']) const appPackageFiles: Readonly> = { '@deepseek-ai/dsh': ['lib/*.js', 'config'], - '@deepseek-ai/dsh-frontend': ['dist'], + // The Web build emits sourcemaps for browser debugging; publishing them is + // what the payload policy forbids, so the bundle ships without them. + '@deepseek-ai/dsh-frontend': ['dist', '!dist/**/*.map'], } /** The subset of package.json fields this constraint check cares about. */ @@ -129,9 +131,8 @@ function workspaceManifests(): WorkspaceManifest[] { } const packageFileExtras: Readonly> = { - // Profile bundles publish their dsh.bundle.patch layer beside the lib; - // dsh-base also ships the win32 shell platform layer the launcher reads. - '@deepseek-ai/dsh-base': ['cordis.patch.yml', 'windows.cordis.patch.yml'], + // Profile bundles publish their dsh.bundle.patch layer beside the lib. + '@deepseek-ai/dsh-base': ['cordis.patch.yml'], '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], @@ -152,7 +153,6 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : [] - const typeRTRemoteNavigation = hasTypeRTRemoteNavigation(manifest) return [ 'lib/index.js', // Every package publishes its invariant ownership companion as a separate @@ -185,13 +185,8 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { ...hasExportPair(manifest, './client/typert', './lib/typert.client.d.ts', './lib/typert.client.js') ? ['lib/typert.client.js', 'lib/typert.client.d.ts'] : [], - ...typeRTRemoteNavigation - ? [ - 'lib/typert.remote-client.js', - 'lib/typert.remote-client.d.ts', - 'lib/typert.remote-client.d.ts.map', - 'src', - ] + ...hasTypeRTRemoteNavigation(manifest) + ? ['lib/typert.remote-client.js', 'lib/typert.remote-client.d.ts'] : [], ] } @@ -270,9 +265,8 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { if (manifest.name?.startsWith('@deepseek-ai/')) { const allowedSources = publicationSourceAllowlist[manifest.name] ?? [] - const publicationPolicy = { typeRTRemoteNavigation: hasTypeRTRemoteNavigation(manifest) } for (const file of manifest.files ?? []) { - if (isForbiddenPublicationFile(file, publicationPolicy) && !allowedSources.includes(file)) { + if (isForbiddenPublicationFile(file) && !allowedSources.includes(file)) { errors.push(`${label}: package.json files must not publish ${JSON.stringify(file)}`) } } diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 2642f234c4..6c12159019 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -127,6 +127,135 @@ describe('E2B e2e workflow', () => { }) }) +describe('Python release workflows', () => { + it('keeps complete wheel validation separate from protected public publication', () => { + const workflow = loadWorkflow('.github/workflows/python-release.yml') + const dispatch = workflowEvent(workflow, 'workflow_dispatch') + const pullRequest = workflowEvent(workflow, 'pull_request') + const build = workflowJob(workflow, 'build') + const pythonCompat = workflowJob(workflow, 'python-compat') + const validate = workflowJob(workflow, 'validate') + const publishRuntime = workflowJob(workflow, 'publish-runtime') + const publishSdk = workflowJob(workflow, 'publish-sdk') + if (!isRecord(dispatch.inputs) + || !isRecord(dispatch.inputs.publish) + || !Array.isArray(pythonCompat.steps) + || !Array.isArray(validate.steps) + || !Array.isArray(publishRuntime.steps) + || !Array.isArray(publishSdk.steps)) { + throw new TypeError('Python release workflow must define publish input and release steps') + } + + expect(dispatch.inputs.publish).toMatchObject({ type: 'boolean', default: false }) + expect(pullRequest).toEqual({ types: ['labeled'] }) + expect(build).toMatchObject({ + if: "github.event_name == 'workflow_dispatch' || github.event.label.name == 'python-release-dry-run'", + uses: './.github/workflows/build-exe-for-python-sdk.yml', + with: { + targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64', + release: true, + }, + }) + expect(pythonCompat.strategy).toMatchObject({ matrix: { python: ['3.10', '3.14'] } }) + expect(JSON.stringify(pythonCompat.steps)).toContain('deepseek-harness-sdk==${{ steps.compatibility-version.outputs.version }}') + const validateSteps = JSON.stringify(validate.steps) + const authorize = validate.steps.filter(isRecord).find(step => step.name === 'Authorize publication request') + if (!isRecord(authorize) || typeof authorize.run !== 'string') { + throw new TypeError('Python release validation must authorize publication requests') + } + expect(validateSteps).toContain('PUBLIC_PYPI_RELEASE_ENABLED') + expect(authorize).toMatchObject({ + env: { + PYPI_PUBLISHER_REPOSITORY: '${{ vars.PYPI_PUBLISHER_REPOSITORY }}', + REPOSITORY: '${{ github.repository }}', + }, + }) + expect(authorize.run).toContain('[ "$REPOSITORY" = "$PYPI_PUBLISHER_REPOSITORY" ]') + expect(validateSteps).toContain('100000000') + expect(publishRuntime).toMatchObject({ + if: "github.event_name == 'workflow_dispatch' && inputs.publish", + needs: 'validate', + environment: 'pypi-runtime', + permissions: { contents: 'read', 'id-token': 'write' }, + }) + expect(publishSdk).toMatchObject({ + if: "github.event_name == 'workflow_dispatch' && inputs.publish", + needs: ['validate', 'publish-runtime'], + environment: 'pypi', + permissions: { contents: 'read', 'id-token': 'write' }, + }) + const runtimeSteps = publishRuntime.steps.filter(isRecord) + const sdkSteps = publishSdk.steps.filter(isRecord) + const runtimePublish = runtimeSteps.find(step => step.name === 'Publish runtime wheels') + const sdkPublish = sdkSteps.find(step => step.name === 'Publish SDK wheel') + const runtimeHashes = runtimeSteps.find(step => step.name === 'Verify release artifact hashes') + const sdkHashes = sdkSteps.find(step => step.name === 'Verify release artifact hashes') + expect([...runtimeSteps, ...sdkSteps].some( + step => typeof step.uses === 'string' && step.uses.startsWith('actions/checkout@'), + )).toBe(false) + expect([...runtimeSteps, ...sdkSteps].filter( + step => step.uses === 'pypa/gh-action-pypi-publish@release/v1', + )).toHaveLength(2) + expect(runtimePublish).toMatchObject({ + with: { 'packages-dir': 'dist/runtime/', attestations: false }, + }) + expect(sdkPublish).toMatchObject({ + with: { 'packages-dir': 'dist/sdk/', attestations: false }, + }) + expect(runtimeHashes).toMatchObject({ run: 'cd dist && sha256sum -c SHA256SUMS' }) + expect(sdkHashes).toMatchObject({ run: 'cd dist && sha256sum -c SHA256SUMS' }) + }) + + it('exposes the native wheel builder to the release caller with normalized versions', () => { + const workflow = loadWorkflow('.github/workflows/build-exe-for-python-sdk.yml') + const call = workflowEvent(workflow, 'workflow_call') + const plan = workflowJob(workflow, 'plan') + const build = workflowJob(workflow, 'build') + if (!isRecord(call.inputs) || !Array.isArray(plan.steps) || !Array.isArray(build.steps)) { + throw new TypeError('Python wheel builder must define workflow_call inputs and plan steps') + } + + const buildSteps: unknown[] = build.steps + const manylinuxAddon = buildSteps.find(step => isRecord(step) && step.name === 'Rebuild Linux node-pty against manylinux 2.28') + const macosCheck = buildSteps.find(step => isRecord(step) && step.name === 'Check macOS deployment target') + const manylinuxSmoke = buildSteps.find(step => isRecord(step) && step.name === 'Run wheel in a manylinux 2.28 container') + expect(call.inputs).toHaveProperty('targets') + expect(call.inputs).toMatchObject({ release: { type: 'boolean', default: false } }) + expect(plan.if).toContain('inputs.release') + expect(JSON.stringify(plan.steps)).toContain('pep440_version') + expect(JSON.stringify(workflow)).toContain('macosx_14_0_arm64') + expect(manylinuxAddon).toMatchObject({ if: "runner.os == 'Linux'" }) + expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_x86_64') + expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_aarch64') + expect(JSON.stringify(manylinuxAddon)).toContain('$HOME/setup-pnpm:$HOME/setup-pnpm:ro') + expect(JSON.stringify(manylinuxAddon)).toContain('node-pty-glibc-versions.txt') + expect(JSON.stringify(manylinuxAddon)).toContain('le 2.28') + expect(macosCheck).toMatchObject({ if: "runner.os == 'macOS'" }) + expect(JSON.stringify(macosCheck)).toContain('scripts/check-macos-deployment-target.py') + expect(JSON.stringify(macosCheck)).toContain('$EXE-spawn-helper') + expect(manylinuxSmoke).toMatchObject({ if: "runner.os == 'Linux'" }) + expect(JSON.stringify(manylinuxSmoke)).toContain('-e DSH_TELEMETRY_DISABLED') + }) + + it('uses the shared macOS deployment-target check in GitLab', () => { + const workflow = loadWorkflow('.gitlab-ci.yml') + const runtimeWheel = workflow['.runtime-wheel'] + if (!isRecord(runtimeWheel) || !Array.isArray(runtimeWheel.script)) { + throw new TypeError('GitLab CI must define the runtime wheel script') + } + const runtimeScript: unknown[] = runtimeWheel.script + const macosCheck = runtimeScript.find( + step => typeof step === 'string' && step.includes('PLATFORM" = macos-arm64'), + ) + if (typeof macosCheck !== 'string') { + throw new TypeError('GitLab CI must check the macOS deployment target') + } + + expect(macosCheck).toContain('scripts/check-macos-deployment-target.py') + expect(macosCheck).toContain('"$EXE" "$EXE-spawn-helper"') + }) +}) + describe('Issue lifecycle workflow', () => { it('uses explicit review handoff events without rerunning when a draft becomes ready', () => { const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml') diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts index 5126fe0986..d9308e2276 100644 --- a/scripts/oxlint-contract.spec.ts +++ b/scripts/oxlint-contract.spec.ts @@ -53,7 +53,9 @@ describe('Oxlint executable contract', () => { ['host package source', 'packages/fs/fs-policy/src', 'packages/fs/fs-policy/tsconfig.json'], ['host package test', 'packages/fs/fs-policy/tests', 'tsconfig.host.json'], ['client package source', 'packages/client/ui-primitives/src', 'packages/client/ui-primitives/tsconfig.json'], - ['client package test', 'packages/client/ui-trajectory/tests', 'tsconfig.client.json'], + // A test under packages/client states its face in the filename, so the + // probe carries the Client suffix to reach the Client aggregate. + ['client package test', 'packages/client/ui-trajectory/tests', 'tsconfig.client.json', '.client.ts'], ['example', 'examples/headless-agent/tests', 'tsconfig.host.json'], ['website', 'website', 'tsconfig.host.json'], ] as const @@ -66,8 +68,8 @@ probePromise() try { const paths: Array = [] - for (const [label, parent, tsconfig] of probes) { - const path = join(repositoryRoot, parent, `oxlint-contract-${suffix}.ts`) + for (const [label, parent, tsconfig, extension = '.ts'] of probes) { + const path = join(repositoryRoot, parent, `oxlint-contract-${suffix}${extension}`) await writeFile(path, source) paths.push([label, relative(repositoryRoot, path), tsconfig]) } @@ -98,7 +100,8 @@ probePromise() expect(output).not.toContain('Unmatched file:') } finally { await Promise.all([ - ...probes.map(([, parent]) => rm(join(repositoryRoot, parent, `oxlint-contract-${suffix}.ts`), { force: true })), + ...probes.map(([, parent, , extension = '.ts']) => + rm(join(repositoryRoot, parent, `oxlint-contract-${suffix}${extension}`), { force: true })), rm(configPath, { force: true }), ]) } diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 107f2c1034..b5f5218de7 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -5,7 +5,7 @@ import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, realpathSyn import { tmpdir } from 'node:os' import { basename, join, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { docsPages, type DocsPage } from '../website/docs.ts' +import { docsPages, landingLink, routeLink, sectionSpec, type DocsPage } from '../website/docs.ts' import { addProjectionFrontmatter, projectedPageContent, publishableImage, rewriteMarkdown, } from './project-doc-site.ts' @@ -364,6 +364,63 @@ describe('docsPages locale routes', () => { }) }) +describe('sidebar ordering', () => { + it('places every section a sidebar collection owns', () => { + for (const page of docsPages) { + if (page.sidebar === null) continue + expect(() => sectionSpec(page.locale, page.section), page.route).not.toThrow() + } + }) + + it('refuses a section with no declared placement', () => { + expect(() => sectionSpec('root', '数据结构')) + .toThrow('Sidebar section "数据结构" has no placement in the root locale.') + }) + + it('declares placements per locale rather than in one shared list', () => { + // `SDK` labels a group in both locales, so one shared list would have to + // rank it against `入门` and against `Guide` at the same position. + expect(sectionSpec('root', 'SDK').index).toBeGreaterThan(sectionSpec('root', '入门').index) + expect(sectionSpec('en', 'SDK').index).toBeGreaterThan(sectionSpec('en', 'Guide').index) + expect(() => sectionSpec('en', '入门')).toThrow() + expect(() => sectionSpec('root', 'Guide')).toThrow() + }) + + it('lands every navigation item on a page the manifest publishes', () => { + // The navigation bar named `/guide/` while the manifest published the guide's + // first page at `guide/quickstart.md`, so the item served a 404. + const collections = [ + ['root', 'zh-guide'], ['root', 'zh-develop'], ['root', 'zh-reference'], + ['en', 'en-guide'], ['en', 'en-develop'], ['en', 'en-reference'], + ] as const + const published = new Set(docsPages.map(page => routeLink(page.route))) + for (const [locale, collection] of collections) { + expect(published, `${locale}/${collection}`).toContain(landingLink(locale, collection)) + } + }) + + it('collapses the subsystem groups and leaves the smaller ones open', () => { + expect(sectionSpec('root', '执行与工具').collapsed).toBe(true) + expect(sectionSpec('en', 'Execution and tools').collapsed).toBe(true) + expect(sectionSpec('root', '概念').collapsed).toBeUndefined() + }) + + it('gives each page its own position within a section', () => { + // Sidebar entries sort by order alone, so a shared value leaves the two + // pages ranked by whichever manifest block happens to be concatenated + // first rather than by an intent the manifest states. + const taken = new Map() + const collisions: string[] = [] + for (const page of docsPages) { + const slot = `${page.locale}/${String(page.sidebar)}/${page.section}#${page.order}` + const holder = taken.get(slot) + if (holder === undefined) taken.set(slot, page.label) + else collisions.push(`${slot}: ${holder} / ${page.label}`) + } + expect(collisions).toEqual([]) + }) +}) + describe('addProjectionFrontmatter', () => { it('adds frontmatter to an ordinary Markdown page', () => { expect(addProjectionFrontmatter('# Guide\n', { source: 'docs/guide.md' })).toBe( @@ -411,6 +468,25 @@ describe('projectedPageContent', () => { expect(projectedPageContent(markdown, page('zh-guide'))).toBe(markdown) }) + it('drops the language switcher the navigation bar already offers', () => { + expect(projectedPageContent('# Guide\n\nEnglish | [中文](./en/guide)\n\nBody.\n', page('zh-guide'))) + .toBe('# Guide\n\nBody.\n') + expect(projectedPageContent('# 指南\n\n[English](./en/guide) | 中文\n\n正文。\n', page('zh-guide'))) + .toBe('# 指南\n\n正文。\n') + }) + + it('drops the repository badge every page links from its footer', () => { + const badge = '[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)' + expect(projectedPageContent(`# Guide\n\nBody.\n\n${badge}\n`, page('zh-guide'))) + .toBe('# Guide\n\nBody.\n') + }) + + it('keeps a switcher-shaped line that is not the page header', () => { + // A tutorial showing the convention must still render the example. + const sample = '# Guide\n\nA\n\nB\n\nC\n\nD\n\nE\n\nEnglish | [中文](./x)\n' + expect(projectedPageContent(sample, page('zh-guide'))).toBe(sample) + }) + it('rejects a locale home source without frontmatter', () => { expect(() => projectedPageContent('# Harness\n', page(null))) .toThrow('locale home source "docs/index.zh.md" must start with YAML frontmatter') diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index e7acc73998..1d0ea9072a 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -292,6 +292,37 @@ export function addProjectionFrontmatter(markdown: string, page: Pick LANGUAGE_SWITCHER.test(line)) + // Only the switcher introducing the page qualifies; further down the same + // text is prose or a sample rather than the page's own header. + if (switcher !== -1 && switcher < 8) { + lines.splice(switcher, lines[switcher + 1] === '' ? 2 : 1) + } + const badge = lines.findLastIndex(line => REPOSITORY_BADGE.test(line)) + if (badge !== -1) { + lines.splice(lines[badge - 1] === '' ? badge - 1 : badge, lines[badge - 1] === '' ? 2 : 1) + } + return lines.join('\n') +} + /** * Select the Markdown rendered for one published page. * @@ -300,7 +331,7 @@ export function addProjectionFrontmatter(markdown: string, page: Pick { String.raw`src\index.ts`, 'lib/types/index.d.ts.map', './lib/types/index.d.ts.map', + 'lib/typert.remote-client.d.ts.map', + 'lib/client.js.map', + './lib/client.js.map', ])('rejects static manifest path %s', (file) => { expect(isForbiddenPublicationFile(file)).toBe(true) }) @@ -40,11 +43,19 @@ describe('publication payload policy', () => { ])).toThrow('fixture.tgz publishes source file package/src/index.ts') }) - it('rejects declaration maps in packed tarballs', () => { + it('rejects source maps in packed tarballs', () => { expect(validateFixtureTarball([ 'package/package.json', 'package/lib/types/index.d.ts.map', - ])).toThrow('fixture.tgz publishes declaration map package/lib/types/index.d.ts.map') + ])).toThrow('fixture.tgz publishes source map package/lib/types/index.d.ts.map') + expect(validateFixtureTarball([ + 'package/package.json', + 'package/lib/typert.remote-client.d.ts.map', + ])).toThrow('fixture.tgz publishes source map package/lib/typert.remote-client.d.ts.map') + expect(validateFixtureTarball([ + 'package/package.json', + 'package/lib/client.js.map', + ])).toThrow('fixture.tgz publishes source map package/lib/client.js.map') }) it('accepts a clean packed tarball', () => { @@ -56,19 +67,6 @@ describe('publication payload policy', () => { ])).not.toThrow() }) - it('allows only the TypeRT declaration map and its navigable source tree when requested', () => { - const policy = { typeRTRemoteNavigation: true } - expect(isForbiddenPublicationFile('src/index.ts', policy)).toBe(false) - expect(isForbiddenPublicationFile('lib/typert.remote-client.d.ts.map', policy)).toBe(false) - expect(isForbiddenPublicationFile('lib/types/index.d.ts.map', policy)).toBe(true) - expect(() => { - validateTarballPayload([ - 'package/lib/typert.remote-client.d.ts.map', - 'package/src/index.ts', - ], 'fixture.tgz', policy) - }).not.toThrow() - }) - it('recognizes only the canonical Host-for-Client export pair', () => { expect(hasTypeRTRemoteNavigation({ exports: { diff --git a/scripts/publication-payload.ts b/scripts/publication-payload.ts index 60f37b4f94..a6b4f6bebf 100644 --- a/scripts/publication-payload.ts +++ b/scripts/publication-payload.ts @@ -1,11 +1,10 @@ /** Publication payload policy shared by static manifests and packed tarballs. */ -/** Publication exceptions required for TypeRT declaration-map navigation. */ -export interface PublicationPayloadPolicy { - readonly typeRTRemoteNavigation?: boolean -} - -/** Whether a package manifest exports generated Host-for-Client metadata with source navigation. */ +/** + * Whether a package manifest exports generated Host-for-Client metadata. + * @param manifest - parsed package manifest to inspect. + * @returns whether the canonical `./remote` export pair is present. + */ export function hasTypeRTRemoteNavigation(manifest: unknown): boolean { if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) return false const exportsField = (manifest as Record).exports @@ -23,35 +22,34 @@ function payloadPath(file: string): string { return normalized.startsWith('package/') ? normalized.slice('package/'.length) : normalized } -/** Whether a package payload path exposes source or declaration-map intermediates. */ -export function isForbiddenPublicationFile( - file: string, - policy: PublicationPayloadPolicy = {}, -): boolean { +/** + * Whether a package payload path exposes source or map intermediates. Maps + * serve editor navigation during development, where a workspace consumer + * resolves their source through the package link; a published map resolves + * nothing, so no payload publishes one. + * @param file - manifest path or tarball member to classify. + * @returns whether publishing this path is forbidden. + */ +export function isForbiddenPublicationFile(file: string): boolean { const normalized = payloadPath(file) - if (policy.typeRTRemoteNavigation === true - && (normalized === 'src' - || normalized.startsWith('src/') - || normalized === 'lib/typert.remote-client.d.ts.map')) { - return false - } return normalized === 'src' || normalized.startsWith('src/') || normalized.endsWith('.d.ts.map') + || normalized.endsWith('.js.map') } -/** Reject source and declaration-map members in a packed npm tarball. */ -export function validateTarballPayload( - files: readonly string[], - context: string, - policy: PublicationPayloadPolicy = {}, -): void { +/** + * Reject source and map members in a packed npm tarball. + * @param files - tarball members to validate. + * @param context - tarball identity named in the failure. + */ +export function validateTarballPayload(files: readonly string[], context: string): void { for (const file of files) { - if (!isForbiddenPublicationFile(file, policy)) continue + if (!isForbiddenPublicationFile(file)) continue const normalized = payloadPath(file) if (normalized === 'src' || normalized.startsWith('src/')) { throw new Error(`${context} publishes source file ${file}`) } - throw new Error(`${context} publishes declaration map ${file}`) + throw new Error(`${context} publishes source map ${file}`) } } diff --git a/scripts/publish-npm-baseline.ts b/scripts/publish-npm-baseline.ts index 320f11997f..d69923910f 100644 --- a/scripts/publish-npm-baseline.ts +++ b/scripts/publish-npm-baseline.ts @@ -18,7 +18,7 @@ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep import { createInterface } from 'node:readline/promises' import { pathToFileURL } from 'node:url' import { parseArgs } from 'node:util' -import { hasTypeRTRemoteNavigation, validateTarballPayload } from './publication-payload.ts' +import { validateTarballPayload } from './publication-payload.ts' const DEFAULT_REGISTRY = 'https://registry.npm.harnessment.com' const DEFAULT_OUTPUT_DIRECTORY = '.artifacts/npm-baseline' @@ -323,9 +323,7 @@ class ReleaseBundle { throw new Error(`unexpected or duplicate packed package: ${artifact.name}`) } if (expected.origin === 'harness') { - validateTarballPayload(artifact.files, tarball, { - typeRTRemoteNavigation: hasTypeRTRemoteNavigation(artifact.manifest), - }) + validateTarballPayload(artifact.files, tarball) } if (artifact.version !== version) { throw new Error(`${tarball} has version ${artifact.version}; expected ${version}`) @@ -401,9 +399,7 @@ class ReleaseBundle { } const artifact = inspectTarball(path, runner) if (pkg.origin === 'harness') { - validateTarballPayload(artifact.files, pkg.tarball, { - typeRTRemoteNavigation: hasTypeRTRemoteNavigation(artifact.manifest), - }) + validateTarballPayload(artifact.files, pkg.tarball) } if (artifact.name !== pkg.name || artifact.version !== this.manifest.version) { throw new Error(`tarball identity mismatch: ${pkg.tarball}`) diff --git a/scripts/release/families.ts b/scripts/release/families.ts index e4c5fda5be..d39552636d 100644 --- a/scripts/release/families.ts +++ b/scripts/release/families.ts @@ -11,7 +11,7 @@ import { globSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { hasTypeRTRemoteNavigation, validateTarballPayload } from '../publication-payload.ts' +import { validateTarballPayload } from '../publication-payload.ts' /** Dependency sections that constrain publish order: a consumer must publish after its dependency. */ const ORDER_SECTIONS = ['dependencies', 'optionalDependencies'] as const @@ -225,9 +225,7 @@ class DshFamily extends ReleaseFamily { * @param files - every path inside its tarball. */ validatePayload(member: ReleaseMember, files: readonly string[]): void { - validateTarballPayload(files, member.name, { - typeRTRemoteNavigation: hasTypeRTRemoteNavigation(member.manifest), - }) + validateTarballPayload(files, member.name) } readonly installedEntry = { packageName: '@deepseek-ai/dsh', binPath: 'lib/bin.js' } diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index d6f84efd63..fc00b3ea9e 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -89,9 +89,9 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ // the creator flow stages and which id the roster reports. { file: 'packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx', upstream: ['cordis'] }, { file: 'packages/client/ui-agent-preset/src/client/index.ts', upstream: ['cordis'] }, - { file: 'packages/client/ui-agent-preset/tests/apply.spec.ts', upstream: ['cordis'] }, - { file: 'packages/client/ui-agent-preset/tests/locales.spec.ts', upstream: ['cordis'] }, - { file: 'packages/client/ui-agent-preset/tests/section.spec.tsx', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/apply.client.spec.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/locales.client.spec.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/section.client.spec.tsx', upstream: ['cordis'] }, { file: 'apps/cli/tests/web-agent-presets.e2e.ts', upstream: ['cordis'] }, { file: 'apps/web/tests/agent-preset-authoring.e2e.ts', upstream: ['cordis'] }, { file: 'packages/preset/agent-presets/tests/session.spec.ts', upstream: ['cordis'] }, @@ -99,6 +99,8 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ // the preset a model mounts, so the scoped name would send the model after an // id no roster reports. { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', upstream: ['cordis'] }, + // The preset-roster loop names the `cordis` preset id, not a package. + { file: 'apps/cli/tests/windows-shell.spec.ts', upstream: ['cordis'] }, // GROUP_ORDER holds `packages//` directory names, not package names. { file: 'scripts/gen-module-graph.ts', upstream: ['cordis'] }, { file: 'scripts/gen-doc-graphs.ts', upstream: ['cordis'] }, @@ -121,12 +123,12 @@ const POSTCONDITIONS: readonly PostCondition[] = [ { file: 'scripts/check-workspace-constraints.ts', text: '?.[\'@deepseek-ai/cordis\']', count: 2 }, { file: 'packages/boot/app-boot/tsdown.config.ts', text: '[\'@deepseek-ai/cordis-plugin-include\']', count: 1 }, { file: 'tsconfig.base.json', text: '"@deepseek-ai/cordis-plugin-loader": ["./vendor/loader/src"]', count: 1 }, - // One insertion, once: a duplicated log entry is what a non-idempotent apply produced. + // The vendored README owns this required entry; reject its deletion or duplication. { file: 'vendor/README.md', text: '17. **`@deepseek-ai` rescope**', count: 1 }, { file: 'knip.json', text: '@cordisjs', count: 0 }, { file: 'pnpm-workspace.yaml', text: 'cordis@4.0.0-rc.7', count: 0 }, // The preset ids in this table are product data, not package names. - { file: 'packages/client/ui-agent-preset/tests/locales.spec.ts', text: '[\'cordis\', \'presetCordisName\'', count: 1 }, + { file: 'packages/client/ui-agent-preset/tests/locales.client.spec.ts', text: '[\'cordis\', \'presetCordisName\'', count: 1 }, // The preset id the shipped composition documents to its own model. { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'The `cordis` agent preset', count: 1 }, { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'corrupting the `cordis` preset', count: 1 }, @@ -239,13 +241,6 @@ const EXACT_EDITS: readonly ExactEdit[] = [ replace: '| Directory | npm name | Upstream name | Version | Upstream repo | Commit |\n|---|---|---|---|---|---|', expect: 1, }, - { - id: 'vendor-readme-local-modification-log', - file: 'vendor/README.md', - find: '\n16. **`cordis/package.json` publishes `src`**', - replace: '\n16. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match.\n17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table\'s `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for(\'schemastery\')` and Schemastery\'s `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table\'s two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).', - expect: 1, - }, { // A plain fence listing the bundle's mounted tree: a bare token, no quotes. id: 'agent-spine-demo-mounted-tree', @@ -330,7 +325,7 @@ const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/ { // The real package references in files whose other `cordis` strings are preset ids. id: 'agent-preset-spec-framework-import', - file: 'packages/client/ui-agent-preset/tests/apply.spec.ts', + file: 'packages/client/ui-agent-preset/tests/apply.client.spec.ts', find: "import { Context } from 'cordis'", replace: "import { Context } from '@deepseek-ai/cordis'", expect: 1, diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 910b4ffc0a..242f1f43b1 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -155,15 +155,16 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: return text_chunks(WORKFLOW_WORKER_TEXT) raise AssertionError(f"unexpected tool follow-up: {tool_name}") + user_prompts = [ + message_text(message.get("content")) + for message in reversed(messages) + if isinstance(message, dict) and message.get("role") == "user" + ] minimal_prompt = next( ( - message_text(message.get("content")) - for message in reversed(messages) - if isinstance(message, dict) - and message.get("role") == "user" - and message_text(message.get("content")).startswith( - f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}" - ) + prompt + for prompt in user_prompts + if prompt.startswith(f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}") ), None, ) @@ -183,7 +184,17 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: "bash", {"command": MINIMAL_BASH_COMMAND}, ) - prompt = message_text(latest.get("content")) + scenario_prompts = { + SNAPSHOT_DIRECT_CHILD_PROMPT, + SNAPSHOT_WORKFLOW_CHILD_PROMPT, + SNAPSHOT_PROMPT, + CODE_PROMPT, + WORKFLOW_PROMPT, + } + prompt = next( + (candidate for candidate in user_prompts if candidate in scenario_prompts), + message_text(latest.get("content")), + ) if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT: return text_chunks("DIRECT_CHILD_OK") if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT: diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index dff04d578e..ee8cdcb05f 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -2334,9 +2334,42 @@ "payload": { "sessionId": "{{child-1}}", "event": { - "type": "session/title", + "type": "user/message", "seq": 6, "time": 0, + "data": { + "content": [ + { + "type": "text", + "text": "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it." + } + ], + "source": { + "kind": "plugin", + "plugin": "@deepseek-ai/dsh-system-prompt", + "form": "snapshot", + "sections": [ + { + "name": "subagent:delegation", + "text": "You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it." + } + ] + }, + "role": "user", + "id": "{{messageId}}" + }, + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "session/title", + "seq": 7, + "time": 0, "data": { "title": "Reply with exactly DIRECT_CHILD_OK and", "messageSeqs": [ @@ -2355,7 +2388,7 @@ "sessionId": "{{child-1}}", "event": { "type": "request/header", - "seq": 7, + "seq": 8, "time": 0, "data": { "header": { @@ -2394,7 +2427,7 @@ "sessionId": "{{child-1}}", "event": { "type": "request/context", - "seq": 8, + "seq": 9, "time": 0, "data": { "provider": "deepseek-official", @@ -2410,7 +2443,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 9, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -2430,7 +2463,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 10, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -2450,7 +2483,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 11, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -2473,7 +2506,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 12, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -2495,7 +2528,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 13, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -2516,7 +2549,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/message", - "seq": 14, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -2542,11 +2575,11 @@ } }, "sourceEventSeqs": [ - 9, 10, 11, 12, - 13 + 13, + 14 ], "surfaceOp": "append" } @@ -2558,7 +2591,7 @@ "sessionId": "{{child-1}}", "event": { "type": "step/end", - "seq": 15, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -2573,7 +2606,7 @@ "sessionId": "{{child-1}}", "event": { "type": "turn/end", - "seq": 16, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -2986,9 +3019,42 @@ "payload": { "sessionId": "{{child-2}}", "event": { - "type": "session/title", + "type": "user/message", "seq": 6, "time": 0, + "data": { + "content": [ + { + "type": "text", + "text": "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it." + } + ], + "source": { + "kind": "plugin", + "plugin": "@deepseek-ai/dsh-system-prompt", + "form": "snapshot", + "sections": [ + { + "name": "subagent:delegation", + "text": "You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it." + } + ] + }, + "role": "user", + "id": "{{messageId}}" + }, + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "session/title", + "seq": 7, + "time": 0, "data": { "title": "Reply with exactly WORKFLOW_CHILD_OK and", "messageSeqs": [ @@ -3007,7 +3073,7 @@ "sessionId": "{{child-2}}", "event": { "type": "request/header", - "seq": 7, + "seq": 8, "time": 0, "data": { "header": { @@ -3046,7 +3112,7 @@ "sessionId": "{{child-2}}", "event": { "type": "request/context", - "seq": 8, + "seq": 9, "time": 0, "data": { "provider": "deepseek-official", @@ -3062,7 +3128,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 9, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -3082,7 +3148,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 10, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -3102,7 +3168,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 11, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -3125,7 +3191,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 12, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -3147,7 +3213,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 13, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -3168,7 +3234,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/message", - "seq": 14, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -3194,11 +3260,11 @@ } }, "sourceEventSeqs": [ - 9, 10, 11, 12, - 13 + 13, + 14 ], "surfaceOp": "append" } @@ -3210,7 +3276,7 @@ "sessionId": "{{child-2}}", "event": { "type": "step/end", - "seq": 15, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -3225,7 +3291,7 @@ "sessionId": "{{child-2}}", "event": { "type": "turn/end", - "seq": 16, + "seq": 17, "time": 0, "data": { "turn": 1, diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl index 3cfcda4d28..73b18b53cd 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -5,14 +5,15 @@ {"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} {"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} -{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} +{"type":"request/context","seq":9,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":17,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl index 926acbcecc..07d43b8a2b 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -5,14 +5,15 @@ {"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} {"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} -{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} +{"type":"request/context","seq":9,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":17,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 6d2784ea0d..29463c17e1 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Run from source\n\nClone this repo, complete the [dependency and API-key setup](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key), then run:\n\n```sh\npnpm dsh web\n```\n\n## Use DeepSeek Harness\n\n### Web UI\n\nStart the recommended local interface from the repository root:\n\n```sh\npnpm dsh web\n```\n\nThe command builds the repository before starting the Web UI, which is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\nThe source CLI boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n\n## Contributing\n\nRead [CONTRIBUTING.md](CONTRIBUTING.md) before contributing to this repository.\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Run\n\nInstall Node.js ^22.19 or >= 24 and pnpm 11, then run the published package:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\nThe command initializes the Web profile and prints the Web UI URL, which is `http://127.0.0.1:3080` by default. Open it, add a DeepSeek API key under **Settings → Models**, then start a session. The invoking directory is the default workspace; try `Summarize this repository and identify its main packages.`\n\nContinue with the [Web UI guide](docs/user/guide/index.md).\n\n### Run from source\n\nTo run a repository checkout instead:\n\n```sh\ngit clone https://github.com/deepseek-harness/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm dsh web\n```\n\nThe last command builds the repository and opens the same Web UI path.\n\n## Profiles and plugins\n\nA profile is an ordered list of plugin bundles. The shipped `web` profile powers `dsh web`. Manage a profile with `dsh plugin --profile `, which forwards the remaining arguments to pnpm in that profile's directory:\n\n```sh\nnpx -p @deepseek-ai/dsh dsh plugin --profile web add \nnpx -p @deepseek-ai/dsh dsh plugin --profile web remove \n```\n\n`add`, `remove`, `update`, `why`, and other pnpm commands work unchanged. The command initializes a missing profile before changing its packages and updates its bundle list from installed packages that declare `dsh.bundle`. See the [CLI reference](apps/cli/reference/README.md#plugin-management) for the exact behavior.\n\nThe [CLI reference](apps/cli/README.md) covers headless execution and custom profiles. The [Python SDK](python/README.md) and [examples](examples/README.md) cover programmatic and custom compositions.\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n\n## Contributing\n\nRead [CONTRIBUTING.md](CONTRIBUTING.md) before contributing to this repository.\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 从源码运行\n\n克隆本仓库,完成[依赖安装和 API 密钥配置](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key),然后运行:\n\n```sh\npnpm dsh web\n```\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n请从仓库根目录启动推荐的本地界面:\n\n```sh\npnpm dsh web\n```\n\n该命令会先构建仓库,再启动 Web UI。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n源码 CLI(命令行界面)会启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

    \n \"DeepSeek\n

    \n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n\n## 参与贡献\n\n向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 运行\n\n安装 Node.js ^22.19 或 >= 24 和 pnpm 11,然后运行已发布的包:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\n该命令会初始化 Web profile 并打印 Web UI 地址,默认地址为 `http://127.0.0.1:3080`。打开该地址,在**设置 → 模型**中添加 DeepSeek API 密钥,然后启动一个会话。调用目录是默认工作区;你可以尝试输入 `Summarize this repository and identify its main packages.`。\n\n下一步请阅读 [Web UI 指南](docs/user/guide/index.md)。\n\n### 从源码运行\n\n如需改为运行仓库 checkout:\n\n```sh\ngit clone https://github.com/deepseek-harness/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm dsh web\n```\n\n最后一条命令会构建仓库,并进入相同的 Web UI 路径。\n\n## Profile 与插件\n\nprofile 是按顺序排列的插件 bundle 列表。随附的 `web` profile 为 `dsh web` 提供功能。使用 `dsh plugin --profile ` 管理 profile;该命令会在对应 profile 目录中将剩余参数转发给 pnpm:\n\n```sh\nnpx -p @deepseek-ai/dsh dsh plugin --profile web add \nnpx -p @deepseek-ai/dsh dsh plugin --profile web remove \n```\n\n`add`、`remove`、`update`、`why` 等 pnpm 命令均可直接使用。该命令会先初始化不存在的 profile,再修改其中的包,并根据声明了 `dsh.bundle` 的已安装包更新 bundle 列表。准确行为见 [CLI 参考](apps/cli/reference/README.md#plugin-management)。\n\n[CLI(命令行界面)参考](apps/cli/README.md)介绍 headless 执行与自定义 profile。[Python SDK](python/README.md) 和[示例](examples/README.md)介绍程序化组合与自定义组合。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

    \n \"DeepSeek\n

    \n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n\n## 参与贡献\n\n向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。\n" }, { "role": "user", @@ -24,19 +24,19 @@ }, { "role": "user", - "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation.\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief ` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write `**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: the root CONTRIBUTING document, every non-vendor README, and every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nRoutine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" + "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation.\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief ` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output. A README published outside GitHub, such as PyPI project metadata, may use the canonical `https://github.com/deepseek-ai/deepseek-harness/blob/master/` URL to the same counterpart so the switcher still resolves there.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write `**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: the root CONTRIBUTING document, every non-vendor README, and every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nRoutine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" }, { "role": "assistant", - "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief ` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write ` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:根目录 CONTRIBUTING 文档、除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" + "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief ` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。发布到 GitHub 以外位置的 README(例如 PyPI 项目元数据)可以改用指向同一对侧文件的规范 `https://github.com/deepseek-ai/deepseek-harness/blob/master/` URL,使切换行在该位置仍可访问。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write ` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:根目录 CONTRIBUTING 文档、除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" }, { "role": "user", - "content": "# Translation rules\n\nEnglish | [中文](translation-rules.zh.md)\n\nHow to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally. Routine agent work translates the changed content directly in one terminology-guided pass; the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow runs only when the user explicitly invokes it. Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary.\n\n## Faithfulness\n\n- The counterpart *MUST* say what the authored side says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the pair disagrees on substance, neither language wins by default: fix the side that is wrong, then bring the other along in the same change.\n- The counterpart *SHOULD* read as natural technical writing in its own language, not word-by-word gloss. Translate meaning, restructure sentences where the target grammar wants it, and keep the author's register — terse stays terse.\n- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an idiom of the source language, translate the idea, not the idiom.\n\n## Voice\n\n- The register is calibrated by [style-samples.md](style-samples.md) — human-approved gold pairs, one per document genre. The counterpart MUST match the target-language side of the nearest sample; where its voice and a prose voice rule disagree, the sample wins. Chinese targets use institutional technical Chinese; English targets use concise professional developer prose.\n- Write as a native technical author restating the content, not as a translator transposing sentences, while preserving every source clause: nothing added, nothing dropped — fluency never justifies losing a clause.\n- Give sentences an explicit actor when the target language would otherwise obscure it; for Chinese, replace vague passives or abstract subjects with the actual actor (系统、门禁、评审人).\n- Prefer established target-language engineering idiom over calques (误报/漏检 for false positive/negative, 执行红线 for enforcement frontier); localize metaphors instead of transplanting them, and unpack noun chains where the target language requires it.\n- Split long paragraphs by semantic unit — one idea per paragraph. Paragraph boundaries MAY differ from the source; the structural signature does not count paragraphs.\n- When translating into Chinese, category nouns use Chinese with a first-mention English annotation (实操手册(cookbook)); when translating into English, use the conventional English category name. Literal directory or file references stay code-formatted English.\n\n## Structure preservation\n\nThe pairing gate checks heading depths, fenced code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, and link targets. Preserve the rest of the frame manually; the paired files MUST match one to one in:\n\n- heading hierarchy (same levels, same order — heading TEXT is translated),\n- list shape and numbering,\n- tables (same columns, same row order; header cells translated per terminology),\n- fenced code blocks — **byte-identical, including comments**; the pairing signature compares their info strings and contents, and ` ```ts ` blocks compile under `doc-typecheck`,\n- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted,\n- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. Link TEXT is translated; the target is not.\n\nThe repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline.\n\n## Terminology\n\n- [terminology.md](terminology.md) is the source of truth in both directions. Before translating, load it; every listed term MUST follow its row and its \"不要译作\" prohibitions. A Chinese target uses the \"中文\" column and its \"首次出现\" annotation; an English target uses the \"English\" column without adding a Chinese gloss.\n- For a Chinese target, an unlisted technical term MAY use an established rendering from a major Chinese-language OSS or vendor source (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs), cited in the PR. Without such precedent it MUST stay in English and be listed under 「待定术语」(pending terms) with a suggested rendering.\n- For an English target, use the established English technical term. If the source term has no unambiguous established equivalent, preserve it with a short explanatory gloss and list it under pending terms. Neither direction may invent a rendering inline; a decided term enters [terminology.md](terminology.md) in the same PR or a follow-up.\n\n## Typography\n\nThese rules govern the Chinese side; the English side follows the repo's normal Markdown conventions (root `AGENTS.md`). The mixed-script rules below follow the cross-project consensus of the [MDN Simplified Chinese translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md), the [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/), the [Vue.js Chinese translation conventions](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5), and [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines), which in turn ground in [W3C clreq](https://www.w3.org/TR/clreq/) and GB/T 15834—2011:\n\n- MUST put one half-width space between Chinese text and Latin words, and between Chinese text and numerals: `每个 plugin 注册 3 个 tool`。No space between a full-width punctuation mark and anything.\n- MUST use full-width (Chinese) punctuation in Chinese prose: `,。:;?!()「」`. Half-width punctuation stays inside code spans, inside complete English sentences quoted as-is, and in numbers (`3.5`, `1,024`).\n- Chinese prose *SHOULD* prefer colons, periods, commas, or parentheses over em dashes. Keep an em dash only when no other punctuation preserves the sentence naturally.\n- Enumeration commas: a Chinese list of parallel items uses 顿号(、), not commas.\n- MUST NOT use full-width digits or full-width Latin letters — `123` never, `123` always.\n- Proper nouns keep their canonical casing: GitHub, TypeScript, DeepSeek — never `github`/`Github` unless quoting code.\n- Second person is 你, not 您 (matches the Vue and Kubernetes Chinese conventions and this repo's direct voice).\n- Emphasis markers (`**bold**`, `*italic*`) stay on the same spans as the source; Chinese has no italics, so the rendered emphasis may look identical — do not substitute quotation marks or other decoration.\n\n## Quality bar\n\n- A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra.\n- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Human review owns list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone.\n\n## References\n\nAuthorities cited by these rules, for humans and agents who want the underlying reasoning:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) — the de-facto community standard for mixed CJK/Latin spacing and punctuation.\n- [MDN zh-CN translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) — an in-repo translation-rules file of the same shape as this one; spacing, punctuation, and glossary practice.\n- [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) — terminology-first-occurrence and punctuation practice from the largest zh localization team.\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) — per-term translate/keep decisions and tone.\n- [zh-style-guide](https://zh-style-guide.readthedocs.io) — a community Chinese technical-writing style guide whose rule-level taxonomy (and RFC 2119 keyword levels) this file borrows; aggregates GB/T 15834/15835, clreq, and vendor guides.\n- [W3C clreq](https://www.w3.org/TR/clreq/) and the [Microsoft Simplified Chinese style guide](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) — the formal typographic and vendor-localization baselines.\n- GB/T 19682-2005《翻译服务译文质量要求》 — the national standard whose three base requirements (忠实原文、术语统一、行文通顺) this file's Faithfulness and Terminology sections operationalize.\n" + "content": "# Translation rules\n\nEnglish | [中文](translation-rules.zh.md)\n\nHow to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally. Routine agent work translates the changed content directly in one terminology-guided pass; the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow runs only when the user explicitly invokes it. Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary.\n\n## Faithfulness\n\n- The counterpart *MUST* say what the authored side says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the pair disagrees on substance, neither language wins by default: fix the side that is wrong, then bring the other along in the same change.\n- The counterpart *SHOULD* read as natural technical writing in its own language, not word-by-word gloss. Translate meaning, restructure sentences where the target grammar wants it, and keep the author's register — terse stays terse.\n- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an idiom of the source language, translate the idea, not the idiom.\n\n## Voice\n\n- The register is calibrated by [style-samples.md](style-samples.md) — human-approved gold pairs, one per document genre. The counterpart MUST match the target-language side of the nearest sample; where its voice and a prose voice rule disagree, the sample wins. Chinese targets use institutional technical Chinese; English targets use concise professional developer prose.\n- Write as a native technical author restating the content, not as a translator transposing sentences, while preserving every source clause: nothing added, nothing dropped — fluency never justifies losing a clause.\n- Give sentences an explicit actor when the target language would otherwise obscure it; for Chinese, replace vague passives or abstract subjects with the actual actor (系统、门禁、评审人).\n- Prefer established target-language engineering idiom over calques (误报/漏检 for false positive/negative, 执行红线 for enforcement frontier); localize metaphors instead of transplanting them, and unpack noun chains where the target language requires it.\n- Split long paragraphs by semantic unit — one idea per paragraph. Paragraph boundaries MAY differ from the source; the structural signature does not count paragraphs.\n- When translating into Chinese, category nouns use Chinese with a first-mention English annotation (实操手册(cookbook)); when translating into English, use the conventional English category name. Literal directory or file references stay code-formatted English.\n\n## Structure preservation\n\nThe pairing gate checks heading depths, fenced code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, and link targets. Preserve the rest of the frame manually; the paired files MUST match one to one in:\n\n- heading hierarchy (same levels, same order — heading TEXT is translated),\n- list shape and numbering,\n- tables (same columns, same row order; header cells translated per terminology),\n- fenced code blocks — **byte-identical, including comments**; the pairing signature compares their info strings and contents, and ` ```ts ` blocks compile under `doc-typecheck`,\n- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted,\n- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. A README rendered outside GitHub MAY use the canonical public repository URL to its exact counterpart as documented in [README.md](README.md). Link TEXT is translated; the target is not.\n\nThe repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline.\n\n## Terminology\n\n- [terminology.md](terminology.md) is the source of truth in both directions. Before translating, load it; every listed term MUST follow its row and its \"不要译作\" prohibitions. A Chinese target uses the \"中文\" column and its \"首次出现\" annotation; an English target uses the \"English\" column without adding a Chinese gloss.\n- For a Chinese target, an unlisted technical term MAY use an established rendering from a major Chinese-language OSS or vendor source (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs), cited in the PR. Without such precedent it MUST stay in English and be listed under 「待定术语」(pending terms) with a suggested rendering.\n- For an English target, use the established English technical term. If the source term has no unambiguous established equivalent, preserve it with a short explanatory gloss and list it under pending terms. Neither direction may invent a rendering inline; a decided term enters [terminology.md](terminology.md) in the same PR or a follow-up.\n\n## Typography\n\nThese rules govern the Chinese side; the English side follows the repo's normal Markdown conventions (root `AGENTS.md`). The mixed-script rules below follow the cross-project consensus of the [MDN Simplified Chinese translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md), the [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/), the [Vue.js Chinese translation conventions](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5), and [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines), which in turn ground in [W3C clreq](https://www.w3.org/TR/clreq/) and GB/T 15834—2011:\n\n- MUST put one half-width space between Chinese text and Latin words, and between Chinese text and numerals: `每个 plugin 注册 3 个 tool`。No space between a full-width punctuation mark and anything.\n- MUST use full-width (Chinese) punctuation in Chinese prose: `,。:;?!()「」`. Half-width punctuation stays inside code spans, inside complete English sentences quoted as-is, and in numbers (`3.5`, `1,024`).\n- Chinese prose *SHOULD* prefer colons, periods, commas, or parentheses over em dashes. Keep an em dash only when no other punctuation preserves the sentence naturally.\n- Enumeration commas: a Chinese list of parallel items uses 顿号(、), not commas.\n- MUST NOT use full-width digits or full-width Latin letters — `123` never, `123` always.\n- Proper nouns keep their canonical casing: GitHub, TypeScript, DeepSeek — never `github`/`Github` unless quoting code.\n- Second person is 你, not 您 (matches the Vue and Kubernetes Chinese conventions and this repo's direct voice).\n- Emphasis markers (`**bold**`, `*italic*`) stay on the same spans as the source; Chinese has no italics, so the rendered emphasis may look identical — do not substitute quotation marks or other decoration.\n\n## Quality bar\n\n- A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra.\n- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Human review owns list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone.\n\n## References\n\nAuthorities cited by these rules, for humans and agents who want the underlying reasoning:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) — the de-facto community standard for mixed CJK/Latin spacing and punctuation.\n- [MDN zh-CN translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) — an in-repo translation-rules file of the same shape as this one; spacing, punctuation, and glossary practice.\n- [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) — terminology-first-occurrence and punctuation practice from the largest zh localization team.\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) — per-term translate/keep decisions and tone.\n- [zh-style-guide](https://zh-style-guide.readthedocs.io) — a community Chinese technical-writing style guide whose rule-level taxonomy (and RFC 2119 keyword levels) this file borrows; aggregates GB/T 15834/15835, clreq, and vendor guides.\n- [W3C clreq](https://www.w3.org/TR/clreq/) and the [Microsoft Simplified Chinese style guide](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) — the formal typographic and vendor-localization baselines.\n- GB/T 19682-2005《翻译服务译文质量要求》 — the national standard whose three base requirements (忠实原文、术语统一、行文通顺) this file's Faithfulness and Terminology sections operationalize.\n" }, { "role": "assistant", - "content": "# 翻译规则\n\n[English](translation-rules.md) | 中文\n\n本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效。日常工作中,agent 会在术语指导下直接一次完成有改动内容的翻译;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时运行。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。\n\n## 忠实性\n\n- 对侧文件*必须*传达与撰写侧相同的内容:不添加行为、前置条件、警告、版本声明或示例,也不漏掉任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜;请修正错误的一侧,并在同一个变更里同步更新另一侧。\n- 对侧文件读起来*应当*是其语言自然的技术文字,而非逐词对照的译文。请根据语义翻译,在目标语言语法需要时重组句子,并保持原作者的语域(比如:简练的保持简练)。\n- 不要翻译不可译的内容:如果一句话依赖源语言的习语、无法自然转换,请翻译它的意思,而非习语本身。\n\n## 行文\n\n- 语体以 [style-samples.md](style-samples.md) 为校准锚点。人工定稿的金标样例按文体各一组,译文必须参照文体最接近的样例,采用其中目标语言一侧的语体;如果样例与本文的行文规则冲突,以样例为准。译成中文时,采用规范的技术制度文;译成英文时,采用简洁、专业的开发者文档语体。\n- 以母语技术作者的身份重述内容,而不是以译者身份逐句转写,同时保留原文的每个语义成分:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。\n- 如果直译会让执行主体含糊,请明确写出实际执行者;译成中文时,应由「系统、门禁、评审人」等实际执行者作主语,避免含糊的被动句或抽象主语。\n- 优先采用目标语言中通行的工程表达,避免生硬直译(false positive/negative→误报/漏检、enforcement frontier→执行红线);隐喻应自然改写,名词链则按目标语言的习惯拆开。\n- 长段按语义单元拆分,一段一件事。段落边界可以与原文不同;结构签名不比对段落数。\n- 翻译为中文时,类别名词使用中文并在首现括注英文(实操手册(cookbook));翻译为英文时,使用通行的英文类别名。指目录或文件本身时保留代码体英文。\n\n## 结构保持\n\n配对门禁会检查标题深度、围栏代码块、表格行列数、列表类型、有序列表起始编号、列表项数量与链接目标;门禁未覆盖的结构仍需人工核对。两个配对文件必须在以下方面一一对应:\n\n- 标题层级(相同级别、相同顺序;标题的**文字**要翻译);\n- 列表形态与编号;\n- 表格(相同的列、相同的行序;表头单元格按术语表翻译);\n- 围栏代码块:**逐字节一致,包括注释**。配对签名比对信息字符串与内容,` ```ts ` 块还要通过 `doc-typecheck` 编译;\n- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号):原样保留,从不翻译或重排;\n- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标(按约定是 `.md` 路径而非 `.zh.md` 兄弟文件),这样即使某对文档先于相邻文件落地,链接也不会悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。\n\n本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。\n\n## 术语\n\n- [terminology.md](terminology.md) 是双向的术语真源。翻译前请先加载它;表内术语必须遵守对应行与「不要译作」禁项。译成中文时,采用「中文」列,并按「首次出现」列括注;译成英文时,采用「English」列,不加中文括注。\n- 译成中文时,术语表未收录的技术术语只有在主流中文 OSS 文档或厂商资料中已有通行译法时才可以翻译(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档),并须在 PR 中注明出处;否则必须保留英文,并在 PR 描述的「待定术语」中给出建议译法。\n- 译成英文时,采用通行的英文技术术语。如果源术语没有明确的通行对应词,则保留原词、附上简短说明,并列入「待定术语」。两个方向都不得自行创造译法;确定后的术语须在同一个 PR 或后续 PR 中加入 [terminology.md](terminology.md)。\n\n## 排版\n\n本节规则约束中文一侧;英文一侧遵循仓库常规的 Markdown 约定(根 `AGENTS.md`)。以下中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) 与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011:\n\n- 必须在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。\n- 中文行文必须使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。\n- 中文行文*应当*优先使用冒号、句号、逗号或括号,尽量不用破折号;只有其他标点都无法自然表达时才保留破折号。\n- 顿号:中文的并列项之间使用顿号(、),而非逗号。\n- 禁止使用全角数字或全角拉丁字母:永远不写 `123`,永远写 `123`。\n- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek。除非引用代码,否则绝不写 `github`/`Github`。\n- 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。\n- 强调标记(`**加粗**`、`*斜体*`)落在与对侧相同的文字段上。中文没有斜体,渲染效果可能看不出差别,不要用引号或其他装饰替代。\n\n## 质量标准\n\n- 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。\n- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体则由人工评审负责。\n\n## 参考资料\n\n本文各规则引用的权威出处,供想了解底层依据的人和 agent 查阅:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines):中西文混排空格与标点的社区事实标准。\n- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md):与本文同形态的仓库内置翻译规则文件;空格、标点与术语表实践。\n- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/):最大的中文本地化团队的术语首现与标点实践。\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5):逐术语的译/留决策与语气。\n- [zh-style-guide](https://zh-style-guide.readthedocs.io):社区中文技术文档写作规范,本文借用了它的规则级别分类体系(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。\n- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides):排版学与厂商本地化的正式基线。\n- GB/T 19682-2005《翻译服务译文质量要求》:国家标准;本文「忠实性」与「术语」两节将其三项基本要求(忠实原文、术语统一、行文通顺)落实为可操作的规则。\n" + "content": "# 翻译规则\n\n[English](translation-rules.md) | 中文\n\n本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效。日常工作中,agent 会在术语指导下直接一次完成有改动内容的翻译;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时运行。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。\n\n## 忠实性\n\n- 对侧文件*必须*传达与撰写侧相同的内容:不添加行为、前置条件、警告、版本声明或示例,也不漏掉任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜;请修正错误的一侧,并在同一个变更里同步更新另一侧。\n- 对侧文件读起来*应当*是其语言自然的技术文字,而非逐词对照的译文。请根据语义翻译,在目标语言语法需要时重组句子,并保持原作者的语域(比如:简练的保持简练)。\n- 不要翻译不可译的内容:如果一句话依赖源语言的习语、无法自然转换,请翻译它的意思,而非习语本身。\n\n## 行文\n\n- 语体以 [style-samples.md](style-samples.md) 为校准锚点。人工定稿的金标样例按文体各一组,译文必须参照文体最接近的样例,采用其中目标语言一侧的语体;如果样例与本文的行文规则冲突,以样例为准。译成中文时,采用规范的技术制度文;译成英文时,采用简洁、专业的开发者文档语体。\n- 以母语技术作者的身份重述内容,而不是以译者身份逐句转写,同时保留原文的每个语义成分:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。\n- 如果直译会让执行主体含糊,请明确写出实际执行者;译成中文时,应由「系统、门禁、评审人」等实际执行者作主语,避免含糊的被动句或抽象主语。\n- 优先采用目标语言中通行的工程表达,避免生硬直译(false positive/negative→误报/漏检、enforcement frontier→执行红线);隐喻应自然改写,名词链则按目标语言的习惯拆开。\n- 长段按语义单元拆分,一段一件事。段落边界可以与原文不同;结构签名不比对段落数。\n- 翻译为中文时,类别名词使用中文并在首现括注英文(实操手册(cookbook));翻译为英文时,使用通行的英文类别名。指目录或文件本身时保留代码体英文。\n\n## 结构保持\n\n配对门禁会检查标题深度、围栏代码块、表格行列数、列表类型、有序列表起始编号、列表项数量与链接目标;门禁未覆盖的结构仍需人工核对。两个配对文件必须在以下方面一一对应:\n\n- 标题层级(相同级别、相同顺序;标题的**文字**要翻译);\n- 列表形态与编号;\n- 表格(相同的列、相同的行序;表头单元格按术语表翻译);\n- 围栏代码块:**逐字节一致,包括注释**。配对签名比对信息字符串与内容,` ```ts ` 块还要通过 `doc-typecheck` 编译;\n- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号):原样保留,从不翻译或重排;\n- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标(按约定是 `.md` 路径而非 `.zh.md` 兄弟文件),这样即使某对文档先于相邻文件落地,链接也不会悬空。唯一的 zh 特有链接是语言切换行。在 GitHub 以外位置渲染的 README 可以按 [README.md](README.md) 的规定,使用指向确切对侧文件的规范公开仓库 URL。链接**文字**翻译;链接目标不翻。\n\n本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。\n\n## 术语\n\n- [terminology.md](terminology.md) 是双向的术语真源。翻译前请先加载它;表内术语必须遵守对应行与「不要译作」禁项。译成中文时,采用「中文」列,并按「首次出现」列括注;译成英文时,采用「English」列,不加中文括注。\n- 译成中文时,术语表未收录的技术术语只有在主流中文 OSS 文档或厂商资料中已有通行译法时才可以翻译(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档),并须在 PR 中注明出处;否则必须保留英文,并在 PR 描述的「待定术语」中给出建议译法。\n- 译成英文时,采用通行的英文技术术语。如果源术语没有明确的通行对应词,则保留原词、附上简短说明,并列入「待定术语」。两个方向都不得自行创造译法;确定后的术语须在同一个 PR 或后续 PR 中加入 [terminology.md](terminology.md)。\n\n## 排版\n\n本节规则约束中文一侧;英文一侧遵循仓库常规的 Markdown 约定(根 `AGENTS.md`)。以下中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) 与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011:\n\n- 必须在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。\n- 中文行文必须使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。\n- 中文行文*应当*优先使用冒号、句号、逗号或括号,尽量不用破折号;只有其他标点都无法自然表达时才保留破折号。\n- 顿号:中文的并列项之间使用顿号(、),而非逗号。\n- 禁止使用全角数字或全角拉丁字母:永远不写 `123`,永远写 `123`。\n- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek。除非引用代码,否则绝不写 `github`/`Github`。\n- 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。\n- 强调标记(`**加粗**`、`*斜体*`)落在与对侧相同的文字段上。中文没有斜体,渲染效果可能看不出差别,不要用引号或其他装饰替代。\n\n## 质量标准\n\n- 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。\n- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体则由人工评审负责。\n\n## 参考资料\n\n本文各规则引用的权威出处,供想了解底层依据的人和 agent 查阅:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines):中西文混排空格与标点的社区事实标准。\n- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md):与本文同形态的仓库内置翻译规则文件;空格、标点与术语表实践。\n- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/):最大的中文本地化团队的术语首现与标点实践。\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5):逐术语的译/留决策与语气。\n- [zh-style-guide](https://zh-style-guide.readthedocs.io):社区中文技术文档写作规范,本文借用了它的规则级别分类体系(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。\n- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides):排版学与厂商本地化的正式基线。\n- GB/T 19682-2005《翻译服务译文质量要求》:国家标准;本文「忠实性」与「术语」两节将其三项基本要求(忠实原文、术语统一、行文通顺)落实为可操作的规则。\n" }, { "role": "user", diff --git a/scripts/translation-pairing-merge.ts b/scripts/translation-pairing-merge.ts index 5ba9ceb0cb..856c909091 100644 --- a/scripts/translation-pairing-merge.ts +++ b/scripts/translation-pairing-merge.ts @@ -12,8 +12,9 @@ import { storeGitBlob, } from './translation-pairing-git.ts' import { - linksTo, isTranslationScopeFile, + languageSwitcherTargets, + linksTo, parseTranslationMarkdown, requiresSourceLanguageSwitcher, translationStructureDiff, @@ -164,15 +165,17 @@ function loadRecordOwners( function assertMergedPairStructure(paths: TranslationPairPaths, source: Buffer, zh: Buffer): void { const sourceTree = parseTranslationMarkdown(source.toString('utf8')) const zhTree = parseTranslationMarkdown(zh.toString('utf8')) - if (requiresSourceLanguageSwitcher(paths.source) && !linksTo(sourceTree, basename(paths.zh))) { + const sourceSwitcherTargets = languageSwitcherTargets(paths.source) + const zhSwitcherTargets = languageSwitcherTargets(paths.zh) + if (requiresSourceLanguageSwitcher(paths.source) && !linksTo(sourceTree, zhSwitcherTargets)) { throw new Error(`${paths.source} clean merge lost its language-switcher link to ${basename(paths.zh)}`) } - if (!linksTo(zhTree, basename(paths.source))) { + if (!linksTo(zhTree, sourceSwitcherTargets)) { throw new Error(`${paths.zh} clean merge lost its language-switcher link to ${basename(paths.source)}`) } const divergences = translationStructureDiff( - translationStructureSignature(sourceTree, basename(paths.zh)), - translationStructureSignature(zhTree, basename(paths.source)), + translationStructureSignature(sourceTree, zhSwitcherTargets), + translationStructureSignature(zhTree, sourceSwitcherTargets), ) if (divergences.length > 0) { throw new Error(`${paths.source} and ${paths.zh} clean merges diverge structurally: ${divergences.join('; ')}`) diff --git a/scripts/translation-pairing.spec.ts b/scripts/translation-pairing.spec.ts index a77dd98248..efddff2bbe 100644 --- a/scripts/translation-pairing.spec.ts +++ b/scripts/translation-pairing.spec.ts @@ -14,6 +14,8 @@ import { import { blobHash, isTranslationScopeFile, + languageSwitcherTargets, + linksTo, pairAnchorOfArgument, parseTranslationMarkdown, parseTranslationPairingCliArgs, @@ -151,6 +153,20 @@ describe('translation pairing switchers', () => { expect(requiresSourceLanguageSwitcher('docs/architecture.md')).toBe(true) expect(requiresSourceLanguageSwitcher('packages/core/session/README.md')).toBe(true) }) + + it('accepts only the canonical public URL for an absolute switcher', () => { + const targets = languageSwitcherTargets('python/sdk/README.zh.md') + const canonical = parseTranslationMarkdown( + '[中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.zh.md)', + ) + const wrongPath = parseTranslationMarkdown( + '[中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/other/README.zh.md)', + ) + + expect(linksTo(canonical, targets)).toBe(true) + expect(translationStructureSignature(canonical, targets).links).toEqual([]) + expect(linksTo(wrongPath, targets)).toBe(false) + }) }) describe('translation pairing records', () => { diff --git a/scripts/translation-pairing.ts b/scripts/translation-pairing.ts index ef94d84e2c..930764034b 100644 --- a/scripts/translation-pairing.ts +++ b/scripts/translation-pairing.ts @@ -302,11 +302,19 @@ export function parseTranslationMarkdown(content: string): Nodes { return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) } -/** Whether the tree contains a link to exactly `target`. */ -export function linksTo(tree: Nodes, target: string): boolean { +const PUBLIC_REPOSITORY_BLOB_ROOT = 'https://github.com/deepseek-ai/deepseek-harness/blob/master/' + +/** Return the accepted relative and public-repository links to one counterpart. */ +export function languageSwitcherTargets(counterpart: string): string[] { + return [basename(counterpart), `${PUBLIC_REPOSITORY_BLOB_ROOT}${counterpart}`] +} + +/** Whether the tree contains a link to any accepted target. */ +export function linksTo(tree: Nodes, targets: string | readonly string[]): boolean { + const accepted = new Set(typeof targets === 'string' ? [targets] : targets) let found = false const visit = (node: Nodes): void => { - if (node.type === 'link' && node.url === target) found = true + if (node.type === 'link' && accepted.has(node.url)) found = true if ('children' in node) for (const child of node.children) visit(child) } visit(tree) @@ -335,8 +343,14 @@ export function requiresSourceLanguageSwitcher(source: string): boolean { ].includes(source) } -/** Collect the ordered structural signature, skipping one switcher target. */ -export function translationStructureSignature(tree: Nodes, switcherTarget: string): TranslationStructureSignature { +/** Collect the ordered structural signature, skipping accepted switcher targets. */ +export function translationStructureSignature( + tree: Nodes, + switcherTargets: string | readonly string[], +): TranslationStructureSignature { + const acceptedSwitchers = new Set( + typeof switcherTargets === 'string' ? [switcherTargets] : switcherTargets, + ) const sig: TranslationStructureSignature = { headings: [], code: [], tables: [], lists: [], links: [] } const visit = (node: Nodes): void => { switch (node.type) { @@ -355,7 +369,7 @@ export function translationStructureSignature(tree: Nodes, switcherTarget: strin : `bullet:items=${node.children.length}`) break case 'link': - if (node.url !== switcherTarget) sig.links.push(node.url) + if (!acceptedSwitchers.has(node.url)) sig.links.push(node.url) break default: // Every other node kind is prose or a container, not part of the signature. diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index be550a580b..35dbb978dd 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -304,7 +304,7 @@ { "doc": "docs/subsystems/commands.md", "symbol": "CommandInputDescriptor", - "source": "packages/interaction/commands/src/index.ts" + "source": "packages/interaction/commands/src/types.ts" }, { "doc": "docs/subsystems/commands.md", @@ -319,12 +319,12 @@ { "doc": "docs/subsystems/commands.md", "symbol": "CommandResult", - "source": "packages/interaction/commands/src/index.ts" + "source": "packages/interaction/commands/src/types.ts" }, { "doc": "docs/subsystems/commands.md", "symbol": "CommandDescriptor", - "source": "packages/interaction/commands/src/index.ts" + "source": "packages/interaction/commands/src/types.ts" }, { "doc": "docs/subsystems/commands.md", diff --git a/scripts/verify-cordis-config.spec.ts b/scripts/verify-cordis-config.spec.ts new file mode 100644 index 0000000000..6c1304e16a --- /dev/null +++ b/scripts/verify-cordis-config.spec.ts @@ -0,0 +1,39 @@ +/** + * The verify-cordis-config metadata contract: `disabled` is the one entry + * metadata field whose `!!js` expression the Loader interpolates; every other + * metadata field must stay static, and a disabled expression must parse. + */ + +import { describe, expect, it } from 'vitest' +import { metadataExpressionErrors } from './verify-cordis-config.ts' + +describe('verify-cordis-config metadata expressions', () => { + it('accepts a disabled !!js expression', () => { + const problems = metadataExpressionErrors( + { id: 'tool-bash', name: '@deepseek-ai/dsh-tool-bash', disabled: { __jsExpr: "process.platform === 'win32'" } }, + '[0]', + ) + expect(problems).toEqual([]) + }) + + it('rejects an expression in a static metadata field', () => { + const problems = metadataExpressionErrors({ id: { __jsExpr: 'process.platform' }, name: 'pkg' }, '[0]') + expect(problems).toContain('[0].id: !!js is not interpolated here') + }) + + it('rejects an expression nested below disabled (only the field itself interpolates)', () => { + const problems = metadataExpressionErrors( + { id: 'tool-bash', name: 'pkg', disabled: { when: { __jsExpr: 'process.platform' } } }, + '[0]', + ) + expect(problems).toContain('[0].disabled.when: !!js is not interpolated here') + }) + + it('rejects a disabled expression that does not parse (the loader would fail the boot)', () => { + const problems = metadataExpressionErrors( + { id: 'tool-bash', name: 'pkg', disabled: { __jsExpr: 'process.platform ===' } }, + '[0]', + ) + expect(problems.some(problem => problem.includes('[0].disabled: disabled expression does not parse'))).toBe(true) + }) +}) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index d834094d78..bb10db0c70 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -1,11 +1,13 @@ /** * Validate Cordis Loader entry metadata and package resolution. * - * The Loader interpolates only a plugin entry's `config`; expression objects in - * fields such as `disabled` remain truthy data and silently change composition. - * Example configs and the dsh Web composition resolve named plugins from their - * owning workspace manifests. Local example packages must also be in the root - * TypeScript project graph. + * The Loader interpolates a plugin entry's `config` (after declared injections + * activate, against that plugin context) and the entry `disabled` field (at + * every mount decision, against the loader context). Every other entry + * metadata field stays static, so an expression there remains truthy data and + * silently changes composition. Example configs and the dsh Web composition + * resolve named plugins from their owning workspace manifests. Local example + * packages must also be in the root TypeScript project graph. */ import { globSync, readFileSync } from 'node:fs' @@ -36,20 +38,23 @@ const appOverlayFiles = new Set([ 'examples/web-schedule/cordis.yml', ...globSync('examples/mcp-memory/*.cordis.yml', { cwd: root }), ]) -const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const +const metadataFields = ['id', 'name', 'group', 'inject', 'intercept', 'isolate'] as const /** The adaptive directory-picker chooser package (mounts a backend row at boot). */ const CHOOSER_PACKAGE = '@deepseek-ai/dsh-host-directory-picker-auto' /** - * The backends the chooser mounts by runtime string (mirror of its exported - * `BACKEND_PACKAGES`), invisible to yml-row scanning: a composition mounting - * the chooser must resolve both, or keyless Linux CI (which only ever - * resolves `browse`) hides a dropped `-native` dependency until a macOS boot. + * The packages the chooser mounts by runtime string (mirror of its exported + * `BACKEND_PACKAGES` and `SURFACE_PACKAGES`), invisible to yml-row scanning: a + * composition mounting the chooser must resolve every one, or keyless Linux CI + * (which only ever resolves `browse`) hides a dropped `-native` dependency + * until a macOS boot. */ const CHOOSER_BACKEND_PACKAGES = [ '@deepseek-ai/dsh-host-directory-picker-native', '@deepseek-ai/dsh-host-directory-picker-browse', + '@deepseek-ai/dsh-client-ui-directory-picker', + '@deepseek-ai/dsh-client-ui-directory-picker-native', ] const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { kind: 'scalar', @@ -61,33 +66,36 @@ const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { }) const schema = yaml.JSON_SCHEMA.extend(jsExprType) -const files = cordisConfigFiles(root) const errors: string[] = [] const pluginReferences: PluginReference[] = [] -for (const file of files) { - const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema }) - if (!isUnknownArray(document)) { - errors.push(`${file}: root must be a Loader entry array`) - continue - } - for (let index = 0; index < document.length; index++) { - validateEntry(document[index], file, `[${index}]`) - } -} +if (import.meta.main) { + const files = cordisConfigFiles(root) -errors.push(...validateExampleResolution()) -errors.push(...validateAppResolution()) -errors.push(...validateSourcePlaneResolution()) -errors.push(...validatePresetPlaneSeparation()) -errors.push(...validateClientHalvesDeclared()) + for (const file of files) { + const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema }) + if (!isUnknownArray(document)) { + errors.push(`${file}: root must be a Loader entry array`) + continue + } + for (let index = 0; index < document.length; index++) { + validateEntry(document[index], file, `[${index}]`) + } + } -if (errors.length > 0) { - console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:') - for (const error of errors) console.error(`- ${error}`) - process.exitCode = 1 -} else { - console.log(`verify-cordis-config: ${files.length} config files passed.`) + errors.push(...validateExampleResolution()) + errors.push(...validateAppResolution()) + errors.push(...validateSourcePlaneResolution()) + errors.push(...validatePresetPlaneSeparation()) + errors.push(...validateClientHalvesDeclared()) + + if (errors.length > 0) { + console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:') + for (const error of errors) console.error(`- ${error}`) + process.exitCode = 1 + } else { + console.log(`verify-cordis-config: ${files.length} config files passed.`) + } } /** @@ -406,11 +414,60 @@ function packageNameFromSpecifier(specifier: string): string | undefined { } function validateMetadata(entry: Record, file: string, path: string): void { + for (const problem of metadataExpressionErrors(entry, path)) { + errors.push(`${file}${problem}`) + } +} + +/** + * Expression-node diagnostics for one entry. `disabled` is the single + * interpolated metadata field: its own `!!js` expression node is allowed and + * must parse, while expressions nested below it stay truthy data; every other + * metadata field must stay fully static. + * @param entry - one loader entry (or patch row). + * @param path - the entry's diagnostic path prefix. + * @returns one diagnostic per offending expression. + */ +export function metadataExpressionErrors(entry: Record, path: string): string[] { + const problems: string[] = [] for (const field of metadataFields) { if (!(field in entry)) continue const expressionPaths: string[] = [] collectExpressionPaths(entry[field], `${path}.${field}`, expressionPaths) - for (const expressionPath of expressionPaths) errors.push(`${file}${expressionPath}: !!js is not interpolated here`) + for (const expressionPath of expressionPaths) problems.push(`${expressionPath}: !!js is not interpolated here`) + } + const disabled = entry.disabled + if (disabled !== undefined) { + if (isJsExpr(disabled)) { + const detail = disabledExpressionProblem(disabled.__jsExpr) + if (detail !== undefined) problems.push(`${path}.disabled${detail}`) + } else { + // A non-expression value gates on Boolean() at mount; an expression + // nested anywhere below it never evaluates, so it must stay literal. + const expressionPaths: string[] = [] + collectExpressionPaths(disabled, `${path}.disabled`, expressionPaths) + for (const expressionPath of expressionPaths) problems.push(`${expressionPath}: !!js is not interpolated here`) + } + } + return problems +} + +/** + * Parse-only validation of a `disabled` expression: the Loader evaluates it + * at every mount decision, and a syntax error would fail the boot — rejecting + * it here moves that failure to the earliest resolvable point. + * @param expression - the `!!js` expression text. + * @returns the diagnostic suffix, or `undefined` when the expression parses. + */ +function disabledExpressionProblem(expression: string): string | undefined { + try { + // Compilation only — the constructor never executes the body. + // oxlint-disable-next-line typescript/no-implied-eval + new Function(`return (${expression})`) + return undefined + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + return `: disabled expression does not parse: ${detail}` } } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 4976b3b1f1..f2270444ac 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -85,10 +85,13 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' }, 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/ui-directory-picker': { kind: 'none', reason: 'Browser-side directory-browsing surface; registers nothing model-facing.' }, + 'packages/client/ui-directory-picker-native': { kind: 'none', reason: 'Browser-side surface driving the host OS chooser; registers nothing model-facing.' }, 'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/ui-plugins': { kind: 'none', reason: 'Browser-side inventory projection; registers nothing model-facing.' }, 'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, @@ -103,6 +106,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/host/directory-picker-native': { kind: 'none', reason: 'The GUI-host picking backend registers nothing model-facing.' }, 'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers nothing model-facing.' }, 'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers nothing model-facing.' }, + 'packages/host/plugin-inventory': { kind: 'none', reason: 'Host-side read-only Loader projection; registers nothing model-facing.' }, 'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model-facing behavior.' }, 'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base and headless bundles.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index e91661e5e4..9bfc1a1d73 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -19,6 +19,7 @@ import { translationPairPaths, } from './translation-pairing-record.ts' import { + languageSwitcherTargets, linksTo, parseTranslationMarkdown, parseTranslationPairingCliArgs, @@ -252,15 +253,17 @@ for (const source of [...pairAnchors].sort()) { const sourceTree = parseTranslationMarkdown(sourceContent.toString('utf8')) const zhTree = parseTranslationMarkdown(zhContent.toString('utf8')) - if (!linksTo(zhTree, basename(source))) { + const sourceSwitcherTargets = languageSwitcherTargets(source) + const zhSwitcherTargets = languageSwitcherTargets(zh) + if (!linksTo(zhTree, sourceSwitcherTargets)) { errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`) } - if (requiresSourceLanguageSwitcher(source) && !linksTo(sourceTree, basename(zh))) { + if (requiresSourceLanguageSwitcher(source) && !linksTo(sourceTree, zhSwitcherTargets)) { errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`) } for (const divergence of translationStructureDiff( - translationStructureSignature(sourceTree, basename(zh)), - translationStructureSignature(zhTree, basename(source)), + translationStructureSignature(sourceTree, zhSwitcherTargets), + translationStructureSignature(zhTree, sourceSwitcherTargets), )) { errors.push(`${source} ↔ ${zh}: ${divergence}`) } diff --git a/tsconfig.base.json b/tsconfig.base.json index ff8e58e361..b373642dab 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -161,6 +161,8 @@ "@deepseek-ai/dsh-host-apiproxy/client": ["./packages/host/apiproxy/src/fetch/client.ts"], "@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"], "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"], + "@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"], + "@deepseek-ai/dsh-host-plugin-inventory/types": ["./packages/host/plugin-inventory/src/types.ts"], "@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"], "@deepseek-ai/dsh-client-ui-attachment": ["./packages/client/ui-attachment/src"], "@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"], @@ -199,6 +201,7 @@ "@deepseek-ai/dsh-client-ui-settings": ["./packages/client/ui-settings/src"], "@deepseek-ai/dsh-client-ui-settings-general": ["./packages/client/ui-settings-general/src"], "@deepseek-ai/dsh-client-ui-models": ["./packages/client/ui-models/src"], + "@deepseek-ai/dsh-client-ui-plugins": ["./packages/client/ui-plugins/src"], "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"], "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"], // sdk/ folders are role-named without their npm-side sdk/jsonrpc prefixes, diff --git a/tsconfig.client.json b/tsconfig.client.json index a054cb8751..3a84926202 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -19,24 +19,25 @@ "packages/client/*/src/css-modules.d.ts", "packages/client/*/tests/**/*.ts", "packages/client/*/tests/**/*.tsx", - "packages/host/directory-picker-browse/tests/**/*.ts", - "packages/host/directory-picker-browse/tests/**/*.tsx", - "packages/host/directory-picker-native/tests/**/*.ts", - "packages/host/directory-picker-native/tests/**/*.tsx", + "packages/*/*/tests/**/*.client.spec.ts", + "packages/*/*/tests/**/*.client.spec.tsx", + "packages/*/*/tests/**/*.client.tsx", + "packages/*/*/tests/**/*.client.ts", "packages/client/tsdown.client.ts", "scripts/client-bundle-css.spec.ts", "scripts/client-bundle-purity.spec.ts" ], + // A `*.host.spec.ts` covers the Host half of a split client package and + // belongs to the host aggregate, which excludes this program's `*.client.*` + // in turn. `exclude` wins over `include`, so the test glob above stays broad. + "exclude": [ + "packages/client/*/tests/**/*.host.spec.ts" + ], "references": [ // Shared leaf: web e2e boots the real host webserver (fixture + real-host // smoke policy). webserver has zero workspace deps and no cordis merge, // so it cannot drag host-side Context augmentation into this program. { "path": "./packages/host/webserver" }, - // Dual-face host leaf: the node half is the native picking backend, the - // browser half registers the picking flow into ui-workspace's slot — - // client-side Context merges keep it out of the host program. - { "path": "./packages/host/directory-picker-native" }, - { "path": "./packages/host/directory-picker-browse" }, // Compaction seam: the client-runtime pin test value-imports the canonical // checkpoint const from the cordis-free dsh-compact/checkpoint leaf and // deliberately never loads the dsh-compact package root or the host-side @@ -51,9 +52,9 @@ { "path": "./packages/client/web-react" }, { "path": "./packages/client/modules" }, { "path": "./packages/client/hmr" }, - { "path": "./packages/client/connection" }, + { "path": "./packages/client/connection/tsconfig.client.json" }, { "path": "./packages/typert/registry" }, - { "path": "./packages/api/gateway" }, + { "path": "./packages/api/gateway/tsconfig.client.json" }, { "path": "./packages/api/remotes/tsconfig.client.json" }, { "path": "./packages/client/runtime" }, { "path": "./packages/client/test-runtime" }, @@ -69,6 +70,8 @@ { "path": "./packages/client/ui-skill" }, { "path": "./packages/client/ui-subagent" }, { "path": "./packages/client/ui-task" }, + { "path": "./packages/client/ui-directory-picker" }, + { "path": "./packages/client/ui-directory-picker-native" }, { "path": "./packages/client/ui-goal" }, { "path": "./packages/client/ui-model" }, { "path": "./packages/client/ui-agent-preset" }, @@ -81,6 +84,7 @@ { "path": "./packages/client/ui-settings" }, { "path": "./packages/client/ui-settings-general" }, { "path": "./packages/client/ui-models" }, + { "path": "./packages/client/ui-plugins" }, { "path": "./packages/client/locale" }, { "path": "./packages/client/web" }, { "path": "./apps/web" } diff --git a/tsconfig.host.json b/tsconfig.host.json index 90a254f72a..bf82f0e965 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -29,6 +29,7 @@ "apps/web/tests/settings-chrome.e2e.ts", "apps/web/tests/models-settings.e2e.ts", "apps/web/tests/onboarding-deepseek-config.e2e.ts", + "apps/web/tests/onboarding-usable-provider.e2e.ts", "apps/web/tests/remote-welcome.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", @@ -87,10 +88,17 @@ "website/**/*.ts", "website/.vitepress/**/*.ts" ], + // Under packages/client a test file names the face it covers: `*.client.*` + // belongs to the Client aggregate, `*.host.spec.ts` to this one. The two + // suffixes are mutually exclusive, so each aggregate excludes the other's + // and the package test glob above needs no per-file entry. "exclude": [ - "packages/client/**", - "packages/host/directory-picker-browse/**", - "packages/host/directory-picker-native/**", + "packages/client/*/src/**", + "packages/*/*/tests/**/*.client.ts", + "packages/*/*/tests/**/*.client.tsx", + "packages/*/*/tests/**/*.client.spec.ts", + "packages/*/*/tests/**/*.client.spec.tsx", + "packages/client/tsdown.client.ts", "scripts/client-bundle-css.spec.ts", "packages/typert/generator/tests/fixtures/**", "scripts/client-bundle-purity.spec.ts" @@ -120,7 +128,7 @@ { "path": "./packages/core/scope" }, { "path": "./packages/typert/type-meta" }, { "path": "./packages/typert/registry" }, - { "path": "./packages/api/gateway" }, + { "path": "./packages/api/gateway/tsconfig.host.json" }, { "path": "./packages/api/remotes/tsconfig.host.json" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session/session-persistence" }, @@ -270,14 +278,10 @@ { "path": "./packages/host/apiproxy" }, { "path": "./packages/host/directory-picker" }, { "path": "./packages/host/directory-picker-auto" }, - // Dual-face backend leaves stay client-registered (their tests and client - // halves are excluded above); these references only let the adaptive - // chooser's composition test import each backend's NODE entry, whose - // declarations carry no client-side Context merge — the mirror of the - // client aggregate's webserver reference. { "path": "./packages/host/directory-picker-browse" }, { "path": "./packages/host/directory-picker-native" }, { "path": "./packages/host/frontend-static" }, + { "path": "./packages/host/plugin-inventory" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/client" }, { "path": "./packages/sdk/protocol" }, diff --git a/vendor/README.md b/vendor/README.md index b132356bf6..d587daf486 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -47,6 +47,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include declares the `EntryGroup.key` tree-carrier marker (as Group does): its config is entry and patch lists, so interpolation keeps it literal and a `!!js` expression inside a nested row's config resolves lazily in that row's own fiber (Include's own `path` therefore stays literal too). Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. 16. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match. 17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). +18. **Entry `disabled` interpolation in `loader/src/config/entry.ts`**: a `disabled: !!js` expression evaluates against the loader context at every mount decision; the raw node stays in the options, so write-back keeps the `!!js` form. `disabled` is the only interpolated metadata field. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts` and `apps/cli/tests/windows-shell.spec.ts`. ## Sync procedure diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 573faad38c..19dc11a40a 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -3,7 +3,7 @@ import { deepEqual, isNullable } from '@deepseek-ai/cosmokit' import { Loader } from '../index.ts' import { EntryGroup } from './group.ts' import { EntryTree } from './tree.ts' -import { evaluate } from './utils.ts' +import { evaluate, isJsExpr } from './utils.ts' /** Serialized plugin entry options stored in loader config files. */ export interface EntryOptions { @@ -88,15 +88,25 @@ export class Entry { private _disabled(options: EntryOptions) { // group is always enabled if (options.group) return false - if (options.disabled) return true + if (this.disabledOf(options)) return true let entry = this.parent.ctx.fiber.entry while (entry) { - if (entry.options.disabled) return true + if (this.disabledOf(entry.options)) return true entry = entry.parent.ctx.fiber.entry } return false } + /** + * Effective disabled state: a `!!js` expression evaluates against the loader + * context. The raw node stays in the options, so write-back keeps the form. + */ + private disabledOf(options: EntryOptions): boolean { + return isJsExpr(options.disabled) + ? Boolean(this.evaluate(options.disabled.__jsExpr)) + : Boolean(options.disabled) + } + evaluate(expr: string) { return evaluate(this.ctx, expr) } diff --git a/vitest.config.ts b/vitest.config.ts index c698057915..9a317029f5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -29,12 +29,26 @@ const windowsUnsupportedPackages = process.platform === 'win32' 'packages/bash/bash-sandbox', 'packages/bash/tool-bash', 'packages/hooks/*', - 'packages/subprocess/*', 'packages/pty/pty-local', 'packages/sandbox/sandbox-local', ] : [] +const windowsUnsupportedTests = process.platform === 'win32' + ? [ + ...windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + 'packages/subprocess/subprocess/tests/**/*.spec.ts', + 'packages/subprocess/subprocess-local/tests/local.spec.ts', + 'packages/subprocess/subprocess-local/tests/process-inspector.spec.ts', + 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', + 'packages/subprocess/subprocess-local/tests/terminal.spec.ts', + ] + : [] + +const windowsUnsupportedCoveragePackages = process.platform === 'win32' + ? [...windowsUnsupportedPackages, 'packages/subprocess/*'] + : [] + // Windows-only packages: their sources execute exclusively on win32 (koffi // loads Win32 libraries), so the Linux coverage lane can never cover them. // The Windows dev/CI lane exercises them through the probe/runner suites; the @@ -92,6 +106,7 @@ const coverageExemptExcludes = coverageExemptRaw === '1' const processBoundTests = [ 'packages/session/session-persistence-jsonl/tests/jsonl.spec.ts', 'packages/subagent/subagent-acp/tests/subagent-acp.spec.ts', + 'packages/subprocess/subprocess-local/tests/process-exit.spec.ts', 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', 'packages/llm/llm-pi-ai/tests/adapter.spec.ts', @@ -105,7 +120,7 @@ export default defineConfig({ setupFiles: ['./scripts/test-invariants.ts'], // .tsx: client component specs (jsdom via per-file @vitest-environment pragma). include: testIncludes, - exclude: windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + exclude: windowsUnsupportedTests, // One coverage invocation aggregates both projects. Every suite forks for // Node stability; process-bound suites stay separate for inventory control. projects: [ @@ -121,7 +136,7 @@ export default defineConfig({ setupFiles: ['./scripts/test-invariants.ts'], include: testIncludes, exclude: [ - ...windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + ...windowsUnsupportedTests, ...processBoundTests, ...coverageExemptExcludes, ], @@ -136,7 +151,7 @@ export default defineConfig({ setupFiles: ['./scripts/test-invariants.ts'], include: processBoundTests, exclude: [ - ...windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + ...windowsUnsupportedTests, ...coverageExemptExcludes, ], }, @@ -239,7 +254,7 @@ export default defineConfig({ 'packages/interaction/commands/src/index.ts', 'packages/interaction/commands/src/invariant.ts', 'packages/session/session-projection/src/index.ts', - ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), + ...windowsUnsupportedCoveragePackages.map(path => `${path}/src/**/*.ts`), ...windowsOnlyCoverageExclusions, ...windowsRunnerCoverageExclusions, ...pwshCoverageExclusions, diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 4cef3f0a67..e451ecb68c 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -1,52 +1,34 @@ /** VitePress configuration for the locally projected documentation site. */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import type { DefaultTheme, PageData } from 'vitepress' import type { ViteDevServer } from 'vite' import { withMermaid } from 'vitepress-plugin-mermaid' -import { docsPages, type DocsPage } from '../docs.ts' +import { landingLink, orderedPages, routeLink, sectionSpec, type DocsLocale, type DocsPage } from '../docs.ts' import { docsSourceFiles, projectDocs } from '../../scripts/project-doc-site.ts' projectDocs() -const sectionOrder = [ - '入门', - '基础', - '框架能力', - '实战', - 'Cordis 教程', - '概念', - '生成参考', - 'Cordis API', - '数据结构', - '开发手册', - 'Guide', - 'Basics', - 'Framework', - 'Practice', - 'Cordis tutorial', - 'Concepts', - 'Generated reference', - 'Cordis Core API', - 'Data structures', - 'Cookbook', -] - -function sidebar(collection: DocsPage['sidebar']): DefaultTheme.SidebarItem[] { - const pages = docsPages.filter(page => page.sidebar === collection) - const sections = new Map() - for (const page of pages) { - const entries = sections.get(page.section) ?? [] +function sidebar(locale: DocsLocale, collection: NonNullable): DefaultTheme.SidebarItem[] { + // `orderedPages` already sorts by section placement, so insertion order + // carries the group order and each group keeps its pages in sequence. + const groups = new Map() + for (const page of orderedPages(locale, collection)) { + const entries = groups.get(page.section) ?? [] entries.push(page) - sections.set(page.section, entries) + groups.set(page.section, entries) } - return [...sections.entries()] - .sort(([left], [right]) => sectionOrder.indexOf(left) - sectionOrder.indexOf(right)) - .map(([text, entries]) => ({ + return [...groups.entries()].map(([text, entries]) => { + const { collapsed } = sectionSpec(locale, text) + return { text, - items: entries - .sort((left, right) => left.order - right.order) - .map(page => ({ text: page.label, link: `/${page.route.replace(/(?:index)?\.md$/, '')}` })), - })) + // A present `collapsed` is what makes the default theme render the + // group as collapsible at all, so an open group must omit the key. + ...(collapsed === undefined ? {} : { collapsed }), + items: entries.map(page => ({ text: page.label, link: routeLink(page.route) })), + } + }) } function watchCanonicalDocs(server: ViteDevServer): void { @@ -107,10 +89,102 @@ const sharedTheme: Pick` would freeze the mark at the colors the file declares. + */ +const wordmark = readFileSync(resolve(import.meta.dirname, '../public/wordmark.svg'), 'utf8') + .trim() + .replace(' { + let idle + addEventListener('scroll', (event) => { + const target = event.target + if (!(target instanceof Element) || !target.classList.contains('VPSidebar')) return + target.dataset.scrolling = '' + clearTimeout(idle) + idle = setTimeout(() => delete target.dataset.scrolling, 800) + }, true) +})() +` + +/** + * Navigation-bar title: the DeepSeek wordmark and the release-stage tag. + * VitePress renders `siteTitle` as HTML. + * + * @param previewTag - Localized release-stage label. + * @returns Markup placed beside the navigation-bar home link. + */ +function siteTitle(previewTag: string): string { + return `${wordmark}${previewTag}` +} + export default withMermaid({ title: 'DeepSeek Harness', description: '用于构建 Agent Harness 的插件化 SDK', - base: process.env.DOCS_BASE ?? '/', + base, + head: [ + // VitePress leaves head hrefs untouched, so the base belongs here explicitly. + ['link', { rel: 'icon', type: 'image/svg+xml', href: `${base}favicon.svg` }], + ['style', {}, siteStyle], + ['script', {}, scrollbarScript], + ], cleanUrls: true, srcDir: '.generated', cacheDir: '.cache', @@ -120,15 +194,16 @@ export default withMermaid({ label: '简体中文', lang: 'zh-CN', themeConfig: { + siteTitle: siteTitle('技术预览'), nav: [ - { text: '入门', link: '/guide/', activeMatch: '^/guide/' }, - { text: '开发', link: '/develop/basic/', activeMatch: '^/develop/' }, - { text: '参考', link: '/reference/', activeMatch: '^/reference/' }, + { text: '入门', link: landingLink('root', 'zh-guide'), activeMatch: '^/guide/' }, + { text: '开发', link: landingLink('root', 'zh-develop'), activeMatch: '^/develop/' }, + { text: '参考', link: landingLink('root', 'zh-reference'), activeMatch: '^/reference/' }, ], sidebar: { - '/guide/': sidebar('zh-guide'), - '/develop/': sidebar('zh-develop'), - '/reference/': sidebar('zh-reference'), + '/guide/': sidebar('root', 'zh-guide'), + '/develop/': sidebar('root', 'zh-develop'), + '/reference/': sidebar('root', 'zh-reference'), }, outline: { label: '本页目录' }, docFooter: { prev: '上一篇', next: '下一篇' }, @@ -146,15 +221,16 @@ export default withMermaid({ lang: 'en-US', link: '/en/', themeConfig: { + siteTitle: siteTitle('Preview'), nav: [ - { text: 'Guide', link: '/en/guide/', activeMatch: '^/en/guide/' }, - { text: 'Develop', link: '/en/develop/basic/', activeMatch: '^/en/develop/' }, - { text: 'Reference', link: '/en/reference/', activeMatch: '^/en/reference/' }, + { text: 'Guide', link: landingLink('en', 'en-guide'), activeMatch: '^/en/guide/' }, + { text: 'Develop', link: landingLink('en', 'en-develop'), activeMatch: '^/en/develop/' }, + { text: 'Reference', link: landingLink('en', 'en-reference'), activeMatch: '^/en/reference/' }, ], sidebar: { - '/en/guide/': sidebar('en-guide'), - '/en/develop/': sidebar('en-develop'), - '/en/reference/': sidebar('en-reference'), + '/en/guide/': sidebar('en', 'en-guide'), + '/en/develop/': sidebar('en', 'en-develop'), + '/en/reference/': sidebar('en', 'en-reference'), }, editLink: { pattern: ({ frontmatter }: PageData) => { @@ -171,6 +247,9 @@ export default withMermaid({ }, }, vite: { + // `srcDir` puts the Vite root inside the disposable generated tree, whose + // own `public/` no tracked asset can live in. + publicDir: resolve(import.meta.dirname, '../public'), plugins: [ { name: 'deepseek-harness-doc-projector', diff --git a/website/docs.ts b/website/docs.ts index 2f25ae9d88..6c75635a80 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -11,7 +11,7 @@ export type DocsLocale = 'root' | 'en' /** Sidebar collection rendered for one locale and top-level module. */ -type DocsSidebar = +export type DocsSidebar = | 'zh-guide' | 'zh-develop' | 'zh-reference' @@ -115,44 +115,28 @@ const homeAndGuide = pairedPages([ }, { source: 'docs/user/guide/index.md', - route: 'guide/index.md', - label: { root: '介绍', en: 'Introduction' }, + route: 'guide/quickstart.md', + label: { root: '使用 Web UI', en: 'Use the Web UI' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, order: 1, sourceAliases: ['docs/user/guide'], }, - { - source: 'docs/user/guide/quickstart.md', - route: 'guide/quickstart.md', - label: { root: '快速开始', en: 'Quick start' }, - sidebar: { root: 'zh-guide', en: 'en-guide' }, - section: { root: '入门', en: 'Guide' }, - order: 2, - }, { source: 'docs/user/guide/providers.md', route: 'guide/providers.md', label: { root: '配置模型', en: 'Configure models' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, - order: 3, + order: 2, }, { source: 'docs/user/guide/python-sdk.md', route: 'guide/python-sdk.md', - label: { root: 'Python SDK', en: 'Python SDK' }, + label: { root: 'Python', en: 'Python' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, - section: { root: '入门', en: 'Guide' }, - order: 4, - }, - { - source: 'docs/user/guide/config.md', - route: 'guide/config.md', - label: { root: '配置文件', en: 'Configuration' }, - sidebar: { root: 'zh-guide', en: 'en-guide' }, - section: { root: '入门', en: 'Guide' }, - order: 5, + section: { root: 'SDK', en: 'SDK' }, + order: 1, }, ]) @@ -160,7 +144,7 @@ const develop = pairedPages([ { source: 'docs/user/develop/basic/index.md', route: 'develop/basic/index.md', - label: { root: '第一个插件', en: 'First plugin' }, + label: { root: '第一个 Harness 插件', en: 'Your first Harness plugin' }, sidebar: { root: 'zh-develop', en: 'en-develop' }, section: { root: '基础', en: 'Basics' }, order: 1, @@ -235,7 +219,7 @@ const develop = pairedPages([ ]) const cordisTutorial = pairedPages(([ - ['index.md', 'Cordis 教程', 'Cordis tutorial'], + ['index.md', '总览', 'Overview'], ['01-first-plugin.md', '1. 第一个插件', '1. Your first plugin'], ['02-lifecycle-and-effects.md', '2. 生命周期与副作用', '2. Lifecycle and effects'], ['03-services.md', '3. 服务', '3. Services'], @@ -248,7 +232,7 @@ const cordisTutorial = pairedPages(([ route: `develop/cordis-tutorial/${file}`, label: { root: rootLabel, en: enLabel }, sidebar: { root: 'zh-develop', en: 'en-develop' }, - section: { root: 'Cordis 教程', en: 'Cordis tutorial' }, + section: { root: 'Cordis 框架教程', en: 'Cordis framework tutorial' }, order, ...(file === 'index.md' ? { sourceAliases: ['docs/cordis-tutorial'] } : {}), }))) @@ -264,55 +248,84 @@ const cordisPrimerReference = pairedPages([ }, ]) -const subsystemsReference = pairedPages(([ - ['README.md', '子系统', 'Subsystems', 0], - ['core.md', '核心', 'Core', 1], - ['scope.md', '作用域', 'Scopes', 2], - ['typert.md', 'TypeRT', 'TypeRT', 39], - ['session.md', '会话', 'Sessions', 3], - ['session-query.md', '会话查询', 'Session query', 4], - ['session-reference.md', '会话引用', 'Session references', 5], - ['session-title.md', '会话标题', 'Session titles', 6], - ['settings.md', '用户设置', 'User settings', 7], - ['credentials.md', '用户凭据', 'User credentials', 8], - ['system-prompt.md', '系统提示词', 'System prompts', 9], - ['tools.md', '工具', 'Tools', 10], - ['llm-streaming.md', 'LLM 流式响应', 'LLM streaming', 11], - ['token-meter.md', 'Token 计量', 'Token metering', 12], - ['bash.md', 'Bash 执行', 'Bash execution', 13], - ['subprocess.md', '子进程', 'Subprocesses', 14], - ['tasks.md', '后台任务', 'Background tasks', 15], - ['filesystem.md', '文件系统', 'Filesystem', 16], - ['lsp.md', 'LSP 导航', 'LSP navigation', 17], - ['code-runtime.md', '代码运行时', 'Code runtime', 18], - ['compaction.md', '上下文压缩', 'Compaction', 19], - ['subagent.md', '子代理', 'Subagents', 20], - ['workflow.md', '工作流', 'Workflows', 21], - ['skills.md', '技能', 'Skills', 22], - ['approval.md', '审批', 'Approvals', 23], - ['permission.md', '权限预设', 'Permission presets', 24], - ['plan.md', '计划模式', 'Plan mode', 25], - ['user-interaction.md', '用户交互', 'User interaction', 26], - ['sandbox.md', '沙箱', 'Sandboxing', 27], - ['web.md', 'Web 访问', 'Web access', 28], - ['spill.md', 'Spill 存储', 'Spill storage', 29], - ['persistence.md', '会话持久化', 'Session persistence', 30], - ['storage.md', '存储', 'Storage', 31], - ['workspace.md', '工作区', 'Workspaces', 32], - ['http-server.md', 'HTTP 服务器', 'HTTP server', 33], - ['client-modules.md', '客户端模块', 'Client modules', 34], - ['invariants.md', '运行时不变式', 'Runtime invariants', 36], - ['session-projection.md', '会话投影', 'Session projections', 37], - ['telemetry.md', '遥测', 'Telemetry', 38], -] as const).map(([file, rootLabel, enLabel, order]): PairedPage => ({ - source: `docs/subsystems/${file}`, - route: file === 'README.md' ? 'reference/subsystems/index.md' : `reference/subsystems/${file}`, - label: { root: rootLabel, en: enLabel }, - sidebar: { root: 'zh-reference', en: 'en-reference' }, - section: { root: '子系统', en: 'Subsystems' }, - order, - ...(file === 'README.md' ? { sourceAliases: ['docs/subsystems'] } : {}), -}))) +/** + * Subsystem pages grouped by the concern they document, as `[Chinese section, + * English section, pages]`. One flat list of every subsystem pushed the rest of + * the reference sidebar below the fold. + */ +const subsystemGroups = [ + ['总览', 'Overview', [ + ['README.md', '子系统', 'Subsystems'], + ]], + ['内核与作用域', 'Core and scopes', [ + ['core.md', '核心', 'Core'], + ['scope.md', '作用域', 'Scopes'], + ['invariants.md', '运行时不变式', 'Runtime invariants'], + ]], + ['会话与持久化', 'Sessions and persistence', [ + ['session.md', '会话', 'Sessions'], + ['session-query.md', '会话查询', 'Session query'], + ['session-reference.md', '会话引用', 'Session references'], + ['session-title.md', '会话标题', 'Session titles'], + ['session-projection.md', '会话投影', 'Session projections'], + ['persistence.md', '会话持久化', 'Session persistence'], + ['spill.md', 'Spill 存储', 'Spill storage'], + ['telemetry.md', '遥测', 'Telemetry'], + ]], + ['模型与上下文', 'Model and context', [ + ['llm-streaming.md', 'LLM 流式响应', 'LLM streaming'], + ['token-meter.md', 'Token 计量', 'Token metering'], + ['system-prompt.md', '系统提示词', 'System prompts'], + ['compaction.md', '上下文压缩', 'Compaction'], + ]], + ['执行与工具', 'Execution and tools', [ + ['tools.md', '工具', 'Tools'], + ['bash.md', 'Bash 执行', 'Bash execution'], + ['subprocess.md', '子进程', 'Subprocesses'], + ['pty.md', 'PTY 会话', 'PTY sessions'], + ['tasks.md', '后台任务', 'Background tasks'], + ['filesystem.md', '文件系统', 'Filesystem'], + ['lsp.md', 'LSP 导航', 'LSP navigation'], + ['code-runtime.md', '代码运行时', 'Code runtime'], + ['web.md', 'Web 访问', 'Web access'], + ['skills.md', '技能', 'Skills'], + ['workflow.md', '工作流', 'Workflows'], + ['subagent.md', '子代理', 'Subagents'], + ]], + ['策略与交互', 'Policy and interaction', [ + ['approval.md', '审批', 'Approvals'], + ['permission.md', '权限预设', 'Permission presets'], + ['sandbox.md', '沙箱', 'Sandboxing'], + ['plan.md', '计划模式', 'Plan mode'], + ['user-interaction.md', '用户交互', 'User interaction'], + ['commands.md', '命令', 'Human commands'], + ['goal.md', '目标', 'Goals'], + ['schedule.md', '定时提醒', 'Scheduled reminders'], + ]], + ['平台与接入', 'Platform and access', [ + ['http-server.md', 'HTTP 服务器', 'HTTP server'], + ['typert.md', 'TypeRT', 'TypeRT'], + ['client-modules.md', '客户端模块', 'Client modules'], + ['storage.md', '存储', 'Storage'], + ['workspace.md', '工作区', 'Workspaces'], + ['settings.md', '用户设置', 'User settings'], + ['credentials.md', '用户凭据', 'User credentials'], + ]], +] as const + +const subsystemsReference = subsystemGroups.flatMap(([rootSection, enSection, files]) => pairedPages( + files.map(([file, rootLabel, enLabel], order): PairedPage => ({ + source: `docs/subsystems/${file}`, + route: file === 'README.md' ? 'reference/subsystems/index.md' : `reference/subsystems/${file}`, + label: { root: rootLabel, en: enLabel }, + sidebar: { root: 'zh-reference', en: 'en-reference' }, + section: { root: rootSection, en: enSection }, + order, + // Subsystem pages carry long third-level sections a two-level outline reaches. + outline: [2, 3], + ...(file === 'README.md' ? { sourceAliases: ['docs/subsystems'] } : {}), + })), +)) const reference = [ ...pairedPages(([ @@ -375,19 +388,6 @@ const reference = [ section: { root: 'Cordis API', en: 'Cordis Core API' }, order: order + 5, }))), - ...pairedPages(([ - ['goal.md', '目标', 'Goals', 14], - ['schedule.md', '定时提醒', 'Scheduled reminders', 15], - ['pty.md', 'PTY 会话', 'PTY sessions', 26], - ['commands.md', '命令', 'Human commands', 38], - ] as const).map(([file, rootLabel, enLabel, order]): PairedPage => ({ - source: `docs/subsystems/${file}`, - route: `reference/subsystems/${file}`, - label: { root: rootLabel, en: enLabel }, - sidebar: { root: 'zh-reference', en: 'en-reference' }, - section: { root: '子系统', en: 'Subsystems' }, - order, - }))), ...pairedPages(([ ['adding-a-package.md', '新增 Package', 'Adding a package'], ['adding-a-tool.md', '新增 Tool', 'Adding a tool'], @@ -411,6 +411,64 @@ const reference = [ }]), ] +/** A sidebar group, matched to pages by `label`. */ +export interface DocsSection { + /** Group heading, equal to the `section` field of every page it holds. */ + label: string + /** Render the group collapsed until it holds the page being read. */ + collapsed?: boolean +} + +/** + * Every sidebar group, in the order its locale renders it. + * + * The subsystem groups collapse because together they outnumber the rest of the + * reference sidebar; expanded, they push every other group below the fold. + */ +const sections: Record = { + root: [ + { label: '入门' }, { label: 'SDK' }, + { label: '基础' }, { label: '框架能力' }, { label: '实战' }, { label: 'Cordis 框架教程' }, + { label: '概念' }, { label: '生成参考' }, { label: 'Cordis API' }, { label: '开发手册' }, + { label: '总览' }, + { label: '内核与作用域', collapsed: true }, + { label: '会话与持久化', collapsed: true }, + { label: '模型与上下文', collapsed: true }, + { label: '执行与工具', collapsed: true }, + { label: '策略与交互', collapsed: true }, + { label: '平台与接入', collapsed: true }, + ], + en: [ + { label: 'Guide' }, { label: 'SDK' }, + { label: 'Basics' }, { label: 'Framework' }, { label: 'Practice' }, { label: 'Cordis framework tutorial' }, + { label: 'Concepts' }, { label: 'Generated reference' }, { label: 'Cordis Core API' }, { label: 'Cookbook' }, + { label: 'Overview' }, + { label: 'Core and scopes', collapsed: true }, + { label: 'Sessions and persistence', collapsed: true }, + { label: 'Model and context', collapsed: true }, + { label: 'Execution and tools', collapsed: true }, + { label: 'Policy and interaction', collapsed: true }, + { label: 'Platform and access', collapsed: true }, + ], +} + +/** + * Placement and collapse behavior of one sidebar group. + * + * @param locale - Route tree whose sidebar is being built. + * @param label - Section label carried by the pages in the group. + * @returns The declared group, plus its zero-based position in the locale. + * @throws When the locale declares no placement for the label. Ranking by list + * membership alone would sort an undeclared group silently ahead of every + * declared one. + */ +export function sectionSpec(locale: DocsLocale, label: string): DocsSection & { index: number } { + const declared = sections[locale] + const section = declared.find(candidate => candidate.label === label) + if (section === undefined) throw new Error(`Sidebar section "${label}" has no placement in the ${locale} locale.`) + return { ...section, index: declared.indexOf(section) } +} + /** Every canonical page published by the documentation website. */ export const docsPages: DocsPage[] = [ ...homeAndGuide, @@ -420,3 +478,47 @@ export const docsPages: DocsPage[] = [ ...subsystemsReference, ...reference, ] + +/** + * Pages of one sidebar collection, in the order the sidebar lists them. + * + * @param locale - Route tree whose sidebar is being built. + * @param collection - Sidebar collection to read. + * @returns The collection's pages, ordered by section placement then by `order`. + */ +export function orderedPages(locale: DocsLocale, collection: DocsSidebar): DocsPage[] { + return docsPages + .filter(page => page.locale === locale && page.sidebar === collection) + .sort((left, right) => ( + sectionSpec(locale, left.section).index - sectionSpec(locale, right.section).index + || left.order - right.order + )) +} + +/** + * Site-relative link for a published route. + * + * @param route - Manifest route, including its `.md` suffix. + * @returns The link VitePress serves the route at. + */ +export function routeLink(route: string): string { + return `/${route.replace(/(?:index)?\.md$/, '')}` +} + +/** + * Where a top-level navigation item lands. + * + * The target is derived rather than written down: a collection whose first page + * is renamed or reordered would otherwise leave the navigation bar pointing at + * a route the manifest no longer publishes. + * + * @param locale - Route tree the navigation item belongs to. + * @param collection - Sidebar collection the item opens. + * @returns Site-relative link of the collection's first page. + * @throws When the collection publishes no page. + */ +export function landingLink(locale: DocsLocale, collection: DocsSidebar): string { + const first = orderedPages(locale, collection)[0] + if (first === undefined) throw new Error(`Sidebar collection "${collection}" publishes no page.`) + return routeLink(first.route) +} diff --git a/website/public/favicon.svg b/website/public/favicon.svg new file mode 100644 index 0000000000..653b77e157 --- /dev/null +++ b/website/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/website/public/wordmark.svg b/website/public/wordmark.svg new file mode 100644 index 0000000000..36e055ff2f --- /dev/null +++ b/website/public/wordmark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + +