diff --git a/.mstar/knowledge/README.md b/.mstar/knowledge/README.md index 30e1937..d92370c 100644 --- a/.mstar/knowledge/README.md +++ b/.mstar/knowledge/README.md @@ -2,8 +2,8 @@ | Document | Source | Description | Status | |----------|--------|-------------|--------| -| `developer-experience/dsh-standalone-plugin-dev.md` | standalone bundle bring-up (peer-stubs → DSH_HOME link farm) | Standalone dsh plugin bundle development against private @deepseek-ai packages: committed peer-stubs (superseding the gitignored shim overlay), prepare-based git-URL installs, plural inject names, install smoke | active | -| `developer-experience/pnpm11-workspace-config-and-windows-link-farm.md` | PR #11 (fix/install: Windows + pnpm 11) | pnpm 11 ignores non-auth .npmrc settings (move autoInstallPeers/nodeLinker/allowBuilds to pnpm-workspace.yaml), peer ranges must match prerelease tags (^0.0.1 vs 0.0.1-rc.1), Windows-safe link farm (junction/file per target, USERPROFILE fallback, separator normalization) | active | +| `developer-experience/dsh-standalone-plugin-dev.md` | standalone bundle bring-up (peer-stubs → link farm → registry peers) | Standalone dsh plugin bundle development against private @deepseek-ai packages: registry peer resolution via autoInstallPeers (superseding the link farm, peer-stubs, and gitignored shim overlay), prepare-based git-URL installs, plural inject names, install smoke | active | +| `developer-experience/pnpm11-workspace-config-and-windows-link-farm.md` | PR #11 (fix/install: Windows + pnpm 11) | pnpm 11 ignores non-auth .npmrc settings (move autoInstallPeers/nodeLinker/allowBuilds to pnpm-workspace.yaml), peer ranges must match prerelease tags (^0.0.1 vs 0.0.1-rc.1), Windows-safe link farm (junction/file per target, USERPROFILE fallback, separator normalization) | superseded (registry peers) | | `architecture-patterns/omp-advisor-dsh-port.md` | core MVP port | omp advisor → dsh mechanism map (cursor/delta/guard/delivery/failure) + MVP decisions + accepted gaps | active | | `architecture-patterns/dsh-plugin-client-half.md` | client half + settings section work | dsh web client half for a standalone plugin: dsh.client declaration (nested under dsh, post-20da39e), closure-factory CJS bundle contract (frozen externals/purity/automatic JSX), CSS-modules inline injection + style-tag lifecycle + bundle hygiene, settings.section slot registration (legacy — the advisor's configuration surface is now the settings.plugin.item card, see dsh-plugin-config-card-surface.md), settings namespace wiring | active | | `architecture-patterns/dsh-plugin-config-card-surface.md` | iteration:iter-20260811-dsh-advisor-n6/guides/plugin-config-migration.md | dsh web "插件配置" page card surface: the settings.plugin.item card slot (declared by the ui-plugin-config settings.section id 'plugins'), generator + yield registration with locale / business-only inject faces, PropsRuntime + PropsLocale + InjectFace contract, type-only peer dependency, load-on-mount invariant, settings-scope vs GatewayService data-channel routes, CSS-fragment build discipline — the advisor's current configuration surface (supersedes the settings.section recipe) | active | diff --git a/.mstar/knowledge/developer-experience/dsh-standalone-plugin-dev.md b/.mstar/knowledge/developer-experience/dsh-standalone-plugin-dev.md index c49b462..b9177eb 100644 --- a/.mstar/knowledge/developer-experience/dsh-standalone-plugin-dev.md +++ b/.mstar/knowledge/developer-experience/dsh-standalone-plugin-dev.md @@ -5,7 +5,7 @@ problem_type: developer_experience category: developer-experience severity: medium title: Building a standalone dsh plugin bundle against private @deepseek-ai packages -description: Verified recipe for a path-free standalone dsh plugin repo: dev-time resolution of private @deepseek-ai packages from a local dsh source tree via a committed link-farm script (superseding the gitignored shim overlay and committed peer-stubs), prepare-based git-URL installs, bundle packaging, and install smoke testing. +description: Verified recipe for a path-free standalone dsh plugin repo: dev-time resolution of private @deepseek-ai packages from the npm registry via autoInstallPeers + registry auth (superseding the committed link-farm script, peer-stubs, and gitignored shim overlay), prepare-based git-URL installs, bundle packaging, and install smoke testing. tags: - dsh - plugin @@ -14,7 +14,7 @@ tags: - peer-dependencies - git-install - prepare -last_updated: 2026-08-11 +last_updated: 2026-08-15 applies_when: - Creating a new standalone dsh plugin package outside the dsh monorepo - Debugging module-resolution failures for @deepseek-ai imports in a plugin @@ -28,21 +28,21 @@ applies_when: dsh (DeepSeek Harness) is Cordis-based: a plugin is a module exporting `apply(ctx)`, shipped as an npm bundle declaring a dsh.bundle manifest pointing at a `cordis.patch.yml`; the patch inserts loader rows (`- insert: - id: name: `). Users install with `dsh plugin --profile add `. -The blocker for out-of-tree development: the `@deepseek-ai/dsh-*` packages are **private (not on npm)** — dev-time typecheck/build needs them locally, while runtime resolution comes from the dsh installation itself (two-anchor bundle resolution: installation first, then profile dir; a flat fallback under `$DSH_HOME` profiles' `node_modules` makes every in-box package Node-resolvable from any profile via parent-walk). Runtime imports therefore belong in **peerDependencies**. +The blocker for out-of-tree development: the `@deepseek-ai/dsh-*` packages are **private on npm** — dev-time typecheck/build needs them resolvable from the registry (auth token in `~/.npmrc`), while runtime resolution comes from the dsh installation itself (two-anchor bundle resolution: installation first, then profile dir; a flat fallback under `$DSH_HOME` profiles' `node_modules` makes every in-box package Node-resolvable from any profile via parent-walk). Runtime imports therefore belong in **peerDependencies**. ## Guidance 1. **Committed files stay path-free.** All machine-local paths live only in gitignored scratch. The earlier gitignored dev-overlay shims were retired (see "What Didn't Work"). -2. **Dev-time resolution via a committed link farm (current, verified).** The private packages stay **peerDependencies only**; dev-time typecheck/build/tests resolve the REAL packages from a local dsh source tree. `scripts/setup-dsh-links.mjs` (wired into `prepare` before `pnpm build`; standalone as `pnpm dsh:link`, checked with `pnpm dsh:link:check`) symlinks every `@deepseek-ai/*` package the tree declares into `node_modules/@deepseek-ai/` — skipping packages that declare a `bin` (tool CLIs: linking them makes pnpm write their bins into the shared tree), providing a bin-less shim for the in-box `cordis` framework (module identity: `import '@deepseek-ai/cordis'` must resolve to the vendored build the real packages type against; the legacy bare `cordis` name is no longer supported), and linking the tree's own `react`/`react-dom` copies (node resolution — including externalized CJS deps — must see ONE react identity, the identity the real client packages use). The farm is idempotent, prunes stale entries, and fails with guidance when the tree is missing or a peer cannot be linked. Source-tree resolution: `$DSH_SOURCE_DIR` → $DSH_HOME/source/current → $HOME/.dsh/source/current. `.npmrc` sets `node-linker=hoisted` (the dsh profile convention, so no `.pnpm` per-package dirs shadow the links) and `auto-install-peers=false` (private peers must never be fetched from the npm registry). +2. **Dev-time resolution from the npm registry via `autoInstallPeers` (current, verified).** The private packages stay **peerDependencies only**; dev-time typecheck/build/tests resolve the REAL packages from the npm registry. `pnpm-workspace.yaml` sets `autoInstallPeers: true` + `nodeLinker: hoisted` (pnpm 11+ ignores non-auth settings in `.npmrc`), and the user-level `~/.npmrc` carries the registry auth token; `prepare` runs `pnpm build` only, so any clone is immediately buildable with no source-tree prerequisite. The cordis peer is scoped `@deepseek-ai/cordis` (never bare `cordis`); peer ranges against prerelease publishes carry the exact tag (`^0.1.0-rc.6` for the dsh-* peers). This supersedes the earlier source-tree linking approach (see "What Didn't Work"). 3. **`moduleResolution: bundler` is required** (cordis's published d.ts has extensionless relative imports; `node16` unusable). For client halves, split tsconfigs (node build excludes `src/client`; `tsconfig.client.json` adds jsx/DOM; `tsconfig.spec.json` for component tests). -4. **Build with `prepack` AND `prepare`.** `prepack` runs at `pnpm pack` time (tarball contains `lib/` + manifest only). `prepare` (the link farm + `pnpm build`) is what makes **git-URL installs** work: pnpm ≥10 runs a git dependency's `prepare` inside a temp clone, gated behind `onlyBuiltDependencies` (the profile workspace manifest) or `allowBuilds` (pnpm ≥10.26); the first `add` fails with `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED` and prints the fix. Tarball installs never run `prepare` (ships built artifacts). +4. **Build with `prepack` AND `prepare`.** `prepack` runs at `pnpm pack` time (tarball contains `lib/` + manifest only). `prepare` (`pnpm build`) is what makes **git-URL installs** work: pnpm ≥10 runs a git dependency's `prepare` inside a temp clone, gated behind `onlyBuiltDependencies` (the profile workspace manifest) or `allowBuilds` (pnpm ≥10.26); the first `add` fails with `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED` and prints the fix. Tarball installs never run `prepare` (ships built artifacts). 5. **Install smoke without touching the real installation**: workspace-local `DSH_HOME` → `dsh plugin --profile add ` → `dsh --profile --dump-config` (row present, 0 stderr). For git-spec verification use a real git spec (`git+file://...#` reproduces the git-dep semantics; a bare directory path is treated as a link and does NOT run prepare). The profile must add `onlyBuiltDependencies` once. 6. **Cordis inject service names are plural**: `sessions`, `agents`, `llm`, `commands` (singular names leave the plugin PENDING forever). `commands` should be injected conditionally (`ctx.inject(['commands'], ...)`). 7. **`skipLibCheck: true` may still be needed** for registry-schemastery/cordis d.ts interplay; keep the flag, drop the stale rationale comment when the reason no longer applies. ## Why This Matters -The naive approaches all fail in confusing ways: `file:` devDeps with absolute paths are uncommittable; a gitignored overlay requires `DSH_SOURCE` pointing at a local dsh checkout (impossible in a git clone — so **git-URL installs fail**); tsconfig `paths` split the runtime cordis identity; symlinks silently destroy type augmentations. The link farm keeps installs buildable with a single documented prerequisite (a local dsh source tree), removes the stub drift surface, and is drift-bounded by the plugin's own typecheck/build/tests (which type and run against the same vendored packages the host uses). +The naive approaches all fail in confusing ways: `file:` devDeps with absolute paths are uncommittable; a gitignored overlay requires `DSH_SOURCE` pointing at a local dsh checkout (impossible in a git clone — so **git-URL installs fail**); tsconfig `paths` split the runtime cordis identity; symlinks silently destroy type augmentations. Registry resolution keeps installs buildable with a single documented prerequisite (a `~/.npmrc` registry token), removes the stub and prior source-tree drift surfaces, and is drift-bounded by the plugin's own typecheck/build/tests (which type and run against the registry peer versions the plugin declares). ## When to Apply @@ -51,10 +51,11 @@ Any standalone dsh plugin repo (this repo is the reference implementation: `dsh- ## What Didn't Work - **Gitignored dev-overlay shims**: worked for local dev but made git-URL install impossible (the clone lacks the overlay and `DSH_SOURCE`), and the 10-package transitive devDeps existed only for the real d.ts closure. Replaced by committed peer-stubs; the overlay files and the workspace manifest were deleted. -- **Committed peer-stubs**: one stub package per directly-consumed private package (type-only stubs for types-only use; minimal-but-honest runtime stand-ins for value imports; a mirror-commit pin in each stub description, enforced mechanically by a test). Hermetic installs, but maintained a parallel stub surface that could drift from the real packages. Superseded by the dev-time **link farm** (item 2), which removed the stub copies entirely. +- **Committed peer-stubs**: one stub package per directly-consumed private package (type-only stubs for types-only use; minimal-but-honest runtime stand-ins for value imports; a mirror-commit pin in each stub description, enforced mechanically by a test). Hermetic installs, but maintained a parallel stub surface that could drift from the real packages. Superseded by the dev-time **link farm** (see below), which removed the stub copies entirely. - **`prepare` as a no-op / build-only**: git installs fail to load unless `prepare` actually builds (`pnpm build`), and pnpm ≥10 gates it behind the allowlist — both must be documented for the operator. +- **Committed dev-time link farm (2026-08-10 PR #3 → removed 2026-08-13, registry-rc.5 peers / PR #13)**: `scripts/setup-dsh-links.mjs` (wired into `prepare` before `pnpm build`; standalone as `pnpm dsh:link`, checked with `pnpm dsh:link:check`) symlinked every `@deepseek-ai/*` package a local dsh source tree declared into `node_modules/@deepseek-ai/` — skipping tool CLIs with a `bin`, providing a bin-less shim for the in-box `cordis` framework, and copying the tree's `react`/`react-dom` for a single module identity. Worked for local dev but made every clone depend on a local dsh source tree (`$DSH_SOURCE_DIR` / `${DSH_HOME}/source/current`) and needed Windows Developer Mode for file symlinks; the registry move (PR #13 / commit `95fc050`, see `CHANGELOG.md`) removed it entirely. ## Examples - Historical evidence (peer-stubs era): `pnpm install` (no `DSH_SOURCE`) exit 0 → typecheck/build/test green → tarball AND a pinned-sha install of the repo's git URL verified (literal command, allowlist fix, `--dump-config` row present). -- Current (link-farm era): the dsh-advisor `prepare` (link farm + build) is exercised on every clone with `$DSH_SOURCE_DIR`/`$DSH_HOME` set; `pnpm dsh:link:check` is the CI-able assertion that the farm is in place. +- Current (registry-peer era): the dsh-advisor `prepare` (`pnpm build`) is exercised on every clone with Node ≥ 22 + a `~/.npmrc` registry token; `tests/peer-deps.test.ts` is the CI-able assertion that the registry-peer contract holds (peer-only, rc.6 pins, `autoInstallPeers`, scoped schemastery, prepare build-only). diff --git a/.mstar/knowledge/developer-experience/pnpm11-workspace-config-and-windows-link-farm.md b/.mstar/knowledge/developer-experience/pnpm11-workspace-config-and-windows-link-farm.md index 8ac4c5c..c062680 100644 --- a/.mstar/knowledge/developer-experience/pnpm11-workspace-config-and-windows-link-farm.md +++ b/.mstar/knowledge/developer-experience/pnpm11-workspace-config-and-windows-link-farm.md @@ -29,6 +29,8 @@ applies_when: # pnpm 11 workspace-config migration and Windows-safe link farm +> **Superseded (2026-08-15):** the dev-time link farm this doc describes was removed — private `@deepseek-ai/*` peers now resolve from the npm registry via `autoInstallPeers: true` + the `~/.npmrc` auth token (see `developer-experience/dsh-standalone-plugin-dev.md`). The pnpm-11 settings-migration and Windows symlink-junction lessons below remain valid history; the `autoInstallPeers: false` guidance in item 1 no longer applies. + ## Context `pnpm install` was broken on Windows under pnpm 11.8 for the dsh-advisor bundle (PR #11, `fix/install` branch). Five distinct issues blocked it; three are general pnpm-11 behaviors, two are Windows-specific gaps in the committed link-farm script. Fixed in commit `cdee4a2`, merged as PR #11, and re-verified on macOS (pnpm 10.28.1): `prepare` links 217 entries from the dsh source tree and the build passes. diff --git a/README.md b/README.md index eccd2a0..5ea4693 100644 --- a/README.md +++ b/README.md @@ -232,59 +232,42 @@ harness iteration roadmap): ## Development -The bundle builds itself on install: `package.json` declares `"prepare": "node -scripts/setup-dsh-links.mjs && pnpm build"` (the dev-time link farm plus the -same build `prepack` runs), so any clone is -immediately buildable **once `DSH_HOME` points at a dsh home whose -`source/current` is a dsh source tree** (or `DSH_SOURCE_DIR` points at such a -tree directly). The private -`@deepseek-ai/dsh-*` runtime dependencies are **peerDependencies only**; at dev -time `scripts/setup-dsh-links.mjs` (wired into `prepare`, standalone as -`pnpm dsh:link`, verified with `pnpm dsh:link:check`) links the REAL packages -from that tree into `node_modules/@deepseek-ai/` — every `@deepseek-ai/*` -package the tree declares (tool CLIs with a `bin` are skipped: linking them -would make pnpm write their bins into the shared tree), a bin-less shim for -the in-box `cordis` framework, and the tree's own `react`/`react-dom` copies -(node resolution — including externalized CJS deps — must see ONE react -identity, the identity the real client packages use; the dsh profile -convention `nodeLinker=hoisted` lives in `pnpm-workspace.yaml` (pnpm 11+ -ignores non-auth settings in `.npmrc`), so no `.pnpm` per-package dirs shadow -those links). The farm is idempotent, prunes stale entries, and fails with -guidance when the tree is missing or a peer cannot be linked. -`pnpm-workspace.yaml` also sets `autoInstallPeers: false` (dsh profile -convention): the private peers must never be fetched from the npm registry. +The bundle builds itself on install: `package.json` declares `"prepare": +"pnpm build"` (the same build `prepack` runs), so any clone is immediately +buildable. The private `@deepseek-ai/dsh-*` runtime dependencies are +**peerDependencies only** (never `dependencies` / `devDependencies`); +`pnpm-workspace.yaml` sets `autoInstallPeers: true` + `nodeLinker: hoisted` +(pnpm 11+ ignores non-auth settings in `.npmrc`), so at dev time pnpm +resolves the real `@deepseek-ai/*` packages from the npm registry using the +auth token in your user-level `~/.npmrc`. There is no local link-farm and no +`DSH_HOME` / `DSH_SOURCE_DIR` prerequisite for dependency resolution. ```sh -export DSH_HOME=~/.dsh # a dsh home with source/current (or set DSH_SOURCE_DIR) -pnpm install # registry deps + link farm (via prepare), no private-registry access +pnpm install # registry deps incl. the @deepseek-ai/* peers (via autoInstallPeers + ~/.npmrc auth) pnpm test # vitest (unit + the composed integration loop) pnpm typecheck # tsc --noEmit (node) + tsc -p tsconfig.client.json --noEmit + tsc -p tsconfig.spec.json --noEmit pnpm build # tsc -p tsconfig.build.json emit to lib/ + node scripts/build-client.mjs (client bundle) pnpm pack # build + produce dsh-advisor-0.0.1.tgz ``` -On Windows the link farm creates directory entries as junctions (no special -privileges), but the cordis shim's file entries use file symlinks, which need -[Developer Mode](https://learn.microsoft.com/windows/apps/get-started/enable-your-device-for-development) -(or an admin shell) — enable it before `pnpm install`. Windows has no -`HOME`, so the script falls back to `USERPROFILE` to resolve the dsh source -tree. - The in-box `cordis` framework is declared as the scoped peer -`@deepseek-ai/cordis: ^4.0.1-rc.1` (the range carries the exact publish tag — -a comparator prerelease such as `^4.0.0-rc.7` never matches the vendored -`4.0.1-rc.1` per the node-semver tuple rule); after install the link farm's -bin-less cordis shim at `node_modules/@deepseek-ai/cordis` answers the scoped -name and resolves to the vendored files, because the real packages type and -run against the vendored build and module identity requires dev-time -`import '@deepseek-ai/cordis'` to resolve to the same files. The public -devDependencies (`@deepseek-ai/schemastery`, `react`, …) resolve from the npm registry as -usual. - -`prepack` runs `pnpm build`; `prepare` runs the link farm and the build, so -`pnpm pack` runs the build twice (once per lifecycle) — the documented -tradeoff that keeps git-install builds working. There is no `postinstall` -step: already-built tarball installs skip the build entirely. +`@deepseek-ai/cordis` (never bare `cordis`) — the declared pin is +`"@deepseek-ai/cordis": "^4.0.1"` (`package.json` peerDependencies). Peer +ranges against prerelease publishes must carry the exact publish tag — e.g. +the `@deepseek-ai/dsh-*` peers are pinned `^0.1.0-rc.6`; per the node-semver +prerelease-tuple rule a comparator with a prerelease only matches the same +`[major, minor, patch]` tuple, so a range like `^4.0.0-rc.7` never matches a +`4.0.1-rc.1` publish. +The scoped peer resolves from the npm registry like the other +`@deepseek-ai/*` peers, so dev-time `import '@deepseek-ai/cordis'` and the +host see the same package identity. + +`prepack` runs `pnpm build`; `prepare` runs `pnpm build`, so `pnpm pack` runs +the build twice (once per lifecycle) — the documented tradeoff that keeps +git-install builds working. There is no `postinstall` step: already-built +tarball installs skip the build entirely. A local `dsh plugin add .` mounts +the bundle from the working tree, so run `pnpm build` (or `pnpm install`) +first — pnpm does not run `prepare` for `link:` dependencies. The integration test (`tests/integration.test.ts`) composes the plugin into a real cordis context with a stub LLM adapter and drives the full diff --git a/README.zh.md b/README.zh.md index e9a9cb2..c540d0d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -137,22 +137,19 @@ MVP 有意放弃与 omp 的完整对等。已接受的差距(在 harness 迭 ## 开发 -组合包在安装时自行构建:`package.json` 声明了 `"prepare": "node scripts/setup-dsh-links.mjs && pnpm build"`(开发期链接农场、与 `prepack` 相同的构建),因此任何克隆在 **`DSH_HOME` 指向一个含 `source/current` 的 dsh home(或 `DSH_SOURCE_DIR` 直接指向一个 dsh 源码树)** 后立即可构建。私有的 `@deepseek-ai/dsh-*` 运行时依赖**只声明为 peerDependencies**;开发期由 `scripts/setup-dsh-links.mjs`(挂在 `prepare` 上、独立命令为 `pnpm dsh:link`、用 `pnpm dsh:link:check` 校验)把该树里的**真实包**链接进 `node_modules/@deepseek-ai/` —— 树声明的每个 `@deepseek-ai/*` 包(声明 `bin` 的工具 CLI 会被跳过:链接它们会让 pnpm 向共享树写入 bin)、无 bin 的内置 `cordis` 框架 shim、以及树自带的 `react`/`react-dom` 副本(node 解析 —— 包括外部化的 CJS 依赖 —— 必须看到同一个 react 身份,即真实 client 包所用的身份;dsh profile 约定 `nodeLinker=hoisted` 放在 `pnpm-workspace.yaml`(pnpm 11+ 忽略 `.npmrc` 中的非认证设置),避免 `.pnpm` 逐包目录遮蔽这些链接)。农场幂等、会清理陈旧条目,并在树缺失或 peer 无法链接时给出明确指引。`pnpm-workspace.yaml` 还设了 `autoInstallPeers: false`(dsh profile 约定):私有 peer 绝不能从 npm registry 获取。 +组合包在安装时自行构建:`package.json` 声明了 `"prepare": "pnpm build"`(与 `prepack` 相同的构建),因此任何克隆都立即可构建。私有的 `@deepseek-ai/dsh-*` 运行时依赖**只声明为 peerDependencies**(绝不进 `dependencies` / `devDependencies`);`pnpm-workspace.yaml` 设了 `autoInstallPeers: true` + `nodeLinker: hoisted`(pnpm 11+ 忽略 `.npmrc` 中的非认证设置),因此开发期 pnpm 用你用户级 `~/.npmrc` 里的认证令牌从 npm registry 解析真实的 `@deepseek-ai/*` 包。没有本地链接农场,依赖解析也不需要 `DSH_HOME` / `DSH_SOURCE_DIR` 前置条件。 ```sh -export DSH_HOME=~/.dsh # 含 source/current 的 dsh home(或直接设置 DSH_SOURCE_DIR) -pnpm install # registry deps + 链接农场(经 prepare),无需访问私有 registry +pnpm install # registry deps,含 @deepseek-ai/* peers(经 autoInstallPeers + ~/.npmrc 认证) pnpm test # vitest (unit + the composed integration loop) pnpm typecheck # tsc --noEmit (node) + tsc -p tsconfig.client.json --noEmit + tsc -p tsconfig.spec.json --noEmit pnpm build # tsc -p tsconfig.build.json emit to lib/ + node scripts/build-client.mjs (client bundle) pnpm pack # build + produce dsh-advisor-0.0.1.tgz ``` -Windows 上链接农场的目录条目以 junction 创建(无需特权),但 cordis shim 的文件条目使用文件符号链接,需要开启[开发者模式](https://learn.microsoft.com/windows/apps/get-started/enable-your-device-for-development)(或以管理员 shell 运行)——请先开启再执行 `pnpm install`。Windows 没有 `HOME`,脚本回退到 `USERPROFILE` 解析 dsh 源码树。 +内置 `cordis` 框架声明为 scoped peer `@deepseek-ai/cordis`(绝不用裸名 `cordis`)。针对 prerelease 发布的 peer 范围必须带精确的发布 tag —— 例如 `@deepseek-ai/dsh-*` peers 钉在 `^0.1.0-rc.6`;按 node-semver prerelease-tuple 规则,带 prerelease 的 comparator 只匹配同 `[major, minor, patch]` tuple,因此 `^4.0.0-rc.7` 这样的范围永远匹配不到 `4.0.1-rc.1` 的发布。scoped peer 与其他 `@deepseek-ai/*` peers 一样从 npm registry 解析,所以开发期的 `import '@deepseek-ai/cordis'` 与宿主看到的是同一个包身份。 -内置 `cordis` 框架声明为 scoped peer `@deepseek-ai/cordis: ^4.0.1-rc.1`(范围必须带精确的发布 tag —— 带 prerelease 的 comparator 只匹配同 `[major, minor, patch]` tuple,`^4.0.0-rc.7` 永远不匹配 vendored 的 `4.0.1-rc.1`);安装后链接农场的无 bin cordis shim 位于 `node_modules/@deepseek-ai/cordis`,以 scoped 名应答并解析到 vendored 文件,因为真实包是对着 vendored 构建类型化/运行的,模块身份要求开发期的 `import '@deepseek-ai/cordis'` 解析到同一份文件。其余公开 devDependencies(`@deepseek-ai/schemastery`、`react` 等)照常从 npm registry 解析。 - -`prepack` 运行 `pnpm build`;`prepare` 运行链接农场与构建,因此 `pnpm pack` 会构建两次(每个生命周期一次)——这是为保持 git 安装可构建而接受的取舍。没有 `postinstall` 步骤:tarball 安装已带构建产物,完全跳过构建。 +`prepack` 运行 `pnpm build`;`prepare` 运行 `pnpm build`,因此 `pnpm pack` 会构建两次(每个生命周期一次)——这是为保持 git 安装可构建而接受的取舍。没有 `postinstall` 步骤:tarball 安装已带构建产物,完全跳过构建。本地 `dsh plugin add .` 从工作树挂载 bundle,因此请先运行 `pnpm build`(或 `pnpm install`)——pnpm 不会为 `link:` 依赖运行 `prepare`。 集成测试(`tests/integration.test.ts`)把插件组合进一个带 stub LLM adapter 的真实 cordis 上下文,驱动完整的 turn → delta → advisor call → inject/steer 循环。 diff --git a/docs/install.md b/docs/install.md index 8e53e14..1f494d6 100644 --- a/docs/install.md +++ b/docs/install.md @@ -9,9 +9,10 @@ uninstall. The quick version lives in the [README](../README.md#install). target profile (e.g. `web`); restart the dsh session after installing. - A registry install needs only pnpm on PATH (`dsh plugin` is a pnpm forwarder). Building from source (git / local / tarball installs below) - additionally needs **node** (≥ 22) and a dsh source tree at `$DSH_SOURCE_DIR` - (default `${DSH_HOME}/source/current`) — the dev-time link farm (the - `prepare` build) and dev-time type checking / tests use it. + additionally needs **node** (≥ 22) and registry auth for the private + `@deepseek-ai/*` peers — `prepare` runs `pnpm build` only (no `DSH_HOME` + source-tree prerequisite for dependency resolution; the peers resolve from + the npm registry via `autoInstallPeers` + the `~/.npmrc` auth token). ## 1. One-line registry install diff --git a/docs/install.zh.md b/docs/install.zh.md index 353ab7e..3fcf156 100644 --- a/docs/install.zh.md +++ b/docs/install.zh.md @@ -5,8 +5,7 @@ ## 前置条件 - 可用的 dsh 运行环境(`$DSH_HOME`,默认 `~/.dsh`)与可写的目标 profile(如 `web`);安装后重启 dsh 会话。 -- registry 安装只需 PATH 上有 pnpm(`dsh plugin` 是 pnpm 转发器)。从源码构建(下文 git / 本地目录 / tarball 安装)另需 **node**(≥ 22)与位于 `$DSH_SOURCE_DIR`(缺省 `${DSH_HOME}/source/current`)的 dsh 源码树——开发期链接农场(`prepare` 构建)与开发期类型检查 / 测试都用它。 -- **Windows**:从源码安装(git / 本地目录)的 `prepare` 链接农场会创建文件符号链接,请先开启[开发者模式](https://learn.microsoft.com/windows/apps/get-started/enable-your-device-for-development)(目录 junction 无需特权)。Windows 没有 `HOME`,链接农场自动回退 `USERPROFILE` 解析 dsh 源码树。 +- registry 安装只需 PATH 上有 pnpm(`dsh plugin` 是 pnpm 转发器)。从源码构建(下文 git / 本地目录 / tarball 安装)另需 **node**(≥ 22)与私有 `@deepseek-ai/*` peers 的 registry 认证——`prepare` 只运行 `pnpm build`(依赖解析不需要 `DSH_HOME` 源码树前置条件;peers 经 `autoInstallPeers` + `~/.npmrc` 认证令牌从 npm registry 解析)。 ## 1. 一条命令的 registry 安装 diff --git a/src/client/advisor-store.ts b/src/client/advisor-store.ts index b8dd1cf..03beda1 100644 --- a/src/client/advisor-store.ts +++ b/src/client/advisor-store.ts @@ -269,7 +269,7 @@ export class AdvisorSettingsStore { /** * Bumped by every successful load(): an in-flight catalog fetch started - * before a bump is stale (the models/changed invalidation that triggered + * before a bump is stale (the connection/reset invalidation that triggered * the reload happened mid-fetch) and caches nothing (qc1 W-1 / qc3 S-1). */ private catalogGeneration = 0 @@ -375,14 +375,17 @@ export class AdvisorSettingsStore { this.store.update((s) => { s.draft = this.seed }) this.draftSeeded = true } - // Invalidation refresh (qc1 W-1 / qc3 S-1): a pushed settings/changed or - // models/changed must re-resolve model options from the fresh directory — - // the per-provider model caches and the host-scoped catalog are - // store-lifetime otherwise, and `ensureModels` early-returns once a - // provider has resolved. The catalog-level in-flight guard + success-only - // failure caching stay; the end-of-load `ensureModels(selected)` below - // then re-resolves the stored provider's options on every invalidation - // without clobbering in-progress draft edits (the draft is seeded once). + // Invalidation refresh (qc1 W-1 / qc3 S-1): a pushed invalidation — the + // connection/reset and the granular remote events (plan 003 / status R3: + // settings/document-updated + llm/adapters-updated, see + // src/client/index.ts — event → refreshIfLoaded → load()) — must + // re-resolve model options from the fresh directory — the per-provider + // model caches and the host-scoped catalog are store-lifetime otherwise, + // and `ensureModels` early-returns once a provider has resolved. The + // catalog-level in-flight guard + success-only failure caching stay; the + // end-of-load `ensureModels(selected)` below then re-resolves the stored + // provider's options on every invalidation without clobbering in-progress + // draft edits (the draft is seeded once). this.catalogGeneration += 1 this.catalog = undefined this.catalogPromise = undefined diff --git a/src/client/index.ts b/src/client/index.ts index 111b2dd..7c1bed9 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -14,6 +14,7 @@ */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: pulls the plugin-config card slot's SlotMap merge (the // 'settings.plugin.item' entry — this half's registration target). Same empty @@ -73,16 +74,31 @@ export function apply(ctx: ClientContext): void { const controller = new AdvisorSettingsStore(connection.api, connection.rpc) const useSnapshot = bindSnapshotSelector(controller.store) - // Pushed invalidations converge the open surface without polling. The - // 20260811 dsh snapshot removed the `settings/changed` / `models/changed` - // host passthroughs from the client runtime Events vocabulary (no - // replacement exists there), so convergence rides `connection/reset` — a - // connection reset invalidates the whole client state (the upstream - // `dsh-client-ui-settings` scope uses the same signal). Same-host config - // changes land via the page's own load path. A burst of resets coalesces - // into a single refetch via the microtask debounce — events in separate - // ticks each trigger a load, and `refreshIfLoaded` keeps an unopened card - // idle. + // Pushed invalidations converge the open surface without polling. Two + // planes feed the shared microtask debounce: + // - `connection/reset` (ctx.on): a connection reset invalidates the whole + // client state (the upstream `dsh-client-ui-settings` scope uses the same + // signal — its `SettingsScopeBinder` also subscribes to the remote + // settings event below); + // - the granular Host invalidation events forwarded to the client remote + // face (`remote.$on`, subscribed on the `ctx.get('remote')` handle — + // feature-detected below, never a hard `ctx.remote` dependency; legal + // key set = `API_REMOTE_FORWARDED_EVENTS` in @deepseek-ai/dsh-api-remotes, + // pinned ^0.1.0-rc.6): + // `settings/document-updated` (a settings namespace document changed on + // the host — e.g. a provider section edited on the Models page) and + // `llm/adapters-updated` (provider/model topology mutation — e.g. a + // model added on the Models page). The 20260811 dsh snapshot removed the + // old `settings/changed` / `models/changed` host passthroughs from the + // client runtime Events vocabulary; the forwarded-event allowlist is + // their replacement (plan 003 / status R3 — restores same-host live + // convergence without a reconnect). + // `remote` is a client-assembly service, resolved with `ctx.get` (not + // injected) and feature-detected: a shell that does not mount it keeps + // today's reset-only behavior — no throw on registration. A burst of + // invalidations coalesces into a single refetch via the microtask debounce + // — events in separate ticks each trigger a load, and `refreshIfLoaded` + // keeps an unopened card idle. ctx.effect(() => { let pending = false const refresh = (): void => { @@ -93,7 +109,17 @@ export function apply(ctx: ClientContext): void { refreshIfLoaded(controller) }) } - const disposers = [ctx.on('connection/reset', refresh)] + const disposers: Array<() => void> = [ctx.on('connection/reset', refresh)] + const remote: TypertClientRemote | undefined = ctx.get('remote') + if (remote) { + // Deliberately unfiltered: the store reads the whole settings surface + // (provider directory + all namespaces + advisor config), so a + // namespace filter (upstream SettingsScopeBinder applies one) would + // miss provider-section changes; the microtask debounce + load()'s + // generation guard bound the cost. + disposers.push(remote.$on('settings/document-updated', refresh)) + disposers.push(remote.$on('llm/adapters-updated', refresh)) + } return () => { for (const dispose of disposers) dispose() } }, 'advisor: pushed invalidations') diff --git a/tests/advisor-card.spec.tsx b/tests/advisor-card.spec.tsx index d753aa3..3d685f2 100644 --- a/tests/advisor-card.spec.tsx +++ b/tests/advisor-card.spec.tsx @@ -208,16 +208,29 @@ function toggleCard(): void { * A minimal fake of the client slots service + context for the registration * ledger test: `inject(name, generator)` runs the generator and records every * `register` call (the real runtime does the same through ctx.effect), and - * `ctx.get('connection')` serves the scripted wire face. Everything else the - * plugin's apply touches (locale register, connection/reset) is recorded but - * inert. + * `ctx.get('connection')` serves the scripted wire face. The optional + * `remote` service mirrors the client assembly's forwarded Host invalidation + * face (plan 003: `ctx.remote.$on` with `settings/document-updated` + + * `llm/adapters-updated`, probe of API_REMOTE_FORWARDED_EVENTS in + * @deepseek-ai/dsh-api-remotes rc.6) — `withRemote: false` simulates a shell + * that never mounted the service (graceful-degrade path). Everything else + * the plugin's apply touches (locale register, connection/reset) is recorded + * but inert. */ -function fakeRuntime(scripted: Scripted) { +function fakeRuntime(scripted: Scripted, withRemote = true) { interface LedgerRow { name: string; options: Record; component: unknown } const ledger: Record = {} const disposers: Array<() => void> = [] + const effectDisposers: Array<() => void> = [] const locales: Record = {} const resetHandlers = new Set<() => void>() + const remoteHandlers: Record void>> = {} + const remote = { + $on: (event: string, handler: () => void): (() => void) => { + ;(remoteHandlers[event] ??= new Set()).add(handler) + return () => { remoteHandlers[event]?.delete(handler) } + }, + } const slots = { register: (options: Record, component: unknown): (() => void) => { const name = options.name as string @@ -241,10 +254,16 @@ function fakeRuntime(scripted: Scripted) { }, bind: (): never => { throw new Error('test: apply must not bind t — the card t seat comes from PropsLocale') }, }, - get: (key: string): unknown => (key === 'connection' ? { api: scripted.api, rpc: scripted.rpc } : undefined), + get: (key: string): unknown => { + if (key === 'connection') return { api: scripted.api, rpc: scripted.rpc } + if (key === 'remote') return withRemote ? remote : undefined + return undefined + }, effect: (fn: () => unknown): (() => void) => { const disposer = fn() - return typeof disposer === 'function' ? disposer as () => void : () => {} + const stop = typeof disposer === 'function' ? disposer as () => void : () => {} + effectDisposers.push(stop) + return stop }, on: (event: string, handler: () => void): (() => void) => { if (event !== 'connection/reset') throw new Error(`test: unexpected event ${event}`) @@ -252,7 +271,11 @@ function fakeRuntime(scripted: Scripted) { return () => { resetHandlers.delete(handler) } }, } - return { ctx, ledger, locales, resetHandlers } + /** Fire one forwarded Host event into the remote subscription table. */ + const fireRemote = (event: string): void => { + for (const handler of remoteHandlers[event] ?? []) handler() + } + return { ctx, ledger, locales, resetHandlers, remoteHandlers, effectDisposers, fireRemote } } describe('AdvisorCard registration (settings.plugin.item)', () => { @@ -286,6 +309,93 @@ describe('AdvisorCard registration (settings.plugin.item)', () => { }) }) +describe('AdvisorCard invalidation refresh (plan 003 / residual R3)', () => { + /** Run apply, then hand back the injected controller (the open card surface). */ + function applyAndController(scripted: Scripted, withRemote = true) { + const runtime = fakeRuntime(scripted, withRemote) + apply(runtime.ctx as unknown as ClientContext) + const cards = runtime.ledger['settings.plugin.item'] ?? [] + const inject = cards[0].options.inject as () => object + const face = inject() as { controller: AdvisorSettingsStore } + return { ...runtime, controller: face.controller } + } + + it('subscribes both granular remote events and refreshes a loaded store, coalescing bursts', async () => { + const scripted = scriptedApi() + const { controller, remoteHandlers, resetHandlers, fireRemote } = applyAndController(scripted) + + // Dual-plane registration: both forwarded Host events on the remote face + // plus the connection/reset fallback (the 20260811 vocabulary removal + // note — plan 003 probe: API_REMOTE_FORWARDED_EVENTS, rc.6). + expect(remoteHandlers['settings/document-updated']?.size).toBe(1) + expect(remoteHandlers['llm/adapters-updated']?.size).toBe(1) + expect(resetHandlers.size).toBe(1) + + // First load (the card opens) — then a same-host burst of invalidations + // (e.g. the Models page edits a provider section AND a model) coalesces + // into ONE refetch via the microtask debounce. + await controller.load() + expect(scripted.describe).toHaveBeenCalledTimes(1) + fireRemote('settings/document-updated') + fireRemote('llm/adapters-updated') + await vi.waitFor(() => expect(scripted.describe).toHaveBeenCalledTimes(2)) + + // A later, separately-ticked granular event (a new model added on the + // Models page) refreshes again — each event keeps its own refresh. + fireRemote('llm/adapters-updated') + await vi.waitFor(() => expect(scripted.describe).toHaveBeenCalledTimes(3)) + fireRemote('settings/document-updated') + await vi.waitFor(() => expect(scripted.describe).toHaveBeenCalledTimes(4)) + + // The connection/reset plane refreshes too when the remote service IS + // mounted — reset and granular events both converge under a + // remote-present assembly (dual-plane lock). + for (const handler of resetHandlers) handler() + await vi.waitFor(() => expect(scripted.describe).toHaveBeenCalledTimes(5)) + }) + + it('does not fetch before the first load (an unopened card stays idle)', async () => { + const scripted = scriptedApi() + const { fireRemote } = applyAndController(scripted) + fireRemote('settings/document-updated') + fireRemote('llm/adapters-updated') + await Promise.resolve() + expect(scripted.describe).not.toHaveBeenCalled() + }) + + it('keeps connection/reset refresh when the remote service is absent (graceful degrade)', async () => { + const scripted = scriptedApi() + // A shell that never mounted `remote` must not throw on registration and + // keeps today's reset-only convergence. + const { controller, resetHandlers, remoteHandlers } = applyAndController(scripted, false) + expect(Object.keys(remoteHandlers)).toHaveLength(0) + expect(resetHandlers.size).toBe(1) + + await controller.load() + expect(scripted.describe).toHaveBeenCalledTimes(1) + for (const handler of resetHandlers) handler() + await vi.waitFor(() => expect(scripted.describe).toHaveBeenCalledTimes(2)) + }) + + it('empties the remote/reset handler sets when the effect disposer runs (teardown)', () => { + const scripted = scriptedApi() + const { effectDisposers, remoteHandlers, resetHandlers } = applyAndController(scripted) + + // Precondition: both planes are registered before teardown. + expect(remoteHandlers['settings/document-updated']?.size).toBe(1) + expect(remoteHandlers['llm/adapters-updated']?.size).toBe(1) + expect(resetHandlers.size).toBe(1) + + // Run the effect disposer (apply teardown) — every registration leaves + // the subscription tables, so later host events reach no handler. + for (const dispose of effectDisposers) dispose() + + expect(remoteHandlers['settings/document-updated']?.size ?? 0).toBe(0) + expect(remoteHandlers['llm/adapters-updated']?.size ?? 0).toBe(0) + expect(resetHandlers.size).toBe(0) + }) +}) + describe('AdvisorCard chrome (upstream PluginCard contract)', () => { it('renders collapsed by default: the header copy and chevron, no form', async () => { const { view, props } = await mountCard() diff --git a/tests/advisor-runtime.test.ts b/tests/advisor-runtime.test.ts index f36ca08..efa53b2 100644 --- a/tests/advisor-runtime.test.ts +++ b/tests/advisor-runtime.test.ts @@ -17,6 +17,11 @@ * with status; quota/rate-limit → pause (`quota_exhausted`), batch retained, * no auto-resume timer; in-flight call aborted on dispose via the signal; * never park the primary. + * - Bounded backlog (spec §6): `maxQueued` (default 32) bounds the waiting + * queue; a delta enqueued while the queue is full is dropped at enqueue + * (drop-newest) with a `debug` log carrying `{ maxQueued }`, an in-flight + * delta does not count against the bound, and accepted deltas drain in FIFO + * order. * - No model call when the config is disabled (explicit gate, S4) — verified at * the plugin `apply` level. * @@ -57,8 +62,17 @@ const TEST_SYSTEM_PROMPT = 'You are an independent reviewer for a coding session /** A rendered transcript delta (shape produced by T3's DeltaRenderer). */ const delta = (markdown: string): Delta => ({ markdown, willContinue: false }) -/** Scripted fake for the runtime's `llm` option: records calls, replays responses. */ -type FakeResponse = { readonly chunks: readonly StreamChunk[] } | { readonly throw: Error } | { readonly hang: true } +/** + * Scripted fake for the runtime's `llm` option: records calls, replays responses. + * `gate` = a releasable hang (settings-live `GatedAdapter` pattern): the stream + * blocks until the gate resolves, then yields `chunks` — used to hold a delta + * in flight while the backlog fills (spec §6 drop-newest tests). + */ +type FakeResponse = + | { readonly chunks: readonly StreamChunk[] } + | { readonly throw: Error } + | { readonly hang: true } + | { readonly gate: Promise; readonly chunks: readonly StreamChunk[] } /** Simulated model capability for {@link FakeLlm.resolveModelInfo}. */ type FakeCapability = 'off' | 'none' | 'throw' @@ -104,6 +118,16 @@ class FakeLlm { await new Promise(() => {}) })() } + if ('gate' in response) { + // A releasable hang: block on the gate, then replay the reply. Unlike the + // black-hole `hang`, the blocked call completes once the test resolves + // the gate, so the drain can finish and the backlog semantics can be + // asserted end to end. + return (async function* () { + await response.gate + yield * response.chunks + })() + } return streamOf(response.chunks) } } @@ -724,6 +748,116 @@ describe('AdvisorRuntime — failure policy (KD-5)', () => { }) }) +// --------------------------------------------------------------------------- +// Bounded backlog (spec §6) — drop-newest with a debug log when the queue is full +// --------------------------------------------------------------------------- + +describe('AdvisorRuntime — bounded backlog drop-newest (spec §6)', () => { + /** Manually-resolvable promise — releases a gated `FakeLlm` stream on demand. */ + function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } + } + + /** Delta markdown carried by one recorded `llm.stream` call (drain-test pattern). */ + const textOf = (options: GenerateOptions): string => { + const block = options.messages[0]!.content[0]! + return block.type === 'text' ? block.text : '' + } + + it('drops the newest delta when the backlog is full, while a delta is in flight', async () => { + // The first delta is held in flight on a releasable gate (the drain parks + // on the stream's first `next()`), so the queue fills to `maxQueued` + // deterministically — no wall-clock timing. + const gate = deferred() + const llm = new FakeLlm([ + { gate: gate.promise, chunks: textReply('{"note":"first"}') }, + { chunks: textReply('{"note":"second"}') }, + { chunks: textReply('{"note":"third"}') }, + ]) + const debug = vi.fn() + const warn = vi.fn() + const { runtime, notes } = makeRuntime(llm, { maxQueued: 2, logger: { debug, warn } }) + + runtime.enqueue(delta('update one')) + await vi.waitFor(() => expect(llm.calls).toHaveLength(1)) // dequeued + in flight + expect(runtime.pendingCount).toBe(0) + + // The queue fills to maxQueued (2) while the first delta is still in flight. + runtime.enqueue(delta('update two')) + runtime.enqueue(delta('update three')) + expect(runtime.pendingCount).toBe(2) + + // Backlog full → drop-newest: the next delta is refused at enqueue and + // never dispatched to the model. + runtime.enqueue(delta('update four')) + expect(runtime.pendingCount).toBe(2) + expect(llm.calls).toHaveLength(1) + + // Observability (spec §6): the drop is logged with the maxQueued payload. + expect(debug).toHaveBeenCalledWith('advisor: enqueue dropped — backlog full', { maxQueued: 2 }) + + // Release the in-flight call: the accepted deltas drain in FIFO order and + // the dropped delta never appears. + gate.resolve() + await runtime.waitForDrain() + + expect([textOf(llm.calls[0]!), textOf(llm.calls[1]!), textOf(llm.calls[2]!)]).toEqual([ + 'update one', + 'update two', + 'update three', + ]) + expect(notes).toEqual([ + { note: 'first', severity: 'nit' }, + { note: 'second', severity: 'nit' }, + { note: 'third', severity: 'nit' }, + ]) + expect(llm.calls).toHaveLength(3) + expect(runtime.pendingCount).toBe(0) + expect(runtime.status()).toBe('running') + }) + + it('retains FIFO order for accepted deltas when the queue fills from capacity-1', async () => { + // maxQueued 3: one delta in flight (gated) + three queued = the queue sits + // at capacity; the next delta is dropped at enqueue; the accepted four + // drain in enqueue order. + const gate = deferred() + const llm = new FakeLlm([ + { gate: gate.promise, chunks: textReply('{"note":"first"}') }, + { chunks: textReply('{"note":"second"}') }, + { chunks: textReply('{"note":"third"}') }, + { chunks: textReply('{"note":"fourth"}') }, + ]) + const { runtime } = makeRuntime(llm, { maxQueued: 3 }) + + runtime.enqueue(delta('update one')) + await vi.waitFor(() => expect(llm.calls).toHaveLength(1)) // in flight + + runtime.enqueue(delta('update two')) + runtime.enqueue(delta('update three')) + runtime.enqueue(delta('update four')) // queue reaches maxQueued (3) + expect(runtime.pendingCount).toBe(3) + runtime.enqueue(delta('update five')) // dropped — the queue is full + expect(runtime.pendingCount).toBe(3) + expect(llm.calls).toHaveLength(1) + + gate.resolve() + await runtime.waitForDrain() + + expect(llm.calls).toHaveLength(4) + expect([ + textOf(llm.calls[0]!), + textOf(llm.calls[1]!), + textOf(llm.calls[2]!), + textOf(llm.calls[3]!), + ]).toEqual(['update one', 'update two', 'update three', 'update four']) + expect(runtime.pendingCount).toBe(0) + }) +}) + // --------------------------------------------------------------------------- // Lifecycle — dispose aborts the in-flight call and stops the drain // --------------------------------------------------------------------------- diff --git a/tests/bench-fingerprint.bench.ts b/tests/bench-fingerprint.bench.ts new file mode 100644 index 0000000..d3d2491 --- /dev/null +++ b/tests/bench-fingerprint.bench.ts @@ -0,0 +1,126 @@ +/** + * Benchmark: DeltaRenderer delivered-prefix fingerprint cost (audit finding 004). + * + * `DeltaRenderer.update()` recomputes an O(prefix) message-id fingerprint on + * every call — the defensive replay check over the delivered prefix + * (`src/transcript.ts:280`) plus the tail assignment (`:290`) — so a long + * session pays two full-prefix hashes per event batch. This bench measures + * both hot paths through the real public API at realistic session sizes and + * feeds the spec-004 Task 1 STOP gate: if the fingerprint work on the + * incremental append path stays well below ~1 ms/op at N=2000 on this + * machine, the plan closes as a documented no-op (no optimization warranted). + * + * - (a) full fingerprint: a no-op `update()` at cursor N (the delivered log + * is unchanged) recomputes `fingerprintOf` twice over all N events — the + * replay check (:280) and the tail assignment (:290) — with zero + * append/render work. This is an upper bound on the fingerprint work of + * any single update, including the hot append path. + * - (b) incremental append: cursor rewind to N-1 (`seedTo`, O(1)) then + * `update()` appends the final event. The rewind clears the cached + * fingerprint, so this measures the append path with ONE full recompute + * (the :290 tail assignment; the :280 defensive check is skipped — the + * cold-cache case, e.g. right after seed-on-enable). The hot steady state + * additionally pays one more full recompute, which (a) already isolates. + * + * Synthetic logs alternate user/assistant surface events with deterministic + * 16-char message ids, mirroring the factories in tests/transcript.test.ts + * (that module cannot be imported here — it registers suites on load). An + * all-message log is the worst case for the hash: every event contributes a + * 16-char id instead of a short `e${index}` fallback. + * + * Run: pnpm exec vitest bench --run tests/bench-fingerprint.bench.ts + * Verdict (audit 004, STOP gate applied): at N=2000 the full-prefix + * recompute measured 0.57–0.64 ms/op and the incremental append path + * 0.28–0.32 ms/op — both well below the 1 ms threshold, so no optimization + * is warranted (documented no-op; numbers re-run on 2026-08-15 on an M1 Max + * with Node 24 / vitest 3.2.7). + */ +import { bench, describe } from 'vitest' +import { MessageId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import type { SessionEvent, SurfaceOp } from '@deepseek-ai/dsh-session' +import { DeltaRenderer } from '../src/transcript' + +interface EventSpec { + type: string + data: unknown + surfaceOp?: SurfaceOp +} + +/** Number events contiguously from seq 0 and cast to the SessionEvent union. */ +function buildEvents(specs: readonly EventSpec[]): SessionEvent[] { + return specs.map((spec, index) => { + const event: Record = { + type: spec.type, + seq: index, + time: 1_000 + index, + data: spec.data, + } + if (spec.surfaceOp !== undefined) event.surfaceOp = spec.surfaceOp + return event as unknown as SessionEvent + }) +} + +const text = (value: string): ContentBlock => ({ type: 'text', text: value }) + +/** + * A log of `count` surface events (alternating user/assistant) whose message + * ids are deterministic 16-char strings — the fingerprint hot case. + */ +function messageLog(count: number): SessionEvent[] { + const specs: EventSpec[] = [] + const width = String(count).length + for (let index = 0; index < count; index++) { + // 16-char id: `m` + zero-padded seq + filler (ids never repeat for + // count < 100_000, so the hash input is stable across rebuilds). + const id = `m${String(index).padStart(width, '0')}${'x'.repeat(15 - width)}` + if (index % 2 === 0) { + const source: MessageSource = { kind: 'user' } + specs.push({ + type: 'user/message', + data: { id: MessageId(id), role: 'user', content: [text('m')], source }, + surfaceOp: 'append', + }) + } else { + specs.push({ + type: 'assistant/message', + data: { + turn: 1, + step: 1, + message: { + id: MessageId(id), + role: 'assistant', + content: [text('m')], + source: { kind: 'model', provider: 'deepseek', model: 'deepseek-chat' }, + }, + }, + surfaceOp: 'append', + }) + } + } + return buildEvents(specs) +} + +describe('DeltaRenderer fingerprint hot path', () => { + for (const n of [500, 2000, 10000]) { + const events = messageLog(n) + + // (a) Full fingerprint cost of one update. The renderer is warmed once at + // module scope (cursor N + cached fingerprint); every timed call is then a + // no-op update that recomputes `fingerprintOf` over all N events twice. + const fullRenderer = new DeltaRenderer() + fullRenderer.update(events) + bench(`(a) full fingerprint recompute, N=${n}`, () => { + fullRenderer.update(events) + }) + + // (b) Incremental append at cursor N-1: O(1) seedTo rewind + the append + // update (one fingerprint recompute over the full N prefix + one message + // append/render). See the file header for the cold-cache caveat. + const appendRenderer = new DeltaRenderer() + bench(`(b) incremental append of 1 event, N=${n}`, () => { + appendRenderer.seedTo(n - 1) + appendRenderer.update(events) + }) + } +})