Pārlūkot izejas kodu

Merge pull request #3650 from deepseek-harness/worktree-feqatest

feat(test-support): whole-client test tier over an endpoint-named Remote mock
imccyu 2 dienas atpakaļ
vecāks
revīzija
25d554d46e
100 mainītis faili ar 6091 papildinājumiem un 1693 dzēšanām
  1. 6 0
      .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml
  2. 90 0
      .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md
  3. 90 0
      .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md
  4. 1 0
      .agents/skills/dsh-pre-push-checks/SKILL.md
  5. 1 0
      THIRD_PARTY_NOTICES.md
  6. 2 2
      docs/config-catalog.i18n.yaml
  7. 1 0
      docs/config-catalog.md
  8. 1 0
      docs/config-catalog.zh.md
  9. 2 2
      docs/module-graph.i18n.yaml
  10. 8 1
      docs/module-graph.md
  11. 8 1
      docs/module-graph.zh.md
  12. 2 2
      packages/api/gateway/README.i18n.yaml
  13. 1 1
      packages/api/gateway/README.md
  14. 1 1
      packages/api/gateway/README.zh.md
  15. 15 2
      packages/api/gateway/src/client/index.ts
  16. 4 1
      packages/api/session-controller/package.json
  17. 109 169
      packages/api/session-controller/tests/client-apply.client.spec.ts
  18. 3 28
      packages/api/session-controller/tests/fake-api.client.ts
  19. 52 60
      packages/api/session-controller/tests/queue-store.client.spec.ts
  20. 28 0
      packages/api/session-controller/tests/remote/bench.client.ts
  21. 48 0
      packages/api/session-controller/tests/remote/history.client.ts
  22. 188 0
      packages/api/session-controller/tests/remote/session.client.ts
  23. 66 88
      packages/api/session-controller/tests/session-pending-submissions.client.spec.ts
  24. 44 0
      packages/api/session-controller/tests/session-remote-rejection.client.spec.ts
  25. 338 426
      packages/api/session-controller/tests/session.client.spec.ts
  26. 2 0
      packages/api/workspace-controller/package.json
  27. 100 0
      packages/api/workspace-controller/tests/remote/workspace.client.ts
  28. 179 300
      packages/api/workspace-controller/tests/transport.client.spec.ts
  29. 10 4
      packages/client/connection/src/client/index.ts
  30. 17 1
      packages/client/connection/tests/client-apply.client.spec.ts
  31. 3 1
      packages/client/hmr/package.json
  32. 19 12
      packages/client/hmr/src/client/index.ts
  33. 2 2
      packages/client/modules/README.i18n.yaml
  34. 1 1
      packages/client/modules/README.md
  35. 1 1
      packages/client/modules/README.zh.md
  36. 1 1
      packages/client/modules/src/client/index.ts
  37. 46 0
      packages/client/modules/src/client/manifest.ts
  38. 1 34
      packages/client/modules/src/index.ts
  39. 1 0
      packages/client/ui-permission-presets/package.json
  40. 7 4
      packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts
  41. 7 6
      packages/client/ui-settings-general/package.json
  42. 181 166
      packages/client/ui-settings-general/tests/apply.client.spec.ts
  43. 83 131
      packages/client/ui-settings-general/tests/shell.client.spec.ts
  44. 1 0
      packages/client/ui-settings-models/package.json
  45. 30 45
      packages/client/ui-settings-models/tests/apply.client.spec.ts
  46. 2 2
      packages/client/web/README.i18n.yaml
  47. 3 1
      packages/client/web/README.md
  48. 3 1
      packages/client/web/README.zh.md
  49. 83 0
      packages/client/web/src/boot-client.ts
  50. 13 62
      packages/client/web/src/boot.ts
  51. 24 0
      packages/client/web/src/mount.ts
  52. 133 0
      packages/client/web/tests/boot-client.client.spec.ts
  53. 47 0
      packages/client/web/tests/mount.client.spec.ts
  54. 2 2
      packages/test-support/README.i18n.yaml
  55. 1 0
      packages/test-support/README.md
  56. 1 0
      packages/test-support/README.zh.md
  57. 2 2
      packages/test-support/client-runtime/README.i18n.yaml
  58. 24 1
      packages/test-support/client-runtime/README.md
  59. 24 1
      packages/test-support/client-runtime/README.zh.md
  60. 24 3
      packages/test-support/client-runtime/package.json
  61. 138 0
      packages/test-support/client-runtime/src/assembly/bundle-roster.ts
  62. 14 0
      packages/test-support/client-runtime/src/assembly/index.ts
  63. 80 0
      packages/test-support/client-runtime/src/assembly/modules.ts
  64. 45 0
      packages/test-support/client-runtime/src/assembly/remote-default-responses.ts
  65. 87 0
      packages/test-support/client-runtime/src/assembly/remote-proxies.ts
  66. 163 0
      packages/test-support/client-runtime/src/assembly/roster.ts
  67. 361 0
      packages/test-support/client-runtime/src/assembly/test-client.ts
  68. 60 0
      packages/test-support/client-runtime/src/assembly/vitest.ts
  69. 0 2
      packages/test-support/client-runtime/src/index.ts
  70. 0 85
      packages/test-support/client-runtime/src/settings-remote.ts
  71. 195 0
      packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts
  72. 37 0
      packages/test-support/client-runtime/tests/assembly-dependencies.client.spec.ts
  73. 105 0
      packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts
  74. 22 0
      packages/test-support/client-runtime/tests/assembly-plan.client.spec.ts
  75. 93 0
      packages/test-support/client-runtime/tests/assembly-remote-proxies.client.spec.ts
  76. 112 0
      packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts
  77. 110 0
      packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts
  78. 178 0
      packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts
  79. 64 0
      packages/test-support/client-runtime/tests/assembly-vitest.client.spec.ts
  80. 0 37
      packages/test-support/client-runtime/tests/remote.client.spec.ts
  81. 26 1
      packages/test-support/client-runtime/tsconfig.json
  82. 6 0
      packages/test-support/remote-mock/README.i18n.yaml
  83. 146 0
      packages/test-support/remote-mock/README.md
  84. 146 0
      packages/test-support/remote-mock/README.zh.md
  85. 42 0
      packages/test-support/remote-mock/package.json
  86. 16 0
      packages/test-support/remote-mock/src/index.ts
  87. 131 0
      packages/test-support/remote-mock/src/log.ts
  88. 385 0
      packages/test-support/remote-mock/src/remote-mock.ts
  89. 29 0
      packages/test-support/remote-mock/src/remote-proxy.ts
  90. 196 0
      packages/test-support/remote-mock/src/streams.ts
  91. 184 0
      packages/test-support/remote-mock/tests/proxy-types.client.spec.ts
  92. 271 0
      packages/test-support/remote-mock/tests/proxy.client.spec.ts
  93. 43 0
      packages/test-support/remote-mock/tests/rpc.client.spec.ts
  94. 198 0
      packages/test-support/remote-mock/tests/streams.client.spec.ts
  95. 107 0
      packages/test-support/remote-mock/tests/unary.client.spec.ts
  96. 21 0
      packages/test-support/remote-mock/tsconfig.json
  97. 6 0
      packages/test-support/remote-mock/tsdown.config.ts
  98. 85 0
      pnpm-lock.yaml
  99. 1 0
      scripts/doc-standard.spec.ts
  100. 1 0
      scripts/verify-package-readme-model-experience.ts

+ 6 - 0
.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.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/testing/2026-09-06-client-assembly-test-line.md
+2026-09-06-client-assembly-test-line.md: 0d7ed86e4f0c8898441737c8d6a4c631586268a2
+2026-09-06-client-assembly-test-line.zh.md: 8b4381dbfa24819e1b1f086b7c318d388b681ef5

+ 90 - 0
.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md

@@ -0,0 +1,90 @@
+# Agent Note: Whole-client test tier over an endpoint-named Remote mock
+
+Status: implemented
+
+English | [中文](2026-09-06-client-assembly-test-line.zh.md)
+
+## Problem
+
+Browser feature specs each hand-build their bench: a bare Cordis context, stand-ins for `locale`, `connection`, and `remote`, and slot declarations the real declarer would have made. Their assertions therefore describe the bench, not the product: a plugin that adds a settings section, a declarer that reloads through the Loader, or a Connection that reconnects is invisible to them, and every bench repeats the same forty lines with small drift.
+
+API client specs drive their objects through a programmable fake of the Remote surface. The fake re-implements Gateway semantics it should only exercise: follow-stream opening snapshots derived from a history list, page cuts, stream pumps with delivery promises, and an envelope layer. Each of those is a second implementation of a contract the product already owns, and it lets tests describe behavior the generated client cannot produce, such as a unary call that rejects.
+
+No focused source test boots the client the way production does — `bootClient` creating one Loader entry per manifest row, then `mountClient` — so composition faults between plugins are hidden by hand-built benches.
+
+## Decision
+
+A whole-client tier lives in `@deepseek-ai/dsh-client-test-runtime` under the deep import `src/assembly/`, and a new test-support package `@deepseek-ai/dsh-remote-mock` answers Remote traffic by endpoint name. Both are described by their READMEs ([client-runtime](../../../../packages/test-support/client-runtime/README.md), [remote-mock](../../../../packages/test-support/remote-mock/README.md)); this note records the decisions behind them.
+
+**The roster is read from the bundles, never copied.** `bundleRoster(bundles)` parses each bundle's `dsh.bundle.patch` with the include plugin's own YAML dialect (`entryListSchema`, which carries `!!js`) and composes the layers with its `applyEntryPatches`, then keeps every enabled row whose package declares `dsh.client.platform === 'web'`, carrying that declaration's `inject` and `immediately`. `webApp` is the `web` profile's roster (`dsh-base`, then `dsh-web-app`), computed at import. A spec names what it tests and derives the rest: `webApp.closure([row])` keeps a row plus its transitive `inject` cone; `pick` and `without` exist for deliberate cuts. The test runtime stays a Client-face package: it imports no Host module, uses no dynamic import for this, and its client-face `types` adds `node` beside `client-build-environment` so the reader can use `node:fs`. `closure` treats the shell's static platform modules (`PLATFORM_MODULES`) as satisfied without a row.
+
+**The production boot path runs unchanged.** `TestClient.start(plan, mock)` installs the mock as the Connection carrier (`__DSH_TRANSPORT__ = { rpc: mock.rpc }`), loads each roster row's `/client` module, registers its factory through the production module facade's `pendingQueue`, boots through `bootClient`, optionally mounts through `mountClient`, and waits for `connected`. `reload(name)` rebuilds a Loader entry through client-hmr's exported `tearDownEntryFiber`; `unload(name)` removes it; `dispose()` tears down and then fails the test on any endpoint that had no rule. jsdom lacks `EventSource` and `ResizeObserver`; `start` installs inert stand-ins only where the global is absent. Boots and entry rebuilds run one at a time per worker, since the `connection` plugin reads the transport global at apply, and each installs the acting client's transport first; the transport and shims are held by reference count, the first client installing them and the last dispose restoring them, so overlapping clients in one test each connect to their own mock, also after a `reload` of the `connection` row.
+
+**`remote.<ns>` is a contract-free proxy, not the generated client.** The `@deepseek-ai/dsh-api-remotes` row is dropped because its generated clients exist only in built `lib/`. For every `remote.<ns>` a roster row injects, plus every namespace the mock has a rule for, the tier provides one Proxy: `ctx.remote.<ns>.<method>(...args)` calls the endpoint `<ns>/<method>` over the roster's own Connection with the positional args, as a stream when the mock registered a stream script for it and as a unary call otherwise. Cordis resolves `ctx.remote.<ns>` to the service `remote.<ns>`, so the Gateway client itself is untouched. A unary answer returns unchanged; a unary rejection folds the way the generated client folds a carrier throw, through the Gateway client's exported `carrierFailure` and `cancelledFailure`, so product code that fires a Remote call without awaiting sees no rejection. Stream items and failures pass through as the stream yields them.
+
+**Native mocks own response configuration and call assertions.** Tests use `mock.remote.<namespace>.<method>` with `mockResolvedValue`, `mockResolvedValueOnce`, `mockReturnValueOnce`, or `mockImplementation`; the generated API supplies the signatures. Each mock instance owns its native response queue. Reusable tables register only default values or positional handlers, and each endpoint retains only its latest default. Stateful callbacks and deferred promises belong to individual tests. `ok` builds the success envelope. Streams require explicit declarations and can receive scripts over the opening args and a handle (`push`, `end`, `fail`); a declaration without a script produces a stream miss, while undeclared endpoints default to unary calls. Values are not validated. `mock.streams` controls scripted streams and exposes readiness/drain waits; `mock.log` records carrier calls (`pending`, `answered`, `failed`), scripted-stream state, first-argument `requests(endpoint?)`, and unmatched requests. `RemoteMock.create()` answers `$events` with a ready frame so the client can connect.
+
+**Vitest owns each test's mock and client lifetime.** `createClientTest(plan, options)` adds native `mock`, `remote`, and `start` fixtures. The mock is fresh and carries the default responses; `remote` is its namespace proxy, and explicit `start()` leaves startup responses configurable and shares one startup promise within the test. Teardown waits for startup, disposes the successful client even after an assertion failure, checks missing responses, and rejects later starts. Callers await startup failures. Independently owned clients still use `TestClient.start`. Scenario data configures native mocks directly; returning a mutation response and updating subsequent describe responses remain separate actions.
+
+**`remoteDefaultResponses` holds default responses for the boot-time Remote endpoints.** The table lists exactly the endpoints the `web` roster calls while booting and rendering with no sessions, no workspaces, and default settings, each row commented with its caller. A spec layers its own `RemoteTable` on top; a new boot-time call fails the spec at `dispose()`.
+
+`mock.remote` uses native `@vitest/spy.fn` functions shared by direct callers and Connection dispatch. `MockedRemote` applies Vitest's deep mock type transformation to the entire generated namespace map; an empty map weakens only this proxy to `any`. Production `Context` and Remote declarations remain strict, with no namespace-specific type copies or compiler flags. The [proxy typing guidance](../../../../packages/test-support/remote-mock/README.md#remote-proxy) requires build-backed local type checking even when unbuilt tests pass.
+
+## Product exports added for the tier
+
+- `client/connection`: `ClientTransportHooks.rpc?` publishes the already decoded carrier the `?fixture` path used internally; `fetch` becomes optional.
+- `client/hmr`: `tearDownEntryFiber(entry)` is the registry-first fiber teardown `reload` already performed.
+- `client/modules`: `parseDshClient` and `exactPackageSpecifier` are exported from the client face and shared by the Host and roster reader. Test factories use the existing registration queue. The roster-to-boot-graph synthesis has only test consumers and lives in the tier.
+- `client/web`: `bootClient` and `mountClient` are extracted from `AppWebEntry.run()`, which now calls them.
+- `api/gateway`: `carrierFailure` and `cancelledFailure` are exported so a stand-in for the generated client folds identically.
+
+## Alternatives considered
+
+**Running the generated `/remote` clients from built `lib/`.** Rejected: it makes source-plane specs depend on a build artifact, and the proxies need only the unary-or-stream declaration the mock already holds.
+
+**A generated static roster module with a drift gate.** Rejected after review: it is a copy of bundle data inside the test package, and every subset written against it is a hand-list that misses rows. Reading the bundles at import through the include plugin's own schema and patch application removes the copy, the generator, and the gate.
+
+**A Host compile face for the test runtime, a dynamic import of a Host module, or a vitest `globalSetup` handing rosters through `provide`/`inject`.** Rejected: a Client test runtime must not import Host code, dynamic imports hide the dependency, and a config-level channel hides the roster's source. The composition functions the launcher uses are face-neutral, so none of these is needed.
+
+**A hand-written test-side YAML and patch parser.** Rejected: `entryListSchema` and `applyEntryPatches` are the launcher's own and carry no Host Context merge; the tier writes only file reading, package.json location, and the web-row filter.
+
+**A mock module standing in for the Gateway client.** Rejected: the mock must not interfere with Gateway internals; installing it on the Connection carrier keeps retry, folding, and stream semantics real.
+
+**A second typed Gateway implementation with an `Api` generic, envelope and error classes, and a fixtures directory.** Rejected: it duplicates Gateway declarations and encoding. The mock derives method types from the generated namespace map and declares only unary or stream behavior at runtime.
+
+**Proxies passing unary rejections through unchanged.** Rejected: product code never awaits a Remote rejection because the generated client folds carrier throws, so an unmatched endpoint produced unhandled rejections; folding through the exported helpers restores the client's face.
+
+**Keeping CallContext and wrapping native spies in an adapter.** Rejected: it makes each test unwrap a synthetic call and retains counter/state machinery with no business-spec consumer. Positional handlers use the existing test ecosystem directly.
+
+**A separate `once` / `sequence` DSL and fallback rule stack.** Rejected: native per-instance queues already express the deferred responses and temporary failures used by consumers. Immutable table declarations with a cursor per registration would allow shared one-shot tables, but no current shared table requires them. The tier gives up newest-registration-first fallback and table-level repeat-last declarations; tests use native queue order and a persistent default instead. Response values and stateful handlers remain borrowed, not cloned.
+
+**A separate pre-materialized-module option or `staticModules`.** Rejected: the existing pending registration queue accepts the same factories before Loader startup. `staticModules` bypasses factory materialization and does not share graph prefetch/invalidation behavior; the queue removes the extra option without losing that behavior. Assembly-only helpers remain in their leaf modules rather than the recommended entry's exports.
+
+**A compiler-wide fallback flag or private augmentation package.** Rejected: declaration merging affects every file in a TypeScript Program that reaches the import; `private: true` only prevents publication. Separate test compiler graphs and additional policy checks add configuration maintenance without narrowing the fallback to its actual helper consumers. A local conditional type limits weakened inference to those consumers.
+
+**Per-namespace helpers with selected method lists and separate spy aliases.** Rejected: they repeat operation names and controls already supplied by native mocks. The generic proxy derives every method from the production namespace map, while fixtures own the returned data rather than a second implementation of domain writes or publication.
+
+## Consequences
+
+Specs boot real plugins: the whole `web` roster costs about five seconds cold and well under a second warm, and a three-row cone about twenty milliseconds per test. Assertions read product facts — the real section list, the real declarer, a Loader rebuild, a second `$events` generation on reconnect — and change when the product changes.
+
+The proxies skip the generated client's zod validation, wire-name mapping, and scoped-identity injection; mock rules read positional `args`, and the generated clients stay covered by the built-artifact e2e lanes. `remoteDefaultResponses` must gain a row when a plugin adds a boot-time call, and fails loud until it does. The two bundle names of the `web` profile are repeated once in `WEB_PROFILE_BUNDLES`, mirroring the launcher's `PROFILE_TEMPLATES.web`, and no check links the two: the client test program cannot import `@deepseek-ai/dsh-app-boot`, whose Host `Context` merges collide with the Client ones, and the test runtime takes no Host dependency even for tests. A template change therefore has to be carried to that constant by hand.
+
+The shared functions keep production and test callers on one implementation. `AppWebEntry.run()` mounts the Loader after the immediate-tier prefetch settles; application entry creation remains after prefetch, so serializing Loader setup with prefetch does not advance application activation.
+
+Native stream overrides may return their own iterable. The caller then owns consumption and cancellation; these iterables bypass scripted-stream logs and controls. Registered scripts retain managed queues and cancellation. This distinction preserves native mock behavior without adding another iterator wrapper or changing pull timing.
+
+## Deferred
+
+Product facts the tier surfaced and leaves as they are:
+
+- No `declare module` augmentation declares `Context.connection`; consumers read `ctx.get('connection') as ConnectionHandle`, and `TestClient.connection` is the typed entry the tier offers.
+- `TestClient.start` has no page-URL option, so a spec that needs the `connection` plugin to classify the page as off-loopback reconfigures the jsdom instance vitest exposes on `globalThis.jsdom`, a private detail of the jsdom environment provider.
+- `ISessions` exposes no queue observation point, so queue frames reach a `Session` through `handleControlFrame` directly rather than over the `session/control` stream.
+- A durable event pushed twice with the same seq is dropped at the tail of `RemoteJournalStream` as a replay and never reaches `SessionQueueMirror.acceptDurable`.
+- The vendored Loader rejects `create()` when a module import fails, so the import-failed branch of `assertEntriesActive` is unreachable through `create()`.
+- The session-controller client casts `ctx.remote as unknown as SessionRemotes`; in the client test program the cast is redundant, since the generated `/remote` merges are visible there.
+
+## Testing
+
+`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, and `TestClient` under jsdom and plain Node. Seven converted specs use the tier. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/api/session-controller/tests/`, the Session, queue-store, and pending-submission specs drive their objects over the roster's real Connection through the `remote.<ns>` proxies, with the Gateway client's own `$stream` retry loop, over the gateway's dependency cone, and the client-apply spec boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state.

+ 90 - 0
.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md

@@ -0,0 +1,90 @@
+# Agent Note: 基于端点具名 Remote mock 的整机客户端测试档
+
+Status: implemented
+
+[English](2026-09-06-client-assembly-test-line.md) | 中文
+
+## 问题
+
+浏览器功能 spec 各自手拼测试台:一个裸 Cordis context、`locale`、`connection`、`remote` 的替身,以及本该由真声明者做出的 slot 声明。它们的断言因此描述的是测试台而不是产品:一个插件新增了设置 section、一个声明者经 Loader 重载、一个 Connection 重连,对它们都不可见,而每个测试台都重复着同样的四十行并各有细小漂移。
+
+API 客户端 spec 用一个可编程的 Remote 面假件驱动对象。这个假件重新实现了它本该只是调用的 Gateway 语义:从历史列表推导 follow 流的开场快照、切页、带投递 promise 的流泵,以及一层信封。每一样都是产品已有契约的第二份实现,它还让测试描述出生成客户端做不出来的行为,比如一次会 reject 的一元调用。
+
+没有聚焦源码测试按生产方式起客户端——`bootClient` 按 manifest 每行建一个 Loader entry,再 `mountClient`——插件之间的组合故障会被手工测试台遮住。
+
+## 决定
+
+整机档放在 `@deepseek-ai/dsh-client-test-runtime` 的深 import `src/assembly/` 下,新的 test-support 包 `@deepseek-ai/dsh-remote-mock` 按端点名应答 Remote 流量。两者的用法由各自 README 描述([client-runtime](../../../../packages/test-support/client-runtime/README.zh.md)、[remote-mock](../../../../packages/test-support/remote-mock/README.zh.md));本文记录它们背后的决定。
+
+**roster 从 bundle 现读,绝不拷贝。** `bundleRoster(bundles)` 用 include 插件自己的 YAML 方言(带 `!!js` 的 `entryListSchema`)解析每个 bundle 的 `dsh.bundle.patch`,用它的 `applyEntryPatches` 合成各层,再保留每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行,带上该声明的 `inject` 与 `immediately`。`webApp` 是 `web` profile 的 roster(先 `dsh-base`、再 `dsh-web-app`),import 时算出。spec 点名它要测的东西,其余推导:`webApp.closure([row])` 保留一行及其传递 `inject` 锥;`pick` 与 `without` 留给刻意裁剪。测试运行时仍是 Client 面的包:它不 import 任何 Host 模块,此处不用动态 import,其 client 面的 `types` 在 `client-build-environment` 之外加了 `node`,好让读取器使用 `node:fs`。`closure` 把 shell 静态种入的平台模块(`PLATFORM_MODULES`)视为无需行即已满足。
+
+**生产启动路径原样运行。** `TestClient.start(plan, mock)` 把 mock 装成 Connection 载体(`__DSH_TRANSPORT__ = { rpc: mock.rpc }`),加载每个 roster 行的 `/client` 模块,经生产模块 facade 的 `pendingQueue` 登记其工厂,经 `bootClient` 启动,可选经 `mountClient` 挂载,然后等 `connected`。`reload(name)` 经 client-hmr 导出的 `tearDownEntryFiber` 重建一个 Loader entry;`unload(name)` 移除它;`dispose()` 拆掉一切,然后对任何没有规则的端点让测试失败。jsdom 没有 `EventSource` 与 `ResizeObserver`;`start` 只在全局缺失处装惰性替身。每个 worker 内启动与 entry 重建逐个进行,因为 `connection` 插件在 apply 时读传输全局,每次都先装上当事客户端的传输;传输与替身按引用计数持有,第一个客户端安装、最后一次 dispose 恢复,因此同一测试里重叠的客户端各连各的 mock,`reload` 了 `connection` 行之后也是。
+
+**`remote.<ns>` 是无契约代理,不是生成客户端。** `@deepseek-ai/dsh-api-remotes` 行被去掉,因为它生成的客户端只存在于构建后的 `lib/`。对 roster 行注入的每个 `remote.<ns>`,加上 mock 有规则的每个命名空间,本档各提供一个 Proxy:`ctx.remote.<ns>.<method>(...args)` 经 roster 自己的 Connection 用位置参数调用端点 `<ns>/<method>`,mock 为它登记了流脚本就走流、否则走一元。Cordis 把 `ctx.remote.<ns>` 解析到服务 `remote.<ns>`,所以 Gateway 客户端本身不动。一元应答原样返回;一元拒绝按生成客户端折叠载体抛错的方式折叠,经 Gateway 客户端导出的 `carrierFailure` 与 `cancelledFailure`,因此不等待就发出 Remote 调用的产品代码看不到任何 reject。流的项与失败按流吐出的样子直传。
+
+**原生 mock 负责响应配置和调用断言。** 测试通过 `mock.remote.<namespace>.<method>` 使用 `mockResolvedValue`、`mockResolvedValueOnce`、`mockReturnValueOnce` 或 `mockImplementation`,签名由生成的 API 提供。每个 mock 实例独立持有原生响应队列。可复用的表只登记默认值或位置参数 handler,每个端点仅保留最新默认响应。有状态回调和 deferred promise 归各测试所有。`ok` 构造成功信封。流需要显式声明,可提供接收打开参数与句柄(`push`、`end`、`fail`)的脚本;无脚本的声明产生流漏配,未声明端点默认走一元。值不校验。`mock.streams` 控制脚本流并提供打开/排空等待;`mock.log` 记录载体调用(`pending`、`answered`、`failed`)、脚本流状态、首参数 `requests(endpoint?)` 和未匹配请求。`RemoteMock.create()` 为 `$events` 应答 ready 帧,让客户端可以连接。
+
+**Vitest 拥有每条测试的 mock 和客户端生命周期。** `createClientTest(plan, options)` 增加原生 `mock`、`remote` 与 `start` fixture。mock 每次新建并携带默认响应;`remote` 是它的命名空间 Proxy,显式 `start()` 留出配置启动期应答的时机,同一测试共用一个启动 Promise。收尾等待启动,即使断言失败也销毁成功创建的客户端、检查漏配,并拒绝后续启动。启动错误由调用方 await 观察。分别拥有多个客户端时仍用 `TestClient.start`。场景数据直接配置原生 mock;返回 mutation 应答与更新后续 describe 应答仍是两个独立操作。
+
+**`remoteDefaultResponses` 是启动期 Remote 端点的默认响应。** 这张表恰好列出 `web` roster 在没有 session、没有 workspace、默认设置下启动并渲染时会打的端点,每行注明调用方。spec 在其上叠加自己的 `RemoteTable`;新的启动期调用会在 `dispose()` 时让 spec 失败。
+
+`mock.remote` 使用直接调用方与 Connection 分发共用的原生 `@vitest/spy.fn` 函数。`MockedRemote` 对完整生成的命名空间映射应用 Vitest 深层 mock 类型转换;映射为空时仅这个 Proxy 弱化为 `any`。生产 `Context` 与 Remote 声明保持严格,不需要命名空间专属类型副本或编译器 Flag。[Proxy 类型指引](../../../../packages/test-support/remote-mock/README.zh.md#remote-proxy)要求即使无构建测试通过,本地也必须执行构建后的类型检查。
+
+## 为本档新增的产品导出
+
+- `client/connection`:`ClientTransportHooks.rpc?` 公开 `?fixture` 路径内部已在用的已解码载体;`fetch` 变为可选。
+- `client/hmr`:`tearDownEntryFiber(entry)` 就是 `reload` 本来执行的 registry 先行的 fiber 拆除。
+- `client/modules`:`parseDshClient` 与 `exactPackageSpecifier` 从 client 面导出,由 Host 和 roster 读取器共用。测试工厂使用已有注册队列。roster 行到 boot graph 的合成只有测试消费者,放在本档里。
+- `client/web`:`bootClient` 与 `mountClient` 从 `AppWebEntry.run()` 抽出,后者现在调用它们。
+- `api/gateway`:导出 `carrierFailure` 与 `cancelledFailure`,让生成客户端的替身折叠得一模一样。
+
+## 考虑过的替代方案
+
+**从构建后的 `lib/` 运行生成的 `/remote` 客户端。** 否决:它让源码面的 spec 依赖构建产物,而代理只需要 mock 已持有的一元或流声明。
+
+**带漂移门禁的生成静态 roster 模块。** 评审后否决:它是测试包内的一份 bundle 数据拷贝,基于它写的每个子集都是会漏行的手列清单。用 include 插件自己的 schema 与补丁应用在 import 时读 bundle,把拷贝、生成器和门禁一起去掉。
+
+**给测试运行时加 Host 编译面、动态 import 一个 Host 模块,或用 vitest `globalSetup` 经 `provide`/`inject` 传 roster。** 否决:Client 测试运行时不得 import Host 代码,动态 import 藏起依赖,配置层通道藏起 roster 的来源。启动器用的合成函数本就面中立,这些都不需要。
+
+**自写一套测试侧的 YAML 与补丁解析器。** 否决:`entryListSchema` 与 `applyEntryPatches` 就是启动器自己的,不带 Host Context 合并;本档只写读文件、定位 package.json 和 web 行过滤。
+
+**用一个 mock 模块替代 Gateway 客户端。** 否决:mock 不得干涉 Gateway 内部;装在 Connection 载体上让重试、折叠与流语义都保持真实。
+
+**第二套带类型的 Gateway 实现,包含 `Api` 泛型、信封与错误类以及 fixtures 目录。** 否决:它重复 Gateway 声明与编解码。mock 从生成的命名空间映射派生方法类型,运行时只声明一元或流行为。
+
+**代理把一元拒绝原样直传。** 否决:产品代码从不等待 Remote 的 reject,因为生成客户端会折叠载体抛错,于是没匹配的端点造成未处理的拒绝;经导出的辅助函数折叠恢复了客户端的面。
+
+**保留 CallContext,再用适配器包装原生 spy。** 否决:每个测试都要解开合成调用对象,还保留没有业务 spec 消费者的计数/状态机制。位置参数 handler 直接使用现有测试生态。
+
+**独立的 `once` / `sequence` DSL 与回退规则栈。** 否决:按实例持有的原生队列已经能表达消费方使用的延迟响应和临时失败。不可变表声明配合每次登记的游标虽能共享一次性响应表,但当前没有共享表需要它。本档放弃最新登记优先的回退和表级末项重复声明,测试改用原生队列顺序与持续默认响应。响应值和有状态 handler 仍按引用借用,不做克隆。
+
+**独立的预加载模块选项或 `staticModules`。** 否决:现有待注册队列能在 Loader 启动前接收同样的工厂。`staticModules` 绕过工厂物化,也不共享图的预取/失效行为;队列在保留这些行为的同时省掉额外选项。仅装配内部使用的 helper 保留在叶模块,不从推荐入口再导出。
+
+**编译器级全局降级 Flag 或私有类型增补包。** 否决:声明合并会影响同一 TypeScript Program 中能够到达该导入的所有文件;`private: true` 只阻止发布。拆分测试编译图或增加策略检查会增加配置维护成本,却不能把降级限制在真正使用它的 Helper 中。局部条件类型只弱化这些消费方的类型推断。
+
+**为每个命名空间编写方法清单和独立 spy 别名的 Helper。** 否决:它们重复操作名称和原生 mock 已经提供的控制功能。通用 Proxy 从生产命名空间映射派生每个方法,fixture 拥有返回数据,而不再实现另一份领域写入或发布机制。
+
+## 后果
+
+spec 起的是真插件:整个 `web` roster 冷启动约五秒、热启动远低于一秒,三行的锥每例约二十毫秒。断言读的是产品事实——真实的 section 清单、真实的声明者、一次 Loader 重建、重连时的第二代 `$events`——并随产品变化而变化。
+
+代理跳过了生成客户端的 zod 校验、wire 字段名映射与 scoped 身份注入;mock 规则读位置 `args`,生成客户端仍由构建产物 e2e 车道覆盖。插件新增启动期调用时 `remoteDefaultResponses` 必须加一行,加之前会响亮失败。`web` profile 的两个 bundle 名在 `WEB_PROFILE_BUNDLES` 里重复了一次,对应启动器的 `PROFILE_TEMPLATES.web`,且两者之间没有机检联系:客户端测试程序不能 import `@deepseek-ai/dsh-app-boot`(其 Host `Context` 合并与 Client 的冲突),而测试运行时连测试也不引入 Host 依赖。模板变更因此要靠人工带到这个常量。
+
+共享函数让生产和测试调用方使用同一份实现。`AppWebEntry.run()` 在立即层预取落定后挂载 Loader;应用 entry 仍在预取之后创建,因此让 Loader 安装与预取串行不会提前应用激活。
+
+原生流覆盖可以返回自有 iterable,此时调用方负责消费与取消,这些 iterable 不参与脚本流日志或控制。已登记的脚本仍使用受控队列与取消机制。这一区分保留原生 mock 行为,无需再添加 iterator 包装器或改变拉取时机。
+
+## 遗留事项
+
+本档暴露出来、原样保留的产品事实:
+
+- 没有任何 `declare module` 增强声明 `Context.connection`;消费方一律 `ctx.get('connection') as ConnectionHandle`,`TestClient.connection` 是本档提供的带类型入口。
+- `TestClient.start` 没有页面 URL 选项,需要 `connection` 插件把页面判为 off-loopback 的 spec 只能重配 vitest 挂在 `globalThis.jsdom` 上的 jsdom 实例,这是 jsdom 环境提供者的私有细节。
+- `ISessions` 没有 queue 观察点,queue 帧只能直接经 `handleControlFrame` 到达 `Session`,而不是走 `session/control` 流。
+- 同 seq 的 durable 事件二次推送在 `RemoteJournalStream` 尾部被当作重放丢弃,到不了 `SessionQueueMirror.acceptDurable`。
+- vendored Loader 在模块 import 失败时让 `create()` reject,因此 `assertEntriesActive` 的 import 失败分支经 `create()` 不可达。
+- session-controller 客户端把 `ctx.remote` cast 成 `SessionRemotes`;在客户端测试程序里这个 cast 是多余的,因为生成的 `/remote` 合并在那里可见。
+
+## 测试
+
+`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理,以及 jsdom 与纯 Node 下的 `TestClient`。七条改造后的 spec 使用本档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission 三条在 gateway 依赖锥上经 `remote.<ns>` 代理走 roster 的真 Connection 驱动对象(`$stream` 的重试循环仍是 Gateway 客户端自己的),client-apply 起插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。

+ 1 - 0
.agents/skills/dsh-pre-push-checks/SKILL.md

@@ -31,6 +31,7 @@ There is no universal local baseline beyond the hooks. Every behavior change nee
 When the outgoing change adds or changes a resource-owning or asynchronous test, fixture, helper, or CI execution path, use [dsh-ci-test-reliability](../dsh-ci-test-reliability/SKILL.md) first to decide whether restoration, negative-control, quiescent-teardown, or concurrent-process evidence applies. This skill still selects the commands and avoids repeating evidence that already passed.
 
 - **Package or script behavior:** run the owning Vitest file or focused test name. Add adjacent package tests when a shared contract changes; leave repository-wide coverage to CI unless the change is genuinely cross-cutting or the user requests it.
+- **Remote mock typing:** unbuilt `any` is an explicit local fallback, not strict evidence. Run `pnpm run typecheck` before handing off Remote/mock changes; rebuild missing, stale, or partial generated declarations before diagnosing remaining errors. Keep the exception in the [test proxy](../../../packages/test-support/remote-mock/README.md#remote-proxy), never in production Remote types, ambient flags, or copied signatures.
 - **Documentation, Agent Notes, catalogs, or doc-linked comments:** run `pnpm run doc-sync`; run full lint when the documentation workflow requires it.
 - **Model-, editor-, CLI-, or terminal-visible output:** run the focused keyless snapshot or real runnable-example scenario that owns the output.
 - **Expected-output placement:** a test whose selected recorded Session generation is replay input and expected persisted output belongs under top-level `snapshots/`, with `snapshot.yml` naming its shipped `dsh` profile and composition/header pin. Canonical parent files are `session[.vN].jsonl`, children are `session.<ordinal>[.vN].jsonl`, and the harness selects the highest generation per role. ARIA, geometry, generator, CLI, and unit expectations without that Session round trip stay beside their owning test under `tests/expected/`; do not place them in `snapshots/` or give them a `*.snapshot.ts` owner. Use the owning `test:expected`, `test:web`, or `test` lane.

+ 1 - 0
THIRD_PARTY_NOTICES.md

@@ -170,6 +170,7 @@ External packages **directly declared** for development, tests, types, or toolin
 | [`@types/ws`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT |
 | [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT |
+| [`@vitest/spy`](https://github.com/vitest-dev/vitest) | MIT |
 | [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) | MIT |
 | [`@yarnpkg/cli-dist`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
 | [`app-builder-lib`](https://github.com/electron-userland/electron-builder) | MIT |

+ 2 - 2
docs/config-catalog.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 docs/config-catalog.md
-config-catalog.md: f33b351fea170124bb1ebc2b3fdf18be7539c65d
-config-catalog.zh.md: 7d69c23e7fed3584a320cee1746ffcf660040193
+config-catalog.md: 9c774217aca68a1ac1dfce73cebb666af3781729
+config-catalog.zh.md: bba70aad7fab71f44e152b61bfb3873f81a543f9

+ 1 - 0
docs/config-catalog.md

@@ -3579,6 +3579,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
 - `@deepseek-ai/dsh-native-command` ([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts))
 - `@deepseek-ai/dsh-output-retention` ([`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts))
 - `@deepseek-ai/dsh-package-manifest` ([`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts))
+- `@deepseek-ai/dsh-remote-mock` ([`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts))
 - `@deepseek-ai/dsh-sandbox-windows-acl` ([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts))
 - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
 - `@deepseek-ai/dsh-sdk-client` ([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts))

+ 1 - 0
docs/config-catalog.zh.md

@@ -3580,6 +3580,7 @@ export interface Config {
 - `@deepseek-ai/dsh-native-command`([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts))
 - `@deepseek-ai/dsh-output-retention`([`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts))
 - `@deepseek-ai/dsh-package-manifest` ([`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts))
+- `@deepseek-ai/dsh-remote-mock` ([`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts))
 - `@deepseek-ai/dsh-sandbox-windows-acl`([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts))
 - `@deepseek-ai/dsh-scope`([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
 - `@deepseek-ai/dsh-sdk-client`([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts))

+ 2 - 2
docs/module-graph.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 docs/module-graph.md
-module-graph.md: cbaa8cb04a7ec2406b1f6e7f2c1dbf0a8dff0527
-module-graph.zh.md: 148b2730fccc1740d1c2f24d766f2aa7213093cf
+module-graph.md: 9e1f873b5690838c8a680ddf4ee3dd9c009cec80
+module-graph.zh.md: 81abb4bb4959ad72901bfda270e08dccd237c256

+ 8 - 1
docs/module-graph.md

@@ -353,6 +353,7 @@ flowchart TD
     pkg_llm_mock_server["llm-mock-server"]
     pkg_llm_replay["llm-replay"]
     pkg_loader_smoke["loader-smoke"]
+    pkg_remote_mock["remote-mock"]
     pkg_session_snapshot["session-snapshot"]
   end
   subgraph group_typert["packages/typert"]
@@ -1156,10 +1157,13 @@ flowchart TD
   pkg_sdk_jsonrpc_server --> pkg_sdk_protocol
   pkg_sdk_jsonrpc_server --> pkg_session
   pkg_sdk_jsonrpc_server --> pkg_subagent
+  pkg_client_test_runtime --> pkg_api_gateway
   pkg_client_test_runtime --> pkg_api_session_controller
   pkg_client_test_runtime --> pkg_api_workspace_controller
   pkg_client_test_runtime --> pkg_attachment
   pkg_client_test_runtime --> pkg_client_connection
+  pkg_client_test_runtime --> pkg_client_hmr
+  pkg_client_test_runtime --> pkg_client_modules
   pkg_client_test_runtime --> pkg_client_store
   pkg_client_test_runtime --> pkg_client_ui_chat
   pkg_client_test_runtime --> pkg_client_ui_conversation
@@ -1167,6 +1171,8 @@ flowchart TD
   pkg_client_test_runtime --> pkg_client_ui_session
   pkg_client_test_runtime --> pkg_client_ui_settings
   pkg_client_test_runtime --> pkg_client_ui_slots
+  pkg_client_test_runtime --> pkg_client_web
+  pkg_client_test_runtime --> pkg_remote_mock
   pkg_client_test_runtime --> pkg_session
   pkg_client_test_runtime --> pkg_subagent
   pkg_client_test_runtime --> pkg_typert_protocol
@@ -1274,6 +1280,7 @@ flowchart TD
 | [`storage`](../packages/storage/storage) | `storage` | — |
 | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — |
 | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — |
+| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | — |
 | [`typert-generator`](../packages/typert/generator) | `typert` | — |
 | [`typert-protocol`](../packages/typert/protocol) | `typert` | — |
 | [`typert-registry`](../packages/typert/registry) | `typert` | — |
@@ -1445,5 +1452,5 @@ flowchart TD
 | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
 | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
-| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
+| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
 | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |

+ 8 - 1
docs/module-graph.zh.md

@@ -355,6 +355,7 @@ flowchart TD
     pkg_llm_mock_server["llm-mock-server"]
     pkg_llm_replay["llm-replay"]
     pkg_loader_smoke["loader-smoke"]
+    pkg_remote_mock["remote-mock"]
     pkg_session_snapshot["session-snapshot"]
   end
   subgraph group_typert["packages/typert"]
@@ -1158,10 +1159,13 @@ flowchart TD
   pkg_sdk_jsonrpc_server --> pkg_sdk_protocol
   pkg_sdk_jsonrpc_server --> pkg_session
   pkg_sdk_jsonrpc_server --> pkg_subagent
+  pkg_client_test_runtime --> pkg_api_gateway
   pkg_client_test_runtime --> pkg_api_session_controller
   pkg_client_test_runtime --> pkg_api_workspace_controller
   pkg_client_test_runtime --> pkg_attachment
   pkg_client_test_runtime --> pkg_client_connection
+  pkg_client_test_runtime --> pkg_client_hmr
+  pkg_client_test_runtime --> pkg_client_modules
   pkg_client_test_runtime --> pkg_client_store
   pkg_client_test_runtime --> pkg_client_ui_chat
   pkg_client_test_runtime --> pkg_client_ui_conversation
@@ -1169,6 +1173,8 @@ flowchart TD
   pkg_client_test_runtime --> pkg_client_ui_session
   pkg_client_test_runtime --> pkg_client_ui_settings
   pkg_client_test_runtime --> pkg_client_ui_slots
+  pkg_client_test_runtime --> pkg_client_web
+  pkg_client_test_runtime --> pkg_remote_mock
   pkg_client_test_runtime --> pkg_session
   pkg_client_test_runtime --> pkg_subagent
   pkg_client_test_runtime --> pkg_typert_protocol
@@ -1276,6 +1282,7 @@ flowchart TD
 | [`storage`](../packages/storage/storage) | `storage` | — |
 | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — |
 | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — |
+| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | — |
 | [`typert-generator`](../packages/typert/generator) | `typert` | — |
 | [`typert-protocol`](../packages/typert/protocol) | `typert` | — |
 | [`typert-registry`](../packages/typert/registry) | `typert` | — |
@@ -1447,5 +1454,5 @@ flowchart TD
 | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
 | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
-| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
+| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
 | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |

+ 2 - 2
packages/api/gateway/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/api/gateway/README.md
-README.md: a234189cfff09bae894de14dada607a357b398fd
-README.zh.md: 7a6e66724724efd83173835095a409c527faccef
+README.md: 561081c3a5de716e563eaa9764f387e329365504
+README.zh.md: 9588b194efc64d14a17a69323a14d6118d0e7d7e

+ 1 - 1
packages/api/gateway/README.md

@@ -43,7 +43,7 @@ Host composition can register one application event source through `registerRemo
 
 Each unary call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. A generated stream method returns an `AsyncIterable` and opens one logical stream through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Unary results and every stream item are validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject.
 
-Every unary call resolves to `RemoteResult<T>` — `{ ok: true, value }` or `{ ok: false, error }` — and never rejects for a carrier problem: this face folds an offline carrier into the error branch and answers `gateway/cancelled` when the caller's signal aborts, so no consumer wraps a call to recover one. Only an assembly fault still rejects: wrong arity, an unmounted method, a withdrawn contribution, a missing Context adapter. `error` is a live `RemoteError` instance, so `throw result.error` keeps throw semantics, and `isRemoteFailure(value)` is the one predicate a consumer needs — a caught value it accepts carries a Host code, and anything it rejects is a local fault the caller should let crash.
+Every unary call resolves to `RemoteResult<T>` — `{ ok: true, value }` or `{ ok: false, error }` — and never rejects for a carrier problem: this face folds an offline carrier into the error branch and answers `gateway/cancelled` when the caller's signal aborts, so no consumer wraps a call to recover one. Only an assembly fault still rejects: wrong arity, an unmounted method, a withdrawn contribution, a missing Context adapter. `error` is a live `RemoteError` instance, so `throw result.error` keeps throw semantics, and `isRemoteFailure(value)` is the one predicate a consumer needs — a caught value it accepts carries a Host code, and anything it rejects is a local fault the caller should let crash. `carrierFailure(endpoint, error)` and `cancelledFailure(endpoint, cause)` build those two folded results, so a test stand-in for this face folds identically.
 
 `ctx.remote.$host` reads the fixed Host facts as plain values: `home` (undefined until the first ready frame) and `isLoopback`. It is not a store — no subscription, no generation counter — so a consumer that must react to reconnection listens for `connection/reset` instead of polling it.
 

+ 1 - 1
packages/api/gateway/README.zh.md

@@ -43,7 +43,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source
 
 每次一元调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的流方法返回 `AsyncIterable`,并在进程内 Connection 载体可用时通过它打开逻辑流,否则通过共享的 Gateway WebSocket 打开。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。一元结果和每个流项都经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。
 
-每次一元调用都解析为 `RemoteResult<T>`——`{ ok: true, value }` 或 `{ ok: false, error }`——且绝不因载体问题 reject:本面把断线载体折入错误分支,调用方 signal 中止时答以 `gateway/cancelled`,因此没有消费方需要包一层来兜载体失败。只有装配故障仍会 reject:参数个数不符、方法未挂载、贡献已撤下、缺少 Context 适配器。`error` 是活的 `RemoteError` 实例,所以 `throw result.error` 保持 throw 语义;而 `isRemoteFailure(value)` 是消费方唯一需要的谓词——它认下的捕获值带着 Host 码,它拒绝的一律是本地故障,调用方应当让其崩掉。
+每次一元调用都解析为 `RemoteResult<T>`——`{ ok: true, value }` 或 `{ ok: false, error }`——且绝不因载体问题 reject:本面把断线载体折入错误分支,调用方 signal 中止时答以 `gateway/cancelled`,因此没有消费方需要包一层来兜载体失败。只有装配故障仍会 reject:参数个数不符、方法未挂载、贡献已撤下、缺少 Context 适配器。`error` 是活的 `RemoteError` 实例,所以 `throw result.error` 保持 throw 语义;而 `isRemoteFailure(value)` 是消费方唯一需要的谓词——它认下的捕获值带着 Host 码,它拒绝的一律是本地故障,调用方应当让其崩掉。`carrierFailure(endpoint, error)` 与 `cancelledFailure(endpoint, cause)` 构造这两种折叠结果,测试里的替代实现据此采用相同的折叠方式。
 
 `ctx.remote.$host` 以普通值读取固定的 Host 事实:`home`(首个 ready 帧之前为 undefined)与 `isLoopback`。它不是存储——没有订阅、没有代次计数——所以需要响应重连的消费方去监听 `connection/reset`,而不是轮询它。
 

+ 15 - 2
packages/api/gateway/src/client/index.ts

@@ -728,11 +728,24 @@ function withdrawn(endpoint: string): Extract<RemoteResult<never>, { readonly ok
   return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`)
 }
 
-function carrierFailure(endpoint: string, error: unknown): Extract<RemoteResult<never>, { readonly ok: false }> {
+/**
+ * The error branch a carrier throw (offline, transport fault) folds into: `gateway/internal` naming the endpoint and
+ * the thrown message. Exported so a stand-in for this face folds identically.
+ * @param endpoint - `<namespace>/<method>` that was called.
+ * @param error - what the carrier threw.
+ * @returns the failed result.
+ */
+export function carrierFailure(endpoint: string, error: unknown): Extract<RemoteResult<never>, { readonly ok: false }> {
   return internalFailure(`client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`)
 }
 
-function cancelledFailure(endpoint: string, cause: unknown): Extract<RemoteResult<never>, { readonly ok: false }> {
+/**
+ * The error branch a call aborted by its caller folds into: `gateway/cancelled` with the carrier's throw as `cause`.
+ * @param endpoint - `<namespace>/<method>` that was called.
+ * @param cause - what the carrier threw when the signal aborted.
+ * @returns the failed result.
+ */
+export function cancelledFailure(endpoint: string, cause: unknown): Extract<RemoteResult<never>, { readonly ok: false }> {
   return {
     ok: false,
     error: new RemoteError('gateway/cancelled', `client api: Remote invocation "${endpoint}" was aborted`, {}, { cause }),

+ 4 - 1
packages/api/session-controller/package.json

@@ -47,7 +47,8 @@
         "@deepseek-ai/dsh-api-gateway/client"
       ],
       "inject": [
-        "@deepseek-ai/dsh-api-gateway"
+        "@deepseek-ai/dsh-api-gateway",
+        "@deepseek-ai/dsh-client-file-upload"
       ],
       "platform": "web"
     }
@@ -128,6 +129,7 @@
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-file-upload": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
+    "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-file-reference": "workspace:^",
     "@deepseek-ai/dsh-fs": "workspace:^",
@@ -136,6 +138,7 @@
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-native-command": "workspace:^",
     "@deepseek-ai/dsh-permission-presets": "workspace:^",
+    "@deepseek-ai/dsh-remote-mock": "workspace:^",
     "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-persistence": "workspace:^",

+ 109 - 169
packages/api/session-controller/tests/client-apply.client.spec.ts

@@ -1,168 +1,98 @@
-import { Context } from '@deepseek-ai/cordis'
-import type { Fiber } from '@deepseek-ai/cordis'
-import type {
-  ConnectionGeneration,
-  ConnectionHandle,
-} from '@deepseek-ai/dsh-client-connection/client'
-import {
-  RemoteStreamCarrierError,
-  RemoteStream,
-  type RemoteStreamOptions,
-} from '@deepseek-ai/dsh-api-gateway/client'
+/**
+ * Session Controller Client apply inside the assembled client: Remote events
+ * arriving as emit frames on the `$events` stream, the control stream over
+ * the real Connection, and Agent Context identity through the Typert registry.
+ */
+import type { Context } from '@deepseek-ai/cordis'
+import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
+import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
+import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import * as SessionClient from '../src/client/index.ts'
+import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import { afterEach, describe, expect, vi, type MockInstance } from 'vitest'
 import { ClientSessions } from '../src/client/sessions/service.ts'
-import { FakeApiClient, fakeRemote } from './fake-api.client.ts'
+import type { SessionListValue } from '../src/types.ts'
 
-const GENERATION: ConnectionGeneration = { id: 1, host: { home: '/home/fixture' } }
+const SELF = '@deepseek-ai/dsh-api-session-controller'
+const ROSTER = webApp.closure([SELF])
+const it = createClientTest({ roster: ROSTER })
+const EVENTS = '$events'
+const CONTROL = 'session/control'
+const BASELINE = { type: 'baseline', value: { queues: {}, jobs: {}, projections: {} } }
+/** The first client boot pays the cold module transform of the cone. */
+const COLD_BOOT_TIMEOUT_MS = 60_000
 
 const sid = (value: string): SessionId => value as SessionId
 
-type RemoteListener = (...args: never[]) => void
-
-interface Bench {
-  readonly ctx: Context
-  readonly api: FakeApiClient
-  readonly fiber: Fiber
-  readonly sessions: ClientSessions
-  dispatch(event: string, ...args: unknown[]): void
-  publishGeneration(generation: ConnectionGeneration | undefined): void
-}
-
-const contexts = new Set<Context>()
-
-afterEach(async () => {
+afterEach(() => {
   vi.restoreAllMocks()
-  await Promise.all([...contexts].map(async (ctx) => { await ctx.fiber.dispose() }))
-  contexts.clear()
 })
 
-async function mount(initialGeneration?: ConnectionGeneration): Promise<Bench> {
-  const ctx = new Context()
-  contexts.add(ctx)
-  await ctx.plugin(TypertRegistry)
-  const api = new FakeApiClient()
-  const remote = fakeRemote(api)
-  const listeners = new Map<string, Set<RemoteListener>>()
-  const generationListeners = new Set<() => void>()
-  let generation = initialGeneration
-  const connection: ConnectionHandle = {
-    isLoopback: true,
-    generation: {
-      getSnapshot: () => generation,
-      subscribe: (listener) => {
-        generationListeners.add(listener)
-        return () => { generationListeners.delete(listener) }
-      },
-    },
-    state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} },
-    rpc: {
-      call: () => Promise.reject(new Error('unexpected generic RPC call')),
-    },
-    reconnect: () => {},
-    registerGenerationSource: () => () => {},
-    start: () => ({ stop: () => {} }),
-  }
-  ctx.reflect.provide('connection', connection)
-  ctx.reflect.provide('fileUpload', {
-    available: true,
-    post: () => Promise.reject(new Error('unexpected file upload')),
-  })
-  ctx.reflect.provide('remote', {
-    ...remote,
-    $stream: <Item>(options: RemoteStreamOptions<Item>) => (
-      new RemoteStream(connection, options)
-    ),
-    get $host() {
-      return { home: generation?.host.home, isLoopback: connection.isLoopback }
-    },
-    $on: (event: string, listener: RemoteListener) => {
-      const eventListeners = listeners.get(event) ?? new Set<RemoteListener>()
-      eventListeners.add(listener)
-      listeners.set(event, eventListeners)
-      return () => { eventListeners.delete(listener) }
-    },
-  })
-  ctx.reflect.provide('remote.commands', remote.commands)
-  ctx.reflect.provide('remote.session', remote.session)
-  ctx.reflect.provide('remote.subagents', remote.subagents)
-  const fiber = ctx.plugin(SessionClient)
-  await fiber
-  const sessions = ctx.sessions as ClientSessions
-  return {
-    ctx,
-    api,
-    fiber,
-    sessions,
-    dispatch: (event, ...args) => {
-      for (const listener of listeners.get(event) ?? []) listener(...args as never[])
-    },
-    publishGeneration: (next) => {
-      generation = next
-      for (const listener of [...generationListeners]) listener()
-    },
-  }
+async function bench(start: () => Promise<TestClient>) {
+  const client = await start()
+  return { client, sessions: client.ctx.sessions as ClientSessions }
+}
+
+/** Deliver one Remote event the way the Host forwards it: an emit frame on the `$events` stream, consumed by the client. */
+async function emit(mock: RemoteMock, event: string, ...args: unknown[]): Promise<void> {
+  mock.streams.push(EVENTS, { type: 'emit', event, args })
+  await mock.streams.drained(EVENTS)
 }
 
-async function flush(): Promise<void> {
-  for (let index = 0; index < 12; index++) await Promise.resolve()
+function baselines(accept: MockInstance): number {
+  return accept.mock.calls.filter(([frame]) => (frame as { type: string }).type === 'baseline').length
 }
 
 describe('Session Controller Client apply', () => {
-  it('routes Session Remote Events and connection generations into the object layer', async () => {
+  it('routes Remote events from the $events stream into the object layer and runs handleConnected once per generation', async ({ mock, start }) => {
     const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected')
     const error = vi.spyOn(ClientSessions.prototype, 'handleSessionError')
-    const bench = await mount()
-    expect(connected).not.toHaveBeenCalled()
-
-    bench.dispatch('api-session/added', {
-      sessionId: sid('session-1'),
-      updatedAt: 1,
-      running: false,
-      blank: true,
-    })
-    await flush()
-    expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({
-      running: false,
-      updatedAt: 1,
+    const { client, sessions } = await bench(start)
+    // The first generation's `connection/reset` already ran it; apply itself saw no Host yet.
+    await vi.waitFor(() => { expect(connected).toHaveBeenCalledOnce() })
+
+    await emit(mock, 'api-session/added', { sessionId: sid('session-1'), updatedAt: 1, running: false, blank: true })
+    await vi.waitFor(() => {
+      expect(sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ running: false, updatedAt: 1 })
     })
 
-    bench.dispatch('api-session/status', sid('session-1'), true)
-    bench.dispatch('api-session/activity', sid('session-1'), 9)
-    bench.dispatch('api-session/error', sid('session-1'), 'agent failed')
-    await flush()
-    expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({
-      running: true,
-      updatedAt: 9,
+    await emit(mock, 'api-session/status', sid('session-1'), true)
+    await emit(mock, 'api-session/activity', sid('session-1'), 9)
+    await emit(mock, 'api-session/error', sid('session-1'), 'agent failed')
+    await vi.waitFor(() => {
+      expect(sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ running: true, updatedAt: 9 })
     })
     expect(error).toHaveBeenCalledWith(sid('session-1'), 'agent failed')
 
-    bench.dispatch('api-session/removed', sid('session-1'))
-    await flush()
-    expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toBeUndefined()
+    await emit(mock, 'api-session/removed', sid('session-1'))
+    await vi.waitFor(() => { expect(sessions.list.getSnapshot().byId[sid('session-1')]).toBeUndefined() })
+
+    client.connection.reconnect()
+    await mock.streams.opened(EVENTS, 2)
+    await vi.waitFor(() => { expect(connected).toHaveBeenCalledTimes(2) })
+  }, COLD_BOOT_TIMEOUT_MS)
 
-    bench.ctx.emit('connection/reset')
-    expect(connected).toHaveBeenCalledOnce()
+  it('runs handleConnected at apply when the Host is already connected, as a reload of the row does', async ({ start }) => {
+    const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected')
+    const { client } = await bench(start)
+    await vi.waitFor(() => { expect(connected).toHaveBeenCalledOnce() })
+    await client.reload(SELF)
+    expect(connected).toHaveBeenCalledTimes(2)
   })
 
-  it('accepts the control baseline, retries a carrier generation, and reports terminal protocol failure', async () => {
+  it('accepts the control baseline, retries a carrier loss once, and reports a second opening snapshot as a protocol failure', async ({ mock, start }) => {
     const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame')
     const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
-    const bench = await mount(GENERATION)
-    await flush()
+    await start()
+    await vi.waitFor(() => { expect(baselines(accept)).toBe(1) })
+    expect(accept).toHaveBeenCalledWith(BASELINE)
 
-    expect(accept).toHaveBeenCalledWith({
-      type: 'baseline',
-      value: { queues: {}, jobs: {}, projections: {} },
-    })
+    // One immediate retry while the Host is available reopens the stream, whose script pushes the baseline again.
+    mock.streams.fail(CONTROL, new RemoteStreamCarrierError('generation lost'))
+    await vi.waitFor(() => { expect(baselines(accept)).toBe(2) })
+    expect(mock.log.streams(CONTROL)).toHaveLength(2)
 
-    bench.api.failStreams(new RemoteStreamCarrierError('generation lost'))
-    await flush()
-    expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2)
-
-    bench.api.pushControl({ type: 'baseline', value: bench.api.controlBaseline } as never)
+    mock.streams.push(CONTROL, BASELINE)
     await vi.waitFor(() => {
       expect(logged).toHaveBeenCalledWith(
         '[session-controller] control stream failed:',
@@ -171,51 +101,61 @@ describe('Session Controller Client apply', () => {
     })
   })
 
-  it('materializes Host-addressed Agent scopes before the Session list arrives', async () => {
-    const bench = await mount()
-    const adapter = bench.ctx.typert.contexts.getClient('agent')
+  it('materializes Host-addressed Agent scopes before the Session list arrives', async ({ mock, start }) => {
+    const list = Promise.withResolvers<RemoteResult<SessionListValue>>()
+    mock.remote.session.list.mockReturnValueOnce(list.promise)
+    const { client, sessions } = await bench(start)
+    const adapter = client.ctx.typert.contexts.getClient('agent')
     const first = adapter?.resolve(sid('agent-early'))
 
     expect(first).toBeDefined()
-    expect(bench.sessions.scopeOf(first as Context)).toBe(sid('agent-early'))
+    expect(sessions.scopeOf(first as Context)).toBe(sid('agent-early'))
     expect(adapter?.resolve(sid('agent-early'))).toBe(first)
+    list.resolve(ok({ items: [] }))
+    await vi.waitFor(() => { expect(sessions.list.getSnapshot().phase).toBe('ready') })
   })
 
-  it('projects Agent Context identity in both directions and withdraws the adapter on disposal', async () => {
-    const bench = await mount(GENERATION)
-    await flush()
-    expect(bench.sessions.list.getSnapshot().phase).toBe('ready')
+  it('projects Agent Context identity in both directions and withdraws the adapter when the row unloads', async ({ mock, start }) => {
+    const { client, sessions } = await bench(start)
+    await vi.waitFor(() => { expect(sessions.list.getSnapshot().phase).toBe('ready') })
 
-    bench.dispatch('api-session/added', {
-      sessionId: sid('agent-1'),
-      updatedAt: 1,
-      running: false,
-      blank: true,
-    })
-    await flush()
-    const scoped = bench.sessions.scope(sid('agent-1'))
-    const adapter = bench.ctx.typert.contexts.getClient('agent')
-    expect(scoped).toBeDefined()
-    expect(adapter?.identity(bench.ctx)).toBeUndefined()
-    expect(adapter?.identity(scoped!)).toBe(sid('agent-1'))
+    await emit(mock, 'api-session/added', { sessionId: sid('agent-1'), updatedAt: 1, running: false, blank: true })
+    await vi.waitFor(() => { expect(sessions.scope(sid('agent-1'))).toBeDefined() })
+    const scoped = sessions.scope(sid('agent-1')) as Context
+    const adapter = client.ctx.typert.contexts.getClient('agent')
+    expect(adapter?.identity(client.ctx)).toBeUndefined()
+    expect(adapter?.identity(scoped)).toBe(sid('agent-1'))
     expect(adapter?.resolve(sid('agent-1'))).toBe(scoped)
 
-    await bench.fiber.dispose()
-    expect(bench.ctx.typert.contexts.getClient('agent')).toBeUndefined()
+    await client.unload(SELF)
+    expect(client.ctx.typert.contexts.getClient('agent')).toBeUndefined()
   })
 
-  it('waits for a Host generation before retrying the control stream', async () => {
+  it('waits for a Host generation before retrying the control stream', async ({ mock, start }) => {
     const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame')
-    const bench = await mount()
-    await flush()
-    expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1)
+    const hostBack = Promise.withResolvers<undefined>()
+    let opens = 0
+    // The second $events generation stays unready until the test lets the Host answer.
+    mock.stream(EVENTS, (_args, stream) => {
+      opens += 1
+      const ready = { type: 'ready', clientId: `mock-client-${String(opens)}`, host: { home: '/home/mock' } }
+      if (opens === 1) stream.push(ready)
+      else void hostBack.promise.then(() => { stream.push(ready) })
+    })
+    const client = await start()
+    await vi.waitFor(() => { expect(baselines(accept)).toBe(1) })
+
+    client.connection.reconnect()
+    await mock.streams.opened(EVENTS, 2)
+    expect(client.connection.generation.getSnapshot()).toBeUndefined()
 
-    bench.api.failStreams(new RemoteStreamCarrierError('offline'))
-    await flush()
-    expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1)
+    mock.streams.fail(CONTROL, new RemoteStreamCarrierError('offline'))
+    await client.flush()
+    expect(baselines(accept)).toBe(1)
+    expect(mock.log.streams(CONTROL)).toHaveLength(1)
 
-    bench.publishGeneration(GENERATION)
-    await flush()
-    expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2)
+    hostBack.resolve(undefined)
+    await vi.waitFor(() => { expect(baselines(accept)).toBe(2) })
+    expect(client.connection.generation.getSnapshot()).toMatchObject({ id: 2 })
   })
 })

+ 3 - 28
packages/api/session-controller/tests/fake-api.client.ts

@@ -28,8 +28,7 @@ import {
   type RemoteStreamOptions,
 } from '@deepseek-ai/dsh-api-gateway/client'
 import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
-import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types'
-import { historyRecordLastSeq } from '../src/client/sessions/history-records.ts'
+import { followSnapshot, pageThrough } from './remote/history.client.ts'
 
 const AVAILABLE_STREAM_CONNECTION = {
   generation: {
@@ -363,11 +362,7 @@ export class FakeApiClient {
     if (!result.ok) return result
     return {
       ok: true,
-      value: {
-        ...result.value,
-        records: result.value.records
-          .filter(record => historyRecordLastSeq(record) <= request.throughSeq),
-      },
+      value: pageThrough(result.value, request.throughSeq),
     }
   }
 
@@ -388,27 +383,7 @@ export class FakeApiClient {
       })
       if (!response.ok) throw response.error
       const page = response.value
-      const tail = page.records.at(-1)
-      const cursor = this.followCursor ?? (tail === undefined ? -1 : historyRecordLastSeq(tail))
-      yield {
-        type: 'snapshot',
-        header: {
-          version: SESSION_FORMAT_VERSION,
-          id: sessionId,
-          createdAt: 0,
-          isSeeded: false,
-          ...(request.address.kind === 'subagent'
-            ? { origin: 'subagent' as const, parentSession: request.address.parentSessionId }
-            : {}),
-        },
-        cursor,
-        records: page.records.filter(record => historyRecordLastSeq(record) <= cursor),
-        hasMore: page.hasMore,
-        projections: page.projections ?? { asOfSeq: cursor, values: {} },
-        ...request.assistantStream === true
-          ? { assistantStream: this.assistantStreamBaseline }
-          : {},
-      }
+      yield followSnapshot(page, request, this.followCursor, this.assistantStreamBaseline)
       yield* stream.values
     } finally {
       stream.dispose()

+ 52 - 60
packages/api/session-controller/tests/queue-store.client.spec.ts

@@ -3,17 +3,23 @@
  * change, reconnect re-baselining, pre-instantiation buffering, editable-text
  * projection, and snapshot reference stability.
  */
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, vi } from 'vitest'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types'
 import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { MessageId, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
 import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
-import { Session } from '../src/client/sessions/session.ts'
+import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
 import { SessionManager } from '../src/client/sessions/manager.ts'
-import { FakeApiClient, fakeRemote } from './fake-api.client.ts'
+import { sessionBench } from './remote/bench.client.ts'
+import { pushEvent, sessionWorld } from './remote/session.client.ts'
 
+/** A Session talks through the Gateway client; its dependency cone is the Typert registry and the Connection. */
+const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
+const it = createClientTest({ roster: API_ROSTER })
 const SID = 'fk-q1' as SessionId
+/** The first client boot pays the cold module transform of the api cone. */
+const COLD_BOOT_TIMEOUT_MS = 60_000
 const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
 const rid = (id: string): RpcId => id as RpcId
 const iid = (id: string): MessageId => id as MessageId
@@ -42,23 +48,9 @@ function queueFrame(items: QueueFixture[]): Extract<SessionControlFrame, { type:
   }
 }
 
-function makeSession(): Session {
-  return makeBench().session
-}
-
-function makeBench(): { api: FakeApiClient; session: Session } {
-  const api = new FakeApiClient()
-  return { api, session: new Session(SID, fakeRemote(api)) }
-}
-
-function makeManager(): SessionManager {
-  const api = new FakeApiClient()
-  return new SessionManager(fakeRemote(api))
-}
-
 describe('Session queue snapshot intake', () => {
-  it('projects stable ids, flat previews, and complete text', () => {
-    const session = makeSession()
+  it('projects stable ids, flat previews, and complete text', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     session.handleControlFrame(queueFrame([
       { id: 'q-1', body: '第一条  排队\n消息' },
     ]))
@@ -71,10 +63,10 @@ describe('Session queue snapshot intake', () => {
         preview: '第一条 排队 消息', text: '第一条  排队\n消息',
       },
     ])
-  })
+  }, COLD_BOOT_TIMEOUT_MS)
 
-  it('marks mixed-content messages non-editable and keeps attachment blocks out of the text preview', () => {
-    const session = makeSession()
+  it('marks mixed-content messages non-editable and keeps attachment blocks out of the text preview', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     session.handleControlFrame(queueFrame([{
       id: 'q-image',
       body: '',
@@ -101,8 +93,8 @@ describe('Session queue snapshot intake', () => {
     ])
   })
 
-  it('caps previews at 200 code points and preserves the full editable text', () => {
-    const session = makeSession()
+  it('caps previews at 200 code points and preserves the full editable text', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     const body = '长'.repeat(201)
     session.handleControlFrame(queueFrame([{ id: 'q-cap', body }]))
     const row = session.getSnapshot().queue[0]
@@ -111,8 +103,8 @@ describe('Session queue snapshot intake', () => {
     expect(row?.text).toBe(body)
   })
 
-  it('replaces content, order, and membership from each authoritative frame', () => {
-    const session = makeSession()
+  it('replaces content, order, and membership from each authoritative frame', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     session.handleControlFrame(queueFrame([
       { id: 'q-1', body: 'one' },
       { id: 'q-2', body: 'two' },
@@ -133,16 +125,16 @@ describe('Session queue snapshot intake', () => {
     expect(session.getSnapshot().queue).toEqual([])
   })
 
-  it('keeps the queue array reference stable across unrelated snapshot swaps', () => {
-    const session = makeSession()
+  it('keeps the queue array reference stable across unrelated snapshot swaps', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     session.handleControlFrame(queueFrame([{ id: 'q-stable', body: '稳定' }]))
     const before = session.getSnapshot().queue
     session.handleAgentError('unrelated')
     expect(session.getSnapshot().queue).toBe(before)
   })
 
-  it('retains steering placement and complete content in the same authoritative snapshot', () => {
-    const session = makeSession()
+  it('retains steering placement and complete content in the same authoritative snapshot', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     session.handleControlFrame(queueFrame([
       { id: 'q-next', body: 'later' },
       { id: 's-now', body: 'interrupt now', placement: 'steering' },
@@ -156,8 +148,8 @@ describe('Session queue snapshot intake', () => {
     ])
   })
 
-  it('hands off exactly one current occurrence when live steering becomes durable', async () => {
-    const { api, session } = makeBench()
+  it('hands off exactly one current occurrence when live steering becomes durable', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     await session.open()
     const message = createUserMessage({
       content: text('same message'),
@@ -175,7 +167,7 @@ describe('Session queue snapshot intake', () => {
       data: message,
     } satisfies SessionEvent
 
-    await api.pushFollow(SID, { type: 'event', event: durable as never })
+    await pushEvent(mock, durable)
     await vi.waitFor(() => {
       expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second'])
     })
@@ -183,14 +175,14 @@ describe('Session queue snapshot intake', () => {
     session.handleControlFrame(queueFrame([
       { id: 's-later', body: '', placement: 'steering', message },
     ]))
-    await api.pushFollow(SID, { type: 'event', event: durable as never })
+    await pushEvent(mock, durable)
     await vi.waitFor(() => {
       expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later'])
     })
   })
 
-  it('hands off live steering when the agent claims it as a user message', async () => {
-    const { api, session } = makeBench()
+  it('hands off live steering when the agent claims it as a user message', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     await session.open()
     const message = createUserMessage({
       content: text('claimed steering'),
@@ -200,16 +192,13 @@ describe('Session queue snapshot intake', () => {
       { id: 's-claimed', body: '', placement: 'steering', message },
     ]))
 
-    await api.pushFollow(SID, {
-      type: 'event',
-      event: {
-        seq: 0,
-        time: 1_700_000_000_000,
-        type: 'user/message',
-        surfaceOp: 'append',
-        data: message,
-      } as never,
-    })
+    await pushEvent(mock, {
+      seq: 0,
+      time: 1_700_000_000_000,
+      type: 'user/message',
+      surfaceOp: 'append',
+      data: message,
+    } as never)
 
     await vi.waitFor(() => {
       expect(session.getSnapshot().queue).toEqual([])
@@ -218,9 +207,8 @@ describe('Session queue snapshot intake', () => {
 })
 
 describe('queue operation transport', () => {
-  it('addresses the session.updateQueue RPC without optimistic local mutation', async () => {
-    const api = new FakeApiClient()
-    const session = new Session(SID, fakeRemote(api))
+  it('addresses the session.updateQueue RPC without optimistic local mutation', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     session.handleControlFrame(queueFrame([{ id: 'q-op', body: 'pending' }]))
     const before = session.getSnapshot().queue
 
@@ -228,7 +216,7 @@ describe('queue operation transport', () => {
       .resolves.toEqual({ ok: true, value: { accepted: true } })
     await expect(session.updateQueue(iid('q-op'), { kind: 'steer' }))
       .resolves.toEqual({ ok: true, value: { accepted: true } })
-    expect(api.callsOf('session.updateQueue')).toEqual([
+    expect(mock.log.requests('session/updateQueue')).toEqual([
       {
         sessionId: SID,
         itemId: 'q-op',
@@ -245,8 +233,8 @@ describe('queue operation transport', () => {
 })
 
 describe('queue reconnect semantics', () => {
-  it('a control baseline clears stale state before a fresh update lands', () => {
-    const session = makeSession()
+  it('a control baseline clears stale state before a fresh update lands', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     session.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧连接' }]))
     session.replaceControl([])
     expect(session.getSnapshot().queue).toEqual([])
@@ -254,16 +242,16 @@ describe('queue reconnect semantics', () => {
     expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
   })
 
-  it('resync does not clear a baseline that raced ahead of the host connection signal', async () => {
-    const session = makeSession()
+  it('resync does not clear a baseline that raced ahead of the host connection signal', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     await session.open()
     session.handleControlFrame(queueFrame([{ id: 'q-fresh', body: '新基线' }]))
     await session.resync()
     expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh'])
   })
 
-  it('running-status changes never guess at queue retirement', () => {
-    const session = makeSession()
+  it('running-status changes never guess at queue retirement', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     session.handleControlFrame(queueFrame([{ id: 'q-live', body: '保留' }]))
     session.handleRunning(true)
     session.handleRunning(false)
@@ -272,15 +260,19 @@ describe('queue reconnect semantics', () => {
 })
 
 describe('manager buffering of queue snapshots', () => {
-  it('replays only the latest snapshot for an uninstantiated session', () => {
-    const manager = makeManager()
+  it('replays only the latest snapshot for an uninstantiated session', async ({ mock, start }) => {
+    mock.load(sessionWorld)
+    const { ctx: { remote } } = await start()
+    const manager = new SessionManager(remote)
     manager.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧' }]))
     manager.handleControlFrame(queueFrame([{ id: 'q-new', body: '新' }]))
     expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
   })
 
-  it('a control baseline replaces the prior queue', () => {
-    const manager = makeManager()
+  it('a control baseline replaces the prior queue', async ({ mock, start }) => {
+    mock.load(sessionWorld)
+    const { ctx: { remote } } = await start()
+    const manager = new SessionManager(remote)
     manager.handleControlFrame(queueFrame([{ id: 'q-g1', body: '第一代' }]))
     const nextQueue = queueFrame([{ id: 'q-g2', body: '第二代' }]).items
     manager.handleControlFrame({

+ 28 - 0
packages/api/session-controller/tests/remote/bench.client.ts

@@ -0,0 +1,28 @@
+/** Session objects owned by a client test's explicitly started Gateway assembly. */
+import { onTestFinished } from 'vitest'
+import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock'
+import type { TestClient } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { Session, type SessionOptions } from '../../src/client/sessions/session.ts'
+import { sessionWorld } from './session.client.ts'
+
+/**
+ * Create a Session after installing its default Remote responses.
+ * @param mock - this case's Remote responses.
+ * @param start - the fixture's lazy client startup.
+ * @param sessionId - Session identity.
+ * @param options - addressed child and parent availability, when applicable.
+ * @returns the Session, disposed at the end of the case.
+ */
+export async function sessionBench(
+  mock: RemoteMock,
+  start: () => Promise<TestClient>,
+  sessionId: SessionId,
+  options: SessionOptions = {},
+): Promise<Session> {
+  mock.load(sessionWorld)
+  const client = await start()
+  const session = new Session(sessionId, client.ctx.remote, options)
+  onTestFinished(() => session.dispose())
+  return session
+}

+ 48 - 0
packages/api/session-controller/tests/remote/history.client.ts

@@ -0,0 +1,48 @@
+/** Pure history responses shared by the local Remote fake and assembled Session tests. */
+import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types'
+import type {
+  SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, SessionPage, SessionProjectionBaseline,
+} from '../../src/types.ts'
+import { historyRecordLastSeq } from '../../src/client/sessions/history-records.ts'
+
+/**
+ * Cut a history page at the Host cursor, preserving its other fields.
+ * @param page - scripted history page.
+ * @param throughSeq - inclusive final sequence.
+ * @returns the page without records beyond the cursor.
+ */
+export function pageThrough(page: SessionPage, throughSeq: number): SessionPage {
+  return { ...page, records: page.records.filter(record => historyRecordLastSeq(record) <= throughSeq) }
+}
+
+/**
+ * Build the opening follow frame for a Session or addressed child.
+ * @param page - history and optional projection baseline.
+ * @param request - addressed follow request.
+ * @param cursor - opening cursor; defaults to the history tail, or -1 when empty.
+ * @param assistantStream - baseline included only when the request opts in.
+ * @returns the opening frame.
+ */
+export function followSnapshot(
+  page: SessionPage & { readonly projections?: SessionProjectionBaseline },
+  request: SessionFollowRequest,
+  cursor = page.records.length === 0 ? -1 : historyRecordLastSeq(page.records.at(-1)!),
+  assistantStream: SessionAssistantStreamBaseline = { revision: 0 },
+): Extract<SessionFollowFrame, { type: 'snapshot' }> {
+  const { address } = request
+  return {
+    type: 'snapshot',
+    header: {
+      version: SESSION_FORMAT_VERSION,
+      id: address.kind === 'session' ? address.sessionId : address.childSessionId,
+      createdAt: 0,
+      isSeeded: false,
+      ...(address.kind === 'subagent' ? { origin: 'subagent', parentSession: address.parentSessionId } : {}),
+    },
+    cursor,
+    records: pageThrough(page, cursor).records,
+    hasMore: page.hasMore,
+    projections: page.projections ?? { asOfSeq: cursor, values: {} },
+    ...(request.assistantStream === true ? { assistantStream } : {}),
+  }
+}

+ 188 - 0
packages/api/session-controller/tests/remote/session.client.ts

@@ -0,0 +1,188 @@
+/**
+ * The Remote side of one Session under test: default answers for every
+ * `session/*` and `subagents/*` endpoint a `Session` calls, builders for
+ * the two history-shaped answers, the `session/follow` opening snapshot and
+ * the `session/page` page, both derived from event lists the way the Host
+ * derives them from its log, and builders for the `session/control` queue
+ * frame and the attachment references the Host's log carries.
+ */
+import { AttachmentId, type FileAttachmentRef, type ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
+import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
+import { ok, type RemoteMock, type RemoteTable, type StreamScript, type UnaryRuleFn } from '@deepseek-ai/dsh-remote-mock'
+import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
+import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import type {
+  SessionControlFrame, SessionFollowFrame, SessionFollowRequest, SessionPage, SessionPageRequest,
+  SessionQueuedItem, SessionRequestId,
+} from '../../src/types.ts'
+import { entries, historyValue } from '../event-script.client.ts'
+import { followSnapshot, pageThrough } from './history.client.ts'
+
+export { followSnapshot } from './history.client.ts'
+
+/** Endpoints a Session opens and calls for its history. */
+export const FOLLOW = 'session/follow'
+export const PAGE = 'session/page'
+
+/** A history answer as the Host returns it, possibly still pending. */
+export type HistoryAnswer = RemoteResult<SessionPage> | Promise<RemoteResult<SessionPage>>
+/** A history answer, or a function of the request that produces one. */
+export type HistorySource<Request> = HistoryAnswer | ((request: Request) => HistoryAnswer)
+
+/**
+ * The failure branch of a Remote result.
+ * @param error - the owner-declared failure.
+ * @returns the result.
+ */
+export function err<T>(error: RemoteFailure): RemoteResult<T> {
+  return { ok: false, error }
+}
+
+/**
+ * The Host's history answer for `events`.
+ * @param events - events in seq order.
+ * @param hasMore - whether older history exists.
+ * @returns the success result.
+ */
+export function history(events: readonly SessionEvent[], hasMore = false): RemoteResult<SessionPage> {
+  return ok(historyValue(events, hasMore))
+}
+
+/**
+ * One live event frame of a follow stream.
+ * @param event - the event.
+ * @returns the frame.
+ */
+export function frame(event: SessionEvent): SessionFollowFrame {
+  return entries([event])[0]!
+}
+
+/**
+ * Deliver one live event to every open follow stream and wait until the client has consumed it.
+ * @param mock - the mock holding the streams.
+ * @param event - the event.
+ */
+export async function pushEvent(mock: RemoteMock, event: SessionEvent): Promise<void> {
+  mock.streams.push(FOLLOW, frame(event))
+  await mock.streams.drained(FOLLOW)
+}
+
+/**
+ * `session/follow` script: the opening snapshot built from the history answer,
+ * then open for pushes. A failed answer fails the stream with its error; a
+ * rejected one fails it with the rejection.
+ * @param history - history answer or a function of the follow request.
+ * @param options - `cursor` overrides the snapshot cursor.
+ * @returns the script.
+ */
+export function followScript(history: HistorySource<SessionFollowRequest>, options: { cursor?: number } = {}): StreamScript {
+  return async ([request], stream) => {
+    const follow = request as SessionFollowRequest
+    const result = await answer(history, follow)
+    if (!result.ok) {
+      stream.fail(result.error)
+      return
+    }
+    stream.push(followSnapshot(result.value, follow, options.cursor))
+  }
+}
+
+/**
+ * `session/page` rule: the history answer cut at the request's `throughSeq`.
+ * @param history - history answer or a function of the page request.
+ * @returns the rule.
+ */
+export function pageRule(
+  history: HistorySource<SessionPageRequest>,
+): UnaryRuleFn<readonly [SessionPageRequest], Promise<RemoteResult<SessionPage>>> {
+  return async (page) => {
+    const result = await answer(history, page)
+    if (!result.ok) return result
+    return ok(pageThrough(result.value, page.throughSeq))
+  }
+}
+
+function answer<Request>(history: HistorySource<Request>, request: Request): HistoryAnswer {
+  return typeof history === 'function' ? history(request) : history
+}
+
+/**
+ * An image attachment reference as the Host's durable log carries it.
+ * @param id - opaque attachment id.
+ * @returns the reference.
+ */
+export function imageRef(id: string): ImageAttachmentRef {
+  return { attachmentId: AttachmentId(id), mediaType: 'image/png', bytes: 1, width: 2, height: 2 }
+}
+
+/**
+ * A file attachment reference as the Host's durable log carries it.
+ * @param id - opaque attachment id.
+ * @param name - display filename.
+ * @returns the reference.
+ */
+export function fileRef(id: string, name = 'notes.txt'): FileAttachmentRef {
+  return { attachmentId: AttachmentId(id), name, bytes: 3 }
+}
+
+/** One pending inbox occurrence of a queue frame; the message id defaults to the item id. */
+export interface QueueItemFixture {
+  readonly id: string
+  readonly placement?: SessionQueuedItem['placement']
+  /** Prompt identity the Host copied from the queued message's user source. */
+  readonly rpcId?: SessionRequestId
+  /** Queued message content; ignored when `message` is given. */
+  readonly content?: readonly ContentBlock[]
+  /** The queued message itself, for a test that also lands it durably (the queue mirror correlates by `id`). */
+  readonly message?: { readonly id: MessageId; readonly content: readonly ContentBlock[] }
+}
+
+/**
+ * One pending inbox occurrence as the Host lists it.
+ * @param fixture - item fields.
+ * @returns the item.
+ */
+export function queueItem(fixture: QueueItemFixture): SessionQueuedItem {
+  const message = fixture.message ?? { id: fixture.id as MessageId, content: fixture.content ?? [] }
+  return {
+    id: fixture.id as MessageId,
+    placement: fixture.placement ?? 'queued',
+    ...(fixture.rpcId === undefined ? {} : { rpcId: fixture.rpcId }),
+    // The Host serializes queued content to JSON; the blocks cross unchanged.
+    message: { id: message.id, content: message.content as unknown as SessionQueuedItem['message']['content'] },
+  }
+}
+
+/**
+ * One authoritative queue frame of the `session/control` stream.
+ * @param sessionId - the addressed Session.
+ * @param items - the complete pending queue.
+ * @returns the frame.
+ */
+export function queueFrame(
+  sessionId: SessionId,
+  items: readonly QueueItemFixture[],
+): Extract<SessionControlFrame, { type: 'queue' }> {
+  return { type: 'queue', sessionId, items: items.map(queueItem) }
+}
+
+/** Default answers: every command accepted, an empty history, one attachment of one zero byte. */
+export const sessionWorld: RemoteTable = {
+  unary: {
+    'session/prompt': ok({ accepted: true }),
+    'session/cancel': ok({ accepted: true }),
+    'session/updateQueue': ok({ accepted: true }),
+    'session/rename': ok({ title: 'fk-renamed', seq: 0 }),
+    'session/attachment': ok({
+      attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
+      data: 'AA==',
+    }),
+    'session/page': pageRule(ok({ records: [], hasMore: false })),
+    'subagents/prompt': ok({ messageId: 'fake-message' }),
+    'subagents/interruptByParent': ok({ accepted: true }),
+  },
+  stream: {
+    'session/follow': followScript(ok({ records: [], hasMore: false })),
+  },
+}

+ 66 - 88
packages/api/session-controller/tests/session-pending-submissions.client.spec.ts

@@ -1,40 +1,34 @@
-/** Local submission echoes: synchronous insertion, observed/failed retirement, and settlement callbacks. */
+/**
+ * Local submission echoes: synchronous insertion, observed/failed retirement,
+ * and settlement callbacks. Prompts and the follow stream cross the assembled
+ * Gateway client and are answered by endpoint name.
+ */
 
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, vi } from 'vitest'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
-import { SessionSeq, type SessionEvent, type SessionId } from '@deepseek-ai/dsh-session/types'
+import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
+import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
-import { Session } from '../src/client/sessions/session.ts'
+import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
 import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts'
-import type { SessionQueuedItem, SessionRequestId } from '../src/types.ts'
-import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts'
-import { historyValue } from './event-script.client.ts'
+import type { SessionRequestId } from '../src/types.ts'
+import { sessionBench } from './remote/bench.client.ts'
+import {
+  FOLLOW, err, fileRef, followScript, history, imageRef, pushEvent, queueFrame,
+} from './remote/session.client.ts'
 
+/** A Session talks through the Gateway client; its dependency cone is the Typert registry and the Connection. */
+const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
+const it = createClientTest({ roster: API_ROSTER })
 const SID = 'fk-s1' as SessionId
+/** The first client boot pays the cold module transform of the api cone. */
+const COLD_BOOT_TIMEOUT_MS = 60_000
 
 afterEach(() => {
   vi.unstubAllGlobals()
 })
 
-function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
-  return { api, session: new Session(SID, fakeRemote(api)) }
-}
-
-function imageRef(id: string): ImageAttachmentRef {
-  return {
-    attachmentId: id,
-    mediaType: 'image/png',
-    bytes: 1,
-    width: 2,
-    height: 2,
-  } as unknown as ImageAttachmentRef
-}
-
-function fileRef(id: string, name = 'notes.txt'): FileAttachmentRef {
-  return { attachmentId: id, name, bytes: 3 } as unknown as FileAttachmentRef
-}
-
 type AttachmentRef = ImageAttachmentRef | FileAttachmentRef
 
 function attachmentBlock(attachment: AttachmentRef) {
@@ -60,16 +54,9 @@ function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly At
   } as unknown as SessionEvent
 }
 
-function queuedItem(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionQueuedItem {
-  return {
-    id: 'm-queued' as SessionQueuedItem['id'],
-    placement: 'queued',
-    rpcId,
-    message: {
-      id: 'm-queued' as SessionQueuedItem['id'],
-      content: refs.map(attachmentBlock) as unknown as SessionQueuedItem['message']['content'],
-    },
-  }
+/** The Host's queue holding one occurrence of the prompt `rpcId`. */
+function queuedFrame(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []) {
+  return queueFrame(SID, [{ id: 'm-queued', rpcId, content: refs.map(attachmentBlock) }])
 }
 
 /** Let the frame-delayed retirement (setTimeout fallback in this node environment) run. */
@@ -78,8 +65,8 @@ function settleFrames(): Promise<void> {
 }
 
 describe('beginSubmission', () => {
-  it('inserts the echo synchronously and flips the engaging edge before any prompt call', () => {
-    const { session } = makeSession()
+  it('inserts the echo synchronously and flips the engaging edge before any prompt call', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false })
     const handle = session.beginSubmission({
       mode: 'queue',
@@ -97,10 +84,11 @@ describe('beginSubmission', () => {
         type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 },
       }],
     }])
-  })
+    expect(mock.log.requests()).toEqual([])
+  }, COLD_BOOT_TIMEOUT_MS)
 
-  it('derives and captures the echo placement from running state and delivery mode', () => {
-    const { session } = makeSession()
+  it('derives and captures the echo placement from running state and delivery mode', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     session.beginSubmission({ mode: 'queue', text: '空闲', attachments: [] })
     session.handleRunning(true)
     session.beginSubmission({ mode: 'queue', text: '排队', attachments: [] })
@@ -113,8 +101,8 @@ describe('beginSubmission', () => {
     ])
   })
 
-  it('abandon retires the echo as failed exactly once', () => {
-    const { session } = makeSession()
+  it('abandon retires the echo as failed exactly once', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     const retirements: PendingSubmissionRetirement[] = []
     const handle = session.beginSubmission({
       mode: 'queue',
@@ -130,9 +118,9 @@ describe('beginSubmission', () => {
 })
 
 describe('prompt-coupled retirement', () => {
-  it('a rejected identified prompt retires its echo immediately alongside promptError', async () => {
-    const { api, session } = makeSession()
-    api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
+  it('a rejected identified prompt retires its echo immediately alongside promptError', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
+    mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
     const retirements: PendingSubmissionRetirement[] = []
     const handle = session.beginSubmission({
       mode: 'queue',
@@ -143,20 +131,20 @@ describe('prompt-coupled retirement', () => {
     const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId)
     expect(result.ok).toBe(false)
     expect(session.getSnapshot().pendingSubmissions).toEqual([])
-    expect(session.getSnapshot().promptError).toMatchObject({ op: 'send' })
+    expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'session/agent-busy' } })
     expect(retirements).toEqual([{ reason: 'failed' }])
   })
 
-  it('sends the echo identity as the prompt requestId', async () => {
-    const { api, session } = makeSession()
+  it('sends the echo identity as the prompt requestId', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     const handle = session.beginSubmission({ mode: 'queue', text: '带 id', attachments: [] })
     await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId)
-    expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }])
+    expect(mock.log.requests('session/prompt')).toMatchObject([{ requestId: handle.requestId, sessionId: SID }])
   })
 
-  it('an unidentified prompt failure leaves registered echoes alone', async () => {
-    const { api, session } = makeSession()
-    api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
+  it('an unidentified prompt failure leaves registered echoes alone', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
+    mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
     session.beginSubmission({ mode: 'queue', text: '还在', attachments: [] })
     await session.prompt([{ type: 'text', text: '另一个' }], 'queue')
     expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
@@ -164,9 +152,8 @@ describe('prompt-coupled retirement', () => {
 })
 
 describe('observed retirement', () => {
-  it('a live durable event carrying the rpcId retires the echo one frame later with the admitted refs', async () => {
-    const { api, session } = makeSession()
-    api.onHistory = () => Promise.resolve(ok(historyValue([])))
+  it('a live durable event carrying the rpcId retires the echo one frame later with the admitted refs', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     await session.open()
     const retirements: PendingSubmissionRetirement[] = []
     const handle = session.beginSubmission({
@@ -176,7 +163,7 @@ describe('observed retirement', () => {
       onRetire: retirement => retirements.push(retirement),
     })
     const refs = [imageRef('att-1')]
-    await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId, refs) as never })
+    await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId, refs))
     // Synchronously after the append the echo is still in the snapshot; the
     // render-time dedupe owns the overlap frame.
     expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
@@ -185,8 +172,8 @@ describe('observed retirement', () => {
     expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
   })
 
-  it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async () => {
-    const { session } = makeSession()
+  it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     const retirements: PendingSubmissionRetirement[] = []
     session.handleRunning(true)
     const handle = session.beginSubmission({
@@ -196,7 +183,7 @@ describe('observed retirement', () => {
       onRetire: retirement => retirements.push(retirement),
     })
     const refs = [imageRef('att-q')]
-    session.handleControlFrame({ type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, refs)] })
+    session.handleControlFrame(queuedFrame(handle.requestId, refs))
     await settleFrames()
     expect(session.getSnapshot().pendingSubmissions).toEqual([])
     expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
@@ -204,9 +191,8 @@ describe('observed retirement', () => {
     expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }])
   })
 
-  it('retires a mixed echo with durable references in original selection order', async () => {
-    const { api, session } = makeSession()
-    api.onHistory = () => Promise.resolve(ok(historyValue([])))
+  it('retires a mixed echo with durable references in original selection order', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     await session.open()
     const retirements: PendingSubmissionRetirement[] = []
     const file = fileRef('file-1')
@@ -221,23 +207,22 @@ describe('observed retirement', () => {
       onRetire: retirement => retirements.push(retirement),
     })
     const refs = [imageRef('image-1'), file, imageRef('image-2')]
-    await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId, refs) as never })
+    await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId, refs))
     await settleFrames()
     expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
   })
 
-  it('a full-window install (reconnect resync) retires echoes observed in the window', async () => {
-    const { api, session } = makeSession()
+  it('a full-window install (reconnect resync) retires echoes observed in the window', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     const handle = session.beginSubmission({ mode: 'queue', text: '重连', attachments: [] })
-    api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(SessionSeq(12), handle.requestId)])))
+    mock.stream(FOLLOW, followScript(history([promptEvent(SessionSeq(12), handle.requestId)])))
     await session.open()
     await settleFrames()
     expect(session.getSnapshot().pendingSubmissions).toEqual([])
   })
 
-  it('the first observation wins: a later prompt failure cannot re-retire an observed echo', async () => {
-    const { api, session } = makeSession()
-    api.onHistory = () => Promise.resolve(ok(historyValue([])))
+  it('the first observation wins: a later prompt failure cannot re-retire an observed echo', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     await session.open()
     const retirements: PendingSubmissionRetirement[] = []
     const handle = session.beginSubmission({
@@ -246,15 +231,14 @@ describe('observed retirement', () => {
       attachments: [],
       onRetire: retirement => retirements.push(retirement),
     })
-    await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never })
+    await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId))
     handle.abandon()
     await settleFrames()
     expect(retirements).toEqual([{ reason: 'observed', attachments: [] }])
   })
 
-  it('retires once when the queue and durable event report the same request id', async () => {
-    const { api, session } = makeSession()
-    api.onHistory = () => Promise.resolve(ok(historyValue([])))
+  it('retires once when the queue and durable event report the same request id', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     await session.open()
     const retirements: PendingSubmissionRetirement[] = []
     const handle = session.beginSubmission({
@@ -263,28 +247,23 @@ describe('observed retirement', () => {
       attachments: [],
       onRetire: retirement => retirements.push(retirement),
     })
-    session.handleControlFrame({
-      type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, [])],
-    })
-    await api.pushFollow(SID, {
-      type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never,
-    })
+    session.handleControlFrame(queuedFrame(handle.requestId))
+    await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId))
     await settleFrames()
     expect(retirements).toEqual([{ reason: 'observed', attachments: [] }])
     expect(session.getSnapshot().pendingSubmissions).toEqual([])
   })
 
-  it('uses requestAnimationFrame for the retirement delay when the runtime provides one', async () => {
+  it('uses requestAnimationFrame for the retirement delay when the runtime provides one', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
+    await session.open()
     const frames: FrameRequestCallback[] = []
     vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
       frames.push(fn)
       return frames.length
     })
-    const { api, session } = makeSession()
-    api.onHistory = () => Promise.resolve(ok(historyValue([])))
-    await session.open()
     const handle = session.beginSubmission({ mode: 'queue', text: '帧', attachments: [] })
-    await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never })
+    await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId))
     expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
     expect(frames).toHaveLength(1)
     frames[0]?.(0)
@@ -293,9 +272,8 @@ describe('observed retirement', () => {
 })
 
 describe('disposal', () => {
-  it('retires unsettled echoes as failed and preserves an already-observed settlement', async () => {
-    const { api, session } = makeSession()
-    api.onHistory = () => Promise.resolve(ok(historyValue([])))
+  it('retires unsettled echoes as failed and preserves an already-observed settlement', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
     await session.open()
     const retirements: { text: string; retirement: PendingSubmissionRetirement }[] = []
     const observed = session.beginSubmission({
@@ -310,7 +288,7 @@ describe('disposal', () => {
       attachments: [],
       onRetire: retirement => retirements.push({ text: '未settle', retirement }),
     })
-    await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), observed.requestId) as never })
+    await pushEvent(mock, promptEvent(SessionSeq(0), observed.requestId))
     await session.dispose()
     await settleFrames()
     expect(retirements).toEqual([

+ 44 - 0
packages/api/session-controller/tests/session-remote-rejection.client.spec.ts

@@ -0,0 +1,44 @@
+/**
+ * A rejection from the Remote face, which the generated client raises only for
+ * an assembly fault (wrong arity, an unmounted method, a withdrawn
+ * contribution), propagates out of the Session command unchanged and records no
+ * prompt error. Carrier failures never reject: they arrive as folded results,
+ * covered by session.client.spec.ts.
+ */
+import { describe, expect, it } from 'vitest'
+import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
+import { Session } from '../src/client/sessions/session.ts'
+import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
+
+const SID = 'fk-s1' as SessionId
+const PARENT = 'fk-parent' as SessionId
+
+/** Every command rejects with `message`; nothing else on the face is reachable from prompt or cancel. */
+function rejecting(message: string): SessionRemotes {
+  const reject = (): Promise<never> => Promise.reject(new Error(message))
+  return {
+    $stream: () => { throw new Error('unused') },
+    commands: { execute: reject },
+    session: { prompt: reject, cancel: reject },
+    subagents: { list: reject, prompt: reject, interruptByParent: reject },
+  } as unknown as SessionRemotes
+}
+
+describe('Session over a rejecting Remote face', () => {
+  it('propagates the rejection from prompt and cancel and records no prompt error', async () => {
+    const session = new Session(SID, rejecting('assembly fault'))
+    await expect(session.prompt([{ type: 'text', text: 'x' }], 'queue')).rejects.toThrow('assembly fault')
+    await expect(session.cancel()).rejects.toThrow('assembly fault')
+    expect(session.getSnapshot().promptError).toBeNull()
+  })
+
+  it('propagates the rejection from a subagent continuation prompt and interrupt', async () => {
+    const session = new Session(SID, rejecting('assembly fault'), {
+      address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
+      parentAvailable: true,
+    })
+    await expect(session.prompt([{ type: 'text', text: 'x' }], 'queue')).rejects.toThrow('assembly fault')
+    await expect(session.cancel()).rejects.toThrow('assembly fault')
+    expect(session.getSnapshot().promptError).toBeNull()
+  })
+})

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 338 - 426
packages/api/session-controller/tests/session.client.spec.ts


+ 2 - 0
packages/api/workspace-controller/package.json

@@ -83,7 +83,9 @@
     "@deepseek-ai/dsh-api-gateway": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
+    "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-host-directory-picker": "workspace:^",
+    "@deepseek-ai/dsh-remote-mock": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-storage-domain": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",

+ 100 - 0
packages/api/workspace-controller/tests/remote/workspace.client.ts

@@ -0,0 +1,100 @@
+/**
+ * The Remote side of one Workspace registry under test: default answers for
+ * every `workspace/*` command a `ClientWorkspaceModel` calls, builders for the
+ * rows and frames of the `workspace/follow` stream, and a script that hands
+ * each physical generation of that stream to its own script. The stream's
+ * boot-time opening baseline lives in `remoteDefaultResponses`.
+ */
+import { ok, type RemoteTable, type StreamScript } from '@deepseek-ai/dsh-remote-mock'
+import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import type {
+  WorkspaceArchiveSessionRequest,
+  WorkspaceArchiveValue,
+  WorkspaceCreateRequest,
+  WorkspaceCreateValue,
+  WorkspaceDeleteRequest,
+  WorkspaceDeleteValue,
+  WorkspaceFollowFrame,
+  WorkspaceId,
+  WorkspaceInsertBeforeRequest,
+  WorkspaceInsertSessionBeforeRequest,
+  WorkspaceOrderValue,
+  WorkspaceRenameRequest,
+  WorkspaceValue,
+  WorkspaceView,
+} from '../../src/types.ts'
+
+/** The Workspace state stream endpoint. */
+export const FOLLOW = 'workspace/follow'
+
+/** The opening frame of one follow generation. */
+export type WorkspaceBaselineFrame = Extract<WorkspaceFollowFrame, { type: 'baseline' }>
+
+/**
+ * The failure branch of a Remote result.
+ * @param error - the owner-declared failure.
+ * @returns the result.
+ */
+export function err<T>(error: RemoteFailure): RemoteResult<T> {
+  return { ok: false, error }
+}
+
+/**
+ * One Workspace row whose id doubles as its title and path segment.
+ * @param id - Workspace id.
+ * @param overrides - fields replacing the derived ones.
+ * @returns the row.
+ */
+export function workspace(id: string, overrides: Partial<WorkspaceView> = {}): WorkspaceView {
+  return {
+    workspaceId: id as WorkspaceId,
+    path: `/work/${id}`,
+    title: id,
+    sessionIds: [],
+    createdAt: '2026-01-01T00:00:00.000Z',
+    updatedAt: '2026-01-01T00:00:00.000Z',
+    ...overrides,
+  }
+}
+
+/**
+ * A baseline frame holding the named Workspaces and no archived Sessions.
+ * @param ids - Workspace ids in registry order.
+ * @returns the frame.
+ */
+export function baseline(...ids: readonly string[]): WorkspaceBaselineFrame {
+  return { type: 'baseline', value: { items: ids.map(id => workspace(id)), archivedSessionIds: [] } }
+}
+
+/**
+ * `workspace/follow` script running the n-th open on the n-th script; an open
+ * past the last script fails the stream.
+ * @param generations - one script per physical generation, in open order.
+ * @returns the script.
+ */
+export function followGenerations(generations: readonly StreamScript[]): StreamScript {
+  let opened = 0
+  return (args, stream) => {
+    const generation = generations[opened++]
+    if (generation === undefined) throw new Error('no scripted Workspace follow generation')
+    return generation(args, stream)
+  }
+}
+
+/** Default answers: every command accepted and echoed back as the row or set it names. */
+export const workspaceWorld: RemoteTable = {
+  unary: {
+    'workspace/create': (request: WorkspaceCreateRequest): RemoteResult<WorkspaceCreateValue> => ok({
+      workspace: workspace('created', { path: request.path }), created: true,
+    }),
+    'workspace/rename': (request: WorkspaceRenameRequest): RemoteResult<WorkspaceValue> => ok({
+      workspace: workspace(String(request.workspaceId), { title: request.title }),
+    }),
+    'workspace/delete': (_request: WorkspaceDeleteRequest): RemoteResult<WorkspaceDeleteValue> => ok({ deleted: true }),
+    'workspace/insertBefore': (request: WorkspaceInsertBeforeRequest): RemoteResult<WorkspaceOrderValue> => ok({ workspaceIds: [request.workspaceId] }),
+    'workspace/insertSessionBefore': (request: WorkspaceInsertSessionBeforeRequest): RemoteResult<WorkspaceValue> => ok({
+      workspace: workspace(String(request.workspaceId), { sessionIds: [request.sessionId] }),
+    }),
+    'workspace/archiveSession': (request: WorkspaceArchiveSessionRequest): RemoteResult<WorkspaceArchiveValue> => ok({ archivedSessionIds: [request.sessionId] }),
+  },
+}

+ 179 - 300
packages/api/workspace-controller/tests/transport.client.spec.ts

@@ -1,101 +1,53 @@
-import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it, vi } from 'vitest'
-import {
-  RemoteStream,
-  RemoteStreamCarrierError,
-  type ClientRemote,
-  type RemoteStreamOptions,
-} from '@deepseek-ai/dsh-api-gateway/client'
-import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
+/**
+ * Workspace Controller client plugin, state stream, and command facade driven
+ * through the assembled Gateway client: every `workspace/*` call crosses the
+ * roster's own Connection and is answered by endpoint name.
+ */
+
+import { describe, expect, onTestFinished, vi } from 'vitest'
+import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
 import { SessionId } from '@deepseek-ai/dsh-session/types'
-import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
-import * as WorkspaceClientPlugin from '../src/client/index.ts'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
+import { frames, openStream, type RemoteMock, type StreamScript } from '@deepseek-ai/dsh-remote-mock'
+import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
 import {
   ClientWorkspaceModel,
   createWorkspaceStateStream,
   WorkspaceController,
   WorkspaceCreateError,
   type WorkspaceFollowSink,
-  type WorkspaceRemote,
 } from '../src/client/index.ts'
-import type {
-  WorkspaceArchiveSessionRequest,
-  WorkspaceArchiveValue,
-  WorkspaceCreateRequest,
-  WorkspaceCreateValue,
-  WorkspaceDeleteRequest,
-  WorkspaceDeleteValue,
-  WorkspaceFollowFrame,
-  WorkspaceInsertBeforeRequest,
-  WorkspaceInsertSessionBeforeRequest,
-  WorkspaceOrderValue,
-  WorkspaceRenameRequest,
-  WorkspaceId,
-  WorkspaceValue,
-  WorkspaceView,
-} from '../src/types.ts'
-
-const AVAILABLE_CONNECTION = {
-  generation: {
-    getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }),
-    subscribe: () => () => {},
-  },
-}
-
-function workspaceClient(
-  remote: WorkspaceRemote,
-  connection: Pick<ConnectionHandle, 'generation'> = AVAILABLE_CONNECTION,
-): ClientRemote {
-  return {
-    workspace: remote,
-    $stream: <Item>(options: RemoteStreamOptions<Item>) => new RemoteStream(connection, options),
-  } as unknown as ClientRemote
-}
-
-interface Generation {
-  readonly frames: readonly WorkspaceFollowFrame[]
-  readonly error?: unknown
-  readonly hold?: boolean
-  readonly afterAbort?: () => void
-  readonly afterAbortError?: unknown
-}
-
-const baseline = (id?: string): Extract<WorkspaceFollowFrame, { type: 'baseline' }> => ({
-  type: 'baseline',
-  value: {
-    items: id === undefined ? [] : [{
-      workspaceId: id as never,
-      path: `/work/${id}`,
-      title: id,
-      sessionIds: [],
-      createdAt: '2026-01-01T00:00:00.000Z',
-      updatedAt: '2026-01-01T00:00:00.000Z',
-    }],
-    archivedSessionIds: [],
-  },
-})
+import type { WorkspaceFollowFrame, WorkspaceId } from '../src/types.ts'
+import { FOLLOW, baseline, err, followGenerations, workspace, workspaceWorld } from './remote/workspace.client.ts'
+
+const SELF = '@deepseek-ai/dsh-api-workspace-controller'
+/** The plugin as the web bundle composes it: itself plus the Gateway client, the Connection, and the Typert registry. */
+const PLUGIN_ROSTER = webApp.closure([SELF])
+/** A stream or model built by hand talks through the Gateway client alone. */
+const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
+const pluginTest = createClientTest({ roster: PLUGIN_ROSTER })
+const it = createClientTest({ roster: API_ROSTER })
+/** The first client boot pays the cold module transform of the plugin cone. */
+const COLD_BOOT_TIMEOUT_MS = 60_000
 
 const wid = (id: string): WorkspaceId => id as WorkspaceId
 const sid = (id: string): SessionId => SessionId(id)
 
-function workspace(id: string, overrides: Partial<WorkspaceView> = {}): WorkspaceView {
-  return {
-    workspaceId: wid(id),
-    path: `/work/${id}`,
-    title: id,
-    sessionIds: [],
-    createdAt: '2026-01-01T00:00:00.000Z',
-    updatedAt: '2026-01-01T00:00:00.000Z',
-    ...overrides,
-  }
+/** Boot the plugin with `follow` answering its state stream. */
+async function pluginClient(mock: RemoteMock, start: () => Promise<TestClient>, follow: StreamScript): Promise<TestClient> {
+  mock.stream(FOLLOW, follow)
+  return start()
 }
 
-function remoteOk<T>(value: T): RemoteResult<T> {
-  return { ok: true, value }
+/** Boot the Gateway client with the Workspace command answers registered, so `remote.workspace` is provided. */
+async function gatewayClient(mock: RemoteMock, start: () => Promise<TestClient>): Promise<{ remote: ClientRemote; client: TestClient }> {
+  mock.load(workspaceWorld)
+  const client = await start()
+  return { remote: client.ctx.remote, client }
 }
 
-function remoteFailure(error: RemoteFailure): RemoteResult<never> {
-  return { ok: false, error }
+const carrierLoss = (message: string): StreamScript => (_args, stream) => {
+  stream.fail(new RemoteStreamCarrierError(message))
 }
 
 function accepts(overrides: Partial<WorkspaceFollowSink> = {}): WorkspaceFollowSink {
@@ -110,166 +62,75 @@ function accepts(overrides: Partial<WorkspaceFollowSink> = {}): WorkspaceFollowS
   }
 }
 
-class ScriptedWorkspaceRemote implements WorkspaceRemote {
-  readonly signals: AbortSignal[] = []
-  calls = 0
-
-  constructor(private readonly generations: readonly Generation[]) {}
-
-  create(_request: WorkspaceCreateRequest): Promise<RemoteResult<WorkspaceCreateValue>> {
-    throw new Error('unused')
-  }
-
-  rename(_request: WorkspaceRenameRequest): Promise<RemoteResult<WorkspaceValue>> {
-    throw new Error('unused')
-  }
-
-  delete(_request: WorkspaceDeleteRequest): Promise<RemoteResult<WorkspaceDeleteValue>> {
-    throw new Error('unused')
-  }
-
-  insertBefore(_request: WorkspaceInsertBeforeRequest): Promise<RemoteResult<WorkspaceOrderValue>> {
-    throw new Error('unused')
-  }
-
-  insertSessionBefore(_request: WorkspaceInsertSessionBeforeRequest): Promise<RemoteResult<WorkspaceValue>> {
-    throw new Error('unused')
-  }
-
-  archiveSession(_request: WorkspaceArchiveSessionRequest): Promise<RemoteResult<WorkspaceArchiveValue>> {
-    throw new Error('unused')
-  }
-
-  async *follow(signal = new AbortController().signal): AsyncIterable<WorkspaceFollowFrame> {
-    const generation = this.generations[this.calls++]
-    if (generation === undefined) throw new Error('no scripted Workspace generation')
-    this.signals.push(signal)
-    for (const frame of generation.frames) yield frame
-    if (generation.error !== undefined) throw generation.error
-    if (generation.hold === true && !signal.aborted) {
-      await new Promise<void>((resolve) => {
-        signal.addEventListener('abort', () => { resolve() }, { once: true })
-      })
-      generation.afterAbort?.()
-      if (generation.afterAbortError !== undefined) throw generation.afterAbortError
-    }
-  }
-}
-
-class CommandWorkspaceRemote implements WorkspaceRemote {
-  readonly create = vi.fn<WorkspaceRemote['create']>(request => Promise.resolve(remoteOk({
-    workspace: workspace('created', { path: request.path }),
-    created: true,
-  })))
-
-  readonly rename = vi.fn<WorkspaceRemote['rename']>(request => Promise.resolve(remoteOk({
-    workspace: workspace(String(request.workspaceId), { title: request.title }),
-  })))
-
-  readonly delete = vi.fn<WorkspaceRemote['delete']>(() => Promise.resolve(remoteOk({ deleted: true })))
-
-  readonly insertBefore = vi.fn<WorkspaceRemote['insertBefore']>(request => Promise.resolve(remoteOk({
-    workspaceIds: [request.workspaceId],
-  })))
-
-  readonly insertSessionBefore = vi.fn<WorkspaceRemote['insertSessionBefore']>(request => Promise.resolve(remoteOk({
-    workspace: workspace(String(request.workspaceId), { sessionIds: [request.sessionId] }),
-  })))
-
-  readonly archiveSession = vi.fn<WorkspaceRemote['archiveSession']>(request => Promise.resolve(remoteOk({
-    archivedSessionIds: [request.sessionId],
-  })))
-
-  async *follow(_signal?: AbortSignal): AsyncIterable<WorkspaceFollowFrame> {}
-}
-
-async function waitFor(check: () => void): Promise<void> {
-  for (let attempt = 0; attempt < 40; attempt++) {
-    try {
-      check()
-      return
-    } catch {
-      await Promise.resolve()
-    }
-  }
-  check()
-}
-
-function provideClientServices(ctx: Context, remote: WorkspaceRemote): void {
-  const connection: ConnectionHandle = {
-    isLoopback: true,
-    generation: AVAILABLE_CONNECTION.generation,
-    state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} },
-    rpc: {
-      call: () => Promise.reject(new Error('unexpected generic RPC call')),
-    },
-    reconnect: () => {},
-    registerGenerationSource: () => () => {},
-    start: () => ({ stop: () => {} }),
-  }
-  ctx.reflect.provide('connection', connection)
-  ctx.reflect.provide('remote', workspaceClient(remote, connection))
-  ctx.reflect.provide('remote.workspace', remote)
+function streamStates(mock: RemoteMock): string[] {
+  return mock.log.streams(FOLLOW).map(row => row.state)
 }
 
 describe('Workspace Controller Client apply', () => {
-  it('provides the Workspace service and stops its follow generation with the plugin fiber', async () => {
-    const ctx = new Context()
-    const remote = new ScriptedWorkspaceRemote([{ frames: [baseline('mounted')], hold: true }])
-    provideClientServices(ctx, remote)
-    const fiber = ctx.plugin(WorkspaceClientPlugin)
-    await fiber
-    await waitFor(() => {
-      expect(ctx.workspaces.list.getSnapshot()).toMatchObject({
+  pluginTest('provides the Workspace service and stops its follow generation with the plugin fiber', async ({ mock, start }) => {
+    const client = await pluginClient(mock, start, openStream([baseline('mounted')]))
+    await vi.waitFor(() => {
+      expect(client.ctx.workspaces.list.getSnapshot()).toMatchObject({
         phase: 'ready',
         state: 'idle',
         items: [{ workspaceId: 'mounted' }],
       })
     })
 
-    await fiber.dispose()
+    await client.unload(SELF)
+
+    expect(streamStates(client.mock)).toEqual(['cancelled'])
+    expect(client.ctx.get('workspaces')).toBeUndefined()
+  }, COLD_BOOT_TIMEOUT_MS)
+
+  pluginTest('reopens the follow and re-provides the service across a Loader rebuild', async ({ mock, start }) => {
+    const client = await pluginClient(mock, start, openStream([baseline('mounted')]))
+    await vi.waitFor(() => {
+      expect(client.ctx.workspaces.list.getSnapshot()).toMatchObject({ phase: 'ready', items: [{ workspaceId: 'mounted' }] })
+    })
+    const before = client.ctx.workspaces
+
+    await client.reload(SELF)
 
-    expect(remote.signals[0]?.aborted).toBe(true)
-    expect(ctx.get('workspaces')).toBeUndefined()
+    expect(streamStates(client.mock)).toEqual(['cancelled', 'open'])
+    expect(client.ctx.workspaces).not.toBe(before)
+    await vi.waitFor(() => {
+      expect(client.ctx.workspaces.list.getSnapshot()).toMatchObject({ phase: 'ready', items: [{ workspaceId: 'mounted' }] })
+    })
   })
 
-  it('publishes exhausted carrier retries as a gateway/internal error state', async () => {
-    const ctx = new Context()
+  pluginTest('publishes exhausted carrier retries as a gateway/internal error state', async ({ mock, start }) => {
     // Neither generation reaches an accepted baseline, so the retry budget runs
     // out and the escaping carrier failure crosses the stream boundary marked.
-    const remote = new ScriptedWorkspaceRemote([
-      { frames: [], error: new RemoteStreamCarrierError('generation lost') },
-      { frames: [], error: new RemoteStreamCarrierError('generation lost again') },
-    ])
-    provideClientServices(ctx, remote)
-    const fiber = ctx.plugin(WorkspaceClientPlugin)
-    await fiber
-    await waitFor(() => {
-      expect(ctx.workspaces.list.getSnapshot()).toMatchObject({
+    const client = await pluginClient(mock, start, followGenerations([
+      carrierLoss('generation lost'),
+      carrierLoss('generation lost again'),
+    ]))
+    await vi.waitFor(() => {
+      expect(client.ctx.workspaces.list.getSnapshot()).toMatchObject({
         state: 'error',
         error: { code: 'gateway/internal', message: 'generation lost again' },
       })
     })
-    expect(remote.calls).toBe(2)
-    await fiber.dispose()
+    expect(streamStates(client.mock)).toEqual(['failed', 'failed'])
   })
 
-  it('marks carrier loss while retrying and publishes a later protocol failure', async () => {
-    const ctx = new Context()
-    const remote = new ScriptedWorkspaceRemote([
-      {
-        frames: [baseline('old')],
-        error: new RemoteStreamCarrierError('generation lost'),
-      },
-      { frames: [baseline('fresh'), baseline('duplicate')] },
-    ])
-    provideClientServices(ctx, remote)
+  pluginTest('marks carrier loss while retrying and publishes a later protocol failure', async ({ mock, start }) => {
     const carrierFailure = vi.spyOn(ClientWorkspaceModel.prototype, 'handleCarrierFailure')
     const streamFailure = vi.spyOn(ClientWorkspaceModel.prototype, 'handleStreamFailure')
-    const fiber = ctx.plugin(WorkspaceClientPlugin)
-    await fiber
-    await waitFor(() => {
-      expect(ctx.workspaces.list.getSnapshot()).toMatchObject({
+    onTestFinished(() => {
+      carrierFailure.mockRestore()
+      streamFailure.mockRestore()
+    })
+    const client = await pluginClient(mock, start, followGenerations([
+      (_args, stream) => {
+        stream.push(baseline('old'))
+        stream.fail(new RemoteStreamCarrierError('generation lost'))
+      },
+      openStream([baseline('fresh'), baseline('duplicate')]),
+    ]))
+    await vi.waitFor(() => {
+      expect(client.ctx.workspaces.list.getSnapshot()).toMatchObject({
         phase: 'ready',
         state: 'error',
         items: [{ workspaceId: 'fresh' }],
@@ -279,38 +140,28 @@ describe('Workspace Controller Client apply', () => {
 
     expect(carrierFailure).toHaveBeenCalledOnce()
     expect(streamFailure).toHaveBeenCalledOnce()
-    await fiber.dispose()
   })
 })
 
 describe('Workspace state stream', () => {
-  it('delivers one baseline followed by increments', async () => {
+  it('delivers one baseline followed by increments', async ({ mock, start }) => {
+    const { remote } = await gatewayClient(mock, start)
     const opening = baseline('one')
-    const workspace = opening.value.items[0]!
-    const remote = new ScriptedWorkspaceRemote([{
-      frames: [
-        opening,
-        { type: 'upsert', workspace },
-        { type: 'remove', workspaceId: workspace.workspaceId },
-        { type: 'order', workspaceIds: [workspace.workspaceId] },
-        { type: 'archived', archivedSessionIds: ['session-one' as never] },
-      ],
-      hold: true,
-    }])
+    const view = opening.value.items[0]!
+    const increments: WorkspaceFollowFrame[] = [
+      { type: 'upsert', workspace: view },
+      { type: 'remove', workspaceId: view.workspaceId },
+      { type: 'order', workspaceIds: [view.workspaceId] },
+      { type: 'archived', archivedSessionIds: [sid('session-one')] },
+    ]
+    mock.stream(FOLLOW, openStream([opening, ...increments]))
     const replaceBaseline = vi.fn<WorkspaceFollowSink['replaceBaseline']>()
     const upsertView = vi.fn<WorkspaceFollowSink['upsertView']>()
     const removeView = vi.fn<WorkspaceFollowSink['removeView']>()
     const replaceOrder = vi.fn<WorkspaceFollowSink['replaceOrder']>()
     const replaceArchived = vi.fn<WorkspaceFollowSink['replaceArchived']>()
-    const accept = accepts({
-      replaceBaseline,
-      upsertView,
-      removeView,
-      replaceOrder,
-      replaceArchived,
-    })
-    const stream = createWorkspaceStateStream(workspaceClient(remote), {
-      accept,
+    const stream = createWorkspaceStateStream(remote, {
+      accept: accepts({ replaceBaseline, upsertView, removeView, replaceOrder, replaceArchived }),
       failed: vi.fn(),
     })
 
@@ -319,24 +170,28 @@ describe('Workspace state stream', () => {
     await vi.waitFor(() => { expect(replaceArchived).toHaveBeenCalledOnce() })
 
     expect(replaceBaseline).toHaveBeenCalledWith(opening.value)
-    expect(upsertView).toHaveBeenCalledWith(workspace)
-    expect(removeView).toHaveBeenCalledWith(workspace.workspaceId)
-    expect(replaceOrder).toHaveBeenCalledWith([workspace.workspaceId])
+    expect(upsertView).toHaveBeenCalledWith(view)
+    expect(removeView).toHaveBeenCalledWith(view.workspaceId)
+    expect(replaceOrder).toHaveBeenCalledWith([view.workspaceId])
     expect(replaceArchived).toHaveBeenCalledWith(['session-one'])
     await stream.dispose()
-    expect(remote.signals[0]?.aborted).toBe(true)
+    expect(streamStates(mock)).toEqual(['cancelled'])
   })
 
-  it('retains the old state across carrier loss and applies the replacement baseline', async () => {
+  it('retains the old state across carrier loss and applies the replacement baseline', async ({ mock, start }) => {
+    const { remote } = await gatewayClient(mock, start)
     const carrier = new RemoteStreamCarrierError('socket lost')
-    const remote = new ScriptedWorkspaceRemote([
-      { frames: [baseline('old')], error: carrier },
-      { frames: [baseline('fresh')], hold: true },
-    ])
+    mock.stream(FOLLOW, followGenerations([
+      (_args, stream) => {
+        stream.push(baseline('old'))
+        stream.fail(carrier)
+      },
+      openStream([baseline('fresh')]),
+    ]))
     const replaceBaseline = vi.fn<WorkspaceFollowSink['replaceBaseline']>()
     const carrierFailed = vi.fn()
     const failed = vi.fn()
-    const stream = createWorkspaceStateStream(workspaceClient(remote), {
+    const stream = createWorkspaceStateStream(remote, {
       accept: accepts({ replaceBaseline }),
       carrierFailed,
       failed,
@@ -351,14 +206,15 @@ describe('Workspace state stream', () => {
     await stream.dispose()
   })
 
-  it('classifies a normal end after the opening baseline as carrier loss', async () => {
-    const remote = new ScriptedWorkspaceRemote([
-      { frames: [baseline('old')] },
-      { frames: [baseline('fresh')], hold: true },
-    ])
+  it('classifies a normal end after the opening baseline as carrier loss', async ({ mock, start }) => {
+    const { remote } = await gatewayClient(mock, start)
+    mock.stream(FOLLOW, followGenerations([
+      frames([baseline('old')]),
+      openStream([baseline('fresh')]),
+    ]))
     const replaceBaseline = vi.fn<WorkspaceFollowSink['replaceBaseline']>()
     const carrierFailed = vi.fn()
-    const stream = createWorkspaceStateStream(workspaceClient(remote), {
+    const stream = createWorkspaceStateStream(remote, {
       accept: accepts({ replaceBaseline }),
       carrierFailed,
       failed: vi.fn(),
@@ -372,21 +228,20 @@ describe('Workspace state stream', () => {
     await stream.dispose()
   })
 
-  it('suppresses callback failure after disposal begins', async () => {
+  it('suppresses callback failure after disposal begins', async ({ mock, start }) => {
+    const { remote } = await gatewayClient(mock, start)
+    mock.stream(FOLLOW, frames([baseline()]))
     const failed = vi.fn()
     let closing: Promise<void> | undefined
-    const stream = createWorkspaceStateStream(
-      workspaceClient(new ScriptedWorkspaceRemote([{ frames: [baseline()] }])),
-      {
-        accept: accepts({
-          replaceBaseline: () => {
-            closing = stream.dispose()
-            throw new Error('disposed callback')
-          },
-        }),
-        failed,
-      },
-    )
+    const stream = createWorkspaceStateStream(remote, {
+      accept: accepts({
+        replaceBaseline: () => {
+          closing = stream.dispose()
+          throw new Error('disposed callback')
+        },
+      }),
+      failed,
+    })
 
     stream.start()
     await vi.waitFor(() => { expect(closing).toBeDefined() })
@@ -394,28 +249,27 @@ describe('Workspace state stream', () => {
     expect(failed).not.toHaveBeenCalled()
   })
 
-  it.each([
+  it.for([
     {
       name: 'an increment before the baseline',
-      frames: [{ type: 'remove', workspaceId: 'one' as never }] as WorkspaceFollowFrame[],
+      items: [{ type: 'remove', workspaceId: wid('one') }] as WorkspaceFollowFrame[],
       message: 'update before its opening snapshot',
     },
     {
       name: 'a duplicate baseline',
-      frames: [baseline(), baseline()] as WorkspaceFollowFrame[],
+      items: [baseline(), baseline()] as WorkspaceFollowFrame[],
       message: 'more than one opening snapshot',
     },
     {
       name: 'a normal end before the baseline',
-      frames: [] as WorkspaceFollowFrame[],
+      items: [] as WorkspaceFollowFrame[],
       message: 'ended before its opening snapshot',
     },
-  ])('reports $name as a terminal failure', async ({ frames, message }) => {
+  ])('reports $name as a terminal failure', async ({ items, message }, { mock, start }) => {
+    const { remote } = await gatewayClient(mock, start)
+    mock.stream(FOLLOW, frames(items))
     const failed = vi.fn()
-    const stream = createWorkspaceStateStream(
-      workspaceClient(new ScriptedWorkspaceRemote([{ frames }])),
-      { accept: accepts(), failed },
-    )
+    const stream = createWorkspaceStateStream(remote, { accept: accepts(), failed })
 
     stream.start()
     await vi.waitFor(() => { expect(failed).toHaveBeenCalledOnce() })
@@ -423,17 +277,20 @@ describe('Workspace state stream', () => {
     expect(failure).toBeInstanceOf(Error)
     if (!(failure instanceof Error)) throw new Error('expected Workspace stream failure')
     expect(failure.message).toContain(message)
+    // A protocol failure is terminal: no retry opens a second generation.
+    expect(mock.log.requests(FOLLOW)).toHaveLength(1)
     await stream.dispose()
   })
 
-  it('restarts a live generation without reporting cancellation as failure', async () => {
-    const remote = new ScriptedWorkspaceRemote([
-      { frames: [baseline('first')], hold: true },
-      { frames: [baseline('second')], hold: true },
-    ])
+  it('restarts a live generation without reporting cancellation as failure', async ({ mock, start }) => {
+    const { remote } = await gatewayClient(mock, start)
+    mock.stream(FOLLOW, followGenerations([
+      openStream([baseline('first')]),
+      openStream([baseline('second')]),
+    ]))
     const replaceBaseline = vi.fn<WorkspaceFollowSink['replaceBaseline']>()
     const failed = vi.fn()
-    const stream = createWorkspaceStateStream(workspaceClient(remote), {
+    const stream = createWorkspaceStateStream(remote, {
       accept: accepts({ replaceBaseline }),
       failed,
     })
@@ -443,18 +300,20 @@ describe('Workspace state stream', () => {
     stream.restart()
     await vi.waitFor(() => { expect(replaceBaseline).toHaveBeenCalledTimes(2) })
     expect(failed).not.toHaveBeenCalled()
+    expect(streamStates(mock)).toEqual(['cancelled', 'open'])
     await stream.dispose()
   })
 })
 
 describe('WorkspaceController', () => {
-  it('publishes the model source and exposes successful Workspace commands', async () => {
-    const remote = new CommandWorkspaceRemote()
-    const model = new ClientWorkspaceModel(remote)
+  it('publishes the model source and exposes successful Workspace commands', async ({ mock, start }) => {
+    const { remote, client } = await gatewayClient(mock, start)
+    const model = new ClientWorkspaceModel(remote.workspace)
     model.replaceBaseline({ items: [workspace('one')], archivedSessionIds: [] })
-    const controller = new WorkspaceController(new Context(), model)
+    const controller = new WorkspaceController(client.ctx, model)
 
     expect(controller.list).toBe(model)
+    expect(client.ctx.workspaces.list).toBe(model)
     await expect(controller.create({ path: '/work/created' })).resolves.toMatchObject({ workspaceId: 'created' })
     await expect(controller.rename(wid('one'), 'renamed')).resolves.toMatchObject({ title: 'renamed' })
     await expect(controller.insertBefore(wid('one'))).resolves.toBeUndefined()
@@ -463,32 +322,52 @@ describe('WorkspaceController', () => {
     })
     await expect(controller.archiveSession(sid('session'))).resolves.toBeUndefined()
     await expect(controller.delete(wid('one'))).resolves.toBeUndefined()
+    // Each command crosses the wire as one positional request object.
+    expect(mock.log.requests('workspace/create')).toEqual([{ path: '/work/created' }])
+    expect(mock.log.requests('workspace/rename')).toEqual([{ workspaceId: 'one', title: 'renamed' }])
+    expect(mock.log.requests('workspace/insertBefore')).toEqual([{ workspaceId: 'one' }])
+    expect(mock.log.requests('workspace/insertSessionBefore')).toEqual([{ workspaceId: 'one', sessionId: 'session' }])
+    expect(mock.log.requests('workspace/archiveSession')).toEqual([{ sessionId: 'session' }])
+    expect(mock.log.requests('workspace/delete')).toEqual([{ workspaceId: 'one' }])
   })
 
-  it('maps generated business failures to the command facade errors', async () => {
-    const remote = new CommandWorkspaceRemote()
-    const controller = new WorkspaceController(new Context(), new ClientWorkspaceModel(remote))
+  it('maps generated business failures to the command facade errors', async ({ mock, start }) => {
+    const { remote, client } = await gatewayClient(mock, start)
+    const controller = new WorkspaceController(client.ctx, new ClientWorkspaceModel(remote.workspace))
     const missingWorkspace = new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('missing') })
     const missingSession = new RemoteError('session/not-found', 'missing session', { sessionId: sid('session') })
 
-    remote.create.mockResolvedValueOnce(remoteFailure(new RemoteError('workspace/invalid-path', 'missing path', { path: '/missing' })))
+    mock.remote.workspace.create.mockResolvedValueOnce(err(new RemoteError('workspace/invalid-path', 'missing path', { path: '/missing' })))
     const create = controller.create({ path: '/missing' })
     await expect(create).rejects.toBeInstanceOf(WorkspaceCreateError)
-    await expect(create).rejects.toThrow('workspace/invalid-path: missing path')
+    await expect(create).rejects.toThrow('workspace create failed: workspace/invalid-path: missing path')
 
-    remote.rename.mockResolvedValueOnce(remoteFailure(missingWorkspace))
+    mock.remote.workspace.rename.mockResolvedValueOnce(err(missingWorkspace))
     await expect(controller.rename(wid('missing'), 'name')).rejects.toThrow('workspace rename failed: workspace/not-found: gone')
-    remote.delete.mockResolvedValueOnce(remoteFailure(missingWorkspace))
+    mock.remote.workspace.delete.mockResolvedValueOnce(err(missingWorkspace))
     await expect(controller.delete(wid('missing'))).rejects.toThrow('workspace delete failed: workspace/not-found: gone')
-    remote.insertBefore.mockResolvedValueOnce(remoteFailure(missingWorkspace))
+    mock.remote.workspace.insertBefore.mockResolvedValueOnce(err(missingWorkspace))
     await expect(controller.insertBefore(wid('missing'))).rejects.toThrow('workspace reorder failed: workspace/not-found: gone')
-    remote.archiveSession.mockResolvedValueOnce(remoteFailure(missingSession))
+    mock.remote.workspace.archiveSession.mockResolvedValueOnce(err(missingSession))
     await expect(controller.archiveSession(sid('session')))
       .rejects.toThrow('workspace session archive failed: session/not-found: missing session')
-    remote.insertSessionBefore.mockResolvedValueOnce(remoteFailure(new RemoteError(
+    mock.remote.workspace.insertSessionBefore.mockResolvedValueOnce(err(new RemoteError(
       'workspace/move-invalid', 'invalid move', { workspaceId: wid('missing'), sessionId: sid('session') },
     )))
     await expect(controller.insertSessionBefore(wid('missing'), sid('session')))
       .rejects.toThrow('workspace move failed: workspace/move-invalid: invalid move')
   })
+
+  it('receives a carrier throw as the client\'s gateway/internal fold, never as a rejection', async ({ mock, start }) => {
+    const { remote, client } = await gatewayClient(mock, start)
+    const controller = new WorkspaceController(client.ctx, new ClientWorkspaceModel(remote.workspace))
+
+    mock.remote.workspace.create.mockImplementation(() => Promise.reject(new Error('create wire down')))
+    const create = controller.create({ path: '/work/created' })
+    await expect(create).rejects.toBeInstanceOf(WorkspaceCreateError)
+    await expect(create).rejects.toThrow(
+      'workspace create failed: gateway/internal: client api: workspace/create failed: create wire down',
+    )
+    expect(mock.log.calls('workspace/create').map(call => call.state)).toEqual(['failed'])
+  })
 })

+ 10 - 4
packages/client/connection/src/client/index.ts

@@ -78,9 +78,15 @@ export const inject: string[] = []
  * provides both halves here instead of forking this plugin.
  */
 export interface ClientTransportHooks {
-  /** Transport for generic unary RPC channels (the Typert gateway). */
-  fetch: RpcFetch
-  /** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket. */
+  /**
+   * Already decoded logical RPC carrier. When present it replaces the HTTP
+   * caller outright: no envelopes, no `fetch`, no `openStream` (an in-process
+   * Host such as a test mock plugs in here).
+   */
+  rpc?: ClientConnectionRpc
+  /** Transport for generic unary RPC channels (the Typert gateway); unused when `rpc` is present. */
+  fetch?: RpcFetch
+  /** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket or `rpc` is present. */
   openStream?: RpcStreamOpen
   /**
    * Bundle transport for the module system, present when the carrier also owns
@@ -185,7 +191,7 @@ export function apply(ctx: Context): void {
   const fixtureRpc = fixture ? createFixtureConnectionRpc() : undefined
   const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__
   const recovery = resolveConnectionConfig((globalThis as ClientTransportGlobal).__DSH_CONNECTION_RECOVERY__)
-  const rpc = fixtureRpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream)
+  const rpc = fixtureRpc ?? transport?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream)
   let generationSource: ConnectionGenerationSource | undefined
   let owner: ConnectionOwner | undefined
   let generationId = 0

+ 17 - 1
packages/client/connection/tests/client-apply.client.spec.ts

@@ -6,8 +6,10 @@ import { Context } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import {
   apply,
+  type ClientConnectionRpc,
   type ClientTransportHooks,
   type ConnectionGenerationSource,
+  type RpcFetch,
   type ConnectionHandle,
   type ConnectionState,
 } from '../src/client/index.ts'
@@ -483,6 +485,20 @@ describe('connection client apply', () => {
     })
   })
 
+  it('uses an already decoded rpc carrier from the transport hooks instead of the HTTP caller', async () => {
+    ;(globalThis as Win).location = { hostname: 'preview.example', search: '' }
+    const rpc: ClientConnectionRpc = {
+      call: vi.fn(async (_channel: string, endpoint: string, payload: unknown) => ({ ok: true as const, value: { endpoint, payload } })),
+      open: vi.fn((_channel: string, endpoint: string) => (async function *(): AsyncGenerator { yield endpoint })()),
+    }
+    ;(globalThis as Win).__DSH_TRANSPORT__ = { rpc }
+    const handle = await mount()
+    expect(handle.rpc).toBe(rpc)
+    await expect(handle.rpc.call('/api', 'session/list', { args: [] })).resolves.toEqual({
+      ok: true, value: { endpoint: 'session/list', payload: { args: [] } },
+    })
+  })
+
   it('exposes a worker-local Gateway stream through connection.rpc.open', async () => {
     ;(globalThis as Win).location = { hostname: 'preview.example', search: '' }
     const openStream = vi.fn<NonNullable<ClientTransportHooks['openStream']>>(
@@ -492,7 +508,7 @@ describe('connection client apply', () => {
       })(),
     )
     ;(globalThis as Win).__DSH_TRANSPORT__ = {
-      fetch: vi.fn<ClientTransportHooks['fetch']>(),
+      fetch: vi.fn<RpcFetch>(),
       openStream,
       ownsHost: true,
     }

+ 3 - 1
packages/client/hmr/package.json

@@ -31,7 +31,9 @@
   },
   "dsh": {
     "client": {
-      "inject": [],
+      "inject": [
+        "@deepseek-ai/dsh-client-modules"
+      ],
       "platform": "web",
       "immediately": true
     }

+ 19 - 12
packages/client/hmr/src/client/index.ts

@@ -75,6 +75,24 @@ export const name = 'client-hmr'
 /** Required services: the vendored Loader (entry governance) and the client module system (boot provide, service name `modules`). */
 export const inject = ['loader', 'modules']
 
+/**
+ * Registry-first teardown of an entry's running fiber so `entry.refresh()`
+ * rebuilds it (see the module comment): delete the runtime record before the
+ * fiber's disposer emits `internal/plugin` (or the Loader flags the entry
+ * disabled), drain the unload so effect disposers finish before a new apply
+ * re-registers, then clear `entry.fiber` so `refresh()` re-imports instead of
+ * no-oping. A fiberless entry is left untouched.
+ * @param entry - the Loader entry to tear down.
+ */
+export async function tearDownEntryFiber(entry: Entry): Promise<void> {
+  const oldFiber = entry.fiber
+  if (oldFiber === undefined) return
+  const runtime = oldFiber.runtime
+  if (runtime !== null) entry.ctx.registry.delete(runtime.callback)
+  while (oldFiber.inertia !== undefined) await oldFiber.inertia
+  delete entry.fiber
+}
+
 /** Find the loader entry whose module specifier is `id` (entry tree ids are random; the package name lives in `options.name`). */
 function findEntry(loader: Loader, id: string): Entry | undefined {
   for (const entry of loader.entries()) {
@@ -115,18 +133,7 @@ export function apply(ctx: Context): void {
     modLoader.invalidate(id, rev)
     await modLoader.prefetch(id)
 
-    const oldFiber = entry.fiber
-    if (oldFiber !== undefined) {
-      // Registry-first teardown (see module comment): the runtime record must
-      // be gone before the fiber's disposer emits internal/plugin, or the
-      // Loader flags the entry disabled.
-      const runtime = oldFiber.runtime
-      if (runtime !== null) entry.ctx.registry.delete(runtime.callback)
-      // Drain the unload: effect disposers (slots, subscriptions) must finish
-      // before the new bundle executes and the new apply re-registers.
-      while (oldFiber.inertia !== undefined) await oldFiber.inertia
-      delete entry.fiber
-    }
+    await tearDownEntryFiber(entry)
     // Old owned styles go before materialization re-injects them (the CSS
     // idempotency guard keys on stable tag ids).
     removeOwnedStyles(id)

+ 2 - 2
packages/client/modules/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/modules/README.md
-README.md: dad3cd22b111de0d482d74ea4c696467d1a3eeb0
-README.zh.md: ef7a01dd1e2a9ed3074fcb7a768ee0e478975fa4
+README.md: 83ba5357cf84a12105518ed057fd120584b636cb
+README.zh.md: 9e5c75838e52fda7d74b074b9c1a9dacef7932a8

+ 1 - 1
packages/client/modules/README.md

@@ -80,7 +80,7 @@ The host contributes structured index rows that inject, into `<head>`: the `wind
 | [`src/index.ts`](src/index.ts) | Node half: `ClientModuleRegistry`, scan, artifact snapshots, optional combo route, structured index rows |
 | [`src/client/index.ts`](src/client/index.ts) | Browser half: bootstrap export, `ctx.modules` enrollment |
 | [`src/client/system.ts`](src/client/system.ts) | `ClientModuleSystem`: load/materialize/invalidate machinery |
-| [`src/client/manifest.ts`](src/client/manifest.ts) | Wire types and boot-manifest parsing |
+| [`src/client/manifest.ts`](src/client/manifest.ts) | Wire types, boot-manifest parsing, and the `dsh.client` declaration parser |
 
 </details>
 

+ 1 - 1
packages/client/modules/README.zh.md

@@ -80,7 +80,7 @@ Node 半侧会在发布前快照每个客户端 bundle 及其现有 source map
 | [`src/index.ts`](src/index.ts) | Node 半侧:`ClientModuleRegistry`、扫描、产物快照、可选 combo 路由、结构化 index 行 |
 | [`src/client/index.ts`](src/client/index.ts) | 浏览器半侧:bootstrap 导出、`ctx.modules` 登记 |
 | [`src/client/system.ts`](src/client/system.ts) | `ClientModuleSystem`:加载/物化/失效机制 |
-| [`src/client/manifest.ts`](src/client/manifest.ts) | 协议类型与启动 manifest 解析 |
+| [`src/client/manifest.ts`](src/client/manifest.ts) | 协议类型、启动清单解析与 `dsh.client` 声明解析器 |
 
 </details>
 

+ 1 - 1
packages/client/modules/src/client/index.ts

@@ -18,7 +18,7 @@ import type {
 } from './manifest.ts'
 
 export { ClientModuleSystem }
-export { parseBootManifest, stripClientSuffix } from './manifest.ts'
+export { exactPackageSpecifier, parseBootManifest, parseDshClient, stripClientSuffix } from './manifest.ts'
 export type {
   BootManifest, BootModuleRow, BootPluginRow, ClientBootstrapModule, ClientBundleRegistration,
   ClientModuleCreateOptions, ClientModuleLoader, ClientModuleLoaderTarget, ClientModuleRecord,

+ 46 - 0
packages/client/modules/src/client/manifest.ts

@@ -30,6 +30,7 @@
  */
 
 import type {} from '@deepseek-ai/cordis'
+import type { DshClientManifest } from '@deepseek-ai/dsh-package-manifest'
 import type { ClientModuleSystem } from './system.ts'
 
 declare module '@deepseek-ai/cordis' {
@@ -144,6 +145,51 @@ export function optionalStringArray(subject: string, field: string, value: unkno
   return value as string[]
 }
 
+/**
+ * Narrow an unknown parsed JSON value to the `dsh.client` declaration. Shared
+ * by the node half's Loader scan and the roster generator, so both read a
+ * package's browser declaration through one validator.
+ * @param pkgName - package name used as the diagnostic prefix.
+ * @param value - the raw `dsh.client` field of the package manifest.
+ * @returns the validated declaration, or undefined when the field is absent.
+ * @throws {Error} when the field is present but any member is malformed.
+ */
+export function parseDshClient(pkgName: string, value: unknown): DshClientManifest | undefined {
+  if (value === undefined) return undefined
+  if (typeof value !== 'object' || value === null) {
+    throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`)
+  }
+  const decl = value as Record<string, unknown>
+  if (typeof decl.platform !== 'string') {
+    throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`)
+  }
+  const inject = optionalStringArray(pkgName, 'dsh.client.inject', decl.inject)
+  const external = optionalStringArray(pkgName, 'dsh.client.external', decl.external)
+  if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
+    throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`)
+  }
+  return {
+    platform: decl.platform,
+    ...(inject !== undefined ? { inject } : {}),
+    ...(external !== undefined ? { external } : {}),
+    ...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
+  }
+}
+
+/**
+ * The bare package-root specifier `specifier` names, or undefined for a subpath, a path, or any scheme-qualified
+ * specifier (`cordis:` builtins, `node:` modules, URLs).
+ * @param specifier - Loader row name.
+ * @returns the package name, or undefined.
+ */
+export function exactPackageSpecifier(specifier: string): string | undefined {
+  if (specifier.startsWith('@')) {
+    const parts = specifier.split('/')
+    return parts.length === 2 && parts.every(Boolean) ? specifier : undefined
+  }
+  return specifier.length > 0 && !specifier.includes('/') && !specifier.includes(':') ? specifier : undefined
+}
+
 /**
  * Normalize a module specifier onto the graph row that owns it: a plugin bundle
  * IS its package's client half, so `<id>/client` (the exports subpath external

+ 1 - 34
packages/client/modules/src/index.ts

@@ -33,8 +33,7 @@ import { Service } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
 import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
 import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
-import type { DshClientManifest } from '@deepseek-ai/dsh-package-manifest'
-import { optionalStringArray, stripClientSuffix } from './client/manifest.ts'
+import { exactPackageSpecifier, parseDshClient, stripClientSuffix } from './client/manifest.ts'
 import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph } from './client/manifest.ts'
 
 export { stripClientSuffix } from './client/manifest.ts'
@@ -173,38 +172,6 @@ const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$
 /** Debugger source name appended to page bundles in the WebWorker image. */
 const SOURCE_URL_TRAILER = /(?:\r?\n)?\/\/# sourceURL=([^\r\n]+)(?:\r?\n)?$/
 
-/** Return a bare package-root specifier, excluding package subpaths and path-like entries. */
-function exactPackageSpecifier(specifier: string): string | undefined {
-  if (specifier.startsWith('@')) {
-    const parts = specifier.split('/')
-    return parts.length === 2 && parts.every(Boolean) ? specifier : undefined
-  }
-  return specifier.length > 0 && !specifier.includes('/') ? specifier : undefined
-}
-
-/** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
-function parseDshClient(pkgName: string, value: unknown): DshClientManifest | undefined {
-  if (value === undefined) return undefined
-  if (typeof value !== 'object' || value === null) {
-    throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`)
-  }
-  const decl = value as Record<string, unknown>
-  if (typeof decl.platform !== 'string') {
-    throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`)
-  }
-  const inject = optionalStringArray(pkgName, 'dsh.client.inject', decl.inject)
-  const external = optionalStringArray(pkgName, 'dsh.client.external', decl.external)
-  if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
-    throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`)
-  }
-  return {
-    platform: decl.platform,
-    ...(inject !== undefined ? { inject } : {}),
-    ...(external !== undefined ? { external } : {}),
-    ...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
-  }
-}
-
 /** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
 function clientExportOf(pkgName: string, exportsField: unknown): string | undefined {
   if (typeof exportsField !== 'object' || exportsField === null) return undefined

+ 1 - 0
packages/client/ui-permission-presets/package.json

@@ -46,6 +46,7 @@
     "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-remote-mock": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",

+ 7 - 4
packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts

@@ -9,11 +9,13 @@
  * its Settings row and invalidates that row on host settings changes.
  */
 import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, onTestFinished } from 'vitest'
 import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
-import { TestRemote, scriptedSettingsRemote } from '@deepseek-ai/dsh-client-test-runtime'
+import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
+import { remoteDefaultResponses } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/remote-default-responses.ts'
+import { RemoteMock } from '@deepseek-ai/dsh-remote-mock'
 import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
 import type { CommandDecoration, PopupSelectSpec } from '@deepseek-ai/dsh-client-ui-commands/client'
 import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client'
@@ -40,8 +42,9 @@ async function bench() {
   const locale = new LocaleRuntime(ctx)
   locale.setLocale('en')
   ctx.provide('locale', locale)
-  const settingsRemote = scriptedSettingsRemote()
-  const remote = new TestRemote(ctx, { settings: settingsRemote.settings })
+  const mock = RemoteMock.create().load(remoteDefaultResponses)
+  onTestFinished(() => { mock.assertNoUnmatched() })
+  const remote = new TestRemote(ctx, { settings: mock.remote.settings })
   ctx.slots.register({
     name: 'root',
     children: {

+ 7 - 6
packages/client/ui-settings-general/package.json

@@ -49,22 +49,23 @@
     "@deepseek-ai/cordis": "workspace:^"
   },
   "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-store": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "@types/react": "~18.3.1",
-    "react": "^18.2.0",
-    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-remote-mock": "workspace:^",
     "@deepseek-ai/dsh-settings": "workspace:^",
-    "clsx": "^2.0.0"
+    "@types/react": "~18.3.1",
+    "clsx": "^2.0.0",
+    "react": "^18.2.0"
   },
   "files": [
     "lib/index.js",

+ 181 - 166
packages/client/ui-settings-general/tests/apply.client.spec.ts

@@ -1,20 +1,30 @@
-/** Ownerless-copy registrations: the five seats, dictionaries, thunked labels, and HMR recovery. */
-import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it, vi } from 'vitest'
+// @vitest-environment jsdom
+/**
+ * Ownerless-copy registrations inside the assembled web client: the five
+ * seats, the `settings` dictionaries, the locale-following nav label, the
+ * loopback-only document action over the real settings mirror, and recovery
+ * across Loader rebuilds of the declaring chain.
+ */
+import { describe, expect, onTestFinished, vi } from 'vitest'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
+import type { SettingsNamespaceView } from '@deepseek-ai/dsh-settings/types'
+import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
-import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
-import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
-import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
-import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
-import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
+import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from '@deepseek-ai/dsh-client-locale/src/locale-settings.ts'
+import { inject } from '../src/client/index.ts'
 import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
 import { GeneralSection } from '../src/client/GeneralSection.tsx'
 import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
 import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx'
 
-// These specs assert the shipped Chinese copy. The lane has no jsdom `window`,
-// so browser-language detection never runs and a fresh LocaleRuntime opens on
-// FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead.
+const SELF = '@deepseek-ai/dsh-client-ui-settings-general'
+const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar'
+const it = createClientTest({ roster: webApp })
+/** The whole roster's first boot pays the cold module transform of every plugin package. */
+const COLD_BOOT_TIMEOUT_MS = 60_000
+/** Dictionary namespace this plugin owns; every seat it fills declares it. */
+const NS = 'settings'
 
 /** The seats this plugin fills for a loopback browser (slot name → expected component). */
 const SEATS = [
@@ -25,56 +35,55 @@ const SEATS = [
   ['settings.section', GeneralSection],
 ] as const
 
-async function bench(isLoopback = true) {
-  const ctx = new Context()
-  await ctx.plugin(SlotRegistry).await()
-  const locale = new LocaleRuntime(ctx)
-  locale.setLocale('zh')
-  ctx.provide('locale', locale)
-  const settingsDescribe = vi.fn(() => Promise.resolve({
-    ok: true as const,
-    value: {
-      writable: true,
-      hasDocument: true,
-      namespaces: [],
-    },
-  }))
-  const settingsOpenDocument = vi.fn(() => Promise.resolve({
-    ok: true as const, value: { opened: true as const },
-  }))
-  const remote = new TestRemote(ctx, {
-    settings: { describe: settingsDescribe, openSettingsDocument: settingsOpenDocument },
-  })
-  // The fixed Host facts the shell reads its loopback-only action from.
-  remote.$host = { home: undefined, isLoopback }
-  ctx.provide('connection', {
-    state: { getSnapshot: () => 'connected', subscribe: () => () => {} },
-    reconnect: () => {},
-  } as never)
-  await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
-  return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, settingsDescribe, settingsOpenDocument }
+/** One Host view of the locale preference, including its revision fence. */
+function localeView(preference: string, revision = 0): SettingsNamespaceView {
+  return {
+    ns: LOCALE_SETTINGS_NAMESPACE,
+    // The Remote wire serializes nested Schema values before the client rehydrates them.
+    schema: JSON.parse(JSON.stringify(LocaleSettingsSchema.toJSON())) as SettingsNamespaceView['schema'],
+    value: { preference },
+    applies: 'live',
+    secrets: [],
+    revision,
+  }
+}
+
+async function client(mock: RemoteMock, start: () => Promise<TestClient>, hasDocument = false) {
+  const settings = mock.remote.settings
+  settings.describe.mockResolvedValue(ok({ writable: true, hasDocument, namespaces: [localeView('zh')] }))
+  const c = await start()
+  // The locale adopts the Host preference once the describe mirror holds the document.
+  await c.ctx.settingsScope.describe().ensure()
+  return { c, settings }
+}
+
+/** This plugin's rows in a seat: the list seats also carry feature-owned rows (the product's other sections and actions). */
+function ownEntries(c: TestClient, name: (typeof SEATS)[number][0]) {
+  return c.ctx.slots.entries(name).filter(entry => entry.locale === NS)
+}
+
+function generalEntry(c: TestClient) {
+  return ownEntries(c, 'settings.section').find(entry => entry.component === GeneralSection)!
 }
 
-/** Declare the shell's six child slots the way ui-settings' entry does. */
-function declare(slots: SlotRegistry): () => void {
-  return slots.register(
-    {
-      name: 'root',
-      children: {
-        'settings.trigger': { kind: 'single', scope: 'root' },
-        'settings.header': { kind: 'single', scope: 'root' },
-        'settings.action': { kind: 'list', scope: 'root' },
-        'settings.close': { kind: 'single', scope: 'root' },
-        'settings.section': { kind: 'list', scope: 'root' },
-        'settings.onboarding': { kind: 'list', scope: 'root' },
-      },
-    } as never,
-    () => null,
-  )
+function generalLabel(c: TestClient): string | undefined {
+  return resolveSlotLabel(generalEntry(c).options.label)
 }
 
-function generalEntry(slots: SlotRegistry) {
-  return slots.entries('settings.section').find(e => e.component === GeneralSection)
+function actionInjectedOf(c: TestClient): SettingsDocumentActionInjected {
+  const entry = ownEntries(c, 'settings.action')[0]!
+  return (entry.inject as unknown as () => SettingsDocumentActionInjected)()
+}
+
+function expectSeated(c: TestClient): void {
+  for (const [name, component] of SEATS) {
+    expect(ownEntries(c, name).map(entry => entry.component)).toEqual([component])
+  }
+}
+
+/** The page authority the `connection` plugin classifies at apply, reconfigured through the jsdom instance vitest exposes. */
+function setPageUrl(url: string): void {
+  (globalThis as unknown as { jsdom: { reconfigure(settings: { url: string }): void } }).jsdom.reconfigure({ url })
 }
 
 describe('ui-settings-general apply', () => {
@@ -82,138 +91,144 @@ describe('ui-settings-general apply', () => {
     expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope'])
   })
 
-  it('fills all five seats for declarations before or after apply', async () => {
-    const before = await bench()
-    declare(before.slots)
-    await before.ctx.plugin({ inject: [...inject], apply }).await()
-    for (const [name, component] of SEATS) {
-      expect(before.slots.entries(name)[0]!.component).toBe(component)
-    }
-    const entry = generalEntry(before.slots)!
+  it('fills the five seats of the shell it declares, with the locale-following General label', async ({ mock, start }) => {
+    const { c } = await client(mock, start)
+    expect(c.ctx.locale.getSnapshot().active).toBe('zh')
+    expectSeated(c)
+    const entry = generalEntry(c)
     expect(entry.options).toMatchObject({ id: 'general', order: 0 })
     // The nav label is a locale-following thunk; owners resolve at read time.
-    expect(resolveSlotLabel(entry.options.label)).toBe('通用设置')
-    expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
-    expect(before.slots.entries('settings.general.item')).toEqual([])
-    // The onboarding hole stays declared for feature-owned steps; this plugin
-    // no longer seats one.
-    expect(before.slots.entries('settings.onboarding')).toEqual([])
-    const action = before.slots.entries('settings.action')[0]!
-    const actionInjected = (action.inject as unknown as () => SettingsDocumentActionInjected)()
-    expect(actionInjected.controller.store.getSnapshot().status).toBe('idle')
-    expect(actionInjected.hooks.snapshot).toBe(actionInjected.controller.store)
-    // Copy rides the standard locale seat: every seat declares the namespace.
-    for (const [name] of SEATS) {
-      expect(before.slots.entries(name)[0]!.locale).toBe('settings')
-    }
-    const after = await bench()
-    await after.ctx.plugin({ inject: [...inject], apply }).await()
-    for (const [name] of SEATS) expect(after.slots.entries(name)).toHaveLength(0)
-    declare(after.slots)
-    await Promise.resolve()
+    expect(generalLabel(c)).toBe('通用设置')
+    expect(c.ctx.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
+    // The General items and the onboarding steps are feature-owned rows; this plugin seats none of its own.
+    expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS)).toEqual([])
+    expect(c.ctx.slots.entries('settings.onboarding').filter(row => row.locale === NS)).toEqual([])
+    const { controller, hooks } = actionInjectedOf(c)
+    expect(controller.store.getSnapshot().status).toBe('idle')
+    expect(hooks.snapshot).toBe(controller.store)
+    // Copy rides the standard locale seat: every row this plugin seats declares the namespace.
     for (const [name, component] of SEATS) {
-      expect(after.slots.entries(name)[0]!.component).toBe(component)
-      // The self-inflicted ledger notifications hit the duplicate guard.
-      expect(after.slots.entries(name)).toHaveLength(1)
+      expect(c.ctx.slots.entries(name).find(row => row.component === component)!.locale).toBe(NS)
     }
+  }, COLD_BOOT_TIMEOUT_MS)
+
+  it('registers the zh/en settings dictionaries and frees the seats when its row unloads', async ({ mock, start }) => {
+    const { c, settings } = await client(mock, start)
+    const english = localeView('en', 1)
+    settings.mutate.mockResolvedValueOnce(ok(english))
+    const t = c.ctx.locale.bind(NS)
+    expect(t('title')).toBe('设置')
+    expect(t('connection.error')).toBe('连接异常')
+    expect(t('connection.connecting')).toBe('自动重连中')
+    expect(t('connection.connected')).toBe('连接成功')
+    c.ctx.locale.setLocale('en')
+    expect(t('close')).toBe('Close')
+    expect(t('connection.reconnect')).toBe('Disconnected, reconnect now')
+    expect(t('connection.connecting')).toBe('Reconnecting')
     await vi.waitFor(() => {
-      expect(after.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
+      expect(settings.mutate.mock.calls).toEqual([
+        [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'en' }], 0],
+      ])
+      expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([english])
     })
-  })
-
-  it('registers the zh/en settings dictionaries and frees the seats on teardown', async () => {
-    const b = await bench()
-    declare(b.slots)
-    const fiber = b.ctx.plugin({ inject: [...inject], apply })
-    await fiber.await()
-    expect(b.locale.bind('settings')('title')).toBe('设置')
-    expect(b.locale.bind('settings')('connection.error')).toBe('连接异常')
-    expect(b.locale.bind('settings')('connection.connecting')).toBe('自动重连中')
-    expect(b.locale.bind('settings')('connection.connected')).toBe('连接成功')
-    b.locale.setLocale('en')
-    expect(b.locale.bind('settings')('close')).toBe('Close')
-    expect(b.locale.bind('settings')('connection.reconnect')).toBe('Disconnected, reconnect now')
-    expect(b.locale.bind('settings')('connection.connecting')).toBe('Reconnecting')
-    b.locale.setLocale('zh')
-    await fiber.dispose()
+    await c.unload(SELF)
+    await c.flush()
     // The (ns, locale) seats are free again — the dictionary disposer ran.
-    expect(() => b.locale.register('settings', 'zh', {})).not.toThrow()
-    expect(() => b.locale.register('settings', 'en', {})).not.toThrow()
+    expect(() => { c.ctx.locale.register(NS, 'zh', {})() }).not.toThrow()
+    expect(() => { c.ctx.locale.register(NS, 'en', {})() }).not.toThrow()
   })
 
-  it('the nav label thunk follows the active locale without re-registration', async () => {
-    const b = await bench()
-    declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    const zhVersions = SEATS.map(([name]) => b.slots.getVersion(name))
-    b.locale.setLocale('en')
+  it('the nav label thunk follows the active locale without re-registration', async ({ mock, start }) => {
+    const { c, settings } = await client(mock, start)
+    const english = localeView('en', 1)
+    const chinese = localeView('zh', 2)
+    settings.mutate.mockResolvedValueOnce(ok(english)).mockResolvedValueOnce(ok(chinese))
+    const zhVersions = SEATS.map(([name]) => c.ctx.slots.getVersion(name))
+    c.ctx.locale.setLocale('en')
     // No ledger churn: freshness rides the thunk (and the renderer's locale
     // subscription), not re-registration.
     SEATS.forEach(([name], i) => {
-      expect(b.slots.getVersion(name)).toBe(zhVersions[i]!)
-      expect(b.slots.entries(name)).toHaveLength(1)
+      expect(c.ctx.slots.getVersion(name)).toBe(zhVersions[i]!)
+      expect(ownEntries(c, name)).toHaveLength(1)
+    })
+    expect(generalLabel(c)).toBe('General')
+    await vi.waitFor(() => {
+      expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([english])
+    })
+    c.ctx.locale.setLocale('zh')
+    expect(generalLabel(c)).toBe('通用设置')
+    await vi.waitFor(() => {
+      expect(settings.mutate.mock.calls).toEqual([
+        [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'en' }], 0],
+        [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'zh' }], 1],
+      ])
+      expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([chinese])
     })
-    expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('General')
-    b.locale.setLocale('zh')
-    expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置')
   })
 
-  it('reads availability from the shared mirror and follows its reconnect refresh', async () => {
-    const b = await bench()
-    declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    const entry = b.slots.entries('settings.action')[0]!
-    const { controller } = (entry.inject as unknown as () => SettingsDocumentActionInjected)()
-    // The mirror read once at its own boot; the action's load adds no read.
-    await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledOnce() })
+  it('reads availability from the shared mirror and follows its reconnect refresh', async ({ mock, start }) => {
+    const { c } = await client(mock, start, true)
+    const { controller } = actionInjectedOf(c)
+    // Boot reads the document twice: the mirror's own `ensure` at apply, then
+    // the `connection/reset` of the first connection. The action's load adds none.
+    expect(c.mock.log.calls('settings/describe')).toHaveLength(2)
     await controller.load()
-    expect(b.settingsDescribe).toHaveBeenCalledOnce()
+    expect(c.mock.log.calls('settings/describe')).toHaveLength(2)
     expect(controller.store.getSnapshot().status).toBe('ready')
-    b.ctx.emit('connection/reset')
-    await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
+    c.connection.reconnect()
+    await c.mock.streams.opened('$events', 2)
+    await vi.waitFor(() => { expect(c.mock.log.calls('settings/describe')).toHaveLength(3) })
   })
 
-  it('withholds the Host document action off-loopback', async () => {
-    const b = await bench(false)
-    declare(b.slots)
-    const fiber = b.ctx.plugin({ inject: [...inject], apply })
-    await fiber.await()
-    expect(b.slots.entries('settings.action')).toEqual([])
-    expect(b.settingsDescribe).not.toHaveBeenCalled()
-    await fiber.dispose()
-    for (const [name] of SEATS) expect(b.slots.entries(name)).toEqual([])
+  it('withholds the Host document action off-loopback', async ({ mock, start }) => {
+    const loopbackUrl = location.href
+    setPageUrl('http://198.51.100.7:3000/')
+    onTestFinished(() => { setPageUrl(loopbackUrl) })
+    const { c } = await client(mock, start)
+    expect(c.connection.isLoopback).toBe(false)
+    expect(ownEntries(c, 'settings.action')).toEqual([])
+    // Off-loopback settings stay process-local: no describe read, so the browser language stands.
+    expect(c.mock.log.calls('settings/describe')).toEqual([])
+    expect(c.ctx.locale.getSnapshot().active).toBe('en')
+    await c.unload(SELF)
+    await c.flush()
+    for (const [name] of SEATS) expect(ownEntries(c, name)).toEqual([])
   })
 
-  it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {
-    const b = await bench()
-    const redeclare = declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    // Declarer unload: the cascade removes every seat entry and the item
-    // declaration while our local disposers go stale.
-    redeclare()
-    for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0)
-    expect(b.slots.spec('settings.general.item')).toBeUndefined()
-    declare(b.slots)
-    await Promise.resolve()
-    for (const [name, component] of SEATS) {
-      expect(b.slots.entries(name)[0]!.component).toBe(component)
-    }
-    expect(b.slots.entries('settings.general.item')).toEqual([])
-    expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
+  it('re-registers after a Loader rebuild of the declaring chain (stale disposers must not block)', async ({ mock, start }) => {
+    const { c, settings } = await client(mock, start)
+    const before = SEATS.map(([name]) => ownEntries(c, name)[0])
+    await c.reload(SIDEBAR)
+    await c.flush()
+    expectSeated(c)
+    SEATS.forEach(([name], index) => {
+      expect(ownEntries(c, name)[0]).not.toBe(before[index])
+    })
+    expect(c.ctx.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
+    expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS)).toEqual([])
     // The recovered registrations still ride the locale path.
-    b.locale.setLocale('en')
-    expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('General')
-    b.locale.setLocale('zh')
+    const english = localeView('en', 1)
+    const chinese = localeView('zh', 2)
+    settings.mutate.mockResolvedValueOnce(ok(english)).mockResolvedValueOnce(ok(chinese))
+    c.ctx.locale.setLocale('en')
+    expect(generalLabel(c)).toBe('General')
+    c.ctx.locale.setLocale('zh')
+    expect(generalLabel(c)).toBe('通用设置')
+    await vi.waitFor(() => {
+      expect(settings.mutate.mock.calls).toEqual([
+        [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'en' }], 0],
+        [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'zh' }], 1],
+      ])
+      expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([chinese])
+    })
   })
 
-  it('removes every seat and the item declaration on teardown', async () => {
-    const b = await bench()
-    declare(b.slots)
-    const fiber = b.ctx.plugin({ inject: [...inject], apply })
-    await fiber.await()
-    expect(b.slots.spec('settings.general.item')).toBeDefined()
-    await fiber.dispose()
-    for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0)
-    expect(b.slots.spec('settings.general.item')).toBeUndefined()
+  it('removes every seat and the item declaration when its row unloads', async ({ mock, start }) => {
+    const { c } = await client(mock, start)
+    expect(c.ctx.slots.spec('settings.general.item')).toBeDefined()
+    await c.unload(SELF)
+    await c.flush()
+    for (const [name] of SEATS) expect(ownEntries(c, name)).toHaveLength(0)
+    expect(c.ctx.slots.spec('settings.general.item')).toBeUndefined()
   })
 })

+ 83 - 131
packages/client/ui-settings-general/tests/shell.client.spec.ts

@@ -1,54 +1,25 @@
-/** Settings shell registration: slot declaration injection, the ledger projections, and HMR recovery. */
-import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it, vi } from 'vitest'
-import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
-import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
-import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
-import { apply, inject } from '../src/client/index.ts'
+// @vitest-environment jsdom
+/**
+ * Settings shell registration inside the assembled web client: the shell
+ * occupies the `sidebar.settings` hole ui-sidebar declares, its ledger
+ * projections read the product's real sections, its connection control is the
+ * roster's Connection, and it survives a Loader rebuild of the declarer.
+ */
+import { describe, expect, vi } from 'vitest'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
+import { inject } from '../src/client/index.ts'
 import type { SettingsRootInjected } from '../src/client/shell-contract.ts'
 import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
 
-async function bench() {
-  const ctx = new Context()
-  await ctx.plugin(SlotRegistry).await()
-  // Copy machinery the shell only reads a revision from; the real locale
-  // plugin would drag its own settings-row dependencies into this bench.
-  ctx.provide('locale', {
-    register: () => () => {},
-    bind: () => (key: string) => key,
-    getSnapshot: () => ({ active: 'zh', locales: [], revision: 0 }),
-    subscribe: () => () => {},
-  } as never)
-  // The shell mounts ui-settings, which injects `remote.settings`; without the
-  // namespace provided its fiber parks and no slot is ever declared.
-  const settings = {
-    describe: async () => ({ ok: false, error: new RemoteError('gateway/internal', 'no settings', {}) }),
-  }
-  const reconnect = vi.fn()
-  const connectionState = {
-    getSnapshot: () => 'connected' as const,
-    subscribe: () => () => {},
-  }
-  ctx.provide('connection', { state: connectionState, reconnect } as never)
-  ctx.provide('remote', {
-    $on: () => () => {},
-    $host: { home: undefined, isLoopback: false },
-    settings,
-  } as never)
-  ctx.provide('remote.settings', settings as never)
-  await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
-  return { ctx, slots: ctx.get('slots') as SlotRegistry, connectionState, reconnect }
-}
-
-function declare(slots: SlotRegistry): () => void {
-  return slots.register(
-    { name: 'root', children: { 'sidebar.settings': { kind: 'single', scope: 'root' } } } as never,
-    () => null,
-  )
-}
+const SELF = '@deepseek-ai/dsh-client-ui-settings-general'
+const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar'
+const it = createClientTest({ roster: webApp })
+/** The whole roster's first boot pays the cold module transform of every plugin package. */
+const COLD_BOOT_TIMEOUT_MS = 60_000
 
-function injectedOf(slots: SlotRegistry): SettingsRootInjected {
-  const entry = slots.entries('sidebar.settings')[0]!
+function injectedOf(c: TestClient): SettingsRootInjected {
+  const entry = c.ctx.slots.entries('sidebar.settings')[0]!
   return (entry.inject as () => SettingsRootInjected)()
 }
 
@@ -61,82 +32,74 @@ const CHILD_SPECS = {
   'settings.section': { kind: 'list', scope: 'root' },
   'settings.onboarding': { kind: 'list', scope: 'root' },
 } as const
+const CHILD_NAMES = Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>
 
-describe('ui-settings apply', () => {
-  it('declares only the slot registry (a pure composition face, no locale)', () => {
-    expect(inject).toEqual([
-      'slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope',
-    ])
-  })
-
-  it('registers the shell and declares every child slot, before or after the declaration', async () => {
-    const before = await bench()
-    declare(before.slots)
-    await before.ctx.plugin({ inject: [...inject], apply }).await()
-    expect(before.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
-    for (const name of Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>) {
-      expect(before.slots.spec(name)).toEqual(CHILD_SPECS[name])
-    }
+/**
+ * Section ids the web-app roster registers, in nav order: this package, then
+ * ui-settings-models, ui-settings-plugins, and ui-agent-preset. A plugin adding
+ * a section changes this list.
+ */
+const PRODUCT_SECTIONS: readonly string[] = ['general', 'models', 'plugins', 'agent-presets']
+/** Onboarding steps the web-app roster registers, in coordinator order; both come from ui-settings-models. */
+const PRODUCT_ONBOARDING: readonly { id: string; order: number }[] = [
+  { id: 'welcome-notice', order: -100 },
+  { id: 'deepseek-official', order: 0 },
+]
 
-    const after = await bench()
-    await after.ctx.plugin({ inject: [...inject], apply }).await()
-    expect(after.slots.entries('sidebar.settings')).toHaveLength(0)
-    declare(after.slots)
-    await Promise.resolve()
-    expect(after.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
-    // The self-inflicted ledger notifications hit the duplicate guard.
-    expect(after.slots.entries('sidebar.settings')).toHaveLength(1)
+describe('ui-settings-general shell', () => {
+  it('declares its services', () => {
+    expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope'])
   })
 
-  it('projects the section ledger into ordered nav rows with option defaults', async () => {
-    const b = await bench()
-    declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    const { sections } = injectedOf(b.slots).hooks
-    // This package registers the General section itself; every other section
-    // arrives from a feature registrant.
-    const GENERAL = { id: 'general', order: 0, label: 'general.nav' }
-    expect(sections.getSnapshot()).toEqual([GENERAL])
-    b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
-    // No order and no label: both projection defaults apply.
-    b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
+  it('occupies sidebar.settings, declared by ui-sidebar, and declares every child slot', async ({ start }) => {
+    const c = await start()
+    expect(c.ctx.slots.entries('sidebar.settings').map(entry => entry.component)).toEqual([SettingsRoot])
+    for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toEqual(CHILD_SPECS[name])
+  }, COLD_BOOT_TIMEOUT_MS)
+
+  it('projects the section ledger: product sections in order, defaults for bare rows, stable snapshots', async ({ start }) => {
+    const c = await start()
+    const { sections } = injectedOf(c).hooks
+    const product = sections.getSnapshot()
+    expect(product.map(row => row.id)).toEqual(PRODUCT_SECTIONS)
+    expect(product[0]).toEqual({ id: 'general', order: 0, label: expect.any(String) as string })
+    c.ctx.slots.register({ name: 'settings.section', id: 'z', order: 1_000, label: 'Z' } as never, () => null)
+    // No order and no label: both projection defaults apply, and order 0 sorts among the product rows.
+    c.ctx.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
     const rows = sections.getSnapshot()
-    expect(rows).toEqual([
-      GENERAL,
-      { id: 'a', order: 0, label: '' },
-      { id: 'z', order: 20, label: 'Z' },
-    ])
+    expect(rows.at(-1)).toEqual({ id: 'z', order: 1_000, label: 'Z' })
+    expect(rows.find(row => row.id === 'a')).toEqual({ id: 'a', order: 0, label: '' })
+    expect(rows.map(row => row.order)).toEqual([...rows.map(row => row.order)].sort((x, y) => x - y))
     // Snapshot identity is stable until the ledger moves (uSES contract).
     expect(sections.getSnapshot()).toBe(rows)
     const listener = vi.fn()
     const off = sections.subscribe(listener)
-    b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null)
+    c.ctx.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null)
     await Promise.resolve()
     expect(listener).toHaveBeenCalled()
     expect(sections.getSnapshot()).not.toBe(rows)
     off()
   })
 
-  it('projects the Gateway connection control without copying its state', async () => {
-    const b = await bench()
-    declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    const injected = injectedOf(b.slots)
-    expect(injected.hooks.connectionState).toBe(b.connectionState)
+  it('projects the roster Connection control without copying its state; reconnect opens a new $events generation', async ({ start }) => {
+    const c = await start()
+    const injected = injectedOf(c)
+    expect(injected.hooks.connectionState).toBe(c.connection.state)
+    expect(injected.hooks.connectionState.getSnapshot()).toBe('connected')
     injected.reconnect()
-    expect(b.reconnect).toHaveBeenCalledOnce()
+    await c.mock.streams.opened('$events', 2)
+    await vi.waitFor(() => { expect(c.connection.state.getSnapshot()).toBe('connected') })
   })
 
-  it('projects onboarding entries into stable coordinator order', async () => {
-    const b = await bench()
-    declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    const { onboardingSteps } = injectedOf(b.slots).hooks
-    b.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null)
-    b.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null)
-    b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null)
+  it('projects onboarding entries into stable coordinator order', async ({ start }) => {
+    const c = await start()
+    const { onboardingSteps } = injectedOf(c).hooks
+    expect(onboardingSteps.getSnapshot()).toEqual(PRODUCT_ONBOARDING)
+    c.ctx.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null)
+    c.ctx.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null)
+    c.ctx.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null)
     const steps = onboardingSteps.getSnapshot()
-    expect(steps).toEqual([
+    expect(steps.filter(step => !PRODUCT_ONBOARDING.some(known => known.id === step.id))).toEqual([
       { id: 'welcome', order: -100 },
       { id: 'credential', order: 0 },
       { id: 'default-order', order: 0 },
@@ -144,39 +107,28 @@ describe('ui-settings apply', () => {
     expect(onboardingSteps.getSnapshot()).toBe(steps)
     const listener = vi.fn()
     const off = onboardingSteps.subscribe(listener)
-    b.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null)
+    c.ctx.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null)
     await Promise.resolve()
     expect(listener).toHaveBeenCalledOnce()
     off()
   })
 
-  it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => {
-    const b = await bench()
-    const redeclare = declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    expect(b.slots.entries('sidebar.settings')).toHaveLength(1)
-    // Declarer unload: the cascade removes our entry and every child
-    // declaration while our local disposer variable goes stale.
-    redeclare()
-    expect(b.slots.entries('sidebar.settings')).toHaveLength(0)
-    expect(b.slots.spec('settings.trigger')).toBeUndefined()
-    declare(b.slots)
-    await Promise.resolve()
-    expect(b.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
-    for (const name of Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>) {
-      expect(b.slots.spec(name)).toEqual(CHILD_SPECS[name])
-    }
+  it('re-registers after the declarer reloads: the cascade removes the shell, the rebuilt declaration takes it back', async ({ start }) => {
+    const c = await start()
+    const before = c.ctx.slots.entries('sidebar.settings')[0]
+    expect(before).toBeDefined()
+    await c.reload(SIDEBAR)
+    await c.flush()
+    expect(c.ctx.slots.entries('sidebar.settings').map(entry => entry.component)).toEqual([SettingsRoot])
+    expect(c.ctx.slots.entries('sidebar.settings')[0]).not.toBe(before)
+    for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toEqual(CHILD_SPECS[name])
   })
 
-  it('unregisters the shell and collapses every child slot on teardown', async () => {
-    const b = await bench()
-    declare(b.slots)
-    const fiber = b.ctx.plugin({ inject: [...inject], apply })
-    await fiber.await()
-    await fiber.dispose()
-    expect(b.slots.entries('sidebar.settings')).toHaveLength(0)
-    for (const name of Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>) {
-      expect(b.slots.spec(name)).toBeUndefined()
-    }
+  it('unregisters the shell and collapses every child slot when its row unloads', async ({ start }) => {
+    const c = await start()
+    await c.unload(SELF)
+    await c.flush()
+    expect(c.ctx.slots.entries('sidebar.settings')).toHaveLength(0)
+    for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toBeUndefined()
   })
 })

+ 1 - 0
packages/client/ui-settings-models/package.json

@@ -44,6 +44,7 @@
     "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-remote-mock": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",

+ 30 - 45
packages/client/ui-settings-models/tests/apply.client.spec.ts

@@ -1,10 +1,12 @@
 /** Models section registration: slot declaration injection, the locale-following label thunk, and HMR recovery. */
 import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
 import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
-import { TestRemote, scriptedSettingsRemote } from '@deepseek-ai/dsh-client-test-runtime'
+import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
+import { remoteDefaultResponses } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/remote-default-responses.ts'
+import { ok, RemoteMock } from '@deepseek-ai/dsh-remote-mock'
 import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
 import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client'
 import {
@@ -19,7 +21,8 @@ import { apply as hostApply } from '../src/index.ts'
 // so browser-language detection never runs and a fresh LocaleRuntime opens on
 // FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead.
 
-async function bench(isLoopback = true, settings?: object, services: object = {}) {
+async function bench(isLoopback = true, mock = RemoteMock.create().load(remoteDefaultResponses), services: object = {}) {
+  onTestFinished(() => { mock.assertNoUnmatched() })
   const ctx = new Context()
   await ctx.plugin(SlotRegistry).await()
   const locale = new LocaleRuntime(ctx)
@@ -37,10 +40,7 @@ async function bench(isLoopback = true, settings?: object, services: object = {}
       discoverModels: vi.fn(() => Promise.resolve({ ok: true, value: [] })),
       ...services,
     },
-    // Without a settings face the mirror's reads fail and stay contained; the
-    // Models join itself never fetches until a section actually loads. The real
-    // ui-settings apply also provides the settingsSchema service.
-    settings: settings ?? scriptedSettingsRemote().settings,
+    settings: mock.remote.settings,
   })
   // The fixed Host facts the settings provider reads its persistence from.
   remote.$host = { home: undefined, isLoopback }
@@ -259,25 +259,18 @@ describe('pushed invalidations', () => {
   it('welcome state follows the shared mirror across document commits', async () => {
     // The welcome notice derives from its settings scope: a document commit
     // reaches it through the mirror's one refresh, with no routing here.
-    const acknowledgement = { current: undefined as string | undefined }
-    const settings = {
-      describe: vi.fn(() => Promise.resolve({
-        ok: true as const,
-        value: {
-          writable: true,
-          hasDocument: false,
-          namespaces: [{
-            ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
-            schema: {},
-            value: acknowledgement.current === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: acknowledgement.current },
-            applies: 'live' as const,
-            secrets: [],
-            revision: 0,
-          }],
-        },
-      })),
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const namespace = {
+      ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
+      schema: {},
+      value: {},
+      applies: 'live' as const,
+      secrets: [],
+      revision: 0,
     }
-    const b = await bench(true, settings)
+    const document = { writable: true, hasDocument: false, namespaces: [namespace] }
+    mock.remote.settings.describe.mockResolvedValue(ok(document))
+    const b = await bench(true, mock)
     declare(b.slots)
     await b.ctx.plugin({ inject: [...inject], apply }).await()
     const entry = b.slots.entries('settings.onboarding')
@@ -290,7 +283,10 @@ describe('pushed invalidations', () => {
     await vi.waitFor(() => {
       expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false })
     })
-    acknowledgement.current = WELCOME_NOTICE_VERSION
+    mock.remote.settings.describe.mockResolvedValue(ok({
+      ...document,
+      namespaces: [{ ...namespace, value: { [WELCOME_NOTICE_ACK_FIELD]: WELCOME_NOTICE_VERSION }, revision: 1 }],
+    }))
     b.remote.emit('settings/document-updated', ['ui-onboarding', 1])
     await vi.waitFor(() => {
       expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
@@ -298,24 +294,13 @@ describe('pushed invalidations', () => {
   })
 
   it('joins the refreshed mirror view on a settings invalidation', async () => {
-    let revision = 1
-    const describe = vi.fn(() => Promise.resolve({
-      ok: true as const,
-      value: {
-        writable: true,
-        hasDocument: false,
-        namespaces: [{
-          ns: 'llm-test',
-          schema: {},
-          value: {},
-          applies: 'live' as const,
-          secrets: [],
-          revision,
-        }],
-      },
-    }))
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const namespace = { ns: 'llm-test', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 1 }
+    const document = { writable: true, hasDocument: false, namespaces: [namespace] }
+    const describe = mock.remote.settings.describe
+    describe.mockResolvedValue(ok(document))
     const listProviders = vi.fn(() => Promise.resolve({ ok: true as const, value: [] }))
-    const b = await bench(true, { describe }, { listProviders })
+    const b = await bench(true, mock, { listProviders })
     declare(b.slots)
     await b.ctx.plugin({ inject: [...inject], apply }).await()
     const entry = b.slots.entries('settings.section')
@@ -327,8 +312,8 @@ describe('pushed invalidations', () => {
     await injected.controller.load()
     expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(1)
 
-    revision = 2
-    b.remote.emit('settings/document-updated', ['llm-test', revision])
+    describe.mockResolvedValue(ok({ ...document, namespaces: [{ ...namespace, revision: 2 }] }))
+    b.remote.emit('settings/document-updated', ['llm-test', 2])
 
     await vi.waitFor(() => {
       expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(2)

+ 2 - 2
packages/client/web/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/web/README.md
-README.md: 723a68ed401558021ca404b4a81e04c0d18d71be
-README.zh.md: 4f6f8eb746ba8ad8715ba4bccf4c3573aa4be24b
+README.md: 6bc0c4f241d1afb28825cadc19f89b7f54d4b637
+README.zh.md: e2d7ab81e82f1b5b1b93b95e4ed117a80f897a8e

+ 3 - 1
packages/client/web/README.md

@@ -72,7 +72,9 @@ The boot page is plain DOM with local CSS whose fallback fonts and colors match
 | File | Role |
 |---|---|
 | [`src/index.ts`](src/index.ts) | Library entry: `AppWebEntry`, `getStaticModules`, platform tables |
-| [`src/boot.ts`](src/boot.ts) | `AppWebEntry`: two-stage boot, activation audit, renderer handoff |
+| [`src/boot.ts`](src/boot.ts) | `AppWebEntry`: module stage, boot page, immediate-tier prefetch, then `bootClient` + `mountClient` |
+| [`src/boot-client.ts`](src/boot-client.ts) | `bootClient` / `assertEntriesActive`: Loader mount, one entry per manifest row, activation audit |
+| [`src/mount.ts`](src/mount.ts) | `mountClient`: renderer handoff through a `uiRenderer` dependency fiber |
 | [`src/boot-page.ts`](src/boot-page.ts) | Framework-free boot page: spinner, per-entry status, failure rendering |
 | [`src/platform.ts`](src/platform.ts) | `PLATFORM_MODULES` / `PRELOADED_CLIENT_EXTERNALS`: the implicit external baseline |
 | [`src/seed.ts`](src/seed.ts) | Static module table handed to the loader at boot |

+ 3 - 1
packages/client/web/README.zh.md

@@ -72,7 +72,9 @@ kind: "package-library"
 | 文件 | 职责 |
 |---|---|
 | [`src/index.ts`](src/index.ts) | 库入口:`AppWebEntry`、`getStaticModules`、平台表 |
-| [`src/boot.ts`](src/boot.ts) | `AppWebEntry`:两阶段启动、激活审计、渲染器交接 |
+| [`src/boot.ts`](src/boot.ts) | `AppWebEntry`:模块阶段、启动页、immediately 层级预取,随后调用 `bootClient` + `mountClient` |
+| [`src/boot-client.ts`](src/boot-client.ts) | `bootClient` / `assertEntriesActive`:挂载 Loader、每个 manifest 行一个 entry、激活审计 |
+| [`src/mount.ts`](src/mount.ts) | `mountClient`:经 `uiRenderer` 依赖 fiber 完成渲染器交接 |
 | [`src/boot-page.ts`](src/boot-page.ts) | 无框架启动页:spinner、逐 entry 状态、失败渲染 |
 | [`src/platform.ts`](src/platform.ts) | `PLATFORM_MODULES` / `PRELOADED_CLIENT_EXTERNALS`:隐式 external 基座 |
 | [`src/seed.ts`](src/seed.ts) | 启动时交给 loader 的静态模块表 |

+ 83 - 0
packages/client/web/src/boot-client.ts

@@ -0,0 +1,83 @@
+/**
+ * Production client composition without the page: mount the Loader over a
+ * module system, create every manifest row, wait for quiescence, and audit
+ * activation. `AppWebEntry` and the whole-client test carrier both call it.
+ * @module @deepseek-ai/dsh-client-web/src/boot-client
+ */
+import type { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import type { BootManifest, ClientModuleLoader } from '@deepseek-ai/dsh-client-modules/client'
+import { STATE_LABELS } from './loader-status.ts'
+
+/** Entry state label as the boot page renders it. */
+export type EntryStateLabel = (typeof STATE_LABELS)[keyof typeof STATE_LABELS] | 'loading' | 'failed'
+
+/** Inputs of {@link bootClient}. */
+export interface ClientBootOptions {
+  /** Fresh root Context that will own the plugin tree. */
+  readonly ctx: Context
+  /** Module system installed as `loader.internal`. */
+  readonly modules: ClientModuleLoader
+  /** Parsed manifest whose `plugins` rows become Loader entries (entry name = row id). */
+  readonly manifest: BootManifest
+  /** Per-entry state reporting (the boot page); omitted when no one renders progress. */
+  readonly onEntryState?: (name: string, state: EntryStateLabel) => void
+}
+
+/**
+ * Compose the client: `ctx.plugin(Loader)`, `loader.internal = modules`, one
+ * `loader.create({ name })` per manifest row, `loader.await()`, then
+ * {@link assertEntriesActive}. A row whose module cannot be imported rejects
+ * `loader.create`, so that import error propagates from here as-is.
+ * @param options - context, module system, manifest, optional progress sink.
+ * @returns resolves after every entry is active; rejects with the audit report otherwise.
+ */
+export async function bootClient(options: ClientBootOptions): Promise<void> {
+  const { ctx, manifest, onEntryState } = options
+  await ctx.plugin(Loader)
+  const loader = ctx.loader
+  loader.internal = options.modules as never
+
+  ctx.on('internal/status', (fiber) => {
+    const entry = fiber.entry
+    if (entry === undefined || entry.fiber === undefined) return
+    onEntryState?.(entry.options.name, STATE_LABELS[entry.fiber.state])
+  })
+
+  const rows = manifest.plugins.map(row => row.id)
+  await Promise.all(rows.map(async (name) => {
+    onEntryState?.(name, 'loading')
+    const id = await loader.create({ name })
+    if (loader.resolve(id).fiber === undefined) onEntryState?.(name, 'failed')
+  }))
+
+  await loader.await()
+  assertEntriesActive(ctx)
+}
+
+/**
+ * Reject entries that failed import/apply or still wait on missing services.
+ * @param ctx - root Context carrying the Loader.
+ * @throws {Error} listing every non-active entry with its reason.
+ */
+export function assertEntriesActive(ctx: Context): void {
+  const failures: string[] = []
+  for (const entry of ctx.loader.entries()) {
+    const name = entry.options.name
+    if (entry.fiber === undefined) {
+      failures.push(`${name}: import failed (see console for the import error)`)
+      continue
+    }
+    const state = STATE_LABELS[entry.fiber.state]
+    if (state === 'active') continue
+    if (state === 'pending') {
+      const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
+      failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
+    } else {
+      failures.push(`${name}: ${state}`)
+    }
+  }
+  if (failures.length > 0) {
+    throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
+  }
+}

+ 13 - 62
packages/client/web/src/boot.ts

@@ -1,18 +1,18 @@
 /**
  * Web boot kernel. It owns only the module system, Cordis loader, and a
- * framework-free boot page. The dynamic UI renderer receives the mount
+ * framework-free boot page; plugin composition and the renderer handoff are
+ * `bootClient` and `mountClient`. The dynamic UI renderer receives the mount
  * point after every client entry activates.
  * @module @deepseek-ai/dsh-client-web/src/boot
  */
 import { Context } from '@deepseek-ai/cordis'
-import Loader from '@deepseek-ai/cordis-plugin-loader'
 import type {
   BootManifest, ClientModuleCreateOptions, ClientModuleSystem, DshWindow,
 } from '@deepseek-ai/dsh-client-modules/client'
-import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import { bootClient } from './boot-client.ts'
 import { BootPage } from './boot-page.ts'
+import { mountClient } from './mount.ts'
 import { getStaticModules } from './seed.ts'
-import { STATE_LABELS } from './loader-status.ts'
 import './base.css'
 
 /** Module transport hook replaced by jsdom tests. */
@@ -76,8 +76,15 @@ export class AppWebEntry {
       const prefetching = this.prefetchImmediateTier()
       const ctx = new Context()
       this.ctx = ctx
-      await this.runPluginBoot(ctx, prefetching)
-      await this.mountApp(ctx)
+      this.page.setTotal(this.manifest.plugins.length)
+      await prefetching
+      await bootClient({
+        ctx,
+        modules: this.modules,
+        manifest: this.manifest,
+        onEntryState: (name, state) => { this.page.setState(name, state) },
+      })
+      await mountClient(ctx, this.container)
     } catch (reason) {
       console.error(reason)
       this.page.fail(reason instanceof Error ? reason.message : String(reason))
@@ -92,14 +99,6 @@ export class AppWebEntry {
     this.page.dispose()
   }
 
-  /** Mount through a dependency fiber so replacing uiRenderer remounts the application. */
-  private async mountApp(ctx: Context): Promise<void> {
-    const mounted = ctx.inject(['uiRenderer'], (scope) => {
-      scope.effect(() => scope.uiRenderer.mount(this.container), 'web boot: application mount')
-    })
-    await mounted
-  }
-
   /** Prefetch stage-one bundles and their dynamic requests before concurrent plugin imports. */
   private async prefetchImmediateTier(): Promise<void> {
     await Promise.all(this.manifest.plugins
@@ -108,52 +107,4 @@ export class AppWebEntry {
         // Prefetch only starts transport early; the Loader import retries and reports this bundle failure.
       })))
   }
-
-  /** Mount the Loader, create all graph entries, await quiescence, and audit activation. */
-  private async runPluginBoot(ctx: Context, prefetching: Promise<void>): Promise<void> {
-    await ctx.plugin(Loader)
-    const loader = ctx.loader
-    loader.internal = this.modules as never
-
-    ctx.on('internal/status', (fiber) => {
-      const entry = fiber.entry
-      if (entry === undefined || entry.fiber === undefined) return
-      this.page.setState(entry.options.name, STATE_LABELS[entry.fiber.state])
-    })
-
-    const rows = this.manifest.plugins.map(row => row.id)
-    this.page.setTotal(rows.length)
-    await prefetching
-    await Promise.all(rows.map(async (name) => {
-      this.page.setState(name, 'loading')
-      const id = await loader.create({ name })
-      if (loader.resolve(id).fiber === undefined) this.page.setState(name, 'failed')
-    }))
-
-    await loader.await()
-    this.assertEntriesActive(ctx)
-  }
-
-  /** Reject entries that failed import/apply or still wait on missing services. */
-  private assertEntriesActive(ctx: Context): void {
-    const failures: string[] = []
-    for (const entry of ctx.loader.entries()) {
-      const name = entry.options.name
-      if (entry.fiber === undefined) {
-        failures.push(`${name}: import failed (see console for the import error)`)
-        continue
-      }
-      const state = STATE_LABELS[entry.fiber.state]
-      if (state === 'active') continue
-      if (state === 'pending') {
-        const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
-        failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
-      } else {
-        failures.push(`${name}: ${state}`)
-      }
-    }
-    if (failures.length > 0) {
-      throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
-    }
-  }
 }

+ 24 - 0
packages/client/web/src/mount.ts

@@ -0,0 +1,24 @@
+/**
+ * Application mount through a dependency fiber, so replacing `uiRenderer`
+ * remounts the application. Shared by `AppWebEntry` and the test carrier.
+ * @module @deepseek-ai/dsh-client-web/src/mount
+ */
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+
+/**
+ * Mount the UI renderer into `container` through a dependency fiber on
+ * `uiRenderer`: the mount effect installs when the service is provided and
+ * reinstalls when it is replaced.
+ * @param ctx - booted root Context.
+ * @param container - application mount point.
+ * @returns resolves once the dependency fiber exists; with `uiRenderer`
+ * already provided (as after `bootClient`) the mount effect is installed by
+ * then, otherwise it installs when the service arrives.
+ */
+export async function mountClient(ctx: Context, container: HTMLElement): Promise<void> {
+  const mounted = ctx.inject(['uiRenderer'], (scope) => {
+    scope.effect(() => scope.uiRenderer.mount(container), 'web boot: application mount')
+  })
+  await mounted
+}

+ 133 - 0
packages/client/web/tests/boot-client.client.spec.ts

@@ -0,0 +1,133 @@
+// @vitest-environment jsdom
+import { Context } from '@deepseek-ai/cordis'
+import {
+  createClientModuleSystem, parseBootManifest,
+  type ClientBundleRegistration, type ClientModuleLoader, type ClientModuleLoaderTarget, type WebBootEntry, type WebBootGraph,
+} from '@deepseek-ai/dsh-client-modules/client'
+import { describe, expect, it } from 'vitest'
+import { assertEntriesActive, bootClient, type EntryStateLabel } from '../src/boot-client.ts'
+import { FIBER_STATE } from '../src/loader-status.ts'
+
+const BOOTSTRAP_ID = '@deepseek-ai/dsh-client-modules'
+
+function graphOf(ids: readonly string[]): WebBootGraph {
+  const entries: WebBootEntry[] = ids.map(id => ({ id, url: `/${id}.js`, rev: '1' }))
+  return {
+    rev: 'graph',
+    entries,
+    batches: [{ phase: 'application', url: '/application.js', rev: 'batch', entries: [...ids] }],
+  }
+}
+
+/** Module system seeded with inline plugin modules; `loaded` records every transport call. */
+function modulesOf(graph: WebBootGraph, staticModules: Record<string, unknown>): { modules: ClientModuleLoader; loaded: string[] } {
+  const loaded: string[] = []
+  const pendingQueue: ClientBundleRegistration[] = []
+  const target: ClientModuleLoaderTarget = {
+    mode: 'queue',
+    pendingQueue,
+    load: (registration) => { pendingQueue.push(registration) },
+    create: options => createClientModuleSystem(target, { id: BOOTSTRAP_ID, exports: {} }, options),
+  }
+  const modules = target.create({
+    boot: graph,
+    staticModules,
+    loadBundle: async (url) => { loaded.push(url) },
+  })
+  return { modules, loaded }
+}
+
+/** Recording progress sink. */
+function stateSink(): { states: Map<string, EntryStateLabel[]>; onEntryState: (name: string, state: EntryStateLabel) => void } {
+  const states = new Map<string, EntryStateLabel[]>()
+  return {
+    states,
+    onEntryState: (name, state) => { states.set(name, [...(states.get(name) ?? []), state]) },
+  }
+}
+
+describe('bootClient', () => {
+  it('activates every seeded row without touching the bundle transport', async () => {
+    const graph = graphOf(['provider', 'consumer'])
+    const { modules, loaded } = modulesOf(graph, {
+      provider: { apply: (ctx: Context) => { ctx.reflect.provide('x', { marker: 'x' }) } },
+      consumer: { inject: ['x'], apply: () => {} },
+    })
+    const ctx = new Context()
+    const sink = stateSink()
+
+    await bootClient({ ctx, modules, manifest: modules.manifest, onEntryState: sink.onEntryState })
+
+    expect(loaded).toEqual([])
+    const consumer = sink.states.get('consumer') ?? []
+    expect(consumer[0]).toBe('loading')
+    expect(consumer.at(-1)).toBe('active')
+    expect(sink.states.get('provider')?.at(-1)).toBe('active')
+    await ctx.fiber.dispose()
+  })
+
+  it('reports a row waiting on a service the roster never provides', async () => {
+    const graph = graphOf(['orphan'])
+    const { modules } = modulesOf(graph, { orphan: { inject: ['nothing'], apply: () => {} } })
+    const ctx = new Context()
+
+    await expect(bootClient({ ctx, modules, manifest: modules.manifest })).rejects.toThrow(
+      'orphan: pending (waiting for service: nothing)',
+    )
+    await ctx.fiber.dispose()
+  })
+
+  it('surfaces the Loader import error for a row that is neither seeded nor a graph row', async () => {
+    const { modules } = modulesOf(graphOf(['seeded']), { seeded: { apply: () => {} } })
+    const manifest = parseBootManifest(graphOf(['ghost']))
+    const ctx = new Context()
+    const sink = stateSink()
+
+    await expect(bootClient({ ctx, modules, manifest, onEntryState: sink.onEntryState })).rejects.toThrow(
+      /failed to import loader entry \S+ \(ghost\): client-modules: cannot resolve/,
+    )
+    expect(sink.states.get('ghost')).toEqual(['loading'])
+    await ctx.fiber.dispose()
+  })
+})
+
+describe('assertEntriesActive', () => {
+  interface FakeEntry { name: string; fiber?: { state: number; inject: Record<string, null> } }
+
+  /** Loader-shaped double: entries with scripted fiber states, services by name. */
+  function auditCtx(entries: readonly FakeEntry[], services: Record<string, unknown> = {}): Context {
+    return {
+      loader: {
+        * entries() {
+          for (const entry of entries) yield { options: { name: entry.name }, fiber: entry.fiber }
+        },
+      },
+      get: (name: string) => services[name],
+    } as unknown as Context
+  }
+
+  it('passes when every entry is active', () => {
+    expect(() => { assertEntriesActive(auditCtx([{ name: 'a', fiber: { state: FIBER_STATE.ACTIVE, inject: {} } }])) }).not.toThrow()
+  })
+
+  it('names import failures, missing services, and other non-active states', () => {
+    const ctx = auditCtx([
+      { name: 'lost' },
+      { name: 'waiting', fiber: { state: FIBER_STATE.PENDING, inject: { present: null, a: null, b: null } } },
+      { name: 'opaque', fiber: { state: FIBER_STATE.PENDING, inject: {} } },
+      { name: 'broken', fiber: { state: FIBER_STATE.FAILED, inject: {} } },
+    ], { present: {} })
+
+    expect(() => { assertEntriesActive(ctx) }).toThrow([
+      'web boot: 4 entries did not activate',
+      'lost: import failed (see console for the import error)',
+      'waiting: pending (waiting for services: a, b)',
+      'opaque: pending (waiting for services: unknown)',
+      'broken: failed',
+    ].join('\n'))
+  })
+
+  it('uses the singular form for one failing entry', () => {
+    expect(() => { assertEntriesActive(auditCtx([{ name: 'lost' }])) }).toThrow('web boot: 1 entry did not activate\n')
+  })
+})

+ 47 - 0
packages/client/web/tests/mount.client.spec.ts

@@ -0,0 +1,47 @@
+// @vitest-environment jsdom
+import { Context } from '@deepseek-ai/cordis'
+import { describe, expect, it, vi } from 'vitest'
+import { mountClient } from '../src/mount.ts'
+
+/** Provide a fake `uiRenderer` from its own plugin fiber so it can be replaced. */
+function provideRenderer(ctx: Context, mount: (container: HTMLElement) => () => void) {
+  return ctx.plugin({ apply: (scope: Context) => { scope.reflect.provide('uiRenderer', { mount }) } })
+}
+
+describe('mountClient', () => {
+  it('mounts into the container and unmounts when the tree is disposed', async () => {
+    const ctx = new Context()
+    const unmount = vi.fn()
+    const mount = vi.fn((_container: HTMLElement) => unmount)
+    provideRenderer(ctx, mount)
+    const container = document.createElement('div')
+
+    await mountClient(ctx, container)
+
+    expect(mount).toHaveBeenCalledExactlyOnceWith(container)
+    expect(unmount).not.toHaveBeenCalled()
+    await ctx.fiber.dispose()
+    expect(unmount).toHaveBeenCalledOnce()
+  })
+
+  it('remounts when uiRenderer is replaced', async () => {
+    const ctx = new Context()
+    const container = document.createElement('div')
+    const first = { unmount: vi.fn(), mount: vi.fn(() => first.unmount) }
+    const second = { unmount: vi.fn(), mount: vi.fn(() => second.unmount) }
+
+    await mountClient(ctx, container)
+    expect(first.mount).not.toHaveBeenCalled()
+
+    const renderer = provideRenderer(ctx, first.mount)
+    await vi.waitFor(() => { expect(first.mount).toHaveBeenCalledExactlyOnceWith(container) })
+
+    await renderer.dispose()
+    expect(first.unmount).toHaveBeenCalledOnce()
+
+    provideRenderer(ctx, second.mount)
+    await vi.waitFor(() => { expect(second.mount).toHaveBeenCalledExactlyOnceWith(container) })
+    await ctx.fiber.dispose()
+    expect(second.unmount).toHaveBeenCalledOnce()
+  })
+})

+ 2 - 2
packages/test-support/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/test-support/README.md
-README.md: 787ebfeb16cd2b07f183cf5b4ab5adda8a95156b
-README.zh.md: 3c98d99b71616e0e23140e736189f7b1768d2bcf
+README.md: d3bd4bbff218227d4b0423f7f1287368e7f87a99
+README.zh.md: db451041f9a3cd3459aa8e591e10f1720e358557

+ 1 - 0
packages/test-support/README.md

@@ -27,6 +27,7 @@ The test-support group gives repository tests deterministic, keyless ways to exe
 | [`session-snapshot`](session-snapshot/README.md) | Provides session-log snapshot support and protocol adapters for profile-driven tests |
 | [`agent-loop-testkit`](agent-loop-testkit/README.md) | Provides the shared prerequisite services for tests that exercise the concrete AgentLoop |
 | [`client-runtime`](client-runtime/README.md) | Provides the jsdom slot test bench for browser feature specs |
+| [`remote-mock`](remote-mock/README.md) | Provides the endpoint-named Typert Remote mock and the Connection carrier face whole-client specs install |
 | [`loader-smoke`](loader-smoke/README.md) | Boots Loader-composed applications and drives fixture turns for smoke tests |
 | [`llm-mock-server`](llm-mock-server/README.md) | Provides a scriptable OpenAI-compatible fault server for recovery tests |
 | [`llm-replay`](llm-replay/README.md) | Replays recorded model streams for keyless tests and demos |

+ 1 - 0
packages/test-support/README.zh.md

@@ -27,6 +27,7 @@ test-support 组为仓库测试提供确定且无须密钥的真实产品测试
 | [`session-snapshot`](session-snapshot/README.zh.md) | 为 profile 驱动的测试提供 session-log 快照支持与协议适配器 |
 | [`agent-loop-testkit`](agent-loop-testkit/README.zh.md) | 为运行具体 AgentLoop 的测试提供共享先决服务 |
 | [`client-runtime`](client-runtime/README.zh.md) | 为浏览器功能测试提供 jsdom slot 测试台 |
+| [`remote-mock`](remote-mock/README.zh.md) | 为整体客户端测试提供端点具名的 Typert Remote mock 与它们安装的 Connection 载体面 |
 | [`loader-smoke`](loader-smoke/README.zh.md) | 启动由 Loader 组合的应用并驱动 fixture(测试前置数据)轮次以执行冒烟测试 |
 | [`llm-mock-server`](llm-mock-server/README.zh.md) | 为恢复测试提供可通过脚本控制的 OpenAI 兼容故障服务器 |
 | [`llm-replay`](llm-replay/README.zh.md) | 为无密钥测试与演示回放已记录的模型流 |

+ 2 - 2
packages/test-support/client-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 packages/test-support/client-runtime/README.md
-README.md: 0b6caf7237520ca1a10fe0bbdf3c12c6d793461f
-README.zh.md: ce78a0ad623247ad769e0ea91895daa1db8e7f1b
+README.md: e32f7858b32e15e5200f9a986cb88e29309c44cb
+README.zh.md: 1b7da6f38723607dedd5eb16a15ade0b974ed135

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 24 - 1
packages/test-support/client-runtime/README.md


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 24 - 1
packages/test-support/client-runtime/README.zh.md


+ 24 - 3
packages/test-support/client-runtime/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-test-runtime",
-  "description": "jsdom slot test runtime: real Cordis Context + SlotRegistry + UI renderer with test-owned session/workspace doubles for feature specs",
+  "description": "Browser test runtimes: a jsdom slot bench with test-owned Session and Workspace doubles, and a whole-client tier that boots the web roster through the production bootClient over an endpoint-named Remote mock",
   "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
@@ -25,13 +25,20 @@
   "dependencies": {
     "@testing-library/dom": "^10.4.1",
     "@testing-library/react": "^16.3.2",
+    "js-yaml": "^4.2.0",
     "vitest": "^4.1.8"
   },
   "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-include": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-api-gateway": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
     "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-client-hmr": "workspace:^",
+    "@deepseek-ai/dsh-client-modules": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
@@ -39,18 +46,28 @@
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-client-web": "workspace:^",
+    "@deepseek-ai/dsh-remote-mock": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
     "react": "^18.2.0",
     "react-dom": "^18.2.0"
   },
   "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-include": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-api-gateway": "workspace:^",
+    "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
     "@deepseek-ai/dsh-attachment": "workspace:^",
+    "@deepseek-ai/dsh-base": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-client-hmr": "workspace:^",
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-modules": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
@@ -59,12 +76,16 @@
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-client-web": "workspace:^",
+    "@deepseek-ai/dsh-remote-mock": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
+    "@deepseek-ai/dsh-web-app": "workspace:^",
+    "@types/js-yaml": "^4.0.9",
     "@types/react": "~18.3.1",
     "@types/react-dom": "~18.3.0",
-    "@deepseek-ai/cordis": "workspace:^",
     "react": "^18.2.0",
     "react-dom": "^18.2.0"
   },

+ 138 - 0
packages/test-support/client-runtime/src/assembly/bundle-roster.ts

@@ -0,0 +1,138 @@
+/**
+ * The browser roster of a `dsh --profile`, read from its bundle patch files
+ * the way the launcher composes them: each bundle's `dsh.bundle.patch` list is
+ * parsed with the include plugin's YAML dialect (`entryListSchema`) and
+ * composed by its `applyEntryPatches`; every enabled row whose package
+ * declares `dsh.client.platform === 'web'` becomes a roster row carrying that
+ * declaration's `inject` and `immediately`; rows nested in Loader groups count
+ * like the Loader counts them, a disabled group disabling every row beneath
+ * it. A patch that matches nothing
+ * throws here where the launcher warns. Nothing is copied from the bundles: a
+ * bundle change is visible at the next import. Node only — the
+ * whole-client tier runs under vitest, and this is the one place it reads the
+ * repository.
+ * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/bundle-roster
+ */
+import { existsSync, readFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
+import { exactPackageSpecifier, parseDshClient } from '@deepseek-ai/dsh-client-modules/client'
+import * as yaml from 'js-yaml'
+import { ClientRoster, type ClientRosterRow } from './roster.ts'
+
+/** The `web` profile's bundle layers, in the order `dsh --profile web` applies them (app-boot `PROFILE_TEMPLATES.web`). */
+export const WEB_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
+
+interface PackageManifest {
+  name?: unknown
+  dsh?: { bundle?: { patch?: unknown }; client?: unknown }
+}
+
+/** One bundle: where its package.json is (plugin names resolve from there) and its parsed patch list. */
+interface BundleLayer {
+  readonly manifestPath: string
+  readonly patches: PatchOptions[]
+}
+
+/**
+ * Compose the browser roster of `bundles`, applied in order.
+ * @param bundles - bundle package names in application order.
+ * @param anchor - file whose package resolution locates the bundles; default this package.
+ * @returns the roster in composition order, one row per package.
+ * @throws {Error} when a bundle, its patch file, or an enabled row's package does not resolve, when the patch list
+ * is not a list or does not apply as written, or when a browser row's `disabled` is a `!!js` expression.
+ */
+export function bundleRoster(bundles: readonly string[], anchor: string = fileURLToPath(import.meta.url)): ClientRoster {
+  const layers = bundles.map(name => readLayer(name, anchor))
+  const entries = applyEntryPatches([], layers.flatMap(layer => layer.patches), (message: string, ...args: unknown[]) => {
+    throw new Error(`client-test-runtime: bundle patch ${describe(message, args)}`)
+  })
+  const anchors = layers.map(layer => layer.manifestPath)
+  const rows: ClientRosterRow[] = []
+  const seen = new Set<string>()
+  for (const { entry, disabled } of flattenGroups(entries)) {
+    const name = exactPackageSpecifier(entry.name)
+    if (name === undefined || disabled === true || seen.has(name)) continue
+    seen.add(name)
+    const manifestPath = locateManifest(anchors, name)
+    if (manifestPath === undefined) {
+      throw new Error(`client-test-runtime: cannot resolve plugin package ${name} from ${bundles.join(', ')}`)
+    }
+    const manifest = readManifest(manifestPath)
+    if (manifest.name !== name) {
+      throw new Error(`client-test-runtime: ${manifestPath} names ${JSON.stringify(manifest.name)}, expected ${name}`)
+    }
+    const declaration = parseDshClient(name, manifest.dsh?.client)
+    if (declaration === undefined || declaration.platform !== 'web') continue
+    if (disabled !== undefined && disabled !== null && typeof disabled !== 'boolean') {
+      throw new Error(`client-test-runtime: browser row ${name} has a \`disabled\` value this reader cannot evaluate (a !!js expression)`)
+    }
+    rows.push({ name, inject: declaration.inject ?? [], immediately: declaration.immediately === true })
+  }
+  return ClientRoster.of(rows)
+}
+
+function readLayer(bundle: string, anchor: string): BundleLayer {
+  const manifestPath = locateManifest([anchor], bundle)
+  if (manifestPath === undefined) throw new Error(`client-test-runtime: cannot resolve bundle ${bundle} from ${anchor}`)
+  const patch = readManifest(manifestPath).dsh?.bundle?.patch
+  if (typeof patch !== 'string') throw new Error(`client-test-runtime: bundle ${bundle} declares no dsh.bundle.patch in ${manifestPath}`)
+  const file = join(dirname(manifestPath), patch)
+  const parsed: unknown = yaml.load(readFileSync(file, 'utf8'), { schema: entryListSchema })
+  if (!Array.isArray(parsed)) throw new Error(`client-test-runtime: ${file} must be a top-level list of patches`)
+  return { manifestPath, patches: parsed as PatchOptions[] }
+}
+
+/** One Loader row with the `disabled` value that governs it: its own, or the nearest enclosing group's when that is set. */
+interface FlatEntry {
+  readonly entry: EntryOptions
+  readonly disabled: unknown
+}
+
+/**
+ * Rows in Loader order with groups descended, as the Loader loads them: a group is never a plugin itself, and a group's
+ * `disabled` disables every row beneath it.
+ * @param entries - composed entries, possibly nested.
+ * @param inherited - the enclosing group's `disabled` when set.
+ * @returns the plugin rows.
+ */
+function flattenGroups(entries: readonly EntryOptions[], inherited?: unknown): FlatEntry[] {
+  const rows: FlatEntry[] = []
+  for (const entry of entries) {
+    const own = (entry as { disabled?: unknown }).disabled
+    const disabled = inherited !== undefined && inherited !== null && inherited !== false ? inherited : own
+    if (entry.group === true && Array.isArray(entry.config)) {
+      rows.push(...flattenGroups(entry.config as EntryOptions[], disabled))
+      continue
+    }
+    rows.push({ entry, disabled })
+  }
+  return rows
+}
+
+function readManifest(path: string): PackageManifest {
+  return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest
+}
+
+/** Locate `<name>/package.json` on the resolution paths of any anchor, without requiring a `./package.json` export. */
+function locateManifest(anchors: readonly string[], name: string): string | undefined {
+  for (const anchor of anchors) {
+    for (const searchPath of createRequire(anchor).resolve.paths(name) ?? []) {
+      const candidate = join(searchPath, name, 'package.json')
+      if (existsSync(candidate)) return candidate
+    }
+  }
+  return undefined
+}
+
+/** The include plugin's `%C` placeholders, filled the way the launcher prints them. */
+function describe(message: string, args: readonly unknown[]): string {
+  let index = 0
+  return message.replace(/%C/g, () => JSON.stringify(args[index++]))
+}
+
+/** The `web` profile's browser roster, composed from its bundles at import. */
+export const webApp: ClientRoster = bundleRoster(WEB_PROFILE_BUNDLES)

+ 14 - 0
packages/test-support/client-runtime/src/assembly/index.ts

@@ -0,0 +1,14 @@
+/**
+ * Whole-client tier entry (deep import only:
+ * `@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts`). Kept out of
+ * the package root so slot-tier specs do not load the assembly machinery.
+ * @module @deepseek-ai/dsh-client-test-runtime/src/assembly
+ */
+export { ClientRoster } from './roster.ts'
+export type { AssemblyPlan, ClientPluginModule, ClientRosterRow } from './roster.ts'
+export { TestClient } from './test-client.ts'
+export type { TestClientOptions } from './test-client.ts'
+export { remoteDefaultResponses } from './remote-default-responses.ts'
+export { bundleRoster, webApp } from './bundle-roster.ts'
+export { createClientTest } from './vitest.ts'
+export type { ClientTestFixtures } from './vitest.ts'

+ 80 - 0
packages/test-support/client-runtime/src/assembly/modules.ts

@@ -0,0 +1,80 @@
+/**
+ * In-process module arrival: import every roster row's `/client` module (or
+ * the plan's replacement) and register each as a pre-arrived factory on a
+ * production `ClientModuleSystem`, so neither the Loader's `internal.import`
+ * nor a stage-one `prefetch` ever fetches a bundle.
+ * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/modules
+ */
+import * as modulesClient from '@deepseek-ai/dsh-client-modules/client'
+import { createClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client'
+import type { ClientModuleLoader, ClientModuleLoaderTarget, WebBootGraph } from '@deepseek-ai/dsh-client-modules/client'
+import type { AssemblyPlan, ClientPluginModule } from './roster.ts'
+
+/** The bootstrap row: always this process's static namespace, never a dynamic import or a `provide` replacement. */
+export const MODULES_PACKAGE = '@deepseek-ai/dsh-client-modules'
+
+/**
+ * Resolve each roster row to its plugin module: `plan.provide[name]` when
+ * present, otherwise a `/client` import resolved by the repository's tsconfig
+ * path aliases under Vitest. The bootstrap row is the
+ * statically imported `@deepseek-ai/dsh-client-modules/client` namespace.
+ * @param plan - validated plan.
+ * @returns package name → module, in roster order.
+ * @throws {Error} when an import fails (the package name prefixes the original message) or the bootstrap row is provided.
+ */
+export async function loadPluginModules(plan: AssemblyPlan): Promise<ReadonlyMap<string, ClientPluginModule>> {
+  const modules = new Map<string, ClientPluginModule>()
+  for (const { name } of plan.roster.rows) {
+    const provided = plan.provide?.[name]
+    if (name === MODULES_PACKAGE) {
+      if (provided !== undefined) {
+        throw new Error(`client-test-runtime: ${MODULES_PACKAGE} is the bootstrap module and cannot be provided`)
+      }
+      modules.set(name, modulesClient)
+      continue
+    }
+    modules.set(name, provided ?? await importClient(name))
+  }
+  return modules
+}
+
+async function importClient(name: string): Promise<ClientPluginModule> {
+  let namespace: unknown
+  try {
+    namespace = await import(/* @vite-ignore */ `${name}/client`)
+  } catch (error) {
+    throw new Error(`client-test-runtime: cannot import ${name}/client: ${String(error)}`, { cause: error })
+  }
+  return namespace as ClientPluginModule
+}
+
+/**
+ * Build the production module system over queued factories returning the
+ * loaded namespaces. The bootstrap row uses `bootstrapModule`; `staticModules`
+ * is empty because namespaces already hold their own imports. Missing factories
+ * reject through `loadBundle` without fetching.
+ * @param graph - raw boot graph from `graphFromRoster`; `createClientModuleSystem` parses it.
+ * @param modules - loaded plugin modules keyed by package name.
+ * @returns module system to install as `loader.internal`; its `manifest` is the parsed graph.
+ */
+export function createInProcessModules(
+  graph: WebBootGraph,
+  modules: ReadonlyMap<string, ClientPluginModule>,
+): ClientModuleLoader {
+  const target: ClientModuleLoaderTarget = {
+    mode: 'queue',
+    pendingQueue: [],
+    /* v8 ignore next -- construction replaces this sink before draining the prefilled queue. */
+    load: () => { throw new Error('client-test-runtime: module facade is not initialized') },
+    create: options => createClientModuleSystem(target, { id: MODULES_PACKAGE, exports: modulesClient }, options),
+  }
+  for (const [id, namespace] of modules) {
+    if (id === MODULES_PACKAGE) continue
+    target.pendingQueue.push({ id, factory: () => namespace as unknown as Record<string, unknown> })
+  }
+  return target.create({
+    boot: graph,
+    staticModules: {},
+    loadBundle: url => Promise.reject(new Error(`client-test-runtime: in-process modules never load bundles (${url})`)),
+  })
+}

+ 45 - 0
packages/test-support/client-runtime/src/assembly/remote-default-responses.ts

@@ -0,0 +1,45 @@
+/**
+ * Default responses for every Remote endpoint the web assembly calls while
+ * booting and rendering with no sessions, no workspaces, and default settings.
+ * The comment above each row names the plugin that calls it; endpoints boot
+ * never touches stay absent so a new call fails loud. `$events` is built into
+ * `RemoteMock`.
+ * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/remote-default-responses
+ */
+import { ok, openStream, type RemoteTable } from '@deepseek-ai/dsh-remote-mock'
+
+/** Default responses of the boot-time Remote endpoints; a spec loads it first and layers its own table on top. */
+export const remoteDefaultResponses: RemoteTable = {
+  unary: {
+    // api-session-controller `sessions.handleConnected()` on `connection/reset`.
+    'session/list': ok({ items: [] }),
+    // ui-settings `mirror.ensure()` at apply and again on `connection/reset`.
+    'settings/describe': ok({ writable: true, hasDocument: false, namespaces: [] }),
+    // ui-model-selection `ModelDirectoryResolver` constructor.
+    'session/modelCatalog': ok({
+      default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+      routableProviders: [],
+      groups: [],
+      failures: [],
+    }),
+    // ui-agent-preset hero chip and header label on first mount.
+    'agentPresets/list': ok({ presets: [], authorable: false }),
+    // cordis-client-runner `ClientCordisInspectRegistry.sync` at apply and on `connection/reset`.
+    'dynamicCordisRunner/syncInspectManifest': ok(null),
+    // ui-cordis inventory at apply and on `connection/reset`.
+    'dynamicCordisRunner/inventory': ok([]),
+    // ui-settings-plugins web-search card `readCredential()` when the settings mirror first publishes.
+    'credentials/describe': ok({}),
+  },
+  // Stream endpoints the roster opens later than boot; declared so a spec that forgets the script gets a stream miss.
+  streams: [
+    // api-session-controller `SessionEventStream.follow` when a Session opens.
+    'session/follow',
+  ],
+  stream: {
+    // api-session-controller client `apply`: the control stream's opening baseline, then open.
+    'session/control': openStream([{ type: 'baseline', value: { queues: {}, jobs: {}, projections: {} } }]),
+    // api-workspace-controller client `apply`: the follow stream's opening baseline, then open.
+    'workspace/follow': openStream([{ type: 'baseline', value: { items: [], archivedSessionIds: [] } }]),
+  },
+}

+ 87 - 0
packages/test-support/client-runtime/src/assembly/remote-proxies.ts

@@ -0,0 +1,87 @@
+/**
+ * `remote.<ns>` services for the whole-client tier, without the generated
+ * Remote clients. Cordis resolves `ctx.remote.<ns>` to whichever service is
+ * registered under `remote.<ns>` (vendor cordis `utils.ts`, traceable get), so
+ * the carrier provides one Proxy per namespace: `remote.<ns>.<method>(...args)`
+ * calls the endpoint `<ns>/<method>` over the roster's own Connection with the
+ * positional `args`, as a stream when the mock registered a stream script for
+ * it and as a unary call otherwise. A unary answer is returned unchanged and a
+ * unary rejection is folded the way the generated client folds a carrier
+ * throw (`gateway/internal`, or `gateway/cancelled` once the caller's signal
+ * aborted), so product code that never awaits a rejection sees none; stream
+ * items and failures pass through as the stream yields them.
+ * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/remote-proxies
+ */
+import type { Context } from '@deepseek-ai/cordis'
+import { cancelledFailure, carrierFailure } from '@deepseek-ai/dsh-api-gateway/client'
+import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
+import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock'
+import type { ClientPluginModule } from './roster.ts'
+
+/** The assembly row the proxies stand in for; its generated clients exist only in built `lib/`. */
+export const REMOTES_PACKAGE = '@deepseek-ai/dsh-api-remotes'
+
+const PREFIX = 'remote.'
+
+/**
+ * Namespaces to provide: every `remote.<ns>` a roster module injects, plus the
+ * namespace of every endpoint the mock has a rule for.
+ * @param modules - loaded roster modules.
+ * @param mock - the spec's mock.
+ * @returns sorted namespace names.
+ */
+export function remoteNamespacesOf(modules: Iterable<ClientPluginModule>, mock: RemoteMock): readonly string[] {
+  const names = new Set<string>()
+  for (const module of modules) {
+    for (const service of injectNames(module.inject)) if (service.startsWith(PREFIX)) names.add(service.slice(PREFIX.length))
+  }
+  for (const endpoint of mock.endpoints()) {
+    const slash = endpoint.indexOf('/')
+    if (slash > 0 && !endpoint.startsWith('$')) names.add(endpoint.slice(0, slash))
+  }
+  return [...names].sort()
+}
+
+function injectNames(inject: ClientPluginModule['inject']): readonly string[] {
+  if (inject === undefined) return []
+  if (Array.isArray(inject)) return inject as readonly string[]
+  return Object.keys(inject)
+}
+
+/**
+ * Plugin providing the namespace proxies; `TestClient.start` mounts it before the Loader rows.
+ * @param namespaces - namespaces to provide.
+ * @param mock - the spec's mock, asked for each endpoint's mode.
+ * @returns the plugin.
+ */
+export function remoteProxiesPlugin(namespaces: readonly string[], mock: RemoteMock): ClientPluginModule {
+  return {
+    inject: ['connection'],
+    apply(ctx: Context) {
+      const connection = ctx.get('connection') as ConnectionHandle
+      for (const namespace of namespaces) ctx.provide(`${PREFIX}${namespace}`, namespaceProxy(namespace, connection, mock))
+    },
+  }
+}
+
+function namespaceProxy(namespace: string, connection: ConnectionHandle, mock: RemoteMock): object {
+  return new Proxy(Object.create(null) as Record<string, unknown>, {
+    get: (_target, property) => {
+      // No `then`: awaiting the namespace object itself must not call a method.
+      if (typeof property !== 'string' || property === 'then') return undefined
+      return (...values: readonly unknown[]): unknown => {
+        const endpoint = `${namespace}/${property}`
+        const args = [...values]
+        const signal = args.at(-1) instanceof AbortSignal ? (args.pop() as AbortSignal) : undefined
+        if (mock.modeOf(endpoint) === 'stream') {
+          const open = connection.rpc.open
+          /* v8 ignore next -- the mock transport always supplies openStream, so the Connection carrier exposes open. */
+          if (open === undefined) throw new Error(`client-test-runtime: ${endpoint} is a stream but the carrier has no in-process opener`)
+          return open('/api', endpoint, { args }, signal ?? new AbortController().signal)
+        }
+        return connection.rpc.call('/api', endpoint, { args }, signal).catch((error: unknown) =>
+          (signal?.aborted === true ? cancelledFailure(endpoint, error) : carrierFailure(endpoint, error)))
+      }
+    },
+  })
+}

+ 163 - 0
packages/test-support/client-runtime/src/assembly/roster.ts

@@ -0,0 +1,163 @@
+/**
+ * Client roster: the ordered package-name rows a whole-client test boots, and
+ * the plan that annotates one with the rows the test provides itself. `webApp`
+ * and `bundleRoster` (`./bundle-roster.ts`) read rosters from the bundle patch
+ * files; a spec may also build one inline with {@link ClientRoster.of}.
+ * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/roster
+ */
+import type { Context } from '@deepseek-ai/cordis'
+import type { WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-client-modules/client'
+import { PLATFORM_MODULES } from '@deepseek-ai/dsh-client-web/src/platform.ts'
+
+/** One browser plugin row as `dsh.client` declares it, keyed by package name. */
+export interface ClientRosterRow {
+  /** Package name (== manifest entry id == Loader entry name). */
+  readonly name: string
+  /** Package-name dependency edges from `dsh.client.inject` ([] when absent). */
+  readonly inject: readonly string[]
+  /** Stage-one prefetch mark from `dsh.client.immediately` (false when absent). */
+  readonly immediately: boolean
+}
+
+/** Revision stamped on every synthesized row and batch; nothing is fetched by it. */
+const LOCAL_REV = 'local'
+
+/**
+ * Synthesize the raw `WebBootGraph` for `rows`: one `application` batch
+ * holding every row, `rev: 'local'`, placeholder `/plugins/<name>/client.js`
+ * URLs, since every module is seeded in process and never fetched. Validation
+ * stays with the production `parseBootManifest` inside the module system:
+ * duplicate names and an empty roster are rejected there, not here.
+ * @param rows - roster rows in composition order.
+ * @returns the unparsed graph, as `createClientModuleSystem` consumes it.
+ */
+export function graphFromRoster(rows: readonly ClientRosterRow[]): WebBootGraph {
+  const entries: WebBootEntry[] = rows.map(row => ({
+    id: row.name,
+    url: `/plugins/${row.name}/client.js`,
+    rev: LOCAL_REV,
+    ...(row.inject.length > 0 ? { inject: [...row.inject] } : {}),
+    ...(row.immediately ? { immediately: true } : {}),
+  }))
+  return {
+    rev: LOCAL_REV,
+    entries,
+    batches: [{
+      phase: 'application',
+      url: '/plugins/local.js',
+      rev: LOCAL_REV,
+      entries: entries.map(entry => entry.id),
+    }],
+  }
+}
+
+/** Module names the shell seeds before any row loads; an inject edge to one of them is satisfied without a row. */
+const PLATFORM_SEED: ReadonlySet<string> = new Set(PLATFORM_MODULES)
+
+/** Immutable, name-addressable roster. */
+export class ClientRoster {
+  /**
+   * Build a roster from rows; duplicate names throw.
+   * @param rows - roster rows in composition order.
+   * @returns roster.
+   */
+  static of(rows: readonly ClientRosterRow[]): ClientRoster {
+    const seen = new Set<string>()
+    const duplicates = new Set<string>()
+    for (const { name } of rows) {
+      if (seen.has(name)) duplicates.add(name)
+      seen.add(name)
+    }
+    if (duplicates.size > 0) {
+      throw new Error(`client-test-runtime: duplicate roster rows: ${[...duplicates].join(', ')}`)
+    }
+    return new ClientRoster(Object.freeze([...rows]))
+  }
+
+  private constructor(readonly rows: readonly ClientRosterRow[]) {}
+
+  /**
+   * Keep only `names`, preserving roster order; an unknown name throws with the roster listed.
+   * @param names - package names to keep.
+   * @returns sub-roster.
+   */
+  pick(names: readonly string[]): ClientRoster {
+    const keep = this.known(names, 'pick')
+    return new ClientRoster(Object.freeze(this.rows.filter(row => keep.has(row.name))))
+  }
+
+  /**
+   * The named rows plus every row they inject, transitively, in roster order: the rows a spec needs to boot the
+   * named plugins as the bundle composes them. The shell's platform modules (`PLATFORM_MODULES`, seeded statically
+   * rather than loaded as rows) end the walk. An unknown name throws with the roster listed; a row injecting any
+   * other package outside the roster throws, since the bundle itself would not boot.
+   * @param names - package names whose dependency cone to keep.
+   * @returns sub-roster.
+   */
+  closure(names: readonly string[]): ClientRoster {
+    this.known(names, 'closure')
+    const byName = new Map(this.rows.map(row => [row.name, row]))
+    const keep = new Set<string>()
+    const visit = (name: string, from: string | undefined): void => {
+      if (keep.has(name) || PLATFORM_SEED.has(name)) return
+      const row = byName.get(name)
+      if (row === undefined) {
+        throw new Error(`client-test-runtime: ${String(from)} injects ${name}, which is outside the roster`)
+      }
+      keep.add(name)
+      for (const dependency of row.inject) visit(dependency, name)
+    }
+    for (const name of names) visit(name, undefined)
+    return new ClientRoster(Object.freeze(this.rows.filter(row => keep.has(row.name))))
+  }
+
+  /**
+   * Drop `names`; an unknown name throws with the roster listed.
+   * @param names - package names to drop.
+   * @returns sub-roster.
+   */
+  without(names: readonly string[]): ClientRoster {
+    const drop = this.known(names, 'without')
+    return new ClientRoster(Object.freeze(this.rows.filter(row => !drop.has(row.name))))
+  }
+
+  private known(names: readonly string[], operation: string): ReadonlySet<string> {
+    const rostered = this.rows.map(row => row.name)
+    const unknown = names.filter(name => !rostered.includes(name))
+    if (unknown.length > 0) {
+      throw new Error(
+        `client-test-runtime: ${operation}() names outside the roster: ${unknown.join(', ')}; roster: ${rostered.join(', ')}`,
+      )
+    }
+    return new Set(names)
+  }
+}
+
+/** The module face the Loader materializes for one client plugin row. */
+export interface ClientPluginModule {
+  apply(ctx: Context, config?: unknown): unknown
+  readonly inject?: readonly string[] | Readonly<Record<string, unknown>>
+  readonly Config?: unknown
+}
+
+/** What to boot and what the test supplies itself. */
+export interface AssemblyPlan {
+  readonly roster: ClientRoster
+  /** Row replacements by package name (the test's own implementation of that row). Names outside the roster throw. */
+  readonly provide?: Readonly<Record<string, ClientPluginModule>>
+}
+
+/**
+ * Validate a plan against its roster.
+ * @param plan - plan to check.
+ * @throws {Error} naming any `provide` key outside the roster.
+ */
+export function assertPlan(plan: AssemblyPlan): void {
+  const rostered = plan.roster.rows.map(row => row.name)
+  const unknown = Object.keys(plan.provide ?? {}).filter(name => !rostered.includes(name))
+  if (unknown.length > 0) {
+    throw new Error(
+      `client-test-runtime: provide names rows outside the roster: ${unknown.join(', ')}; roster: ${rostered.join(', ')}`,
+    )
+  }
+}

+ 361 - 0
packages/test-support/client-runtime/src/assembly/test-client.ts

@@ -0,0 +1,361 @@
+/**
+ * Whole-client test carrier: boots an {@link AssemblyPlan} through the
+ * production `bootClient` over an in-process module table, with a
+ * `RemoteMock` installed as the Connection carrier through `__DSH_TRANSPORT__.rpc`.
+ * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/test-client
+ */
+import { Context, type Plugin } from '@deepseek-ai/cordis'
+import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
+import { tearDownEntryFiber } from '@deepseek-ai/dsh-client-hmr/client'
+import type { ClientTransportHooks, ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
+import { bootClient } from '@deepseek-ai/dsh-client-web/src/boot-client.ts'
+import { mountClient } from '@deepseek-ai/dsh-client-web/src/mount.ts'
+import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock'
+import { act } from '@testing-library/react'
+import { createInProcessModules, loadPluginModules } from './modules.ts'
+import { assertPlan, graphFromRoster, type AssemblyPlan } from './roster.ts'
+import { REMOTES_PACKAGE, remoteNamespacesOf, remoteProxiesPlugin } from './remote-proxies.ts'
+
+/** Carrier options. */
+export interface TestClientOptions {
+  /**
+   * Mount `uiRenderer` into an element (a fresh `document.body` child when `true`); requires jsdom and a roster
+   * that provides `uiRenderer`. Default false.
+   */
+  readonly mount?: boolean | HTMLElement
+  /** Wait for `ctx.connection.state === 'connected'` before returning. Default true. */
+  readonly awaitConnected?: boolean
+  /** Readiness budget in milliseconds before `start` rejects with the mock log summary. Default 5000. */
+  readonly connectTimeoutMs?: number
+}
+
+/** Page global the connection plugin reads its carrier from. */
+interface TransportGlobal {
+  __DSH_TRANSPORT__?: ClientTransportHooks
+}
+
+const transportGlobal = globalThis as TransportGlobal
+
+/**
+ * Process globals every live client in this worker shares: the transport the
+ * `connection` plugin reads at apply, and the jsdom shims. The first holder
+ * installs them and remembers what was there; the last release removes the
+ * shims and restores the transport. A holder installs its transport for its
+ * own boot and for each rebuild it runs, both under the worker's boot turn, so
+ * overlapping clients and out-of-order disposal neither clobber a booting or
+ * rebuilding client nor leak into later tests.
+ */
+class SharedGlobals {
+  private holders = 0
+  private previousTransport: ClientTransportHooks | undefined
+  private removeShims: (() => void) | undefined
+
+  /**
+   * Point the transport global at `transport` for the boot or rebuild about to run; the holder must already hold.
+   * @param transport - carrier the next `connection` apply reads.
+   */
+  install(transport: ClientTransportHooks): void {
+    transportGlobal.__DSH_TRANSPORT__ = transport
+  }
+
+  /**
+   * Hold the globals with `transport` installed.
+   * @param transport - carrier the next boot reads.
+   * @returns the release for this holder.
+   */
+  acquire(transport: ClientTransportHooks): () => void {
+    if (this.holders === 0) {
+      this.previousTransport = transportGlobal.__DSH_TRANSPORT__
+      this.removeShims = installJsdomShims()
+    }
+    this.holders += 1
+    this.install(transport)
+    return () => {
+      this.holders -= 1
+      if (this.holders > 0) return
+      this.removeShims?.()
+      this.removeShims = undefined
+      if (this.previousTransport === undefined) delete transportGlobal.__DSH_TRANSPORT__
+      else transportGlobal.__DSH_TRANSPORT__ = this.previousTransport
+      this.previousTransport = undefined
+    }
+  }
+}
+
+const sharedGlobals = new SharedGlobals()
+
+/**
+ * Boots and entry rebuilds run one at a time per worker: the transport global
+ * must stay the acting client's until its `connection` row applies.
+ */
+let bootTurn: Promise<unknown> = Promise.resolve()
+
+/** Run `work` as the next boot turn; its failure is the caller's, never the next turn's. */
+function takeTurn(work: () => Promise<void>): Promise<void> {
+  const turn = bootTurn.then(work)
+  bootTurn = turn.catch(() => undefined)
+  return turn
+}
+
+/** Default readiness budget; the mock answers `$events` immediately, so a miss means a boot-time fixture is absent. */
+const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
+
+/**
+ * Browser globals jsdom lacks that roster plugins touch at apply or mount:
+ * client-hmr opens an `EventSource`, layout components observe element size.
+ * Inert stand-ins, installed only where the global is absent.
+ */
+const JSDOM_SHIMS: Readonly<Record<string, unknown>> = {
+  EventSource: class {
+    addEventListener(): void {}
+    close(): void {}
+  },
+  ResizeObserver: class {
+    observe(): void {}
+    disconnect(): void {}
+  },
+}
+
+/** Install a shim for each absent global; the disposer deletes exactly those. */
+function installJsdomShims(): () => void {
+  const globals = globalThis as Record<string, unknown>
+  const installed = Object.keys(JSDOM_SHIMS).filter(name => globals[name] === undefined)
+  for (const name of installed) globals[name] = JSDOM_SHIMS[name]
+  return () => { for (const name of installed) Reflect.deleteProperty(globals, name) }
+}
+
+/** The Error a thrown value stands for: itself, or a new Error carrying its string form. */
+function toError(error: unknown): Error {
+  return error instanceof Error ? error : new Error(String(error))
+}
+
+function messageOf(error: unknown): string {
+  return toError(error).message
+}
+
+function connectionOf(ctx: Context): ConnectionHandle {
+  const connection = ctx.get('connection') as ConnectionHandle | undefined
+  if (connection === undefined) {
+    throw new Error('client-test-runtime: the roster provides no `connection` service')
+  }
+  return connection
+}
+
+/** Mount point plus whether `start` created it (and so removes it on dispose). */
+interface MountPoint {
+  readonly element: HTMLElement | undefined
+  readonly owned: boolean
+}
+
+function resolveMountPoint(mount: boolean | HTMLElement | undefined): MountPoint {
+  if (mount === undefined || mount === false) return { element: undefined, owned: false }
+  if (typeof document === 'undefined') {
+    throw new Error('client-test-runtime: mount requires a DOM; add `// @vitest-environment jsdom` to the spec')
+  }
+  if (mount === true) {
+    const element = document.createElement('div')
+    document.body.appendChild(element)
+    return { element, owned: true }
+  }
+  return { element: mount, owned: false }
+}
+
+/** Run `fn` inside React `act` when a DOM exists; plain await otherwise. */
+async function settle(fn: () => Promise<void>): Promise<void> {
+  if (typeof document === 'undefined') {
+    await fn()
+    return
+  }
+  await act(async () => { await fn() })
+}
+
+async function awaitConnected(ctx: Context, mock: RemoteMock, timeoutMs: number): Promise<void> {
+  const { state } = connectionOf(ctx)
+  if (state.getSnapshot() === 'connected') return
+  await new Promise<void>((resolve, reject) => {
+    const timer = setTimeout(() => {
+      unsubscribe()
+      const unmatched = mock.log.unmatched().map(row => `${row.mode} ${row.endpoint}`)
+      const streams = mock.log.streams().map(row => `${row.endpoint} (${row.state})`)
+      reject(new Error(
+        `client-test-runtime: connection state is ${String(state.getSnapshot())} after ${timeoutMs}ms; `
+        + `unmatched: [${unmatched.join(', ')}]; streams: [${streams.join(', ')}]`,
+      ))
+    }, timeoutMs)
+    const unsubscribe = state.subscribe(() => {
+      if (state.getSnapshot() !== 'connected') return
+      clearTimeout(timer)
+      unsubscribe()
+      resolve()
+    })
+  })
+}
+
+/** A booted client under test. */
+export class TestClient {
+  /**
+   * Load the roster's modules, then, holding this worker's boot turn, install
+   * the mock as the Connection carrier and the jsdom shims and boot through
+   * `bootClient` over the synthesized boot graph; afterwards optionally mount
+   * and wait for the connection. The `@deepseek-ai/dsh-api-remotes` row is
+   * dropped from the roster: its generated Remote clients exist only in built
+   * `lib/`, and the `remote.<ns>` services the roster injects (plus the
+   * namespaces the mock has rules for at this point) are provided as
+   * contract-free proxies over the same Connection instead; a `provide` entry
+   * for that row is refused. On any failure the context is disposed, an owned
+   * mount removed, and this client's hold on the globals released before the
+   * original error is rethrown.
+   * @param plan - roster and annotations.
+   * @param mock - Remote mock answering every Gateway call.
+   * @param options - mount and readiness options.
+   * @returns the booted client.
+   */
+  static async start(
+    plan: AssemblyPlan,
+    mock: RemoteMock,
+    options: TestClientOptions = {},
+  ): Promise<TestClient> {
+    assertPlan(plan)
+    if (plan.provide?.[REMOTES_PACKAGE] !== undefined) {
+      throw new Error(`client-test-runtime: ${REMOTES_PACKAGE} cannot be provided; its remote.<ns> services are the tier's proxies`)
+    }
+    const roster = plan.roster.rows.some(row => row.name === REMOTES_PACKAGE)
+      ? plan.roster.without([REMOTES_PACKAGE])
+      : plan.roster
+    const ctx = new Context()
+    const transport: ClientTransportHooks = { rpc: mock.rpc }
+    let mountPoint: MountPoint = { element: undefined, owned: false }
+    let release: (() => void) | undefined
+    const restore = (): void => {
+      if (mountPoint.owned) mountPoint.element?.remove()
+      release?.()
+    }
+    try {
+      const modules = await loadPluginModules({ ...plan, roster })
+      mountPoint = resolveMountPoint(options.mount)
+      await takeTurn(async () => {
+        release = sharedGlobals.acquire(transport)
+        const system = createInProcessModules(graphFromRoster(roster.rows), modules)
+        ctx.plugin(remoteProxiesPlugin(remoteNamespacesOf(modules.values(), mock), mock) as unknown as Plugin)
+        await bootClient({ ctx, modules: system, manifest: system.manifest })
+      })
+      if (mountPoint.element !== undefined) {
+        if (ctx.get('uiRenderer') === undefined) {
+          throw new Error('client-test-runtime: mount requested, but the roster provides no `uiRenderer`')
+        }
+        await mountClient(ctx, mountPoint.element)
+      }
+      if (options.awaitConnected !== false) {
+        await awaitConnected(ctx, mock, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
+      }
+    } catch (error) {
+      try {
+        await settle(() => ctx.fiber.dispose())
+      } catch {
+        // The boot failure is the error to report; a half-built tree failing to dispose adds nothing to it.
+      } finally {
+        restore()
+      }
+      throw error
+    }
+    return new TestClient(ctx, mock, mountPoint.element, restore)
+  }
+
+  private disposing: Promise<void> | undefined
+
+  private constructor(
+    readonly ctx: Context,
+    readonly mock: RemoteMock,
+    readonly container: HTMLElement | undefined,
+    private readonly restore: () => void,
+  ) {}
+
+  /** The carrier this client's `connection` row reads when it applies. */
+  private get transport(): ClientTransportHooks {
+    return { rpc: this.mock.rpc }
+  }
+
+  /** The roster's Connection service (no `Context` augmentation declares it); throws when the roster provides none. */
+  get connection(): ConnectionHandle {
+    return connectionOf(this.ctx)
+  }
+
+  /** Flush pending React work and microtasks inside `act` (plain microtask flush without a DOM). */
+  async flush(): Promise<void> {
+    await settle(() => Promise.resolve())
+  }
+
+  /**
+   * Rebuild one Loader entry: client-hmr's registry-first fiber teardown, then `entry.refresh()`. The rebuild
+   * takes the worker's boot turn with this client's carrier installed, so a rebuilt `connection` row reads its
+   * own mock even while another client is live. Requires a live client: after `dispose()` the Loader holds no
+   * entries and the lookup throws before anything is installed.
+   * @param name - package name of the row.
+   */
+  async reload(name: string): Promise<void> {
+    const entry = this.entryOf(name)
+    await takeTurn(async () => {
+      sharedGlobals.install(this.transport)
+      await tearDownEntryFiber(entry)
+      await entry.refresh()
+      await this.ctx.loader.await()
+    })
+  }
+
+  /**
+   * Remove one Loader entry.
+   * @param name - package name of the row.
+   */
+  async unload(name: string): Promise<void> {
+    await this.ctx.loader.remove(this.entryOf(name).id)
+  }
+
+  /**
+   * Dispose the plugin tree, then drop an owned mount and release this
+   * client's hold on the shared globals even when the tree fails to dispose,
+   * then `mock.assertNoUnmatched()` last so its failure is the test's reason
+   * without skipping the cleanup; when both the tree and the check fail, one
+   * error carries both messages. The first call owns the teardown and reports
+   * its failure; every later call waits for that teardown and resolves.
+   */
+  async dispose(): Promise<void> {
+    if (this.disposing !== undefined) {
+      await this.disposing.catch(() => undefined)
+      return
+    }
+    this.disposing = this.teardown()
+    await this.disposing
+  }
+
+  private async teardown(): Promise<void> {
+    let failure: Error | undefined
+    try {
+      await settle(() => this.ctx.fiber.dispose())
+    } catch (error) {
+      failure = toError(error)
+    } finally {
+      this.restore()
+    }
+    if (failure === undefined) {
+      this.mock.assertNoUnmatched()
+      return
+    }
+    try {
+      this.mock.assertNoUnmatched()
+    } catch (unmatched) {
+      throw new Error(`${messageOf(unmatched)}\nclient-test-runtime: the plugin tree also failed to dispose: ${failure.message}`, { cause: failure })
+    }
+    throw failure
+  }
+
+  private entryOf(name: string): Entry {
+    const entries = [...this.ctx.loader.entries()]
+    const entry = entries.find(candidate => candidate.options.name === name)
+    if (entry === undefined) {
+      throw new Error(
+        `client-test-runtime: no Loader entry named ${name}; entries: ${entries.map(row => row.options.name).join(', ')}`,
+      )
+    }
+    return entry
+  }
+}

+ 60 - 0
packages/test-support/client-runtime/src/assembly/vitest.ts

@@ -0,0 +1,60 @@
+/** Test-scoped Remote mock and lazy client boot, owned by native Vitest fixtures. */
+import { test, type TestAPI } from 'vitest'
+import { RemoteMock, type MockedRemote } from '@deepseek-ai/dsh-remote-mock'
+import type { AssemblyPlan } from './roster.ts'
+import { remoteDefaultResponses } from './remote-default-responses.ts'
+import { TestClient, type TestClientOptions } from './test-client.ts'
+
+/** Per-test controls; configure the mock before awaiting `start()`. */
+export interface ClientTestFixtures {
+  /** Fresh mock with the assembly's default responses already loaded. */
+  mock: RemoteMock
+  /** Namespace proxy backed by the same native mocks used by the Connection carrier. */
+  remote: MockedRemote
+  /** Await the one client owned by this test; rejects after the fixture closes. */
+  start: () => Promise<TestClient>
+}
+
+/**
+ * Extend Vitest with a fresh mock and a lazy, automatically disposed client. Repeated `start()` calls share one
+ * promise. Await it to observe startup failures; cleanup waits for startup but does not rethrow its rejection.
+ * Missing mock responses still fail teardown even when no client was started. Page globals remain environment-owned.
+ * @param plan - roster and replacement modules, shared as configuration rather than as a running client.
+ * @param options - mount and readiness settings passed to `TestClient.start`.
+ * @returns Vitest's test function with test-scoped `mock` and `start` fixtures.
+ */
+export function createClientTest(plan: AssemblyPlan, options: TestClientOptions = {}): TestAPI<ClientTestFixtures> {
+  return test.extend<ClientTestFixtures>({
+    remote: async ({ mock }, use) => {
+      await use(mock.remote)
+    },
+    mock: async ({}, use) => {
+      const mock = RemoteMock.create().load(remoteDefaultResponses)
+      try {
+        await use(mock)
+      } finally {
+        mock.assertNoUnmatched()
+      }
+    },
+    start: async ({ mock }, use) => {
+      let pending: Promise<TestClient> | undefined
+      let closed = false
+      const start = (): Promise<TestClient> => {
+        if (closed) return Promise.reject(new Error('client-test-runtime: start() called after its test fixture closed'))
+        if (pending === undefined) {
+          pending = TestClient.start(plan, mock, options)
+          // Startup belongs to the caller; keep a rejection handler attached until teardown can await it.
+          void pending.catch(() => undefined)
+        }
+        return pending
+      }
+      try {
+        await use(start)
+      } finally {
+        closed = true
+        const client = await pending?.catch(() => undefined)
+        await client?.dispose()
+      }
+    },
+  })
+}

+ 0 - 2
packages/test-support/client-runtime/src/index.ts

@@ -45,8 +45,6 @@ export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot
 export { FixtureSession, TestSessions } from './sessions.ts'
 export { stubSettingsScope } from './settings-scope.ts'
 export type { StubSettingsScope } from './settings-scope.ts'
-export { scriptedSettingsRemote } from './settings-remote.ts'
-export type { ScriptedNamespace, ScriptedSettingsRemote } from './settings-remote.ts'
 export { TestWorkspaces } from './workspaces.ts'
 export { RemoteError, TestRemote } from './remote.ts'
 export {

+ 0 - 85
packages/test-support/client-runtime/src/settings-remote.ts

@@ -1,85 +0,0 @@
-/** Test double for the `settings` Remote namespace a bench's plugins inject. */
-import { vi } from 'vitest'
-
-/** The minimum a scripted namespace view carries for the double's own bookkeeping. */
-export interface ScriptedNamespace {
-  /** Namespace key the write addresses. */
-  ns: string
-}
-
-/** One scripted `settings` namespace face plus the controls a bench drives it with. */
-export interface ScriptedSettingsRemote<View extends ScriptedNamespace> {
-  /**
-   * The namespace face handed to `TestRemote` as `settings`. A plugin injecting
-   * `remote.settings` unparks on it, which is what most benches need; the
-   * describe answer is the same one the shared mirror would read.
-   */
-  settings: {
-    describe(): Promise<{ ok: true; value: { writable: boolean; hasDocument: boolean; namespaces: readonly View[] } }>
-    update(ns: string, patch: unknown, expectedRevision: number | undefined): Promise<
-      | { ok: true; value: View }
-      | { ok: false; error: { code: string; message: string; details: object } }
-    >
-    replace(ns: string, section: unknown, expectedRevision: number | undefined): Promise<
-      | { ok: true; value: View }
-      | { ok: false; error: { code: string; message: string; details: object } }
-    >
-    mutate(ns: string, ops: unknown, expectedRevision: number | undefined): Promise<
-      | { ok: true; value: View }
-      | { ok: false; error: { code: string; message: string; details: object } }
-    >
-  }
-  /** Spy behind `settings.update`, for argument assertions. */
-  update: ReturnType<typeof vi.fn>
-  /** Spy behind `settings.replace`, for argument assertions. */
-  replace: ReturnType<typeof vi.fn>
-  /** Spy behind `settings.mutate`, for argument assertions. */
-  mutate: ReturnType<typeof vi.fn>
-  /**
-   * Replace what the next describe answers with, as a Host commit would.
-   * @param namespaces - the namespace views to serve from now on.
-   */
-  publish(namespaces: readonly View[]): void
-}
-
-/**
- * Build a scripted `settings` Remote namespace for a bench. Each write answers
- * with the addressed namespace unchanged, so a bench that only needs its
- * plugins to activate scripts nothing; one asserting a write reads the
- * corresponding spy or replaces the face.
- * @param namespaces - namespace views the first describe answers with.
- * @param options - deployment facts the describe answer reports.
- * @returns the face and its controls.
- */
-export function scriptedSettingsRemote<View extends ScriptedNamespace>(
-  namespaces: readonly View[] = [],
-  options: { writable?: boolean; hasDocument?: boolean } = {},
-): ScriptedSettingsRemote<View> {
-  let served = namespaces
-  const writable = options.writable ?? true
-  const hasDocument = options.hasDocument ?? false
-  const answer = (ns: string) => {
-    const view = served.find(candidate => candidate.ns === ns)
-    return Promise.resolve(view === undefined
-      ? {
-        ok: false as const,
-        error: { code: 'settings/rejected', message: `no scripted namespace "${ns}"`, details: { ns } },
-      }
-      : { ok: true as const, value: view })
-  }
-  const update = vi.fn((ns: string, _patch: unknown, _expectedRevision: number | undefined) => answer(ns))
-  const replace = vi.fn((ns: string, _section: unknown, _expectedRevision: number | undefined) => answer(ns))
-  const mutate = vi.fn((ns: string, _ops: unknown, _expectedRevision: number | undefined) => answer(ns))
-  return {
-    settings: {
-      describe: () => Promise.resolve({ ok: true as const, value: { writable, hasDocument, namespaces: served } }),
-      update: (ns, patch, expectedRevision) => update(ns, patch, expectedRevision),
-      replace: (ns, section, expectedRevision) => replace(ns, section, expectedRevision),
-      mutate: (ns, ops, expectedRevision) => mutate(ns, ops, expectedRevision),
-    },
-    update,
-    replace,
-    mutate,
-    publish(next) { served = next },
-  }
-}

+ 195 - 0
packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts

@@ -0,0 +1,195 @@
+/** bundleRoster: the real web profile read from its bundles, and every reader decision on a scratch installation. */
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { getStaticModules } from '@deepseek-ai/dsh-client-web/src/seed.ts'
+import { afterAll, describe, expect, it } from 'vitest'
+import { MODULES_PACKAGE } from '../src/assembly/modules.ts'
+import { WEB_PROFILE_BUNDLES, bundleRoster, webApp } from '../src/assembly/bundle-roster.ts'
+
+describe('webApp (the real web profile)', () => {
+  it('composes dsh-base then dsh-web-app: unique names, inject edges on roster rows or platform seed words', () => {
+    expect(WEB_PROFILE_BUNDLES).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
+    const names = webApp.rows.map(row => row.name)
+    expect(new Set(names).size).toBe(names.length)
+    const known = new Set([...names, ...Object.keys(getStaticModules())])
+    const dangling = webApp.rows.flatMap(row => row.inject.filter(target => !known.has(target)).map(target => `${row.name} -> ${target}`))
+    expect(dangling).toEqual([])
+    expect(bundleRoster(WEB_PROFILE_BUNDLES).rows).toEqual(webApp.rows)
+  })
+
+  it('keeps browser rows with their declarations and drops Host-only, disabled, and subpath rows', () => {
+    const immediate = new Set(webApp.rows.filter(row => row.immediately).map(row => row.name))
+    expect(immediate.has(MODULES_PACKAGE)).toBe(true)
+    expect(immediate.has('@deepseek-ai/dsh-client-connection')).toBe(true)
+    expect(webApp.rows.find(row => row.name === '@deepseek-ai/dsh-api-gateway')?.inject)
+      .toEqual(['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'])
+    const names = webApp.rows.map(row => row.name)
+    expect(names).toContain('@deepseek-ai/dsh-client-ui-settings-general')
+    expect(names).not.toContain('@deepseek-ai/dsh-llm') // Host only
+    expect(names).not.toContain('@deepseek-ai/dsh-client-ui-schedule') // inserted disabled
+    expect(names).not.toContain('@deepseek-ai/dsh-web-app') // Host runtime glue, its `/startup` row is a subpath
+  })
+})
+
+/** A scratch installation: an anchor package, bundles under its node_modules, plugin packages beside them. */
+class Scratch {
+  readonly root = mkdtempSync(join(tmpdir(), 'bundle-roster-'))
+  readonly anchor = join(this.root, 'app', 'package.json')
+
+  constructor() {
+    mkdirSync(join(this.root, 'app'), { recursive: true })
+    writeFileSync(this.anchor, JSON.stringify({ name: 'app' }))
+  }
+
+  pkg(name: string, manifest: Record<string, unknown>, files: Record<string, string> = {}): void {
+    const dir = join(this.root, 'app', 'node_modules', name)
+    mkdirSync(dir, { recursive: true })
+    writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, ...manifest }))
+    for (const [file, content] of Object.entries(files)) writeFileSync(join(dir, file), content)
+  }
+
+  bundle(name: string, patch: string): void {
+    this.pkg(name, { dsh: { bundle: { patch: './cordis.patch.yml' } } }, { 'cordis.patch.yml': patch })
+  }
+
+  web(name: string, client: Record<string, unknown> = {}): void {
+    this.pkg(name, { dsh: { client: { platform: 'web', ...client } } })
+  }
+
+  roster(bundles: readonly string[]): readonly string[] {
+    return bundleRoster(bundles, this.anchor).rows.map(row => row.name)
+  }
+}
+
+describe('bundleRoster on a scratch installation', () => {
+  const scratch = new Scratch()
+  afterAll(() => { rmSync(scratch.root, { recursive: true, force: true }) })
+
+  it('applies the layers in order and keeps enabled browser rows once, with their dsh.client declaration', () => {
+    scratch.web('@t/a', { inject: ['@t/b'], immediately: true })
+    scratch.web('@t/b')
+    scratch.web('@t/c')
+    scratch.web('plain')
+    scratch.pkg('@t/host', { dsh: {} })
+    scratch.pkg('@t/node', { dsh: { client: { platform: 'node' } } })
+    scratch.bundle('@t/base', `
+- insert:
+    - id: a
+      name: '@t/a'
+      config:
+        root: !!js process.cwd()
+    - id: host
+      name: '@t/host'
+      disabled: !!js process.platform === 'win32'
+    - id: node
+      name: '@t/node'
+    - id: c
+      name: '@t/c'
+    - id: sub
+      name: '@t/a/extra'
+    - id: builtin
+      name: 'cordis:group'
+`)
+    scratch.bundle('@t/web', `
+- id: c
+  disabled: true
+- insert:
+    - id: b
+      name: '@t/b'
+    - id: b-again
+      name: '@t/b'
+    - id: plain
+      name: plain
+    - id: off
+      name: '@t/off'
+      disabled: true
+`)
+    const roster = bundleRoster(['@t/base', '@t/web'], scratch.anchor)
+    expect(roster.rows).toEqual([
+      { name: '@t/a', inject: ['@t/b'], immediately: true },
+      { name: '@t/b', inject: [], immediately: false },
+      { name: 'plain', inject: [], immediately: false },
+    ])
+    expect(scratch.roster(['@t/base'])).toEqual(['@t/a', '@t/c'])
+  })
+
+  it('descends into Loader groups and lets a disabled group disable every row beneath it', () => {
+    scratch.web('@t/grouped')
+    scratch.web('@t/grouped-off')
+    scratch.web('@t/nested')
+    scratch.bundle('@t/groups', `
+- insert:
+    - id: on
+      name: cordis:group
+      group: true
+      config:
+        - id: grouped
+          name: '@t/grouped'
+        - id: inner
+          name: cordis:group
+          group: true
+          config:
+            - id: nested
+              name: '@t/nested'
+    - id: off
+      name: cordis:group
+      group: true
+      disabled: true
+      config:
+        - id: grouped-off
+          name: '@t/grouped-off'
+`)
+    expect(scratch.roster(['@t/groups'])).toEqual(['@t/grouped', '@t/nested'])
+  })
+
+  it('refuses a browser row whose disabled value is a !!js expression', () => {
+    scratch.web('@t/maybe')
+    scratch.bundle('@t/maybe-bundle', `
+- insert:
+    - id: maybe
+      name: '@t/maybe'
+      disabled: !!js process.platform === 'win32'
+`)
+    expect(() => scratch.roster(['@t/maybe-bundle'])).toThrow('browser row @t/maybe has a `disabled` value this reader cannot evaluate')
+  })
+
+  it('fails loud on a bundle that does not resolve, declares no patch, or whose patch is not a list', () => {
+    expect(() => scratch.roster(['@t/missing'])).toThrow('cannot resolve bundle @t/missing from')
+    scratch.pkg('@t/no-patch', { dsh: {} })
+    expect(() => scratch.roster(['@t/no-patch'])).toThrow('bundle @t/no-patch declares no dsh.bundle.patch in')
+    scratch.bundle('@t/not-a-list', 'insert: []\n')
+    expect(() => scratch.roster(['@t/not-a-list'])).toThrow('must be a top-level list of patches')
+  })
+
+  it('fails loud on a patch that does not apply as written', () => {
+    scratch.bundle('@t/dangling', `
+- id: nowhere
+  disabled: true
+`)
+    expect(() => scratch.roster(['@t/dangling'])).toThrow('bundle patch patch: entry "nowhere" not found')
+  })
+
+  it('fails loud on an enabled row whose package does not resolve or names another package', () => {
+    scratch.bundle('@t/unresolved', `
+- insert:
+    - id: ghost
+      name: '@t/ghost'
+`)
+    expect(() => scratch.roster(['@t/unresolved'])).toThrow('cannot resolve plugin package @t/ghost from @t/unresolved')
+    scratch.bundle('@t/builtin', `
+- insert:
+    - id: fs
+      name: fs
+`)
+    // A Node builtin name has no resolution paths at all.
+    expect(() => scratch.roster(['@t/builtin'])).toThrow('cannot resolve plugin package fs from @t/builtin')
+    scratch.pkg('@t/alias', { name: '@t/real' })
+    scratch.bundle('@t/misnamed', `
+- insert:
+    - id: alias
+      name: '@t/alias'
+`)
+    expect(() => scratch.roster(['@t/misnamed'])).toThrow('names "@t/real", expected @t/alias')
+  })
+})

+ 37 - 0
packages/test-support/client-runtime/tests/assembly-dependencies.client.spec.ts

@@ -0,0 +1,37 @@
+/** Real bundle dependency closures activate their requested client plugins without extra roster rows. */
+import { FiberState } from '@deepseek-ai/cordis'
+import { describe, expect, vi } from 'vitest'
+import { createClientTest, webApp } from '../src/assembly/index.ts'
+
+const HMR = '@deepseek-ai/dsh-client-hmr'
+const MODULES = '@deepseek-ai/dsh-client-modules'
+const SESSIONS = '@deepseek-ai/dsh-api-session-controller'
+const FILE_UPLOAD = '@deepseek-ai/dsh-client-file-upload'
+const hmrRoster = webApp.closure([HMR])
+const sessionRoster = webApp.closure([SESSIONS])
+const hmrTest = createClientTest({ roster: hmrRoster }, { awaitConnected: false })
+const sessionTest = createClientTest({ roster: sessionRoster })
+
+describe('bundle dependency closures', () => {
+  hmrTest('activates HMR with its declared module provider and no Connection', async ({ start }) => {
+    expect(hmrRoster.rows.map(row => row.name)).toContain(MODULES)
+    const client = await start()
+    const entry = [...client.ctx.loader.entries()].find(row => row.options.name === HMR)
+    expect(entry?.fiber?.state).toBe(FiberState.ACTIVE)
+    expect(client.ctx.get('modules')).toBeDefined()
+    expect(client.ctx.get('connection')).toBeUndefined()
+  }, 60_000)
+
+  sessionTest('activates Session Controller with its declared file-upload provider', async ({ mock, start }) => {
+    expect(sessionRoster.rows.map(row => row.name)).toContain(FILE_UPLOAD)
+    const client = await start()
+    const entry = [...client.ctx.loader.entries()].find(row => row.options.name === SESSIONS)
+    expect(entry?.fiber?.state).toBe(FiberState.ACTIVE)
+    expect(client.ctx.get('fileUpload')).toBeDefined()
+    await vi.waitFor(() => {
+      expect(client.ctx.sessions.list.getSnapshot().phase).toBe('ready')
+      expect(mock.log.calls('session/list')).toHaveLength(1)
+      expect(mock.log.streams('session/control')).toHaveLength(1)
+    })
+  }, 60_000)
+})

+ 105 - 0
packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts

@@ -0,0 +1,105 @@
+/**
+ * In-process module arrival: dynamic `/client` imports resolve through the
+ * repository path aliases, provided rows replace them, the bootstrap row is the
+ * static namespace, and the resulting module system serves the vendored Loader
+ * without ever loading a bundle.
+ */
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import * as modulesClient from '@deepseek-ai/dsh-client-modules/client'
+import { parseBootManifest } from '@deepseek-ai/dsh-client-modules/client'
+import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules/client'
+import * as uiRenderer from '@deepseek-ai/dsh-client-ui-renderer/client'
+import * as typertRegistry from '@deepseek-ai/dsh-typert-registry/client'
+import { describe, expect, it, onTestFinished } from 'vitest'
+import { ClientRoster } from '../src/assembly/index.ts'
+import { MODULES_PACKAGE, createInProcessModules, loadPluginModules } from '../src/assembly/modules.ts'
+
+const RENDERER = '@deepseek-ai/dsh-client-ui-renderer'
+const TYPERT = '@deepseek-ai/dsh-typert-registry'
+const BRAND = '@deepseek-ai/dsh-client-ui-brand-official'
+const MISSING = '@deepseek-ai/dsh-client-does-not-exist'
+
+const row = (name: string, immediately = false) => ({ name, inject: [], immediately })
+
+function graph(names: readonly string[]): WebBootGraph {
+  return {
+    rev: 'local',
+    entries: names.map(id => ({ id, url: `/plugins/${id}/client.js`, rev: 'local' })),
+    batches: [{ phase: 'application', url: '/plugins/all.js', rev: 'local', entries: [...names] }],
+  }
+}
+
+describe('loadPluginModules', () => {
+  it('imports real rows dynamically, takes provided rows from the plan, and pins the bootstrap row', async () => {
+    const provided = { apply: () => {} }
+    const roster = ClientRoster.of([row(TYPERT), row(MODULES_PACKAGE), row(BRAND), row(RENDERER)])
+    const modules = await loadPluginModules({ roster, provide: { [BRAND]: provided } })
+    expect([...modules.keys()]).toEqual([TYPERT, MODULES_PACKAGE, BRAND, RENDERER])
+    expect(modules.get(TYPERT)).toBe(typertRegistry)
+    expect(modules.get(RENDERER)).toBe(uiRenderer)
+    expect(modules.get(MODULES_PACKAGE)).toBe(modulesClient)
+    expect(modules.get(BRAND)).toBe(provided)
+  })
+
+  it('prefixes the package name when a /client import fails', async () => {
+    const roster = ClientRoster.of([row(MISSING)])
+    await expect(loadPluginModules({ roster })).rejects.toThrow(
+      new RegExp(`^client-test-runtime: cannot import ${MISSING}/client: .+`),
+    )
+  })
+
+  it('refuses a provided bootstrap row', async () => {
+    const roster = ClientRoster.of([row(MODULES_PACKAGE)])
+    await expect(loadPluginModules({ roster, provide: { [MODULES_PACKAGE]: { apply: () => {} } } }))
+      .rejects.toThrow(`${MODULES_PACKAGE} is the bootstrap module and cannot be provided`)
+  })
+})
+
+describe('createInProcessModules', () => {
+  it('serves import() and prefetch() from registered factories and never loads a bundle', async () => {
+    const roster = ClientRoster.of([row(MODULES_PACKAGE, true), row(TYPERT, true), row(RENDERER)])
+    const modules = await loadPluginModules({ roster })
+    const system = createInProcessModules(graph(roster.rows.map(r => r.name)), modules)
+
+    await expect(system.prefetch(TYPERT)).resolves.toBeUndefined()
+    await expect(system.prefetch(RENDERER)).resolves.toBeUndefined()
+    await expect(system.prefetch(MODULES_PACKAGE)).resolves.toBeUndefined()
+    expect(system.loadCache.has(TYPERT)).toBe(false)
+    expect(system.loadCache.has(RENDERER)).toBe(false)
+    await expect(system.import(RENDERER, '', {})).resolves.toBe(uiRenderer)
+    await expect(system.import(`${TYPERT}/client`, '', {})).resolves.toBe(typertRegistry)
+    await expect(system.import(MODULES_PACKAGE, '', {})).resolves.toBe(modulesClient)
+    expect(system.loadCache.get(TYPERT)?.exports).toBe(typertRegistry)
+    expect(system.loadCache.get(RENDERER)?.exports).toBe(uiRenderer)
+    await expect(system.import('@deepseek-ai/dsh-client-unknown', '', {})).rejects.toThrow('cannot resolve')
+  })
+
+  it('rejects loudly instead of fetching when a graph row has no loaded module', async () => {
+    const system = createInProcessModules(graph(['ghost']), new Map())
+    await expect(system.prefetch('ghost')).rejects.toThrow('in-process modules never load bundles (/plugins/all.js)')
+    await expect(system.import('ghost', '', {})).rejects.toThrow('in-process modules never load bundles (/plugins/all.js)')
+  })
+
+  it('exposes the graph parsed by the production validator as its manifest', () => {
+    const raw = graph(['a', 'b'])
+    expect(createInProcessModules(raw, new Map()).manifest).toEqual(parseBootManifest(raw))
+    expect(() => createInProcessModules({ ...raw, batches: [] }, new Map())).toThrow('belongs to no initial-load batch')
+  })
+
+  it('boots the vendored Loader over the in-process table with every entry active', async () => {
+    const roster = ClientRoster.of([row(TYPERT, true), row(RENDERER, true), row(MODULES_PACKAGE, true)])
+    const modules = await loadPluginModules({ roster })
+    const system = createInProcessModules(graph(roster.rows.map(r => r.name)), modules)
+    const ctx = new Context()
+    onTestFinished(() => ctx.fiber.dispose())
+    await ctx.plugin(Loader)
+    ctx.loader.internal = system as never
+    await Promise.all(roster.rows.map(r => ctx.loader.create({ name: r.name })))
+    await ctx.loader.await()
+    expect(ctx.get('typert')).toBeDefined()
+    expect(ctx.get('slots')).toBeDefined()
+    expect(ctx.get('modules')).toBe(system)
+    expect([...ctx.loader.entries()].every(entry => entry.fiber !== undefined)).toBe(true)
+  })
+})

+ 22 - 0
packages/test-support/client-runtime/tests/assembly-plan.client.spec.ts

@@ -0,0 +1,22 @@
+/** assertPlan: provide keys must name roster rows. */
+import { describe, expect, it } from 'vitest'
+import { assertPlan } from '../src/assembly/roster.ts'
+import { ClientRoster } from '../src/assembly/roster.ts'
+
+const roster = ClientRoster.of([
+  { name: 'a', inject: [], immediately: false },
+  { name: 'b', inject: [], immediately: false },
+])
+const plugin = { apply: () => {} }
+
+describe('assertPlan', () => {
+  it('accepts a bare roster and provided roster rows', () => {
+    expect(() => { assertPlan({ roster }) }).not.toThrow()
+    expect(() => { assertPlan({ roster, provide: { a: plugin, b: plugin } }) }).not.toThrow()
+  })
+
+  it('rejects provide keys outside the roster and lists the roster', () => {
+    expect(() => { assertPlan({ roster, provide: { a: plugin, zz: plugin, yy: plugin } }) })
+      .toThrow('provide names rows outside the roster: zz, yy; roster: a, b')
+  })
+})

+ 93 - 0
packages/test-support/client-runtime/tests/assembly-remote-proxies.client.spec.ts

@@ -0,0 +1,93 @@
+/** Remote proxies: namespace discovery from roster injects and mock rules, and per-call routing over the Connection to the mock. */
+import { RemoteMock, frames, ok, openStream } from '@deepseek-ai/dsh-remote-mock'
+import { describe, expect, it, onTestFinished } from 'vitest'
+import type { ClientPluginModule } from '../src/assembly/index.ts'
+import { TestClient, remoteDefaultResponses, webApp } from '../src/assembly/index.ts'
+import { remoteNamespacesOf } from '../src/assembly/remote-proxies.ts'
+
+/** The api-remotes row and its cone (Gateway client, Typert registry, Connection); TestClient drops the api-remotes row itself. */
+const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-remotes'])
+
+type RemoteFace = Record<string, Record<string, (...args: unknown[]) => unknown>>
+
+async function drain(source: AsyncIterable<unknown>): Promise<unknown[]> {
+  const items: unknown[] = []
+  for await (const item of source) items.push(item)
+  return items
+}
+
+describe('remoteNamespacesOf', () => {
+  it('collects remote.<ns> injects in both inject forms plus the namespaces of registered endpoints, skipping Gateway-internal ones', () => {
+    const modules: ClientPluginModule[] = [
+      { apply() {}, inject: ['slots', 'remote.settings', 'remote'] },
+      { apply() {}, inject: { 'remote.session': { required: true }, connection: { required: false } } },
+      { apply() {} },
+    ]
+    const mock = RemoteMock.create().unary('goals/create', ok({ id: 'g' })).stream('workspace/follow', openStream())
+    expect(remoteNamespacesOf(modules, mock)).toEqual(['goals', 'session', 'settings', 'workspace'])
+  })
+})
+
+describe('remote proxies over a booted client', () => {
+  async function booted(configure: (mock: RemoteMock) => void = () => {}) {
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    configure(mock)
+    const client = await TestClient.start({ roster: API_ROSTER }, mock)
+    onTestFinished(() => client.dispose())
+    return { client, mock, remote: (client.ctx as unknown as { remote: RemoteFace }).remote }
+  }
+
+  it('drops the api-remotes row and answers ctx.remote.<ns>.<method> with the mock value, logging positional args', async () => {
+    const { client, mock, remote } = await booted((m) => {
+      m.unary('session/rename', ok({ title: 'renamed', seq: 3 }))
+      m.unary('session/cancel', { ok: false, error: { code: 'session/not-found', message: 'gone', details: {} } })
+    })
+    expect([...client.ctx.loader.entries()].map(entry => entry.options.name)).not.toContain('@deepseek-ai/dsh-api-remotes')
+    await expect(remote.session!.rename!({ sessionId: 's1', title: 'renamed' })).resolves.toEqual({ ok: true, value: { title: 'renamed', seq: 3 } })
+    await expect(remote.session!.cancel!({ sessionId: 's1' }, new AbortController().signal))
+      .resolves.toEqual({ ok: false, error: { code: 'session/not-found', message: 'gone', details: {} } })
+    expect(mock.log.calls('session/rename').map(call => call.args)).toEqual([[{ sessionId: 's1', title: 'renamed' }]])
+    expect(mock.log.calls('session/cancel').map(call => call.args)).toEqual([[{ sessionId: 's1' }]])
+    expect((remote.session as unknown as { then?: unknown }).then).toBeUndefined()
+    expect((remote.session as unknown as Record<symbol, unknown>)[Symbol.toStringTag]).toBeUndefined()
+  }, 60_000)
+
+  it('folds a call without a rule into gateway/internal and leaves the miss in the log for dispose() to report', async () => {
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const client = await TestClient.start({ roster: API_ROSTER }, mock)
+    const remote = (client.ctx as unknown as { remote: RemoteFace }).remote
+    await expect(remote.session!.search!({ query: 'x' })).resolves.toMatchObject({
+      ok: false,
+      error: { code: 'gateway/internal', message: expect.stringMatching(/^client api: session\/search failed: remote-mock: no rule for session\/search; registered: \$events, /) as string },
+    })
+    expect(mock.log.unmatched()).toEqual([{ endpoint: 'session/search', mode: 'unary' }])
+    await expect(client.dispose()).rejects.toThrow('session/search (unary)')
+  })
+
+  it('folds a rule rejection like the generated client: gateway/internal, or gateway/cancelled once the signal aborted', async () => {
+    const { mock, remote } = await booted((m) => {
+      m.unary('session/rename', () => Promise.reject(new Error('wire down')))
+      m.unary('session/cancel', () => new Promise(() => {}))
+    })
+    await expect(remote.session!.rename!({ sessionId: 's1', title: 't' })).resolves.toMatchObject({
+      ok: false, error: { code: 'gateway/internal', message: 'client api: session/rename failed: wire down' },
+    })
+    const controller = new AbortController()
+    const cancelling = remote.session!.cancel!({ sessionId: 's1' }, controller.signal) as Promise<unknown>
+    controller.abort()
+    await expect(cancelling).resolves.toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } })
+    expect(mock.log.calls('session/rename').map(call => call.state)).toEqual(['failed'])
+    expect(mock.log.calls('session/cancel').map(call => call.state)).toEqual(['pending'])
+  })
+
+  it('opens registered streams and hands their items and failures through unchanged', async () => {
+    const { remote } = await booted((m) => {
+      m.stream('session/follow', frames([{ type: 'a' }, { type: 'b' }]))
+      m.stream('session/control', ((_args, stream) => { stream.fail(new Error('flap')) }))
+    })
+    const signal = new AbortController().signal
+    await expect(drain(remote.session!.follow!({ address: { kind: 'session', sessionId: 's1' } }, signal) as AsyncIterable<unknown>))
+      .resolves.toEqual([{ type: 'a' }, { type: 'b' }])
+    await expect(drain(remote.session!.control!({}) as AsyncIterable<unknown>)).rejects.toThrow('flap')
+  })
+})

+ 112 - 0
packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts

@@ -0,0 +1,112 @@
+/** ClientRoster: construction, duplicate refusal, order-preserving pick/closure/without with loud unknown names; graphFromRoster. */
+import { createClientModuleSystem, parseBootManifest, type ClientBundleRegistration, type ClientModuleLoaderTarget } from '@deepseek-ai/dsh-client-modules/client'
+import { describe, expect, it } from 'vitest'
+import { ClientRoster, graphFromRoster, type ClientRosterRow } from '../src/assembly/roster.ts'
+import { MODULES_PACKAGE } from '../src/assembly/modules.ts'
+
+const ROWS: readonly ClientRosterRow[] = [
+  { name: MODULES_PACKAGE, inject: [], immediately: true },
+  { name: '@x/a', inject: [], immediately: false },
+  { name: '@x/b', inject: ['@x/a'], immediately: true },
+]
+
+describe('graphFromRoster', () => {
+  it('synthesizes one application batch over every row with placeholder URLs', () => {
+    expect(graphFromRoster(ROWS)).toEqual({
+      rev: 'local',
+      entries: [
+        { id: MODULES_PACKAGE, url: `/plugins/${MODULES_PACKAGE}/client.js`, rev: 'local', immediately: true },
+        { id: '@x/a', url: '/plugins/@x/a/client.js', rev: 'local' },
+        { id: '@x/b', url: '/plugins/@x/b/client.js', rev: 'local', inject: ['@x/a'], immediately: true },
+      ],
+      batches: [{ phase: 'application', url: '/plugins/local.js', rev: 'local', entries: [MODULES_PACKAGE, '@x/a', '@x/b'] }],
+    })
+  })
+
+  it('parses through the production validator to plugin rows that mirror the roster, refusing duplicates and an empty roster', () => {
+    const manifest = parseBootManifest(graphFromRoster(ROWS))
+    expect(manifest.plugins).toEqual(ROWS.map(row => ({ id: row.name, inject: [...row.inject], immediately: row.immediately })))
+    expect(manifest.modules.map(row => row.initialUrl)).toEqual(['/plugins/local.js', '/plugins/local.js', '/plugins/local.js'])
+    const row = ROWS[1]!
+    expect(() => parseBootManifest(graphFromRoster([row, row]))).toThrow('duplicate graph entry "@x/a"')
+    expect(() => parseBootManifest(graphFromRoster([]))).toThrow('must be a non-empty string array')
+  })
+
+  it('imports seeded rows without reaching the bundle transport', async () => {
+    const loaded: string[] = []
+    const pendingQueue: ClientBundleRegistration[] = []
+    const target: ClientModuleLoaderTarget = {
+      mode: 'queue',
+      pendingQueue,
+      load: (registration) => { pendingQueue.push(registration) },
+      create: options => createClientModuleSystem(target, { id: MODULES_PACKAGE, exports: {} }, options),
+    }
+    const a = { apply() {} }
+    const b = { apply() {} }
+    const modules = target.create({
+      boot: graphFromRoster(ROWS),
+      staticModules: { '@x/a': a, '@x/b': b },
+      loadBundle: async (url) => { loaded.push(url) },
+    })
+    await expect(modules.import('@x/a')).resolves.toBe(a)
+    await expect(modules.import('@x/b')).resolves.toBe(b)
+    expect(modules.manifest).toEqual(parseBootManifest(graphFromRoster(ROWS)))
+    expect(loaded).toEqual([])
+  })
+})
+
+const A = { name: 'a', inject: [], immediately: true }
+const B = { name: 'b', inject: ['a'], immediately: false }
+const C = { name: 'c', inject: ['b'], immediately: false }
+
+describe('ClientRoster', () => {
+  it('freezes a copy of the rows in composition order', () => {
+    const rows = [A, B, C]
+    const roster = ClientRoster.of(rows)
+    expect(roster.rows).toEqual([A, B, C])
+    expect(roster.rows).not.toBe(rows)
+    expect(Object.isFrozen(roster.rows)).toBe(true)
+    rows.push(A)
+    expect(roster.rows).toHaveLength(3)
+  })
+
+  it('refuses duplicate names, listing each duplicate once', () => {
+    expect(() => ClientRoster.of([A, B, A, C, B, A])).toThrow('duplicate roster rows: a, b')
+  })
+
+  it('pick keeps roster order regardless of the requested order', () => {
+    const roster = ClientRoster.of([A, B, C])
+    expect(roster.pick(['c', 'a']).rows.map(row => row.name)).toEqual(['a', 'c'])
+    expect(roster.pick([]).rows).toEqual([])
+    expect(Object.isFrozen(roster.pick(['b']).rows)).toBe(true)
+  })
+
+  it('without drops the named rows', () => {
+    const roster = ClientRoster.of([A, B, C])
+    expect(roster.without(['b']).rows.map(row => row.name)).toEqual(['a', 'c'])
+    expect(roster.without(['a', 'b', 'c']).rows).toEqual([])
+  })
+
+  it('closure keeps the named rows and everything they inject, transitively, in roster order', () => {
+    const D = { name: 'd', inject: [], immediately: false }
+    const roster = ClientRoster.of([A, D, B, C])
+    expect(roster.closure(['c']).rows.map(row => row.name)).toEqual(['a', 'b', 'c'])
+    expect(roster.closure(['b', 'd']).rows.map(row => row.name)).toEqual(['a', 'd', 'b'])
+    expect(roster.closure(['a']).rows.map(row => row.name)).toEqual(['a'])
+    expect(roster.closure([]).rows).toEqual([])
+    expect(Object.isFrozen(roster.closure(['c']).rows)).toBe(true)
+  })
+
+  it('pick, closure, and without throw on unknown names with the roster listed; closure also refuses an inject outside the roster', () => {
+    const roster = ClientRoster.of([A, B])
+    expect(() => roster.pick(['a', 'zz'])).toThrow('pick() names outside the roster: zz; roster: a, b')
+    expect(() => roster.closure(['zz'])).toThrow('closure() names outside the roster: zz; roster: a, b')
+    expect(() => roster.without(['x', 'y'])).toThrow('without() names outside the roster: x, y; roster: a, b')
+    expect(() => ClientRoster.of([B]).closure(['b'])).toThrow('b injects a, which is outside the roster')
+  })
+
+  it('closure treats the platform seed modules as satisfied without a row', () => {
+    const seeded = { name: 'seeded', inject: ['@deepseek-ai/dsh-client-ui-primitives', 'react', 'a'], immediately: false }
+    expect(ClientRoster.of([A, seeded]).closure(['seeded']).rows.map(row => row.name)).toEqual(['a', 'seeded'])
+  })
+})

+ 110 - 0
packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts

@@ -0,0 +1,110 @@
+/** TestClient without a DOM: the api roster boots and connects; mount is refused; flush degrades to a microtask flush. */
+import type { ClientTransportHooks } from '@deepseek-ai/dsh-client-connection/client'
+import { RemoteMock } from '@deepseek-ai/dsh-remote-mock'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
+import { TestClient, remoteDefaultResponses, webApp } from '../src/assembly/index.ts'
+
+/** The Gateway client and what it injects: the Typert registry and the Connection. */
+const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
+const TYPERT_ONLY = webApp.pick(['@deepseek-ai/dsh-typert-registry'])
+const globals = globalThis as { __DSH_TRANSPORT__?: ClientTransportHooks }
+
+describe('TestClient (node environment)', () => {
+  it('boots the api roster, connects, and flushes without a DOM', async () => {
+    expect(typeof document).toBe('undefined')
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const client = await TestClient.start({ roster: API_ROSTER }, mock)
+    onTestFinished(() => client.dispose())
+    expect(client.connection.state.getSnapshot()).toBe('connected')
+    await client.flush()
+  }, 60_000)
+
+  it('releases the shared globals even when the plugin tree fails to dispose, and still rethrows', async () => {
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const client = await TestClient.start({ roster: API_ROSTER }, mock)
+    const dispose = client.ctx.fiber.dispose.bind(client.ctx.fiber)
+    const failure = vi.spyOn(client.ctx.fiber, 'dispose').mockImplementation(async () => {
+      await dispose()
+      // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Exercises normalization of non-Error teardown failures.
+      return Promise.reject('teardown boom')
+    })
+    onTestFinished(async () => {
+      failure.mockRestore()
+      try { await dispose() } finally { await client.dispose() }
+    })
+    await expect(client.dispose()).rejects.toThrow('teardown boom')
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+    expect(mock.log.streams('$events').map(stream => stream.state)).toEqual(['cancelled'])
+    await client.dispose() // idempotent after a failed teardown
+  })
+
+  it('rethrows a row that fails to apply, releases the globals, and lets the next boot take its turn', async () => {
+    const failing = { apply(): void { throw new Error('apply boom') } }
+    await expect(TestClient.start({ roster: TYPERT_ONLY, provide: { '@deepseek-ai/dsh-typert-registry': failing } }, RemoteMock.create()))
+      .rejects.toThrow(/apply boom|typert-registry/)
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const client = await TestClient.start({ roster: API_ROSTER }, mock)
+    onTestFinished(() => client.dispose())
+    expect(client.connection.state.getSnapshot()).toBe('connected')
+  })
+
+  it('refuses a provide entry for the api-remotes row, whose services are the proxies', async () => {
+    const roster = webApp.closure(['@deepseek-ai/dsh-api-remotes'])
+    await expect(TestClient.start({ roster, provide: { '@deepseek-ai/dsh-api-remotes': { apply() {} } } }, RemoteMock.create()))
+      .rejects.toThrow('@deepseek-ai/dsh-api-remotes cannot be provided; its remote.<ns> services are the tier\'s proxies')
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+  })
+
+  it('reports unmatched requests alongside a failed teardown instead of hiding them', async () => {
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const client = await TestClient.start({ roster: API_ROSTER }, mock)
+    const dispose = client.ctx.fiber.dispose.bind(client.ctx.fiber)
+    const failure = vi.spyOn(client.ctx.fiber, 'dispose').mockImplementation(async () => {
+      await dispose()
+      throw new Error('teardown boom')
+    })
+    onTestFinished(async () => {
+      failure.mockRestore()
+      try { await dispose() } finally { await client.dispose() }
+    })
+    await mock.dispatch('nowhere/call', []).catch(() => undefined)
+    await expect(client.dispose()).rejects.toThrow(/nowhere\/call \(unary\)[\s\S]*also failed to dispose: teardown boom/)
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+    expect(mock.log.streams('$events').map(stream => stream.state)).toEqual(['cancelled'])
+  })
+
+  it('lets a concurrent second dispose wait for the first teardown instead of returning early', async () => {
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const client = await TestClient.start({ roster: API_ROSTER }, mock)
+    const first = client.dispose()
+    const second = client.dispose()
+    await second
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+    await first
+  })
+
+  it('refuses to mount without a DOM before installing the transport', async () => {
+    await expect(TestClient.start({ roster: API_ROSTER }, RemoteMock.create(), { mount: true }))
+      .rejects.toThrow('mount requires a DOM')
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+  })
+
+  it('fails loud, then restores the transport, when the roster cannot provide a connection', async () => {
+    await expect(TestClient.start({ roster: TYPERT_ONLY }, RemoteMock.create()))
+      .rejects.toThrow('provides no `connection` service')
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+  })
+
+  it('skips readiness on request and restores a pre-existing transport on dispose', async () => {
+    const previous: ClientTransportHooks = { fetch: () => Promise.reject(new Error('unused')) }
+    globals.__DSH_TRANSPORT__ = previous
+    onTestFinished(() => { delete globals.__DSH_TRANSPORT__ })
+    const client = await TestClient.start({ roster: TYPERT_ONLY }, RemoteMock.create(), { awaitConnected: false })
+    expect(globals.__DSH_TRANSPORT__).not.toBe(previous)
+    expect(client.ctx.get('typert')).toBeDefined()
+    expect(() => client.connection).toThrow('provides no `connection` service')
+    await client.dispose()
+    expect(globals.__DSH_TRANSPORT__).toBe(previous)
+  })
+})

+ 178 - 0
packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts

@@ -0,0 +1,178 @@
+// @vitest-environment jsdom
+/**
+ * TestClient over the web profile's roster read from its bundles: production
+ * `bootClient` over in-process modules, every Remote call answered by a
+ * `RemoteMock` installed as the Connection carrier, mount, HMR-style reload,
+ * unload, and fail-loud teardown.
+ */
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import { RemoteMock, ok, openStream } from '@deepseek-ai/dsh-remote-mock'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
+import type { AssemblyPlan, TestClientOptions } from '../src/assembly/index.ts'
+import { ClientRoster, TestClient, remoteDefaultResponses, webApp } from '../src/assembly/index.ts'
+
+/** The Gateway client and what it injects: the Typert registry and the Connection. */
+const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
+const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar'
+/** Declared by ui-sidebar, whose SlotMap merge is outside this package's compilation face. */
+const SIDEBAR_SETTINGS = 'sidebar.settings' as never
+const BRAND = '@deepseek-ai/dsh-client-ui-brand-official'
+const globals = globalThis as { __DSH_TRANSPORT__?: unknown; EventSource?: unknown; ResizeObserver?: unknown }
+/** The whole roster's first boot pays the cold module transform of every plugin package. */
+const COLD_BOOT_TIMEOUT_MS = 60_000
+
+async function started(plan: AssemblyPlan, options?: TestClientOptions): Promise<TestClient> {
+  const mock = RemoteMock.create().load(remoteDefaultResponses)
+  const client = await TestClient.start(plan, mock, options)
+  onTestFinished(() => client.dispose())
+  return client
+}
+
+describe('TestClient (jsdom)', () => {
+  it('boots the whole web-app roster, connects, mounts, and disposes with nothing unmatched', async () => {
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const client = await TestClient.start({ roster: webApp }, mock, { mount: true })
+    expect(client.connection.state.getSnapshot()).toBe('connected')
+    expect(mock.log.streams('$events')).toHaveLength(1)
+    const container = client.container!
+    expect(document.body.contains(container)).toBe(true)
+    expect(container.childElementCount).toBeGreaterThan(0)
+    expect(globals.__DSH_TRANSPORT__).toBeDefined()
+    expect(globals.EventSource).toBeDefined()
+    expect(globals.ResizeObserver).toBeDefined()
+    await client.dispose()
+    expect(document.body.contains(container)).toBe(false)
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+    expect(globals.EventSource).toBeUndefined()
+    expect(globals.ResizeObserver).toBeUndefined()
+    await client.dispose()
+  }, COLD_BOOT_TIMEOUT_MS)
+
+  it('leaves a pre-existing global alone and removes only the shims it installed', async () => {
+    const existing = { existing: true }
+    vi.stubGlobal('ResizeObserver', existing)
+    onTestFinished(() => { vi.unstubAllGlobals() })
+    const client = await started({ roster: API_ROSTER })
+    expect(globals.ResizeObserver).toBe(existing)
+    expect(globals.EventSource).toBeDefined()
+    await client.dispose()
+    expect(globals.ResizeObserver).toBe(existing)
+    expect(globals.EventSource).toBeUndefined()
+  })
+
+  it('boots overlapping clients one at a time onto their own mocks and keeps the shared globals until the last dispose', async () => {
+    const mockA = RemoteMock.create().load(remoteDefaultResponses).unary('session/rename', ok({ title: 'a', seq: 1 }))
+    const mockB = RemoteMock.create().load(remoteDefaultResponses).unary('session/rename', ok({ title: 'b', seq: 1 }))
+    const [a, b] = await Promise.all([
+      TestClient.start({ roster: API_ROSTER }, mockA),
+      TestClient.start({ roster: API_ROSTER }, mockB),
+    ])
+    onTestFinished(() => b.dispose())
+    const rename = async (client: TestClient): Promise<unknown> =>
+      (client.ctx as unknown as { remote: { session: { rename(request: unknown): Promise<unknown> } } }).remote.session.rename({ sessionId: 's', title: 't' })
+    await expect(rename(a)).resolves.toEqual({ ok: true, value: { title: 'a', seq: 1 } })
+    await expect(rename(b)).resolves.toEqual({ ok: true, value: { title: 'b', seq: 1 } })
+    expect(mockA.log.calls('session/rename')).toHaveLength(1)
+    expect(mockB.log.calls('session/rename')).toHaveLength(1)
+    // A rebuilt connection row reads its own mock even after another client installed the transport last.
+    await a.reload('@deepseek-ai/dsh-client-connection')
+    await vi.waitFor(() => { expect(a.connection.state.getSnapshot()).toBe('connected') })
+    await expect(rename(a)).resolves.toEqual({ ok: true, value: { title: 'a', seq: 1 } })
+    expect(mockA.log.calls('session/rename')).toHaveLength(2)
+    expect(mockB.log.calls('session/rename')).toHaveLength(1)
+    await a.dispose()
+    expect(globals.__DSH_TRANSPORT__).toBeDefined()
+    expect(globals.EventSource).toBeDefined()
+    await b.dispose()
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+    expect(globals.EventSource).toBeUndefined()
+  })
+
+  it('boots the api subset without a mount and exposes the typed Remote', async () => {
+    const client = await started({ roster: API_ROSTER })
+    expect(client.container).toBeUndefined()
+    expect(client.ctx.remote.session).toBeDefined()
+    expect(client.connection.state.getSnapshot()).toBe('connected')
+  })
+
+  it('refuses to mount a roster that provides no uiRenderer instead of returning an empty container', async () => {
+    const before = document.body.childElementCount
+    await expect(TestClient.start({ roster: API_ROSTER }, RemoteMock.create().load(remoteDefaultResponses), { mount: true }))
+      .rejects.toThrow('mount requested, but the roster provides no `uiRenderer`')
+    expect(document.body.childElementCount).toBe(before)
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+  })
+
+  it('creates no mount element when the roster cannot be loaded', async () => {
+    const roster = ClientRoster.of([{ name: '@deepseek-ai/dsh-client-test-runtime-missing', inject: [], immediately: true }])
+    const before = document.body.childElementCount
+    await expect(TestClient.start({ roster }, RemoteMock.create(), { mount: true })).rejects.toThrow()
+    expect(document.body.childElementCount).toBe(before)
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+  })
+
+  it('mounts into a caller-supplied element and leaves it in place on dispose', async () => {
+    const host = document.createElement('main')
+    document.body.appendChild(host)
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const client = await TestClient.start({ roster: webApp }, mock, { mount: host })
+    expect(client.container).toBe(host)
+    expect(host.childElementCount).toBeGreaterThan(0)
+    await client.dispose()
+    expect(document.body.contains(host)).toBe(true)
+    host.remove()
+  })
+
+  it('boots with a provided row in place of the real plugin', async () => {
+    const apply = vi.fn()
+    const client = await started({ roster: webApp, provide: { [BRAND]: { apply } } }, { mount: true })
+    expect(apply).toHaveBeenCalledOnce()
+    expect([...client.ctx.loader.entries()].some(entry => entry.options.name === BRAND)).toBe(true)
+  })
+
+  it('reload rebuilds the declaring entry; unload collapses it', async () => {
+    const client = await started({ roster: webApp }, { mount: true })
+    expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(1)
+    await client.reload(SIDEBAR)
+    await client.flush()
+    expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(1)
+    await client.unload(SIDEBAR)
+    await client.flush()
+    expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(0)
+    await expect(client.reload(SIDEBAR)).rejects.toThrow(`no Loader entry named ${SIDEBAR}`)
+  })
+
+  it('fails loud by teardown at the latest when a boot-time endpoint has no fixture', async () => {
+    const mock = RemoteMock.create()
+    const run = TestClient.start({ roster: webApp }, mock, { mount: true }).then(client => client.dispose())
+    await expect(run).rejects.toThrow(/session\/control|workspace\/follow|session\/list|settings\/describe/)
+  })
+
+  it('waits through a carrier flap for the connection to become ready', async () => {
+    let opens = 0
+    const mock = RemoteMock.create().load(remoteDefaultResponses).stream('$events', (_args, stream) => {
+      opens += 1
+      const first = opens === 1
+      setTimeout(() => {
+        if (first) stream.fail(new Error('flap'))
+        // Branded on the Gateway side; the test mints a plain string.
+        else stream.push({ type: 'ready', clientId: 'reconnected' as never, host: { home: '/home/mock' } })
+      }, 20)
+    })
+    const client = await TestClient.start({ roster: API_ROSTER }, mock)
+    onTestFinished(() => client.dispose())
+    expect(client.connection.state.getSnapshot()).toBe('connected')
+    expect(mock.log.streams('$events')).toHaveLength(2)
+  })
+
+  it('reports the log when the connection never becomes ready', async () => {
+    // No fixtures: workspace-controller's follow has no rule, so the proxy dispatches it as a unary call the mock
+    // logs as unmatched, while $events never sends ready.
+    const roster = webApp.closure(['@deepseek-ai/dsh-api-workspace-controller'])
+    const mock = RemoteMock.create().stream('$events', openStream([]))
+    await expect(TestClient.start({ roster }, mock, { connectTimeoutMs: 300 }))
+      .rejects.toThrow(/connection state is \S+ after 300ms; unmatched: \[unary workspace\/follow\]; streams: \[.*\$events \(open\).*\]/)
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+    expect(globals.EventSource).toBeUndefined()
+  })
+})

+ 64 - 0
packages/test-support/client-runtime/tests/assembly-vitest.client.spec.ts

@@ -0,0 +1,64 @@
+/** Native fixture cleanup remains active after assertion failure and without a started client. */
+import { afterAll, describe, expect } from 'vitest'
+import { ok } from '@deepseek-ai/dsh-remote-mock'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { createClientTest, webApp, type TestClient } from '../src/assembly/index.ts'
+
+const test = createClientTest({ roster: webApp.closure(['@deepseek-ai/dsh-api-gateway']) })
+const clients: TestClient[] = []
+const expired: (() => Promise<TestClient>)[] = []
+
+afterAll(async () => {
+  expect(clients).toHaveLength(2)
+  for (const client of clients) {
+    expect(client.ctx.get('loader')).toBeUndefined()
+    expect(client.mock.log.streams('$events').map(stream => stream.state)).toEqual(['cancelled'])
+  }
+  for (const start of expired) {
+    await expect(start()).rejects.toThrow('after its test fixture closed')
+  }
+  expect('__DSH_TRANSPORT__' in globalThis).toBe(false)
+})
+
+describe('createClientTest', () => {
+  test('allows response configuration before boot and shares concurrent starts', async ({ mock, remote, start }) => {
+    expired.push(start)
+    expect(remote).toBe(mock.remote)
+    remote.session.rename.mockResolvedValueOnce(ok({ title: 'test title', seq: 1 }))
+    const first = start()
+    const second = start()
+    expect(second).toBe(first)
+    const client = await first
+    clients.push(client)
+    expect(client.mock).toBe(mock)
+    expect(mock.log.streams('$events')).toHaveLength(1)
+    await expect(client.ctx.remote.session.rename({ sessionId: 's' as SessionId, title: 't' }))
+      .resolves.toEqual(ok({ title: 'test title', seq: 1 }))
+    expect(remote.session.rename).toHaveBeenCalledExactlyOnceWith({ sessionId: 's', title: 't' })
+  })
+
+  test.fails('disposes its client even when the test assertion fails', async ({ start }) => {
+    expired.push(start)
+    clients.push(await start())
+    expect.fail('fixture cleanup negative control')
+  })
+
+  test('does not boot an unused start fixture', ({ mock, start }) => {
+    expired.push(start)
+    expect(mock.log.streams()).toEqual([])
+    expect('__DSH_TRANSPORT__' in globalThis).toBe(false)
+  })
+
+  test('owns a fresh mock when only the mock fixture is requested', ({ mock }) => {
+    expect(mock.log.calls()).toEqual([])
+    expect(mock.log.streams()).toEqual([])
+    expect(mock.endpoints()).not.toContain('session/rename')
+  })
+})
+
+const missingConnection = createClientTest({ roster: webApp.pick(['@deepseek-ai/dsh-typert-registry']) })
+missingConnection('keeps a rejected startup with its caller and releases its globals', async ({ start }) => {
+  expired.push(start)
+  await expect(start()).rejects.toThrow('provides no `connection` service')
+  expect('__DSH_TRANSPORT__' in globalThis).toBe(false)
+})

+ 0 - 37
packages/test-support/client-runtime/tests/remote.client.spec.ts

@@ -6,7 +6,6 @@
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it } from 'vitest'
 import { TestRemote } from '../src/remote.ts'
-import { scriptedSettingsRemote } from '../src/settings-remote.ts'
 
 describe('TestRemote', () => {
   it('delivers a forwarded event to its subscribers and stops after disposal', async () => {
@@ -60,39 +59,3 @@ describe('TestRemote', () => {
     await ctx.fiber.dispose()
   })
 })
-
-describe('scriptedSettingsRemote', () => {
-  it('serves, writes, and replaces its scripted namespace list', async () => {
-    const first = { ns: 'first', revision: 1 }
-    const second = { ns: 'second', revision: 2 }
-    const remote = scriptedSettingsRemote([first])
-
-    await expect(remote.settings.describe()).resolves.toEqual({
-      ok: true,
-      value: { writable: true, hasDocument: false, namespaces: [first] },
-    })
-    await expect(remote.settings.update('first', {}, undefined)).resolves.toEqual({ ok: true, value: first })
-    await expect(remote.settings.replace('missing', {}, undefined)).resolves.toMatchObject({
-      ok: false,
-      error: { code: 'settings/rejected', details: { ns: 'missing' } },
-    })
-    await expect(remote.settings.mutate('first', [], undefined)).resolves.toEqual({ ok: true, value: first })
-    expect(remote.update).toHaveBeenCalledWith('first', {}, undefined)
-    expect(remote.replace).toHaveBeenCalledWith('missing', {}, undefined)
-    expect(remote.mutate).toHaveBeenCalledWith('first', [], undefined)
-
-    remote.publish([second])
-    await expect(remote.settings.describe()).resolves.toEqual({
-      ok: true,
-      value: { writable: true, hasDocument: false, namespaces: [second] },
-    })
-  })
-
-  it('reports explicit deployment facts', async () => {
-    const remote = scriptedSettingsRemote([], { writable: false, hasDocument: true })
-    await expect(remote.settings.describe()).resolves.toEqual({
-      ok: true,
-      value: { writable: false, hasDocument: true, namespaces: [] },
-    })
-  })
-})

+ 26 - 1
packages/test-support/client-runtime/tsconfig.json

@@ -2,7 +2,8 @@
   "extends": "../../../tsconfig.base.client.json",
   "compilerOptions": {
     "rootDir": "src",
-    "outDir": "lib/types"
+    "outDir": "lib/types",
+    "types": ["client-build-environment", "node"]
   },
   "include": [
     "src"
@@ -11,6 +12,15 @@
     {
       "path": "../../../vendor/cordis"
     },
+    {
+      "path": "../../../vendor/loader"
+    },
+    {
+      "path": "../../../vendor/include"
+    },
+    {
+      "path": "../../api/gateway/tsconfig.client.json"
+    },
     {
       "path": "../../api/session-controller/tsconfig.client.json"
     },
@@ -55,6 +65,21 @@
     },
     {
       "path": "../../subagent/subagent"
+    },
+    {
+      "path": "../../api/remotes/tsconfig.client.json"
+    },
+    {
+      "path": "../../client/modules"
+    },
+    {
+      "path": "../../client/web"
+    },
+    {
+      "path": "../remote-mock"
+    },
+    {
+      "path": "../../client/hmr"
     }
   ]
 }

+ 6 - 0
packages/test-support/remote-mock/README.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 packages/test-support/remote-mock/README.md
+README.md: c3182b61a67aa6af799fb4fbac85083fbe805e59
+README.zh.md: 8199bc748d96df34de28d225db1289d76be6eed2

+ 146 - 0
packages/test-support/remote-mock/README.md

@@ -0,0 +1,146 @@
+---
+description: "Endpoint-named mock for Typert Remote traffic: a table of unary answers and stream scripts, live stream control, a log, and the Connection carrier face, for test authors booting a real browser client without a Host."
+kind: "package-library"
+---
+
+# @deepseek-ai/dsh-remote-mock
+
+English | [中文](README.zh.md)
+
+## Summary
+
+`dsh-remote-mock` lets tests configure Host responses through `mock.remote.<namespace>.<method>` using native Vitest mock methods. The same functions answer direct calls and real Connection traffic; reusable tables supply default responses, and explicitly declared streams support test-driven frames and cancellation. Missing responses fail the call and are reported again by `assertNoUnmatched()` at teardown. The package runs without a business Host in Node or a browser page, imports no DOM, React, or Node modules, and is consumed from `devDependencies` only.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+### When to use it
+
+Use it when a spec boots real client plugins that talk to `ctx.remote` and wants to script the Host side by endpoint name: whole-client jsdom specs through `__DSH_TRANSPORT__`, and unit specs that call `dispatch` / `open` directly. Endpoints are the Gateway's wire names (`session/page`, `settings/describe`); `args` is the caller's positional argument list with a trailing `AbortSignal` removed; a value is whatever the test registers and is answered unchanged. The only declaration is whether an endpoint is unary (`unary`) or a stream (`stream`).
+
+<a id="remote-proxy"></a>
+### Use the Remote proxy
+
+`mock.remote` exposes every namespace and method without a method list or a domain-specific helper. Each accessed endpoint uses a cached native Vitest mock; `@vitest/spy.fn` is the implementation behind `vi.fn` and also works in a browser page without the Vitest runner. The same mock answers direct calls and Connection traffic, so return overrides and call assertions observe the functions the client actually invokes:
+
+```text
+const mock = RemoteMock.create().load(remoteDefaultResponses)
+mock.remote.settings.describe.mockResolvedValue(ok({
+  writable: true, hasDocument: false, namespaces: [],
+}))
+mock.remote.settings.mutate.mockResolvedValueOnce(ok(updatedNamespace))
+// After the client writes:
+expect(mock.remote.settings.mutate).toHaveBeenCalledWith('locale', operations, revision)
+```
+
+Use `mockResolvedValue` for a persistent response, `mockResolvedValueOnce` or `mockReturnValueOnce` for queued responses, and `mockImplementation` for argument-dependent behavior. Native queued responses run in registration order, then the mock's current implementation answers. Its initial implementation reads the registered default. `mockClear()` preserves responses and queues; `mockReset()` clears overrides and restores that initial implementation, which reads the latest default. Missing defaults still fail after queued responses are exhausted, including direct calls, which can throw synchronously.
+
+Only explicit `stream()` or table stream declarations select stream methods; everything else uses unary mocks. Accessing a method does not fabricate a successful business result. Declare stream mode before retaining a method reference: each endpoint/mode owns its own mock. Namespace and method `then` probes and symbol reads are inert.
+
+`MockedRemote` uses Vitest's deep mock transformation over the entire generated `TypertRemoteNamespaceMap`. A non-empty map preserves its namespace and method names, parameters, results, and native spy types. An empty map makes only this test proxy `any`, allowing arbitrary namespaces and methods; it does not augment or weaken production Remote declarations. No copied method signatures, optional generated-module suppressions, or compiler-wide flags are needed. Before handing off Remote/mock changes, run `pnpm run typecheck` to generate and check the real Client types; missing, stale, or partial declarations require rebuilding first. Unbuilt test success or `any` inference is not strict type evidence.
+
+### Register default responses
+
+`load(table)` installs reusable `unary` values or handlers, `stream` scripts, and scriptless `streams` declarations. `unary(endpoint, value)` and `unary(endpoint, fn)` register individual defaults; handlers receive the caller's positional arguments and use existing request types. Each endpoint holds only its latest default, including an explicit `undefined`; updating defaults does not discard native overrides. `ok(value)` builds `{ ok: true, value }`; failures use `{ ok: false, error: { code, message, details } }`. Keep stateful handlers, promises, and native queues local to each test:
+
+```text
+const initial = { writable: true, hasDocument: false, namespaces: [] }
+const mock = RemoteMock.create().load({
+  unary: { 'settings/describe': ok(initial) },
+})
+mock.remote.settings.describe.mockResolvedValueOnce(ok({ ...initial, hasDocument: true }))
+```
+
+### Drive streams
+
+A stream script is a function of the open `args` and a `StreamHandle` (`push`, `end`, `fail(error)`, `signal`); the stream stays open after the script returns until the handle ends or fails it. `frames(items)` builds a script that yields the items and ends; `openStream(initial)` one that yields them and stays open. `mock.streams` controls streams the client currently holds open, filtered by the args they were opened with; `opened(endpoint, count)` resolves once the endpoint has been opened that many times, and `drained(endpoint)` once the consumer of every matching stream has pulled everything pushed so far, an open stream's consumer waiting for more and a settled stream's queue empty (pulled from the queue; that equals processed only for a consumer that handles items inside its read loop):
+
+```text
+mock.stream('session/follow', openStream([snapshotFrame]))
+await mock.streams.opened('session/follow', 1)
+mock.streams.push('session/follow', eventFrame, ([request]) => (request as { sessionId: string }).sessionId === SID)
+mock.streams.fail('session/follow', new Error('gone'))
+await mock.streams.drained('session/follow')
+```
+
+A failed stream rejects the consumer's next read with the given `Error`. Consumer cancellation (the opening signal or an early iterator `return()`) aborts `StreamHandle.signal`, ends the iteration without throwing, and logs the stream as `cancelled`.
+
+### Connect a client
+
+`mock.rpc` is the `ClientConnectionRpc` face: install it as `globalThis.__DSH_TRANSPORT__ = { rpc: mock.rpc }` and the production `connection` plugin uses it in place of the HTTP caller, so every Remote call reaches `dispatch` and every stream `open` with no envelopes in between. Payloads carry `{ args }` as the whole-client proxies send them (an array) or as the Gateway's own endpoints send them (one object, delivered as one positional arg); a call whose signal aborts rejects with the abort reason. `RemoteMock.create()` registers one stream, `$events`, that answers the Gateway client's opening with `{ type: 'ready', clientId, host: { home } }` (host from `RemoteMockOptions.host`, default `/home/mock`) and stays open, which is what lets the assembled client reach `connected`; a spec overrides or fails it like any other stream.
+
+### Observe and assert
+
+`mock.log.calls(endpoint?)` lists unary calls through `dispatch` or `rpc.call` (`args`, `seq`, a live `state` of `pending` / `answered` / `failed`, and the answer or thrown error as `result`), `streams(endpoint?)` lists scripted opens with their live `state` and `pushed` count, `requests(endpoint?)` lists the first positional arg of calls and opens in order (without an endpoint, excluding the Gateway's own `$`-prefixed endpoints), and `unmatched()` lists requests that found no rule. Native `.mock.calls` additionally includes direct proxy calls; carrier stream mocks receive their final cancellation signal. `assertNoUnmatched()` reports misses at teardown. `modeOf(endpoint)` reports explicit registrations; `endpoints()` also includes accessed proxy methods so assembly can provide their namespaces.
+
+### What can go wrong
+
+- **A request has no rule** — `dispatch` rejects and `open` throws `remote-mock: no rule for <endpoint>; registered: …`, and the log records the miss; register the endpoint.
+- **A payload is not `{ args: unknown[] | object }`** — `rpc.call` rejects and `rpc.open` throws a `TypeError`; the whole-client proxies send the array form and the Gateway's own endpoints the object form, so a hand-written call is at fault.
+- **A second concurrent read on one stream** — the read rejects; the Gateway reads streams sequentially, so this names a test-side misuse.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+### Design
+
+`dispatch` and `open` take the endpoint and positional args; `rpc` exposes the same core through Connection's decoded carrier interface. Each mock owns its native functions and queued overrides; shared tables supply defaults without copying handlers or answer objects. Each scripted stream owns its queue, its single pending read, and its log entry; the first of `end`, `fail`, or consumer cancellation settles it.
+
+### Source map
+
+| File | Role |
+|---|---|
+| [`src/index.ts`](src/index.ts) | Public face re-exports |
+| [`src/remote-mock.ts`](src/remote-mock.ts) | `RemoteMock`: default responses, native mocks, Connection dispatch, controlled streams and missing-response checks; `ok` |
+| [`src/remote-proxy.ts`](src/remote-proxy.ts) | Namespace/method lookup and generated-map mock types |
+| [`src/streams.ts`](src/streams.ts) | `frames` / `openStream` scripts and `MockStream` (handle + `AsyncIterable`) |
+| [`src/log.ts`](src/log.ts) | Log store with the shared `seq` counter |
+| — | No runtime invariant companion is published; this test-support library owns no production event stream or mutable process state, and its behavior is exercised by its package tests. |
+
+</details>
+
+-----
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as this package is browser-side test infrastructure; nothing here reaches a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- **In-process carrier only** — `rpc` serves a client in the same realm through `__DSH_TRANSPORT__.rpc`; no HTTP or WebSocket carrier for browser-lane specs is provided.
+- **Values cross by reference** — answers and stream items reach the client unserialized, so a non-JSON value that the real wire would reject passes through unchanged.
+- **Values are not checked** — a unary answer must be the result the caller reads (`{ ok, value }` or `{ ok: false, error }`); the mock passes it through unchanged and does not check those fields.
+- **No payload matching** — rules match on endpoint only; discriminate on business arguments inside a handler.
+- **Native stream overrides own their iterable** — an override returning its own iterable bypasses scripted-stream logs, `requests`, `opened`, `drained`, and `push` / `end` / `fail`; the caller also owns cancellation. Native call assertions still work. Use a registered stream script when a scenario needs those controls.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>

+ 146 - 0
packages/test-support/remote-mock/README.zh.md

@@ -0,0 +1,146 @@
+---
+description: "Typert Remote 流量的端点具名 mock:一元应答与流脚本的表、活流控制、日志与 Connection 载体面,供测试作者在没有 Host 的情况下启动真实浏览器客户端。"
+kind: "package-library"
+---
+
+# @deepseek-ai/dsh-remote-mock
+
+[English](README.md) | 中文
+
+## 概述
+
+`dsh-remote-mock` 让测试通过 `mock.remote.<namespace>.<method>`,使用原生 Vitest mock 方法配置 Host 响应。同一组函数应答直接调用与真实 Connection 流量;可复用的表提供默认响应,显式声明的流支持测试驱动的推帧与取消。缺少响应时调用失败,`assertNoUnmatched()` 会在收尾时再次报告。本包无需业务 Host 即可在 Node 或浏览器页面中运行,不导入 DOM、React 或 Node 模块,只从 `devDependencies` 消费。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+### 何时使用
+
+当测试要启动与 `ctx.remote` 对话的真实客户端插件、并想按端点名脚本化 Host 侧时使用它:经 `__DSH_TRANSPORT__` 的整体 jsdom 测试,以及直接调用 `dispatch` / `open` 的单元测试。端点是 Gateway 的 wire 名(`session/page`、`settings/describe`);`args` 是调用方的位置参数列表,末尾的 `AbortSignal` 已剥掉;值就是测试登记的东西,原样应答。唯一的声明是端点是一元(`unary`)还是流(`stream`)。
+
+<a id="remote-proxy"></a>
+### 使用 Remote Proxy
+
+`mock.remote` 无需方法清单或领域专属 Helper 即可提供每个命名空间和方法。每个访问过的端点使用缓存的原生 Vitest mock;`@vitest/spy.fn` 就是 `vi.fn` 背后的实现,也能在没有 Vitest runner 的浏览器页面中运行。同一个 mock 应答直接调用与 Connection 流量,因此返回值覆盖和调用断言观察的是客户端实际调用的函数:
+
+```text
+const mock = RemoteMock.create().load(remoteDefaultResponses)
+mock.remote.settings.describe.mockResolvedValue(ok({
+  writable: true, hasDocument: false, namespaces: [],
+}))
+mock.remote.settings.mutate.mockResolvedValueOnce(ok(updatedNamespace))
+// After the client writes:
+expect(mock.remote.settings.mutate).toHaveBeenCalledWith('locale', operations, revision)
+```
+
+使用 `mockResolvedValue` 设置持续响应,使用 `mockResolvedValueOnce` 或 `mockReturnValueOnce` 排队设置响应,使用 `mockImplementation` 按参数决定行为。原生队列按登记顺序消费响应,耗尽后由 mock 的当前实现应答;初始实现读取已登记的默认响应。`mockClear()` 保留响应与队列;`mockReset()` 清除覆盖并恢复初始实现,由它读取最新默认响应。若未配置默认响应,排队响应耗尽后仍会失败,包括可能同步抛错的直接调用。
+
+只有显式 `stream()` 或响应表中的流声明才选择流方法;其余均使用一元 mock。访问方法不会凭空构造成功的业务结果。保存方法引用前先声明流模式:每个端点/模式拥有各自的 mock。命名空间和方法的 `then` 探测及 symbol 读取均无副作用。
+
+`MockedRemote` 使用 Vitest 的深层 mock 类型转换,覆盖完整生成的 `TypertRemoteNamespaceMap`。非空映射保留命名空间与方法名、参数、返回值和原生 spy 类型。映射为空时只有这个测试 Proxy 变成 `any`,允许任意命名空间和方法;它不增补或弱化生产 Remote 声明。不需要复制方法签名、抑制可选生成模块错误或开启编译器级全局 Flag。交付 Remote/mock 改动前运行 `pnpm run typecheck`,生成并检查真实 Client 类型;声明缺失、陈旧或不完整时先重新构建。无构建测试通过或推断为 `any` 都不是严格类型证据。
+
+### 登记默认响应
+
+`load(table)` 安装可复用的 `unary` 值或 handler、`stream` 脚本以及无脚本的 `streams` 声明。`unary(endpoint, value)` 与 `unary(endpoint, fn)` 登记单个默认响应;handler 接收调用方的位置参数,并使用现有请求类型。每个端点仅保存最新默认响应,包括显式 `undefined`;更新默认响应不会清除原生覆盖。`ok(value)` 构造 `{ ok: true, value }`;失败使用 `{ ok: false, error: { code, message, details } }`。有状态 handler、promise 与原生队列均由各测试独立持有:
+
+```text
+const initial = { writable: true, hasDocument: false, namespaces: [] }
+const mock = RemoteMock.create().load({
+  unary: { 'settings/describe': ok(initial) },
+})
+mock.remote.settings.describe.mockResolvedValueOnce(ok({ ...initial, hasDocument: true }))
+```
+
+### 驾驭流
+
+流脚本是一个接收打开时的 `args` 与 `StreamHandle`(`push`、`end`、`fail(error)`、`signal`)的函数;脚本返回后流保持打开,直到句柄结束或失败。`frames(items)` 构造吐完即结束的脚本,`openStream(initial)` 构造吐完后保持打开的脚本。`mock.streams` 控制客户端当前打开着的流,可按打开时的参数过滤;`opened(endpoint, count)` 在该端点被打开达到该次数时 resolve,`drained(endpoint)` 在每条匹配流的消费方都拉完了迄今推入的全部内容时 resolve——打开的流要其消费方再次等待,已定局的流要队列已空(拉完指从队列取走;只有在读循环内处理项的消费方才等于处理完):
+
+```text
+mock.stream('session/follow', openStream([snapshotFrame]))
+await mock.streams.opened('session/follow', 1)
+mock.streams.push('session/follow', eventFrame, ([request]) => (request as { sessionId: string }).sessionId === SID)
+mock.streams.fail('session/follow', new Error('gone'))
+await mock.streams.drained('session/follow')
+```
+
+失败的流让消费方的下一次读取以给定的 `Error` reject。消费方取消(打开时的 signal 或 iterator 提前 `return()`)会中止 `StreamHandle.signal`、结束迭代而不抛错,并把该流记为 `cancelled`。
+
+### 接上客户端
+
+`mock.rpc` 是 `ClientConnectionRpc` 面:装成 `globalThis.__DSH_TRANSPORT__ = { rpc: mock.rpc }`,生产的 `connection` 插件就用它替代 HTTP 调用方,每次 Remote 调用直达 `dispatch`、每条流直达 `open`,中间没有信封。payload 携带 `{ args }`——整机代理发数组、Gateway 自身端点发一个对象(到达时是一个位置参数);signal 中止的调用以中止原因 reject。`RemoteMock.create()` 登记一条流 `$events`,用 `{ type: 'ready', clientId, host: { home } }`(host 来自 `RemoteMockOptions.host`,默认 `/home/mock`)应答 Gateway 客户端的打开并保持打开——这正是整机能达到 `connected` 的原因;测试可以像任何流一样覆盖或让它失败。
+
+### 观察与断言
+
+`mock.log.calls(endpoint?)` 列出经 `dispatch` 或 `rpc.call` 的一元调用(`args`、`seq`、实时 `state` 为 `pending` / `answered` / `failed`,以及作为 `result` 的应答值或抛出的错误),`streams(endpoint?)` 列出脚本流的打开记录及其实时 `state` 与 `pushed` 计数,`requests(endpoint?)` 按顺序列出调用与打开的首个位置参数(不带端点时去掉 Gateway 自己带 `$` 前缀的端点),`unmatched()` 列出没找到规则的请求。原生 `.mock.calls` 还包含直接 Proxy 调用;载体的流 mock 会收到末尾的取消信号。`assertNoUnmatched()` 在收尾时报告漏配。`modeOf(endpoint)` 报告显式登记;`endpoints()` 还包含访问过的 Proxy 方法,使装配能够提供它们的命名空间。
+
+### 可能出什么问题
+
+- **请求没有规则**——`dispatch` reject、`open` 抛出 `remote-mock: no rule for <endpoint>; registered: …`,日志记下这次漏配;请登记该端点。
+- **payload 不是 `{ args: unknown[] | object }`**——`rpc.call` reject、`rpc.open` 抛 `TypeError`;整机代理发数组形式、Gateway 自身端点发对象形式,所以问题出在手写调用。
+- **同一条流上有第二个并发读取**——该读取 reject;Gateway 顺序读取流,因此这指向测试侧误用。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<details>
+<summary>实现细节——点击展开</summary>
+
+### 设计
+
+`dispatch` 与 `open` 接收端点与位置参数;`rpc` 通过 Connection 的已解码载体接口暴露同一个核心。每个 mock 独立持有原生函数及排队覆盖;共享表提供默认响应,不复制 handler 或应答对象。每条脚本流拥有自己的队列、唯一挂起读取和日志条目;`end`、`fail`、消费方取消三者中最先发生者定局。
+
+### 源码地图
+
+| 文件 | 职责 |
+|---|---|
+| [`src/index.ts`](src/index.ts) | 公开面转出 |
+| [`src/remote-mock.ts`](src/remote-mock.ts) | `RemoteMock`:默认响应、原生 mock、Connection 分发、受控流与缺失响应检查;`ok` |
+| [`src/remote-proxy.ts`](src/remote-proxy.ts) | 命名空间/方法查找与生成映射的 mock 类型 |
+| [`src/streams.ts`](src/streams.ts) | `frames` / `openStream` 脚本与 `MockStream`(句柄 + `AsyncIterable`) |
+| [`src/log.ts`](src/log.ts) | 带共享 `seq` 计数器的日志 |
+| — | 不发布运行时不变量伴生件;本测试支持库不拥有任何生产事件流或可变进程状态,其行为由本包测试覆盖。 |
+
+</details>
+
+-----
+
+<a id="model-experience"></a>
+## 模型体验
+
+无;本包是浏览器侧测试基础设施,无一物到达模型请求。
+
+#### KV Cache 影响
+
+无;本包既不组装也不发送提供方请求。
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- **仅进程内载体**——`rpc` 经 `__DSH_TRANSPORT__.rpc` 服务同一 realm 的客户端;不提供给浏览器车道测试用的 HTTP 或 WebSocket 载体。
+- **值按引用传递**——应答与流项都未经序列化就到达客户端,真实线路会拒绝的非 JSON 值在这里原样通过。
+- **不校验值**——一元应答必须是调用方读取的结果(`{ ok, value }` 或 `{ ok: false, error }`);mock 原样传递它,不检查这些字段。
+- **不做 payload 匹配**——规则只按端点匹配;在 handler 内按业务参数判别。
+- **原生流覆盖自行管理 iterable**——覆盖返回自有 iterable 时,不参与脚本流日志、`requests`、`opened`、`drained` 以及 `push` / `end` / `fail`;调用方也负责取消。原生调用断言仍然有效。需要这些控制能力的场景应使用已登记的流脚本。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者的工作上下文——点击展开</summary>
+
+无。
+
+</details>

+ 42 - 0
packages/test-support/remote-mock/package.json

@@ -0,0 +1,42 @@
+{
+  "name": "@deepseek-ai/dsh-remote-mock",
+  "description": "Endpoint-named mock for Typert Remote traffic: unary answers and stream scripts per <namespace>/<method>, live stream control, a log, and the Connection carrier face whole-client specs install",
+  "version": "0.1.5-rc.1",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/test-support/remote-mock"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "license": "MIT",
+  "dependencies": {
+    "@vitest/spy": "^4.1.8"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-api-settings-controller": "workspace:^",
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
+    "typescript": "^6.0.3"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts"
+  ]
+}

+ 16 - 0
packages/test-support/remote-mock/src/index.ts

@@ -0,0 +1,16 @@
+/**
+ * Endpoint-named mock for Typert Remote traffic: a table of unary answers and
+ * stream scripts keyed by `<namespace>/<method>`, scripted-stream control, a
+ * carrier log, and the Connection carrier face the `connection` plugin
+ * accepts through `__DSH_TRANSPORT__.rpc`. Values are whatever the test
+ * registers; the only declaration is whether an endpoint is unary or a
+ * stream. Browser-safe: no DOM, React, or Node imports, no runtime import
+ * from another harness package.
+ * @module @deepseek-ai/dsh-remote-mock
+ */
+export { RemoteMock, ok } from './remote-mock.ts'
+export type { OpenStreams, RemoteMockOptions, RemoteTable, StreamFilter, UnaryRuleFn } from './remote-mock.ts'
+export { frames, openStream } from './streams.ts'
+export type { StreamHandle, StreamScript } from './streams.ts'
+export type { LoggedCall, LoggedMiss, LoggedStream, MockLog } from './log.ts'
+export type { MockedRemote } from './remote-proxy.ts'

+ 131 - 0
packages/test-support/remote-mock/src/log.ts

@@ -0,0 +1,131 @@
+/** What a `RemoteMock` saw: answered calls, opened streams, and requests without a rule. */
+
+/** One unary call, logged when its rule is selected and settled when the rule does. */
+export interface LoggedCall {
+  readonly endpoint: string
+  /** Positional args as the caller passed them. */
+  readonly args: readonly unknown[]
+  /** `pending` until the rule settles, then `answered` or `failed`. */
+  readonly state: 'pending' | 'answered' | 'failed'
+  /** The answer once `answered`, the thrown error once `failed`. */
+  readonly result: unknown
+  /** Global order across calls and stream opens. */
+  readonly seq: number
+}
+
+/** One stream open and its current state. */
+export interface LoggedStream {
+  readonly endpoint: string
+  /** Positional args the stream was opened with. */
+  readonly args: readonly unknown[]
+  readonly state: 'open' | 'ended' | 'failed' | 'cancelled'
+  /** Items delivered to the consumer so far. */
+  readonly pushed: number
+  /** Global order across calls and stream opens. */
+  readonly seq: number
+}
+
+/** One request that found no rule. */
+export interface LoggedMiss {
+  readonly endpoint: string
+  readonly mode: 'unary' | 'stream'
+}
+
+/** Read side of the log. */
+export interface MockLog {
+  /**
+   * Unary calls, oldest first, with their live state.
+   * @param endpoint - restrict to one endpoint.
+   * @returns matching entries.
+   */
+  calls(endpoint?: string): readonly LoggedCall[]
+  /**
+   * Stream opens, oldest first, with their live state.
+   * @param endpoint - restrict to one endpoint.
+   * @returns matching entries.
+   */
+  streams(endpoint?: string): readonly LoggedStream[]
+  /**
+   * The first positional arg of every unary call and stream open, oldest first: the request object of the Gateway's
+   * one-parameter endpoints. Without `endpoint`, every endpoint the client asked the Host for, except the Gateway's
+   * own `$`-prefixed ones (`$events`).
+   * @param endpoint - restrict to one endpoint.
+   * @returns the requests.
+   */
+  requests(endpoint?: string): readonly unknown[]
+  /** Requests that found no rule, oldest first. */
+  unmatched(): readonly LoggedMiss[]
+}
+
+/** Mutable call entry the dispatcher settles. */
+export interface CallRecord extends LoggedCall {
+  state: LoggedCall['state']
+  result: unknown
+}
+
+/** Mutable stream entry shared with the live stream. */
+export interface StreamRecord extends LoggedStream {
+  state: LoggedStream['state']
+  pushed: number
+}
+
+/** The mock's log store. */
+export class MockLogStore implements MockLog {
+  private readonly callEntries: CallRecord[] = []
+  private readonly streamEntries: StreamRecord[] = []
+  private readonly missEntries: LoggedMiss[] = []
+  private seq = 0
+
+  calls(endpoint?: string): readonly LoggedCall[] {
+    return this.callEntries.filter(entry => endpoint === undefined || entry.endpoint === endpoint)
+  }
+
+  streams(endpoint?: string): readonly LoggedStream[] {
+    return this.streamEntries.filter(entry => endpoint === undefined || entry.endpoint === endpoint)
+  }
+
+  requests(endpoint?: string): readonly unknown[] {
+    const entries: (LoggedCall | LoggedStream)[] = [...this.calls(endpoint), ...this.streams(endpoint)]
+    return entries
+      .filter(entry => endpoint !== undefined || !entry.endpoint.startsWith('$'))
+      .sort((a, b) => a.seq - b.seq)
+      .map(entry => entry.args[0])
+  }
+
+  unmatched(): readonly LoggedMiss[] {
+    return [...this.missEntries]
+  }
+
+  /**
+   * Record one call whose rule was selected.
+   * @param endpoint - endpoint.
+   * @param args - positional args.
+   * @returns the mutable entry the dispatcher settles.
+   */
+  call(endpoint: string, args: readonly unknown[]): CallRecord {
+    const record: CallRecord = { endpoint, args, state: 'pending', result: undefined, seq: ++this.seq }
+    this.callEntries.push(record)
+    return record
+  }
+
+  /**
+   * Record one stream open.
+   * @param endpoint - endpoint.
+   * @param args - positional args.
+   * @returns the mutable entry the stream updates.
+   */
+  stream(endpoint: string, args: readonly unknown[]): StreamRecord {
+    const record: StreamRecord = { endpoint, args, state: 'open', pushed: 0, seq: ++this.seq }
+    this.streamEntries.push(record)
+    return record
+  }
+
+  /**
+   * Record one request without a rule.
+   * @param endpoint - endpoint.
+   * @param mode - how it was requested.
+   */
+  miss(endpoint: string, mode: 'unary' | 'stream'): void {
+    this.missEntries.push({ endpoint, mode })
+  }
+}

+ 385 - 0
packages/test-support/remote-mock/src/remote-mock.ts

@@ -0,0 +1,385 @@
+/** `RemoteMock`: an endpoint table (unary answers or stream scripts), live stream control, a log, and the Connection carrier face. */
+
+import type { ClientConnectionRpc, ConnectionRpcResult } from '@deepseek-ai/dsh-client-connection/client'
+import { fn, type Mock } from '@vitest/spy'
+import { MockLogStore, type MockLog } from './log.ts'
+import { MockStream, toError, type StreamScript } from './streams.ts'
+import { createRemoteProxy, type MockedRemote } from './remote-proxy.ts'
+
+/** A unary handler receives the caller's positional arguments, without its trailing `AbortSignal`. */
+export type UnaryRuleFn<Args extends readonly unknown[] = readonly unknown[], Result = unknown> = (...args: Args) => Result
+
+/** Endpoint defaults: unary values or positional handlers, stream scripts, and stream declarations. */
+export interface RemoteTable {
+  readonly unary?: Readonly<Record<string, unknown>>
+  readonly stream?: Readonly<Record<string, StreamScript>>
+  /** Endpoints declared as streams without a script, so an open finds a stream miss rather than a unary one. */
+  readonly streams?: readonly string[]
+}
+
+/** Construction options. */
+export interface RemoteMockOptions {
+  /** Host facts the built-in `$events` ready frame carries; default `{ home: '/home/mock' }`. */
+  readonly host?: { readonly home: string }
+}
+
+/** Filter over the args a stream was opened with. */
+export type StreamFilter = (args: readonly unknown[]) => boolean
+
+/** Control and synchronization for streams opened by registered scripts; native overrides are excluded. */
+export interface OpenStreams {
+  /**
+   * Push one item into every open stream on `endpoint`, optionally filtered by its open args.
+   * @param endpoint - endpoint.
+   * @param item - item to deliver.
+   * @param where - filter over open args.
+   * @returns how many streams received it.
+   */
+  push(endpoint: string, item: unknown, where?: StreamFilter): number
+  /**
+   * End every matching open stream.
+   * @param endpoint - endpoint.
+   * @param where - filter over open args.
+   * @returns how many streams ended.
+   */
+  end(endpoint: string, where?: StreamFilter): number
+  /**
+   * Fail every matching open stream with `error`.
+   * @param endpoint - endpoint.
+   * @param error - error the consumer's read rejects with.
+   * @param where - filter over open args.
+   * @returns how many streams failed.
+   */
+  fail(endpoint: string, error: Error, where?: StreamFilter): number
+  /**
+   * Resolve once registered scripts have opened `endpoint` at least `count` times in total.
+   * @param endpoint - endpoint.
+   * @param count - opens to wait for.
+   */
+  opened(endpoint: string, count: number): Promise<void>
+  /**
+   * Resolve once the consumer of every matching stream has pulled everything pushed so far and, for a stream still
+   * open, waits for more; a settled stream counts once its queue is empty, and a stream nobody reads never drains.
+   * Pulled means taken from the queue: a consumer that processes items asynchronously after pulling them may still
+   * be working on the last one.
+   * @param endpoint - endpoint.
+   * @param where - filter over open args.
+   */
+  drained(endpoint: string, where?: StreamFilter): Promise<void>
+}
+
+const EVENTS_ENDPOINT = '$events'
+
+/**
+ * The success envelope the Client's Remote callers read: `{ ok: true, value }`.
+ * @param value - success value.
+ * @returns the envelope.
+ */
+export function ok<T>(value: T): { readonly ok: true; readonly value: T } {
+  return { ok: true, value }
+}
+
+interface OpenWaiter {
+  readonly endpoint: string
+  readonly count: number
+  resolve(): void
+}
+
+/** A missing default answer is an unmatched request, not a selected rule that failed. */
+class MissingUnaryRule extends Error {}
+
+/**
+ * Endpoint-named Remote mock. `dispatch` / `open` are the core; `rpc` is the
+ * same core as the Connection carrier the `connection` plugin accepts through
+ * `__DSH_TRANSPORT__.rpc`.
+ */
+export class RemoteMock {
+  /**
+   * Create a mock whose `$events` stream answers the Gateway client's opening
+   * with one ready frame and then stays open, so the assembled client connects.
+   * @param options - host facts for the ready frame.
+   * @returns the mock.
+   */
+  static create(options: RemoteMockOptions = {}): RemoteMock {
+    const mock = new RemoteMock()
+    const host = options.host ?? { home: '/home/mock' }
+    let generation = 0
+    return mock.stream(EVENTS_ENDPOINT, (_args, stream) => {
+      generation += 1
+      stream.push({ type: 'ready', clientId: `mock-client-${String(generation)}`, host })
+    })
+  }
+
+  private readonly unaryDefaults = new Map<string, unknown>()
+  private readonly scripts = new Map<string, StreamScript | undefined>()
+  private live: MockStream[] = []
+  private openWaiters: OpenWaiter[] = []
+  private readonly logStore = new MockLogStore()
+  private readonly unaryMocks = new Map<string, Mock<UnaryRuleFn>>()
+  private readonly streamMocks = new Map<string, Mock<UnaryRuleFn>>()
+  private readonly proxy = createRemoteProxy(endpoint => this.modeOf(endpoint) === 'stream'
+    ? this.streamMock(endpoint)
+    : this.unaryMock(endpoint))
+
+  /**
+   * Native mock functions for every namespace: configure answers and inspect calls without declaring method names.
+   * Explicit stream registrations select stream mocks; all other methods use unary mocks.
+   * Native overrides belong to this instance; `mockReset()` restores the implementation reading current defaults.
+   * Generated declarations provide exact signatures; absent declarations weaken only this test-owned proxy.
+   */
+  // oxlint-disable-next-line typescript/no-unsafe-assignment -- Only this test proxy becomes any without generated namespaces.
+  readonly remote: MockedRemote = this.proxy as MockedRemote
+
+  /** Everything observed so far. */
+  readonly log: MockLog = this.logStore
+
+  /** Control over streams opened by registered scripts. */
+  readonly streams: OpenStreams = {
+    push: (endpoint, item, where) => this.forEachOpen(endpoint, where, (stream) => { stream.push(item) }),
+    end: (endpoint, where) => this.forEachOpen(endpoint, where, (stream) => { stream.end() }),
+    fail: (endpoint, error, where) => this.forEachOpen(endpoint, where, (stream) => { stream.fail(error) }),
+    opened: (endpoint, count) => {
+      if (this.logStore.streams(endpoint).length >= count) return Promise.resolve()
+      return new Promise<void>((resolve) => { this.openWaiters.push({ endpoint, count, resolve }) })
+    },
+    drained: (endpoint, where) => Promise.all(this.matching(endpoint, where).map(stream => stream.drained())).then(() => undefined),
+  }
+
+  /**
+   * The Connection carrier face: `call` dispatches, `open` opens; payloads carry
+   * `{ args }` as the whole-client proxies (an array) or the Gateway's own
+   * endpoints (one object) send them, and a call aborted by its signal rejects.
+   */
+  readonly rpc: ClientConnectionRpc = {
+    call: async (_channel, endpoint, payload, signal) => {
+      const pending = this.dispatch(endpoint, argsOf(endpoint, payload))
+      const value = await (signal === undefined ? pending : settleOrAbort(pending, signal))
+      // The carrier contract names the result envelope; the registered value is taken as that envelope unchecked.
+      return value as ConnectionRpcResult<unknown>
+    },
+    open: (_channel, endpoint, payload, signal) => this.open(endpoint, argsOf(endpoint, payload), signal),
+  }
+
+  private constructor() {}
+
+  /**
+   * Set the endpoint's default positional-argument handler without changing its native mock overrides.
+   * @param endpoint - `<namespace>/<method>`.
+   * @param rule - handler computing the answer.
+   * @returns this.
+   */
+  unary<Args extends readonly unknown[]>(endpoint: string, rule: UnaryRuleFn<Args>): this
+  /**
+   * Set the endpoint's default answer, including explicit `undefined`, without changing its native mock overrides.
+   * @param endpoint - `<namespace>/<method>`.
+   * @param rule - default answer.
+   * @returns this.
+   */
+  unary(endpoint: string, rule: unknown): this
+  unary(endpoint: string, rule: unknown): this {
+    this.unaryDefaults.set(endpoint, rule)
+    return this
+  }
+
+  /**
+   * Declare `endpoint` as a stream and replace its default script when supplied. A declaration without a script
+   * preserves any existing script; an endpoint declared without one fails an open as a stream miss.
+   * @param endpoint - `<namespace>/<method>`.
+   * @param script - script (`frames` / `openStream` build the common ones).
+   * @returns this.
+   */
+  stream(endpoint: string, script?: StreamScript): this {
+    if (script !== undefined || !this.scripts.has(endpoint)) this.scripts.set(endpoint, script)
+    return this
+  }
+
+  /**
+   * Register endpoint defaults without changing native mock overrides.
+   * @param table - unary answers and stream scripts.
+   * @returns this.
+   */
+  load(table: RemoteTable): this {
+    for (const [endpoint, rule] of Object.entries(table.unary ?? {})) this.unary(endpoint, rule)
+    for (const endpoint of table.streams ?? []) this.stream(endpoint)
+    for (const [endpoint, script] of Object.entries(table.stream ?? {})) this.stream(endpoint, script)
+    return this
+  }
+
+  /**
+   * Whether `endpoint` is declared a stream (with or without a script) or has a unary rule — the one declaration
+   * the whole-client proxies need; an endpoint neither declared nor ruled is dispatched as a unary call.
+   * @param endpoint - endpoint.
+   * @returns the mode, or undefined when nothing is registered.
+   */
+  modeOf(endpoint: string): 'unary' | 'stream' | undefined {
+    if (this.scripts.has(endpoint)) return 'stream'
+    if (this.unaryDefaults.has(endpoint)) return 'unary'
+    return undefined
+  }
+
+  /**
+   * Registered endpoints and accessed proxy methods, sorted.
+   * @returns endpoint names.
+   */
+  endpoints(): readonly string[] {
+    const names = new Set<string>(this.scripts.keys())
+    for (const endpoint of this.unaryDefaults.keys()) names.add(endpoint)
+    for (const [namespace, methods] of Object.entries(this.proxy)) {
+      for (const method of Object.keys(methods)) names.add(`${namespace}/${method}`)
+    }
+    return [...names].sort()
+  }
+
+  /**
+   * Answer one unary call with the registered rule's value, verbatim. The call
+   * is logged as soon as its rule is selected and settles with the rule: a
+   * rule that throws or rejects fails the call with that error.
+   * @param endpoint - endpoint.
+   * @param args - positional args.
+   * @returns the answer.
+   * @throws {Error} when no rule is registered (logged as unmatched).
+   */
+  async dispatch(endpoint: string, args: readonly unknown[]): Promise<unknown> {
+    let answer: unknown
+    try {
+      answer = this.unaryMock(endpoint)(...args)
+    } catch (error) {
+      if (error instanceof MissingUnaryRule) throw error
+      answer = Promise.reject(toError(error))
+    }
+    const record = this.logStore.call(endpoint, args)
+    try {
+      record.result = await answer
+    } catch (error) {
+      record.state = 'failed'
+      record.result = error
+      throw error
+    }
+    record.state = 'answered'
+    return record.result
+  }
+
+  private unaryMock(endpoint: string): Mock<UnaryRuleFn> {
+    let mock = this.unaryMocks.get(endpoint)
+    if (mock === undefined) {
+      mock = fn((...args: readonly unknown[]) => Promise.resolve(this.unaryAnswer(endpoint, args)))
+      this.unaryMocks.set(endpoint, mock)
+    }
+    return mock
+  }
+
+  private unaryAnswer(endpoint: string, args: readonly unknown[]): unknown {
+    if (!this.unaryDefaults.has(endpoint)) {
+      this.logStore.miss(endpoint, 'unary')
+      throw new MissingUnaryRule(this.noRuleMessage(endpoint))
+    }
+    const rule = this.unaryDefaults.get(endpoint)
+    return isRuleFn(rule) ? answerOf(rule, args) : rule
+  }
+
+  /**
+   * Open through the endpoint's native mock; its default runs the registered script as a controlled stream.
+   * A native override returns its own iterable: the caller owns consumption and cancellation, outside `OpenStreams`.
+   * @param endpoint - endpoint.
+   * @param args - positional args.
+   * @param signal - consumer cancellation.
+   * @returns the controlled script stream or the native override's caller-owned iterable.
+   * @throws {Error} when the default runs without a registered script (logged as unmatched).
+   */
+  open(endpoint: string, args: readonly unknown[], signal: AbortSignal): AsyncIterable<unknown> {
+    return this.streamMock(endpoint)(...args, signal) as AsyncIterable<unknown>
+  }
+
+  private streamMock(endpoint: string): Mock<UnaryRuleFn> {
+    let mock = this.streamMocks.get(endpoint)
+    if (mock === undefined) {
+      mock = fn((...values: readonly unknown[]) => {
+        const args = [...values]
+        const signal = args.at(-1) instanceof AbortSignal ? args.pop() as AbortSignal : new AbortController().signal
+        return this.openScript(endpoint, args, signal)
+      })
+      this.streamMocks.set(endpoint, mock)
+    }
+    return mock
+  }
+
+  private openScript(endpoint: string, args: readonly unknown[], signal: AbortSignal): AsyncIterable<unknown> {
+    const script = this.scripts.get(endpoint)
+    if (script === undefined) {
+      this.logStore.miss(endpoint, 'stream')
+      throw new Error(this.noRuleMessage(endpoint))
+    }
+    const stream = new MockStream(this.logStore.stream(endpoint, args), signal)
+    this.live.push(stream)
+    this.wakeOpened(endpoint)
+    stream.run(script, args)
+    return stream
+  }
+
+  /** Throw when any request found no rule, naming the endpoints and the registered ones. */
+  assertNoUnmatched(): void {
+    const unmatched = this.logStore.unmatched()
+    if (unmatched.length === 0) return
+    const lines = unmatched.map(entry => `  ${entry.endpoint} (${entry.mode})`)
+    throw new Error(`remote-mock: ${String(unmatched.length)} unmatched request(s):\n${lines.join('\n')}\nregistered: ${this.endpoints().join(', ')}`)
+  }
+
+  private noRuleMessage(endpoint: string): string {
+    return `remote-mock: no rule for ${endpoint}; registered: ${this.endpoints().join(', ')}`
+  }
+
+  /** Streams on `endpoint` still open or still holding items their consumer has not pulled; the rest are forgotten. */
+  private matching(endpoint: string, where: StreamFilter | undefined): MockStream[] {
+    this.live = this.live.filter(stream => stream.record.state === 'open' || stream.queued > 0)
+    return this.live.filter(stream => stream.record.endpoint === endpoint && (where === undefined || where(stream.record.args)))
+  }
+
+  private forEachOpen(endpoint: string, where: StreamFilter | undefined, action: (stream: MockStream) => void): number {
+    const targets = this.matching(endpoint, where).filter(stream => stream.record.state === 'open')
+    for (const stream of targets) action(stream)
+    return targets.length
+  }
+
+  private wakeOpened(endpoint: string): void {
+    const opened = this.logStore.streams(endpoint).length
+    const ready = this.openWaiters.filter(waiter => waiter.endpoint === endpoint && opened >= waiter.count)
+    this.openWaiters = this.openWaiters.filter(waiter => !ready.includes(waiter))
+    for (const waiter of ready) waiter.resolve()
+  }
+}
+
+function isRuleFn(rule: unknown): rule is UnaryRuleFn {
+  return typeof rule === 'function'
+}
+
+/** A rule's synchronous throw becomes a rejection so the call settles through one path. */
+function answerOf(rule: UnaryRuleFn, args: readonly unknown[]): unknown {
+  try {
+    return rule(...args)
+  } catch (error) {
+    return Promise.reject(toError(error))
+  }
+}
+
+/** Positional args from a carrier payload: the array the whole-client proxies send, or the one object the Gateway's own endpoints send. */
+function argsOf(endpoint: string, payload: unknown): readonly unknown[] {
+  if (typeof payload === 'object' && payload !== null && 'args' in payload) {
+    const { args } = payload
+    if (Array.isArray(args)) return args
+    if (typeof args === 'object' && args !== null) return [args]
+  }
+  throw new TypeError(`remote-mock: payload of ${endpoint} must be { args: unknown[] | object }`)
+}
+
+/**
+ * Settle with the call, or reject with the abort reason first; the call itself
+ * always keeps a handler so its own outcome is never an unhandled rejection.
+ */
+function settleOrAbort<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
+  return new Promise<T>((resolve, reject) => {
+    const abort = (): void => { reject(signal.reason instanceof Error ? signal.reason : new Error('remote-mock: call aborted', { cause: signal.reason })) }
+    signal.addEventListener('abort', abort, { once: true })
+    pending.then(resolve, reject).finally(() => { signal.removeEventListener('abort', abort) })
+    if (signal.aborted) abort()
+  })
+}

+ 29 - 0
packages/test-support/remote-mock/src/remote-proxy.ts

@@ -0,0 +1,29 @@
+/** Lazy namespace access for native Remote mocks; production Remote declarations stay unchanged. */
+import type { MaybeMockedDeep } from '@vitest/spy'
+import type { TypertRemoteNamespaceMap } from '@deepseek-ai/dsh-typert-protocol'
+
+/**
+ * Native mocks for every generated Remote namespace, or an unrestricted local proxy without generated types.
+ * @template Api - generated namespace map available in the caller's TypeScript program.
+ */
+// oxlint-disable-next-line typescript/no-explicit-any -- An unbuilt namespace map has neither method names nor signatures.
+export type MockedRemote<Api = TypertRemoteNamespaceMap> = keyof Api extends never ? any : MaybeMockedDeep<Api>
+
+/**
+ * Cache accessed namespaces and expose the current native mock for each method.
+ * @param method - selects a stable endpoint mock for its configured invocation mode.
+ * @returns enumerable accessed namespaces and methods; symbols and thenable probes remain inert.
+ */
+export function createRemoteProxy(method: (endpoint: string) => unknown): Record<string, object> {
+  return new Proxy(Object.create(null) as Record<string, object>, {
+    get(namespaces, namespace) {
+      if (typeof namespace !== 'string' || namespace === 'then') return undefined
+      return namespaces[namespace] ??= new Proxy(Object.create(null) as Record<string, unknown>, {
+        get(methods, name) {
+          if (typeof name !== 'string' || name === 'then') return undefined
+          return methods[name] = method(`${namespace}/${name}`)
+        },
+      })
+    },
+  })
+}

+ 196 - 0
packages/test-support/remote-mock/src/streams.ts

@@ -0,0 +1,196 @@
+/** Stream scripts and the pushable, abort-aware stream a script drives. */
+
+import type { StreamRecord } from './log.ts'
+
+/** Test-side controls over one open stream. */
+export interface StreamHandle {
+  /** Queue one item for the consumer. */
+  push(item: unknown): void
+  /** End the stream after the queued items drain. */
+  end(): void
+  /** Fail the consumer's next read with `error` after the queued items drain. */
+  fail(error: Error): void
+  /** Aborts when the opening signal aborts or the consumer returns early. */
+  readonly signal: AbortSignal
+}
+
+/**
+ * How a stream endpoint answers an open: receives the open args and the
+ * handle; the stream stays open after the script returns until `end()` or
+ * `fail()`. A script that throws or rejects fails the stream with that error.
+ */
+export type StreamScript = (args: readonly unknown[], stream: StreamHandle) => void | Promise<void>
+
+/**
+ * Script yielding `items`, then ending.
+ * @param items - items in order.
+ * @returns the script.
+ */
+export function frames(items: readonly unknown[]): StreamScript {
+  return (_args, stream) => {
+    for (const item of items) stream.push(item)
+    stream.end()
+  }
+}
+
+/**
+ * Script yielding `initial`, then staying open for `streams.push`.
+ * @param initial - items yielded on open.
+ * @returns the script.
+ */
+export function openStream(initial: readonly unknown[] = []): StreamScript {
+  return (_args, stream) => {
+    for (const item of initial) stream.push(item)
+  }
+}
+
+type Settled = { readonly kind: 'end' } | { readonly kind: 'fail'; readonly error: Error }
+
+/** One open stream: a queue the script pushes into and a single consumer reads from; the log entry tracks its state. */
+export class MockStream implements StreamHandle, AsyncIterable<unknown> {
+  private readonly queue: unknown[] = []
+  private settled: Settled | undefined
+  private waiting: { readonly resolve: (result: IteratorResult<unknown>) => void; readonly reject: (error: unknown) => void } | undefined
+  private drainWaiters: (() => void)[] = []
+  private readonly cancellation = new AbortController()
+  readonly signal = this.cancellation.signal
+
+  /** Consumer cancellation listener; attached while the stream is open, removed when it settles or cancels. */
+  private readonly onAbort = (): void => { this.cancel(this.sourceSignal.reason) }
+
+  /** Items pushed but not yet pulled by the consumer. */
+  get queued(): number {
+    return this.queue.length
+  }
+
+  /**
+   * @param record - log entry this stream updates.
+   * @param sourceSignal - cancellation from the caller that opened the stream.
+   */
+  constructor(readonly record: StreamRecord, private readonly sourceSignal: AbortSignal) {
+    if (sourceSignal.aborted) this.cancel(sourceSignal.reason)
+    else sourceSignal.addEventListener('abort', this.onAbort, { once: true })
+  }
+
+  push(item: unknown): void {
+    if (this.record.state !== 'open') return
+    this.record.pushed += 1
+    if (this.waiting !== undefined) {
+      const { resolve } = this.waiting
+      this.waiting = undefined
+      resolve({ value: item, done: false })
+      return
+    }
+    this.queue.push(item)
+  }
+
+  end(): void {
+    this.settle({ kind: 'end' }, 'ended')
+  }
+
+  fail(error: Error): void {
+    this.settle({ kind: 'fail', error }, 'failed')
+  }
+
+  /**
+   * Start `script` on this stream.
+   * @param script - the registered script.
+   * @param args - open args.
+   */
+  run(script: StreamScript, args: readonly unknown[]): void {
+    let outcome: void | Promise<void>
+    try {
+      outcome = script(args, this)
+    } catch (error) {
+      this.fail(toError(error))
+      return
+    }
+    if (outcome instanceof Promise) void outcome.catch((error: unknown) => { this.fail(toError(error)) })
+  }
+
+  /**
+   * Resolve once the consumer has pulled every queued item and waits for the next one, or the stream is no longer
+   * open and holds nothing the consumer could still pull (a consumer that returns discards what it left).
+   * @returns settles when drained.
+   */
+  drained(): Promise<void> {
+    if (this.isDrained()) return Promise.resolve()
+    return new Promise<void>((resolve) => { this.drainWaiters.push(resolve) })
+  }
+
+  [Symbol.asyncIterator](): AsyncIterator<unknown> {
+    return {
+      next: () => this.next(),
+      return: () => {
+        this.cancel()
+        return Promise.resolve({ value: undefined, done: true })
+      },
+    }
+  }
+
+  private next(): Promise<IteratorResult<unknown>> {
+    const result = this.pull()
+    this.wakeDrained()
+    return result
+  }
+
+  private pull(): Promise<IteratorResult<unknown>> {
+    if (this.queue.length > 0) return Promise.resolve({ value: this.queue.shift(), done: false })
+    if (this.settled !== undefined) return this.finish(this.settled)
+    if (this.record.state === 'cancelled') return Promise.resolve({ value: undefined, done: true })
+    if (this.waiting !== undefined) return Promise.reject(new Error(`remote-mock: ${this.record.endpoint} stream has one consumer`))
+    return new Promise((resolve, reject) => { this.waiting = { resolve, reject } })
+  }
+
+  private finish(settled: Settled): Promise<IteratorResult<unknown>> {
+    return settled.kind === 'end' ? Promise.resolve({ value: undefined, done: true }) : Promise.reject(settled.error)
+  }
+
+  private settle(settled: Settled, state: 'ended' | 'failed'): void {
+    if (this.record.state !== 'open') return
+    this.record.state = state
+    this.settled = settled
+    this.sourceSignal.removeEventListener('abort', this.onAbort)
+    this.wakeDrained()
+    // A pending read exists only while the queue is empty: push() resolves it directly instead of queueing.
+    const waiting = this.waiting
+    if (waiting === undefined) return
+    this.waiting = undefined
+    void this.finish(settled).then(waiting.resolve, waiting.reject)
+  }
+
+  private cancel(reason?: unknown): void {
+    if (this.record.state === 'open') {
+      this.record.state = 'cancelled'
+      this.sourceSignal.removeEventListener('abort', this.onAbort)
+      const waiting = this.waiting
+      this.waiting = undefined
+      this.queue.length = 0
+      this.cancellation.abort(reason)
+      waiting?.resolve({ value: undefined, done: true })
+    }
+    // Whether cancelled or left after the producer settled, the consumer pulls nothing more: drop what it left.
+    this.queue.length = 0
+    this.wakeDrained()
+  }
+
+  private isDrained(): boolean {
+    return this.queue.length === 0 && (this.waiting !== undefined || this.record.state !== 'open')
+  }
+
+  private wakeDrained(): void {
+    if (!this.isDrained()) return
+    const waiters = this.drainWaiters
+    this.drainWaiters = []
+    for (const resolve of waiters) resolve()
+  }
+}
+
+/**
+ * The `Error` a thrown value stands for: itself, or a new Error carrying its string form.
+ * @param reason - thrown value.
+ * @returns the error.
+ */
+export function toError(reason: unknown): Error {
+  return reason instanceof Error ? reason : new Error(String(reason))
+}

+ 184 - 0
packages/test-support/remote-mock/tests/proxy-types.client.spec.ts

@@ -0,0 +1,184 @@
+/** Mock-local unbuilt typing and native deep mocks over generated Remote declarations. */
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import ts from 'typescript'
+import type { MaybeMockedDeep } from '@vitest/spy'
+import { describe, expect, expectTypeOf, it } from 'vitest'
+import type { TypertRemoteNamespace, TypertRemoteNamespaceMap } from '@deepseek-ai/dsh-typert-protocol'
+import type {} from '@deepseek-ai/dsh-api-settings-controller/remote'
+import type { RemoteMock } from '../src/index.ts'
+
+const root = resolve(import.meta.dirname, '../../../..')
+const probePath = resolve(import.meta.dirname, '__remote_proxy_probe.ts')
+const artifactPath = resolve(import.meta.dirname, '__remote_proxy_artifact.d.ts')
+const protocolPath = resolve(root, 'packages/typert/protocol/src/types.ts')
+const proxyPath = resolve(import.meta.dirname, '../src/remote-proxy.ts')
+const protocolEntry = resolve(import.meta.dirname, '__remote_protocol.d.ts')
+const cordisEntry = resolve(import.meta.dirname, '__remote_cordis.d.ts')
+
+function modulePath(path: string): string {
+  return JSON.stringify(path.replaceAll('\\', '/'))
+}
+
+function virtualKey(path: string): string {
+  const normalized = path.replaceAll('\\', '/')
+  return ts.sys.useCaseSensitiveFileNames ? normalized : normalized.toLowerCase()
+}
+
+function compile(source: string, artifact?: string) {
+  const config = ts.readConfigFile(resolve(root, 'tsconfig.base.client.json'), filename => ts.sys.readFile(filename))
+  if (config.error !== undefined) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
+  const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root)
+  const options: ts.CompilerOptions = {
+    ...parsed.options,
+    composite: false,
+    incremental: false,
+    noEmit: true,
+    declaration: false,
+    declarationMap: false,
+    rewriteRelativeImportExtensions: false,
+    types: ['node'],
+    paths: {
+      '@deepseek-ai/cordis': [cordisEntry],
+      '@deepseek-ai/dsh-typert-protocol': [protocolEntry],
+      '@deepseek-ai/fixture/remote': [artifactPath],
+    },
+  }
+  // The real protocol and Mock alias are compiled; only unrelated Cordis/runtime assembly is excluded.
+  const virtual = new Map([
+    [virtualKey(probePath), source],
+    [virtualKey(protocolEntry), `export * from ${modulePath(protocolPath)}\nexport { RemoteError } from ${modulePath(resolve(root, 'packages/typert/protocol/src/remote-error.ts'))}`],
+    [virtualKey(cordisEntry), 'export interface Context {}\nexport interface Events {}'],
+  ])
+  if (artifact !== undefined) virtual.set(virtualKey(artifactPath), artifact)
+  const host = ts.createCompilerHost(options, true)
+  const read = host.readFile.bind(host)
+  const exists = host.fileExists.bind(host)
+  const hidden = (path: string): boolean => path.replaceAll('\\', '/').includes('/typert.remote-client.')
+  host.fileExists = path => virtual.has(virtualKey(path)) || !hidden(path) && exists(path)
+  host.readFile = path => virtual.get(virtualKey(path)) ?? (hidden(path) ? undefined : read(path))
+  host.getSourceFile = (path, languageVersion) => {
+    const text = host.readFile(path)
+    return text === undefined ? undefined : ts.createSourceFile(path, text, languageVersion, true)
+  }
+  const program = ts.createProgram([probePath], options, host)
+  expect(program.getSourceFile(protocolPath)).toBeDefined()
+  expect(program.getSourceFile(proxyPath)).toBeDefined()
+  expect(program.getSourceFiles().some(file => hidden(file.fileName))).toBe(false)
+  expect(program.getSourceFile(artifactPath) !== undefined).toBe(artifact !== undefined)
+  const diagnostics = ts.getPreEmitDiagnostics(program)
+  const formatted = ts.formatDiagnosticsWithColorAndContext(diagnostics, {
+    getCanonicalFileName: path => path,
+    getCurrentDirectory: () => root,
+    getNewLine: () => '\n',
+  })
+  expect(diagnostics.length, formatted).toBe(0)
+}
+
+describe('RemoteMock proxy types', { timeout: 60_000 }, () => {
+  it('keeps production closed while an empty generated map permits Mock calls and overrides', () => {
+    compile(`
+import type { TypertRemoteNamespace, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol'
+import type { MockedRemote } from '../src/remote-proxy.ts'
+type IsAny<T> = 0 extends (1 & T) ? true : false
+declare const local: MockedRemote
+const loose: IsAny<MockedRemote> = true
+local.fixture.echo(123, 'unbuilt')
+local.fixture.echo.mockResolvedValueOnce({ arbitrary: 'answer' })
+local.fixture.echo.mockImplementation((value: unknown, extra: unknown) => [value, extra])
+declare const production: TypertClientRemote
+declare const strict: TypertRemoteNamespace<'fixture'>
+// @ts-expect-error -- Importing the Mock alias cannot add a production namespace.
+production.fixture.echo('unbuilt')
+// @ts-expect-error -- Production methods stay unavailable without generated declarations.
+strict.echo('unbuilt')
+void loose
+`)
+  })
+
+  it('preserves generated arguments, results and native spy overrides', () => {
+    compile(`
+import type { MockedRemote } from '../src/remote-proxy.ts'
+import type {} from '@deepseek-ai/fixture/remote'
+type IsAny<T> = 0 extends (1 & T) ? true : false
+declare const local: MockedRemote
+const spy = local.fixture.echo
+spy.mockImplementation(value => value.length)
+const argumentIsTyped: IsAny<Parameters<typeof spy>[0]> = false
+const resultIsTyped: IsAny<ReturnType<typeof spy>> = false
+const result: number = spy('typed')
+spy.mockReturnValueOnce(2)
+// @ts-expect-error -- Generated arguments remain checked through the native spy.
+spy(123)
+// @ts-expect-error -- Generated return values remain checked through native overrides.
+spy.mockReturnValueOnce('wrong')
+// @ts-expect-error -- Known namespaces do not gain arbitrary methods.
+local.fixture.missing()
+// @ts-expect-error -- A non-empty map does not gain arbitrary namespaces.
+local.missing.echo('wrong')
+void [argumentIsTyped, resultIsTyped, result]
+`, fixtureDeclaration)
+  })
+
+  it('keeps partial and empty namespaces closed inside a non-empty map', () => {
+    compile(`
+import type { TypertClientRemote, TypertRemoteNamespace } from '@deepseek-ai/dsh-typert-protocol'
+import type { MockedRemote } from '../src/remote-proxy.ts'
+import type {} from '@deepseek-ai/fixture/remote'
+type PartialMap = { fixture: Pick<TypertRemoteNamespace<'fixture'>, 'echo'>; empty: {} }
+declare const local: MockedRemote<PartialMap>
+declare const production: TypertClientRemote
+const known: number = local.fixture.echo('known')
+// @ts-expect-error -- A partial namespace remains partial.
+local.fixture.watch()
+// @ts-expect-error -- Only an empty whole map enables the Mock fallback.
+local.empty.echo()
+// @ts-expect-error -- Local Mock types cannot open production namespace lookup.
+production.notANamespace.echo()
+void known
+`, fixtureDeclaration)
+  })
+
+  // Ordinary built Client tsc checks these generated business types; Vitest itself remains build-free.
+  it('derives real settings mocks from the generated namespace map and native deep mapping', () => {
+    type Settings = RemoteMock['remote']['settings']
+    type Generated = TypertRemoteNamespace<'settings'>
+    expectTypeOf<RemoteMock['remote']>().toEqualTypeOf<MaybeMockedDeep<TypertRemoteNamespaceMap>>()
+    expectTypeOf<ReturnType<Settings['describe']>>().not.toBeAny()
+    expectTypeOf<ReturnType<Settings['update']>>().not.toBeAny()
+    expectTypeOf<Parameters<Settings['update']>>().toEqualTypeOf<Parameters<Generated['update']>>()
+    expectTypeOf<Parameters<Settings['update']>[1]>().not.toBeAny()
+    expectTypeOf<Parameters<Settings['mutate']>>().toEqualTypeOf<Parameters<Generated['mutate']>>()
+    expectTypeOf<[number, boolean, string]>().not.toExtend<Parameters<Settings['update']>>()
+    expectTypeOf<{ ok: true; value: number }>().not.toExtend<Awaited<ReturnType<Settings['update']>>>()
+    expectTypeOf<Settings>().not.toHaveProperty('missing')
+  })
+
+  it('retains the native/generated type dependencies only in the proxy declaration', () => {
+    const source = readFileSync(proxyPath, 'utf8')
+    const declaration = ts.transpileDeclaration(source, { fileName: proxyPath })
+    expect(declaration.diagnostics ?? []).toHaveLength(0)
+    expect(declaration.outputText).toContain('MaybeMockedDeep')
+    expect(declaration.outputText).toContain('TypertRemoteNamespaceMap')
+    const runtime = ts.transpileModule(source, {
+      fileName: proxyPath,
+      compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2024 },
+    })
+    expect(runtime.outputText).not.toContain('@deepseek-ai/dsh-typert-protocol')
+    expect(runtime.outputText).not.toContain('@vitest/spy')
+  })
+})
+
+/** Synthetic Remote methods exercise the mapping without copying business signatures. */
+const fixtureDeclaration = `
+import type { TypertRemoteNamespace } from '@deepseek-ai/dsh-typert-protocol'
+declare module '@deepseek-ai/dsh-typert-protocol' {
+  interface TypertRemoteMap {
+    'fixture/echo': (value: string) => number
+    'fixture/watch': (after: number, signal?: AbortSignal) => AsyncIterable<number>
+  }
+  interface TypertRemoteNamespaceMap {
+    fixture: TypertRemoteNamespace<'fixture'>
+  }
+}
+`

+ 271 - 0
packages/test-support/remote-mock/tests/proxy.client.spec.ts

@@ -0,0 +1,271 @@
+/** Native namespace mocks shared by local callers and the Connection carrier. */
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
+import { RemoteMock, frames, ok, openStream, type StreamHandle } from '../src/index.ts'
+
+const describeValue = (hasDocument: boolean) => ok({ writable: true, hasDocument, namespaces: [] })
+const baseline = { type: 'baseline' as const, value: { queues: {}, jobs: {}, projections: {} } }
+
+async function drain(source: AsyncIterable<unknown>): Promise<unknown[]> {
+  const values: unknown[] = []
+  for await (const value of source) values.push(value)
+  return values
+}
+
+describe('RemoteMock.remote unary proxies', () => {
+  it('caches native namespace/method identities and leaves thenable/symbol probes inert', async () => {
+    const mock = RemoteMock.create()
+    const settings = mock.remote.settings
+    const describe = settings.describe
+    expect(mock.remote.settings).toBe(settings)
+    expect(settings.describe).toBe(describe)
+    expect(vi.isMockFunction(describe)).toBe(true)
+    expect(RemoteMock.create().remote.settings.describe).not.toBe(describe)
+    for (const value of [mock.remote, settings]) {
+      expect(Reflect.get(value, 'then')).toBeUndefined()
+      expect(Reflect.get(value, Symbol.iterator)).toBeUndefined()
+      expect(Reflect.get(value, Symbol.toStringTag)).toBeUndefined()
+      expect(await Promise.resolve(value)).toBe(value)
+    }
+    expect(describe).not.toHaveBeenCalled()
+    expect(mock.endpoints()).toEqual(['$events', 'settings/describe'])
+    expect(mock.log.calls()).toEqual([])
+    expect(mock.log.streams()).toEqual([])
+    expect(mock.log.unmatched()).toEqual([])
+  })
+
+  it('consumes native one-shot answers in FIFO order across direct and carrier calls, then uses the default handler', async () => {
+    const fallback = describeValue(false)
+    const local = describeValue(true)
+    const dispatched = { ...describeValue(false), value: { writable: false, hasDocument: false, namespaces: [] } }
+    const carrier = describeValue(true)
+    const handler = vi.fn(() => fallback)
+    const mock = RemoteMock.create().unary('settings/describe', handler)
+    const describe = mock.remote.settings.describe
+    describe.mockReturnValueOnce(Promise.resolve(local)).mockResolvedValueOnce(dispatched).mockResolvedValueOnce(carrier)
+
+    await expect(describe()).resolves.toBe(local)
+    await expect(mock.dispatch('settings/describe', [])).resolves.toBe(dispatched)
+    await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).resolves.toBe(carrier)
+    await expect(describe()).resolves.toBe(fallback)
+
+    expect(handler).toHaveBeenCalledOnce()
+    expect(describe.mock.calls).toEqual([[], [], [], []])
+    expect(mock.log.calls('settings/describe').map(call => [call.args, call.state, call.result]))
+      .toEqual([[[], 'answered', dispatched], [[], 'answered', carrier]])
+    mock.assertNoUnmatched()
+  })
+
+  it('records every business argument while the carrier keeps its signal out of positional args', async () => {
+    const mock = RemoteMock.create()
+    const mutate = mock.remote.settings.mutate
+    const result = ok({ ns: 'locale', schema: {}, value: { preference: 'en' }, applies: 'live' as const, secrets: [], revision: 8 })
+    const ops = [{ op: 'set' as const, path: ['preference'], value: 'en' }]
+    mutate.mockResolvedValue(result)
+
+    await expect(mutate('locale', ops, 7)).resolves.toBe(result)
+    await expect(mock.rpc.call('/api', 'settings/mutate', { args: ['locale', ops, 7] }, new AbortController().signal))
+      .resolves.toBe(result)
+    expect(mutate.mock.calls).toEqual([['locale', ops, 7], ['locale', ops, 7]])
+    expect(mutate.mock.calls[1]?.[1]).toBe(ops)
+    expect(mock.log.calls('settings/mutate').map(call => call.args)).toEqual([['locale', ops, 7]])
+  })
+
+  it('keeps native one-shot answers local to each mock when they load the same default table', async () => {
+    const fallback = describeValue(false)
+    const table = { unary: { 'settings/describe': fallback } }
+    const a = RemoteMock.create().load(table)
+    const b = RemoteMock.create().load(table)
+    const first = describeValue(true)
+    const second = { ...describeValue(true), value: { writable: false, hasDocument: true, namespaces: [] } }
+    a.remote.settings.describe.mockResolvedValueOnce(first)
+    b.remote.settings.describe.mockResolvedValueOnce(second)
+
+    await expect(a.dispatch('settings/describe', [])).resolves.toBe(first)
+    await expect(a.dispatch('settings/describe', [])).resolves.toBe(fallback)
+    expect(b.remote.settings.describe).not.toHaveBeenCalled()
+    await expect(b.rpc.call('/api', 'settings/describe', { args: [] })).resolves.toBe(second)
+    await expect(b.remote.settings.describe()).resolves.toBe(fallback)
+  })
+
+  it('keeps queued overrides on mockClear and restores the live default rule on mockReset', async () => {
+    const original = describeValue(false)
+    const updated = describeValue(true)
+    const override = { ...describeValue(false), value: { writable: false, hasDocument: false, namespaces: [] } }
+    const mock = RemoteMock.create().unary('settings/describe', original)
+    const describe = mock.remote.settings.describe
+    await describe()
+    describe.mockResolvedValueOnce(override)
+    describe.mockClear()
+    await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).resolves.toBe(override)
+    expect(describe.mock.calls).toEqual([[]])
+
+    describe.mockResolvedValue(override)
+    mock.load({ unary: { 'settings/describe': updated } })
+    await expect(mock.dispatch('settings/describe', [])).resolves.toBe(override)
+    describe.mockReset()
+    await expect(mock.dispatch('settings/describe', [])).resolves.toBe(updated)
+    expect(mock.remote.settings.describe).toBe(describe)
+    expect(describe.mock.calls).toEqual([[]])
+    expect(mock.log.calls('settings/describe').map(call => call.result)).toEqual([override, override, updated])
+  })
+
+  it('reports missing defaults after native one-shot answers are exhausted', async () => {
+    const mock = RemoteMock.create()
+    const describe = mock.remote.settings.describe
+    expect(mock.modeOf('settings/describe')).toBeUndefined()
+    const answer = describeValue(false)
+    describe.mockResolvedValueOnce(answer)
+    await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).resolves.toBe(answer)
+    expect(() => describe()).toThrow('no rule for settings/describe')
+    await expect(mock.dispatch('settings/describe', [])).rejects.toThrow('no rule for settings/describe')
+    await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).rejects.toThrow('no rule for settings/describe')
+    expect(describe).toHaveBeenCalledTimes(4)
+    expect(mock.log.calls('settings/describe').map(call => [call.state, call.result])).toEqual([['answered', answer]])
+    expect(mock.log.unmatched()).toEqual(Array.from({ length: 3 }, () => ({ endpoint: 'settings/describe', mode: 'unary' })))
+    expect(() => { mock.assertNoUnmatched() }).toThrow('3 unmatched request(s)')
+  })
+
+  it('records native rejected answers as failed carrier calls without unmatched entries', async () => {
+    const mock = RemoteMock.create()
+    const failure = new Error('mock service unavailable')
+    const describe = mock.remote.settings.describe.mockRejectedValueOnce(failure)
+    await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).rejects.toBe(failure)
+    expect(describe.mock.calls).toEqual([[]])
+    expect(mock.log.calls('settings/describe').map(call => [call.state, call.result])).toEqual([['failed', failure]])
+    mock.assertNoUnmatched()
+  })
+
+  it('preserves synchronous native override failures in the async carrier log', async () => {
+    const mock = RemoteMock.create()
+    const failure = new Error('handler threw')
+    const describe = mock.remote.settings.describe.mockImplementation(() => { throw failure })
+    await expect(mock.dispatch('settings/describe', [])).rejects.toBe(failure)
+    expect(describe).toHaveBeenCalledOnce()
+    expect(mock.log.calls('settings/describe').map(call => [call.state, call.result])).toEqual([['failed', failure]])
+    mock.assertNoUnmatched()
+  })
+})
+
+describe('RemoteMock.remote stream proxies', () => {
+  it('selects the cached spy for the current mode and preserves its identity across script replacement', async () => {
+    const mock = RemoteMock.create()
+    const session = mock.remote.session
+    const unary = session.control
+    expect(() => unary()).toThrow('no rule for session/control')
+    expect(mock.log.unmatched()).toEqual([{ endpoint: 'session/control', mode: 'unary' }])
+
+    mock.stream('session/control', frames([baseline])).stream('session/control')
+    const stream = session.control
+    expect(mock.remote.session).toBe(session)
+    expect(stream).not.toBe(unary)
+    expect(vi.isMockFunction(stream)).toBe(true)
+    expect(session.control).toBe(stream)
+    const opened = stream()
+    mock.stream('session/control', frames([]))
+    expect(session.control).toBe(stream)
+    await expect(drain(opened)).resolves.toEqual([baseline])
+    await expect(drain(stream())).resolves.toEqual([])
+    expect(unary).toHaveBeenCalledOnce()
+    expect(stream).toHaveBeenCalledTimes(2)
+    expect(mock.log.streams('session/control').map(entry => entry.state)).toEqual(['ended', 'ended'])
+  })
+
+  it('shares stream spies with the carrier, preserves its args, and reflects each caller cancellation', async () => {
+    const signals: AbortSignal[] = []
+    const script = vi.fn((_args: readonly unknown[], stream: StreamHandle) => {
+      signals.push(stream.signal)
+      stream.push(baseline)
+    })
+    const mock = RemoteMock.create().stream('session/control', script)
+    const localController = new AbortController()
+    const wireController = new AbortController()
+    onTestFinished(async () => {
+      localController.abort()
+      wireController.abort()
+      await mock.streams.drained('session/control')
+    })
+    const control = mock.remote.session.control
+    const request = { after: 7 }
+    const local = control(localController.signal)[Symbol.asyncIterator]()
+    const wire = mock.rpc.open!('/api', 'session/control', { args: [request] }, wireController.signal)[Symbol.asyncIterator]()
+    await expect(local.next()).resolves.toEqual({ value: baseline, done: false })
+    await expect(wire.next()).resolves.toEqual({ value: baseline, done: false })
+    expect(control.mock.calls).toEqual([[localController.signal], [request, wireController.signal]])
+    expect(script.mock.calls.map(([args]) => args)).toEqual([[], [request]])
+    expect(signals).toHaveLength(2)
+    expect(signals.map(signal => signal.aborted)).toEqual([false, false])
+    expect(mock.log.streams('session/control').map(entry => entry.args)).toEqual([[], [request]])
+
+    const waiting = local.next()
+    localController.abort()
+    await expect(waiting).resolves.toEqual({ value: undefined, done: true })
+    await wire.return!()
+    await mock.streams.drained('session/control')
+    expect(signals.map(signal => signal.aborted)).toEqual([true, true])
+    expect(mock.streams.push('session/control', baseline)).toBe(0)
+    expect(mock.log.streams('session/control').map(entry => entry.state)).toEqual(['cancelled', 'cancelled'])
+  })
+
+  it('provides a cancellable default signal when a local stream call omits one', async () => {
+    let openedSignal: AbortSignal | undefined
+    const mock = RemoteMock.create().stream('session/control', (_args, stream) => { openedSignal = stream.signal })
+    const control = mock.remote.session.control
+    const reader = control()[Symbol.asyncIterator]()
+    onTestFinished(async () => { await reader.return!() })
+    expect(openedSignal).toBeInstanceOf(AbortSignal)
+    expect(openedSignal?.aborted).toBe(false)
+    const waiting = reader.next()
+    await reader.return!()
+    await expect(waiting).resolves.toEqual({ value: undefined, done: true })
+    expect(control.mock.calls).toEqual([[]])
+    expect(mock.log.streams('session/control')[0]?.state).toBe('cancelled')
+  })
+
+  it('excludes unread native stream overrides from controlled streams and restores the script on reset', async () => {
+    const mock = RemoteMock.create().stream('session/control', frames([baseline]))
+    const control = mock.remote.session.control
+    const signal = new AbortController().signal
+    let consumed = false
+    async function* response() {
+      consumed = true
+      yield baseline
+    }
+    const native = response()
+    onTestFinished(async () => { await native.return(undefined) })
+    control.mockReturnValueOnce(native)
+    const overridden = mock.open('session/control', [], signal)
+    expect(overridden).toBe(native)
+    await expect(mock.streams.drained('session/control')).resolves.toBeUndefined()
+    expect(consumed).toBe(false)
+    expect(control.mock.calls).toEqual([[signal]])
+    expect(mock.log.streams('session/control')).toHaveLength(0)
+    await expect(drain(overridden)).resolves.toEqual([baseline])
+    expect(consumed).toBe(true)
+
+    control.mockReset()
+    await expect(drain(mock.rpc.open!('/api', 'session/control', { args: [] }, signal))).resolves.toEqual([baseline])
+    await expect(mock.streams.opened('session/control', 1)).resolves.toBeUndefined()
+    await expect(mock.streams.drained('session/control')).resolves.toBeUndefined()
+    expect(mock.remote.session.control).toBe(control)
+    expect(control.mock.calls).toEqual([[signal]])
+    expect(mock.log.streams('session/control').map(entry => [entry.state, entry.pushed])).toEqual([['ended', 1]])
+    mock.assertNoUnmatched()
+  })
+
+  it('requires an explicit stream script and keeps live failures observable by the consumer', async () => {
+    const mock = RemoteMock.create().stream('session/control')
+    expect(() => mock.remote.session.control()).toThrow('no rule for session/control')
+    expect(mock.log.unmatched()).toEqual([{ endpoint: 'session/control', mode: 'stream' }])
+    mock.stream('session/control', openStream())
+    const controller = new AbortController()
+    onTestFinished(() => { controller.abort() })
+    const reader = mock.remote.session.control(controller.signal)[Symbol.asyncIterator]()
+    const failure = new Error('stream disconnected')
+    const pending = expect(reader.next()).rejects.toBe(failure)
+    expect(mock.streams.fail('session/control', failure)).toBe(1)
+    await pending
+    await mock.streams.drained('session/control')
+    expect(mock.log.streams('session/control')[0]?.state).toBe('failed')
+    expect(mock.streams.push('session/control', baseline)).toBe(0)
+  })
+})

+ 43 - 0
packages/test-support/remote-mock/tests/rpc.client.spec.ts

@@ -0,0 +1,43 @@
+/** The Connection carrier face: payload forms, streams, unmatched rejections, and abort. */
+import { describe, expect, it } from 'vitest'
+import { RemoteMock, ok, openStream } from '../src/index.ts'
+
+const idle = (): AbortSignal => new AbortController().signal
+
+describe('RemoteMock.rpc', () => {
+  it('calls and opens by endpoint, taking the proxies\' positional args or the Gateway\'s single object', async () => {
+    const mock = RemoteMock.create()
+      .unary('session/list', ok({ items: [] }))
+      .unary('$events/result', (...args) => ok(args))
+      .stream('session/control', openStream([{ type: 'baseline' }]))
+    await expect(mock.rpc.call('/api', 'session/list', { args: [{}] })).resolves.toEqual({ ok: true, value: { items: [] } })
+    await expect(mock.rpc.call('/api', '$events/result', { args: { clientId: 'c' } }, idle())).resolves.toEqual({ ok: true, value: [{ clientId: 'c' }] })
+    const stream = mock.rpc.open!('/api', 'session/control', { args: [{ since: 1 }] }, idle())[Symbol.asyncIterator]()
+    await expect(stream.next()).resolves.toEqual({ value: { type: 'baseline' }, done: false })
+    expect(mock.log.calls().map(call => [call.endpoint, call.args])).toEqual([['session/list', [{}]], ['$events/result', [{ clientId: 'c' }]]])
+    expect(mock.log.streams('session/control').map(open => open.args)).toEqual([[{ since: 1 }]])
+  })
+
+  it('rejects malformed payloads and unmatched endpoints, logging the miss', async () => {
+    const mock = RemoteMock.create()
+    await expect(mock.rpc.call('/api', 'a/b', 'bare')).rejects.toThrow('remote-mock: payload of a/b must be { args: unknown[] | object }')
+    await expect(mock.rpc.call('/api', 'a/b', { args: 'x' })).rejects.toThrow('must be { args: unknown[] | object }')
+    expect(() => mock.rpc.open!('/api', 'a/b', null, idle())).toThrow('payload of a/b must be { args: unknown[] | object }')
+    await expect(mock.rpc.call('/api', 'a/b', { args: [] })).rejects.toThrow('remote-mock: no rule for a/b')
+    expect(mock.log.unmatched()).toEqual([{ endpoint: 'a/b', mode: 'unary' }])
+  })
+
+  it('rejects an aborted call: immediately when already aborted, with the reason while the rule is pending', async () => {
+    const mock = RemoteMock.create().unary('slow/call', () => new Promise(() => {}))
+    const pending = new AbortController()
+    const request = mock.rpc.call('/api', 'slow/call', { args: [] }, pending.signal)
+    pending.abort(new Error('caller left'))
+    await expect(request).rejects.toThrow('caller left')
+    const already = new AbortController()
+    already.abort('not an error')
+    await expect(mock.rpc.call('/api', 'slow/call', { args: [] }, already.signal)).rejects.toThrow('remote-mock: call aborted')
+    await expect(mock.rpc.call('/api', 'session/list', { args: [] }, idle())).rejects.toThrow('no rule for session/list')
+    // The abort rejects the caller; the never-settling rules stay pending in the log, and the miss is not a call.
+    expect(mock.log.calls().map(call => [call.endpoint, call.state])).toEqual([['slow/call', 'pending'], ['slow/call', 'pending']])
+  })
+})

+ 198 - 0
packages/test-support/remote-mock/tests/streams.client.spec.ts

@@ -0,0 +1,198 @@
+/** Stream scripts, live stream control, cancellation, and the built-in `$events` opening. */
+import { describe, expect, it } from 'vitest'
+import { RemoteMock, frames, openStream } from '../src/index.ts'
+
+const idle = (): AbortSignal => new AbortController().signal
+
+async function drain(source: AsyncIterable<unknown>): Promise<unknown[]> {
+  const items: unknown[] = []
+  for await (const item of source) items.push(item)
+  return items
+}
+
+async function take(source: AsyncIterable<unknown>, count: number): Promise<unknown[]> {
+  const items: unknown[] = []
+  for await (const item of source) {
+    items.push(item)
+    if (items.length === count) break
+  }
+  return items
+}
+
+describe('RemoteMock streams', () => {
+  it('yields frames() then ends, logging the open, and throws on an unmatched endpoint', async () => {
+    const mock = RemoteMock.create().stream('s/f', frames([{ n: 1 }, { n: 2 }]))
+    await expect(drain(mock.open('s/f', [{ id: 'a' }], idle()))).resolves.toEqual([{ n: 1 }, { n: 2 }])
+    expect(mock.log.streams('s/f')).toEqual([{ endpoint: 's/f', args: [{ id: 'a' }], state: 'ended', pushed: 2, seq: 1 }])
+    expect(mock.log.streams('other')).toEqual([])
+    expect(() => mock.open('s/g', [], idle())).toThrow('remote-mock: no rule for s/g; registered: $events, s/f')
+    expect(mock.log.unmatched()).toEqual([{ endpoint: 's/g', mode: 'stream' }])
+  })
+
+  it('keeps openStream() open for pushes, filters by open args, and reports delivery counts', async () => {
+    const mock = RemoteMock.create().stream('s/f', openStream(['hello']))
+    const sessionOf = ([request]: readonly unknown[]): string => (request as { sessionId: string }).sessionId
+    const a = mock.open('s/f', [{ sessionId: 'a' }], idle())
+    const b = mock.open('s/f', [{ sessionId: 'b' }], idle())
+    expect(mock.streams.push('s/f', 'only-b', open => sessionOf(open) === 'b')).toBe(1)
+    expect(mock.streams.push('s/f', 'both')).toBe(2)
+    expect(mock.streams.fail('s/f', new Error('gone'), open => sessionOf(open) === 'a')).toBe(1)
+    expect(mock.streams.end('s/f')).toBe(1)
+    await expect(drain(b)).resolves.toEqual(['hello', 'only-b', 'both'])
+    await expect(drain(a)).rejects.toThrow('gone')
+    expect(mock.log.streams().map(entry => [entry.state, entry.pushed])).toEqual([['failed', 2], ['ended', 3]])
+    expect(mock.streams.push('s/f', 'late')).toBe(0)
+  })
+
+  it('resolves a pending read on push, end, or fail; a second concurrent read is a bug; settling twice is a no-op', async () => {
+    const mock = RemoteMock.create().stream('s/f', openStream())
+    const first = mock.open('s/f', [], idle())[Symbol.asyncIterator]()
+    const pending = first.next()
+    await expect(first.next()).rejects.toThrow('remote-mock: s/f stream has one consumer')
+    mock.streams.push('s/f', 'x')
+    await expect(pending).resolves.toEqual({ value: 'x', done: false })
+    const ending = first.next()
+    mock.streams.end('s/f')
+    await expect(ending).resolves.toEqual({ value: undefined, done: true })
+    const second = mock.open('s/f', [], idle())[Symbol.asyncIterator]()
+    const failing = second.next()
+    mock.streams.fail('s/f', new Error('boom'))
+    await expect(failing).rejects.toThrow('boom')
+    await expect(second.next()).rejects.toThrow('boom')
+    expect(mock.streams.end('s/f')).toBe(0)
+    const settled = new AbortController()
+    const ended = mock.open('s/f', [], settled.signal)
+    mock.streams.end('s/f')
+    settled.abort()
+    await expect(drain(ended)).resolves.toEqual([])
+    expect(mock.log.streams().map(entry => entry.state)).toEqual(['ended', 'failed', 'ended'])
+  })
+
+  it('lets a consumer that returns after the producer ended drop the rest, so drained() settles', async () => {
+    const mock = RemoteMock.create().stream('s/f', frames(['a', 'b']))
+    const reader = mock.open('s/f', [], idle())[Symbol.asyncIterator]()
+    await expect(reader.next()).resolves.toEqual({ value: 'a', done: false })
+    const pending = mock.streams.drained('s/f')
+    await expect(reader.return!()).resolves.toEqual({ value: undefined, done: true })
+    await expect(pending).resolves.toBeUndefined()
+    expect(mock.log.streams('s/f')).toEqual([{ endpoint: 's/f', args: [], state: 'ended', pushed: 2, seq: 1 }])
+    expect(mock.streams.push('s/f', 'late')).toBe(0)
+  })
+
+  it('treats consumer abort or return() as cancellation and drops later pushes', async () => {
+    const mock = RemoteMock.create().stream('s/f', openStream(['queued']))
+    const controller = new AbortController()
+    const cancelled = mock.open('s/f', [], controller.signal)[Symbol.asyncIterator]()
+    await expect(cancelled.next()).resolves.toEqual({ value: 'queued', done: false })
+    const waiting = cancelled.next()
+    controller.abort()
+    await expect(waiting).resolves.toEqual({ value: undefined, done: true })
+    expect(mock.streams.push('s/f', 'after')).toBe(0)
+    const aborted = new AbortController()
+    aborted.abort()
+    await expect(drain(mock.open('s/f', [], aborted.signal))).resolves.toEqual([])
+    await expect(take(mock.open('s/f', [], idle()), 1)).resolves.toEqual(['queued'])
+    expect(mock.log.streams().map(entry => entry.state)).toEqual(['cancelled', 'cancelled', 'cancelled'])
+  })
+
+  it('aborts the stream handle signal when the consumer returns', async () => {
+    let signal: AbortSignal | undefined
+    const mock = RemoteMock.create().stream('s/f', (_args, stream) => {
+      signal = stream.signal
+      stream.push('first')
+    })
+    const reader = mock.open('s/f', [], idle())[Symbol.asyncIterator]()
+    await expect(reader.next()).resolves.toEqual({ value: 'first', done: false })
+    await expect(reader.return!()).resolves.toEqual({ value: undefined, done: true })
+    expect({
+      state: mock.log.streams('s/f')[0]?.state,
+      signalAborted: signal?.aborted,
+    }).toEqual({ state: 'cancelled', signalAborted: true })
+  })
+
+  it('runs script functions with the open args and fails the stream when they throw or reject', async () => {
+    const mock = RemoteMock.create()
+      .stream('s/echo', (args, stream) => {
+        stream.push(args)
+        stream.end()
+        stream.end()
+        stream.fail(new Error('too late'))
+      })
+      .stream('s/async', async (_args, stream) => {
+        await Promise.resolve()
+        stream.push('later')
+        stream.end()
+      })
+      .stream('s/throws', () => { throw new Error('sync boom') })
+      .stream('s/rejects', () => Promise.reject(new Error('async boom')))
+      .stream('s/odd', () => { throw 'string reason' })
+    await expect(drain(mock.open('s/echo', [{ a: 1 }], idle()))).resolves.toEqual([[{ a: 1 }]])
+    await expect(drain(mock.open('s/async', [], idle()))).resolves.toEqual(['later'])
+    await expect(drain(mock.open('s/throws', [], idle()))).rejects.toThrow('sync boom')
+    await expect(drain(mock.open('s/rejects', [], idle()))).rejects.toThrow('async boom')
+    await expect(drain(mock.open('s/odd', [], idle()))).rejects.toThrow('string reason')
+  })
+
+  it('drained() settles once the consumer has pulled every push and waits again, or the stream closed', async () => {
+    const mock = RemoteMock.create().stream('s/f', openStream(['first']))
+    await expect(mock.streams.drained('s/f')).resolves.toBeUndefined() // nothing open: nothing to drain
+    const reader = mock.open('s/f', [], idle())[Symbol.asyncIterator]()
+    const unread = mock.streams.drained('s/f')
+    let settled = false
+    void unread.then(() => { settled = true })
+    await Promise.resolve()
+    expect(settled).toBe(false) // 'first' is queued and nobody has pulled it
+    await expect(reader.next()).resolves.toEqual({ value: 'first', done: false })
+    await Promise.resolve()
+    expect(settled).toBe(false) // pulled, but the consumer is not waiting for more yet
+    const waiting = reader.next()
+    await expect(unread).resolves.toBeUndefined()
+    mock.streams.push('s/f', 'second')
+    await expect(waiting).resolves.toEqual({ value: 'second', done: false })
+    mock.streams.push('s/f', 'third')
+    mock.streams.end('s/f')
+    const ended = mock.streams.drained('s/f')
+    let endedSettled = false
+    void ended.then(() => { endedSettled = true })
+    await Promise.resolve()
+    expect(endedSettled).toBe(false) // ended, but 'third' is still queued
+    await expect(reader.next()).resolves.toEqual({ value: 'third', done: false })
+    await expect(ended).resolves.toBeUndefined() // queue empty: a settled stream counts as drained
+    await expect(reader.next()).resolves.toEqual({ value: undefined, done: true })
+    await expect(reader.return!()).resolves.toEqual({ value: undefined, done: true }) // returning a settled stream changes nothing
+    const second = mock.open('s/f', [], idle())[Symbol.asyncIterator]()
+    await expect(second.next()).resolves.toEqual({ value: 'first', done: false })
+    const parked = second.next()
+    await expect(mock.streams.drained('s/f')).resolves.toBeUndefined() // its consumer is waiting
+    const controller = new AbortController()
+    const third = mock.open('s/f', [], controller.signal)
+    const cancelling = mock.streams.drained('s/f', () => true) // third holds 'first' that nobody has read
+    controller.abort()
+    await expect(cancelling).resolves.toBeUndefined() // cancellation discards the queue: closed counts as drained
+    await expect(drain(third)).resolves.toEqual([])
+    mock.streams.end('s/f')
+    await expect(parked).resolves.toEqual({ value: undefined, done: true })
+  })
+
+  it('declares a stream without a script: modeOf answers stream and an open is a stream miss', () => {
+    const mock = RemoteMock.create().load({ streams: ['s/declared'] })
+    expect(mock.modeOf('s/declared')).toBe('stream')
+    expect(mock.endpoints()).toEqual(['$events', 's/declared'])
+    expect(() => mock.open('s/declared', [], idle())).toThrow('remote-mock: no rule for s/declared; registered: $events, s/declared')
+    expect(mock.log.unmatched()).toEqual([{ endpoint: 's/declared', mode: 'stream' }])
+    mock.stream('s/declared', frames(['now']))
+    expect(mock.modeOf('s/declared')).toBe('stream')
+  })
+
+  it('waits for opens with opened(), and answers $events with one ready frame per generation', async () => {
+    const mock = RemoteMock.create({ host: { home: '/home/me' } })
+    const second = mock.streams.opened('$events', 2)
+    const first = mock.open('$events', [{}], idle())
+    await expect(take(first, 1)).resolves.toEqual([{ type: 'ready', clientId: 'mock-client-1', host: { home: '/home/me' } }])
+    const again = mock.open('$events', [{}], idle())
+    await expect(second).resolves.toBeUndefined()
+    await expect(mock.streams.opened('$events', 1)).resolves.toBeUndefined()
+    await expect(take(again, 1)).resolves.toEqual([{ type: 'ready', clientId: 'mock-client-2', host: { home: '/home/me' } }])
+    expect(RemoteMock.create().modeOf('$events')).toBe('stream')
+  })
+})

+ 107 - 0
packages/test-support/remote-mock/tests/unary.client.spec.ts

@@ -0,0 +1,107 @@
+/** Registered unary defaults and the carrier's request and outcome log. */
+import { describe, expect, expectTypeOf, it, vi } from 'vitest'
+import { RemoteMock, ok, openStream, type RemoteTable, type UnaryRuleFn } from '../src/index.ts'
+
+describe('RemoteMock unary answers', () => {
+  it('answers a fixed value verbatim and logs args and result with a global seq', async () => {
+    const mock = RemoteMock.create().unary('session/list', ok({ items: [] }))
+    await expect(mock.dispatch('session/list', [{}])).resolves.toEqual({ ok: true, value: { items: [] } })
+    expect(mock.log.calls()).toEqual([{ endpoint: 'session/list', args: [{}], state: 'answered', result: { ok: true, value: { items: [] } }, seq: 1 }])
+    expect(mock.log.calls('other/x')).toEqual([])
+    expect(mock.modeOf('session/list')).toBe('unary')
+    expect(mock.modeOf('other/x')).toBeUndefined()
+    expect(mock.endpoints()).toEqual(['$events', 'session/list'])
+  })
+
+  it('replaces defaults on registration and table reload, including an explicit undefined answer', async () => {
+    const table: RemoteTable = { unary: { 'a/b': 'table' } }
+    const mock = RemoteMock.create().load(table)
+    await expect(mock.dispatch('a/b', [])).resolves.toBe('table')
+    mock.unary('a/b', 'replacement')
+    await expect(mock.dispatch('a/b', [])).resolves.toBe('replacement')
+    mock.load(table)
+    await expect(mock.dispatch('a/b', [])).resolves.toBe('table')
+    mock.load({ unary: { 'a/b': undefined } })
+    await expect(mock.dispatch('a/b', [])).resolves.toBeUndefined()
+    expect(mock.modeOf('a/b')).toBe('unary')
+    expect(mock.endpoints()).toEqual(['$events', 'a/b'])
+    mock.assertNoUnmatched()
+  })
+
+  it('passes positional arguments to native spies and leaves scenario state with the handler', async () => {
+    let n = 0
+    const handler = vi.fn((request: { id: number }, extra?: string) => {
+      expectTypeOf(request).toEqualTypeOf<{ id: number }>()
+      expectTypeOf(extra).toEqualTypeOf<string | undefined>()
+      return ok(++n)
+    })
+    const mock = RemoteMock.create()
+      .unary('a/b', handler)
+      .unary('a/c', () => Promise.resolve(n))
+    await expect(mock.dispatch('a/b', [{ id: 1 }])).resolves.toEqual(ok(1))
+    await expect(mock.dispatch('a/b', [{ id: 2 }, 'more'])).resolves.toEqual(ok(2))
+    await expect(mock.dispatch('a/c', [])).resolves.toBe(2)
+    expect(handler).toHaveBeenCalledTimes(2)
+    expect(handler).toHaveBeenNthCalledWith(1, { id: 1 })
+    expect(handler).toHaveBeenNthCalledWith(2, { id: 2 }, 'more')
+  })
+
+  it('keeps raw answers and typed handlers in tables without wrapping their arguments', async () => {
+    const handler: UnaryRuleFn<[namespace: string, value: number]> = (namespace, value) => ok({ namespace, value })
+    const mock = RemoteMock.create().load({ unary: { 'a/read': handler, 'a/void': undefined, 'a/null': null } })
+    await expect(mock.dispatch('a/read', ['settings', 2])).resolves.toEqual(ok({ namespace: 'settings', value: 2 }))
+    await expect(mock.dispatch('a/void', [])).resolves.toBeUndefined()
+    await expect(mock.dispatch('a/null', [])).resolves.toBeNull()
+    mock.unary('a/inferred', (value) => {
+      expectTypeOf(value).toEqualTypeOf<unknown>()
+      return value
+    })
+    await expect(mock.dispatch('a/inferred', [3])).resolves.toBe(3)
+  })
+
+  it('logs a call when its rule is selected and settles it with the rule: pending, answered, or failed', async () => {
+    const gate = Promise.withResolvers<unknown>()
+    const mock = RemoteMock.create()
+      .unary('a/slow', () => gate.promise)
+      .unary('a/throws', () => { throw new Error('sync boom') })
+      .unary('a/rejects', () => Promise.reject(new Error('async boom')))
+    const slow = mock.dispatch('a/slow', [1])
+    expect(mock.log.calls('a/slow')).toEqual([{ endpoint: 'a/slow', args: [1], state: 'pending', result: undefined, seq: 1 }])
+    gate.resolve('late')
+    await expect(slow).resolves.toBe('late')
+    expect(mock.log.calls('a/slow')[0]).toMatchObject({ state: 'answered', result: 'late' })
+    await expect(mock.dispatch('a/throws', [])).rejects.toThrow('sync boom')
+    await expect(mock.dispatch('a/rejects', [])).rejects.toThrow('async boom')
+    expect(mock.log.calls().map(call => [call.endpoint, call.state, call.result instanceof Error ? call.result.message : call.result]))
+      .toEqual([['a/slow', 'answered', 'late'], ['a/throws', 'failed', 'sync boom'], ['a/rejects', 'failed', 'async boom']])
+    expect(mock.log.unmatched()).toEqual([])
+  })
+
+  it('requests() lists the first arg of calls and opens in order, per endpoint or across all but the Gateway\'s own', async () => {
+    const mock = RemoteMock.create()
+      .unary('session/page', ok(null))
+      .stream('session/follow', openStream([]))
+    const signal = new AbortController().signal
+    await mock.dispatch('session/page', [{ throughSeq: 5 }])
+    mock.open('session/follow', [{ address: 'a' }], signal)
+    mock.open('$events', [{}], signal)
+    await mock.dispatch('session/page', [{ throughSeq: 9 }, 'extra'])
+    expect((mock.log.requests('session/page') as { throughSeq: number }[]).map(request => request.throughSeq)).toEqual([5, 9])
+    expect(mock.log.requests('session/follow')).toEqual([{ address: 'a' }])
+    expect(mock.log.requests('$events')).toEqual([{}])
+    expect(mock.log.requests()).toEqual([{ throughSeq: 5 }, { address: 'a' }, { throughSeq: 9 }])
+    expect(RemoteMock.create().log.requests()).toEqual([])
+  })
+
+  it('loads a whole table', () => {
+    const mock = RemoteMock.create().load({
+      unary: { 'session/list': ok({ items: [] }), 'a/b': 1 },
+      stream: { 'session/control': openStream([{ type: 'baseline' }]) },
+    })
+    expect(mock.modeOf('session/list')).toBe('unary')
+    expect(mock.modeOf('a/b')).toBe('unary')
+    expect(mock.modeOf('session/control')).toBe('stream')
+    expect(() => RemoteMock.create().load({})).not.toThrow()
+    RemoteMock.create().assertNoUnmatched()
+  })
+})

+ 21 - 0
packages/test-support/remote-mock/tsconfig.json

@@ -0,0 +1,21 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../typert/protocol"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../client/connection/tsconfig.client.json"
+    }
+  ]
+}

+ 6 - 0
packages/test-support/remote-mock/tsdown.config.ts

@@ -0,0 +1,6 @@
+import { clientLibrary } from '../../client/tsdown.client.ts'
+
+export default clientLibrary(
+  '@deepseek-ai/dsh-remote-mock',
+  ['lib/types/index.js'],
+)

+ 85 - 0
pnpm-lock.yaml

@@ -1018,6 +1018,9 @@ importers:
       '@deepseek-ai/dsh-client-store':
         specifier: workspace:^
         version: link:../../client/store
+      '@deepseek-ai/dsh-client-test-runtime':
+        specifier: workspace:^
+        version: link:../../test-support/client-runtime
       '@deepseek-ai/dsh-commands':
         specifier: workspace:^
         version: link:../../interaction/commands
@@ -1042,6 +1045,9 @@ importers:
       '@deepseek-ai/dsh-permission-presets':
         specifier: workspace:^
         version: link:../../interaction/permission-presets
+      '@deepseek-ai/dsh-remote-mock':
+        specifier: workspace:^
+        version: link:../../test-support/remote-mock
       '@deepseek-ai/dsh-scope':
         specifier: workspace:^
         version: link:../../core/scope
@@ -1155,9 +1161,15 @@ importers:
       '@deepseek-ai/dsh-client-store':
         specifier: workspace:^
         version: link:../../client/store
+      '@deepseek-ai/dsh-client-test-runtime':
+        specifier: workspace:^
+        version: link:../../test-support/client-runtime
       '@deepseek-ai/dsh-host-directory-picker':
         specifier: workspace:^
         version: link:../../host/directory-picker
+      '@deepseek-ai/dsh-remote-mock':
+        specifier: workspace:^
+        version: link:../../test-support/remote-mock
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
@@ -3259,6 +3271,9 @@ importers:
       '@deepseek-ai/dsh-permission-presets':
         specifier: workspace:^
         version: link:../../interaction/permission-presets
+      '@deepseek-ai/dsh-remote-mock':
+        specifier: workspace:^
+        version: link:../../test-support/remote-mock
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
@@ -3611,6 +3626,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-remote-mock':
+        specifier: workspace:^
+        version: link:../../test-support/remote-mock
       '@deepseek-ai/dsh-settings':
         specifier: workspace:^
         version: link:../../settings/settings
@@ -3653,6 +3671,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-remote-mock':
+        specifier: workspace:^
+        version: link:../../test-support/remote-mock
       '@deepseek-ai/dsh-util-values':
         specifier: workspace:^
         version: link:../../util/values
@@ -9987,6 +10008,9 @@ importers:
       '@testing-library/react':
         specifier: ^16.3.2
         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)
+      js-yaml:
+        specifier: ^4.2.0
+        version: 4.3.1
       vitest:
         specifier: ^4.1.8
         version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(happy-dom@20.11.6)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
@@ -9994,6 +10018,18 @@ importers:
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis
+      '@deepseek-ai/cordis-plugin-include':
+        specifier: workspace:^
+        version: link:../../../vendor/include
+      '@deepseek-ai/cordis-plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@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-api-session-controller':
         specifier: workspace:^
         version: link:../../api/session-controller
@@ -10003,9 +10039,21 @@ importers:
       '@deepseek-ai/dsh-attachment':
         specifier: workspace:^
         version: link:../../attachment/attachment
+      '@deepseek-ai/dsh-base':
+        specifier: workspace:^
+        version: link:../../bundle/base
       '@deepseek-ai/dsh-client-connection':
         specifier: workspace:^
         version: link:../../client/connection
+      '@deepseek-ai/dsh-client-hmr':
+        specifier: workspace:^
+        version: link:../../client/hmr
+      '@deepseek-ai/dsh-client-locale':
+        specifier: workspace:^
+        version: link:../../client/locale
+      '@deepseek-ai/dsh-client-modules':
+        specifier: workspace:^
+        version: link:../../client/modules
       '@deepseek-ai/dsh-client-store':
         specifier: workspace:^
         version: link:../../client/store
@@ -10030,15 +10078,30 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../../client/ui-slots
+      '@deepseek-ai/dsh-client-web':
+        specifier: workspace:^
+        version: link:../../client/web
+      '@deepseek-ai/dsh-remote-mock':
+        specifier: workspace:^
+        version: link:../remote-mock
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-settings':
+        specifier: workspace:^
+        version: link:../../settings/settings
       '@deepseek-ai/dsh-subagent':
         specifier: workspace:^
         version: link:../../subagent/subagent
       '@deepseek-ai/dsh-typert-protocol':
         specifier: workspace:^
         version: link:../../typert/protocol
+      '@deepseek-ai/dsh-web-app':
+        specifier: workspace:^
+        version: link:../../bundle/web-app
+      '@types/js-yaml':
+        specifier: ^4.0.9
+        version: 4.0.9
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
@@ -10111,6 +10174,28 @@ importers:
         specifier: workspace:^
         version: link:../../core/session
 
+  packages/test-support/remote-mock:
+    dependencies:
+      '@vitest/spy':
+        specifier: ^4.1.8
+        version: 4.1.8
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-api-settings-controller':
+        specifier: workspace:^
+        version: link:../../api/settings-controller
+      '@deepseek-ai/dsh-client-connection':
+        specifier: workspace:^
+        version: link:../../client/connection
+      '@deepseek-ai/dsh-typert-protocol':
+        specifier: workspace:^
+        version: link:../../typert/protocol
+      typescript:
+        specifier: ^6.0.3
+        version: 6.0.3
+
   packages/test-support/session-snapshot:
     dependencies:
       '@agentclientprotocol/sdk':

+ 1 - 0
scripts/doc-standard.spec.ts

@@ -78,6 +78,7 @@ const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
   'packages/test-support/client-runtime': 'Browser-side test infrastructure.',
   'packages/test-support/llm-mock-server': 'Test server library; substitutes provider wire behavior.',
   'packages/test-support/loader-smoke': 'Test harness library; mounts nothing into a product composition.',
+  'packages/test-support/remote-mock': 'Browser-side test infrastructure; mounts nothing into a product composition.',
   'packages/typert/generator': 'Build-time generator run outside any agent runtime.',
   'packages/typert/protocol': 'Compiler-independent protocol declarations.',
   'packages/util/atomic-write': 'Zero-dependency filesystem write utility.',

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -175,6 +175,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/test-support/loader-smoke': { kind: 'none', reason: 'The test harness submits an ordinary user task but delegates prompt and tool composition to the loaded tree.' },
   'packages/test-support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' },
   'packages/test-support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
+  'packages/test-support/remote-mock': { kind: 'none', reason: 'Browser-side test infrastructure (Remote endpoint mock); registers nothing model-facing.' },
   'packages/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' },
   'packages/api/session-controller': { kind: 'none', reason: 'Session API and transport owner; invoked Agent commands own any model-visible effect.' },
   'packages/api/settings-controller': { kind: 'none', reason: 'Configuration-surface API owner; it registers no prompt, tool, or session event.' },

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels