Prechádzať zdrojové kódy

test(session-controller): migrate remaining Remote fixtures

imccyu 1 týždeň pred
rodič
commit
9b091919e2

+ 2 - 2
.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md
-2026-09-06-client-assembly-test-line.md: 9778ed7fea930dadeae7d786d750f53a0a387f23
-2026-09-06-client-assembly-test-line.zh.md: 944008e493a63de278543ae5c941af95943e0f41
+2026-09-06-client-assembly-test-line.md: d3ffb01302a60f7b664f6b225648180d2ec50408
+2026-09-06-client-assembly-test-line.zh.md: 66ecf460e65046eb356b0e35d0c497c6e5774e9c

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

@@ -26,7 +26,7 @@ The modules plugin reads the module system from its own `ctx.loader.internal` wh
 
 **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.
+**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. Object-layer specs use `remote` directly when they do not need the Connection; cases requiring Gateway `$stream` call `start()`. 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()`.
 
@@ -91,4 +91,4 @@ Product facts the tier surfaced and leaves as they are:
 
 ## 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, parallel client startup, instance-local module and Connection reload, and `TestClient` under jsdom and plain Node. Seven converted specs use the whole-client 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/client/ui-message-feedback/tests/`, object-layer specs pass the `messageFeedback` RemoteMock namespace directly. 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.
+`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, parallel client startup, instance-local module and Connection reload, and `TestClient` under jsdom and plain Node. 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/client/ui-message-feedback/tests/`, object-layer specs pass the `messageFeedback` RemoteMock namespace directly. In `packages/api/session-controller/tests/`, Session, queue-store, pending-submission, projection tail-page, sessions-service, and the manager cases that exercise streams start the Gateway dependency cone; the other manager cases and projection-store's manager-routing cases use the same per-test Remote fixtures without starting a client; and client-apply 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.

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

@@ -26,7 +26,7 @@ modules 插件激活时从自己的 `ctx.loader.internal` 读取模块系统。
 
 **原生 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 应答仍是两个独立操作。
+**Vitest 拥有每条测试的 mock 和客户端生命周期。** `createClientTest(plan, options)` 增加原生 `mock`、`remote` 与 `start` fixture。mock 每次新建并携带默认响应;`remote` 是它的命名空间 Proxy,显式 `start()` 留出配置启动期应答的时机,同一测试共用一个启动 Promise。不需要 Connection 的对象层 spec 直接使用 `remote`;需要 Gateway `$stream` 的用例调用 `start()`。收尾等待启动,即使断言失败也销毁成功创建的客户端、检查漏配,并拒绝后续启动。启动错误由调用方 await 观察。分别拥有多个客户端时仍用 `TestClient.start`。场景数据直接配置原生 mock;返回 mutation 应答与更新后续 describe 应答仍是两个独立操作。
 
 **`remoteDefaultResponses` 是启动期 Remote 端点的默认响应。** 这张表恰好列出 `web` roster 在没有 session、没有 workspace、默认设置下启动并渲染时会打的端点,每行注明调用方。spec 在其上叠加自己的 `RemoteTable`;新的启动期调用会在 `dispose()` 时让 spec 失败。
 
@@ -91,4 +91,4 @@ spec 起的是真插件:整个 `web` roster 冷启动约五秒、热启动远
 
 ## 测试
 
-`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理、并行客户端启动、实例局部的模块与 Connection 重载,以及 jsdom 与纯 Node 下的 `TestClient`。七条改造后的 spec 使用整机客户端档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/client/ui-message-feedback/tests/` 下,对象层 spec 直接传入 RemoteMock 的 `messageFeedback` 命名空间。`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` 流的取消状态。
+`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理、并行客户端启动、实例局部的模块与 Connection 重载,以及 jsdom 与纯 Node 下的 `TestClient`。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/client/ui-message-feedback/tests/` 下,对象层 spec 直接传入 RemoteMock 的 `messageFeedback` 命名空间。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission、projection tail-page、sessions-service 以及验证流的 manager 用例启动 Gateway 依赖锥;其余 manager 用例与 projection-store 的 manager-routing 用例直接使用同一组逐测试 Remote fixture,不启动客户端;client-apply 则启动插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。

+ 0 - 476
packages/api/session-controller/tests/fake-api.client.ts

@@ -1,476 +0,0 @@
-// Test-local programmable Remote fake (NOT the fixture: fixture is a demo
-// data source on a real clock; behavior tests need per-case responses and
-// deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl.
-import type {
-  MessageId,
-  SessionId, SessionSearchItem,
-  SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt,
-  WorkspaceId, WorkspaceView,
-} from '@deepseek-ai/dsh-api-remotes/client'
-import type {
-  SessionAddress,
-  SessionAssistantStreamBaseline,
-  SessionControlBaseline,
-  SessionControlFrame,
-  SessionFollowFrame,
-  SessionFollowRequest,
-  SessionPage,
-  SessionPageRequest,
-  SessionProjectionBaseline,
-  SessionSelectModelRequest,
-  SessionSelectModelValue,
-} from '@deepseek-ai/dsh-api-session-controller/types'
-import type { WorkspaceRemote } from '@deepseek-ai/dsh-api-workspace-controller/client'
-import type { WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-controller/types'
-import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
-import {
-  RemoteStream,
-  type RemoteStreamOptions,
-} from '@deepseek-ai/dsh-api-gateway/client'
-import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
-import { followSnapshot, pageThrough } from './remote/history.client.ts'
-
-const AVAILABLE_STREAM_CONNECTION = {
-  generation: {
-    getSnapshot: () => ({ id: 1, host: { home: '/h' } }),
-    subscribe: () => () => {},
-  },
-}
-
-/** Programmable-default workspace row (branded id, ISO-ish times). */
-function fakeWorkspace(id: string, over: Partial<WorkspaceView> = {}): WorkspaceView {
-  return {
-    workspaceId: id as WorkspaceId,
-    path: '/f/ws',
-    title: 'ws',
-    sessionIds: [],
-    createdAt: '2026-01-01T00:00:00.000Z',
-    updatedAt: '2026-01-01T00:00:00.000Z',
-    ...over,
-  }
-}
-
-function addressSessionId(address: SessionAddress): SessionId {
-  return address.kind === 'session' ? address.sessionId : address.childSessionId
-}
-
-export interface Deferred<T> {
-  promise: Promise<T>
-  resolve(value: T): void
-  reject(error: unknown): void
-}
-
-/** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */
-export function deferred<T>(): Deferred<T> {
-  let resolve!: (value: T) => void
-  let reject!: (error: unknown) => void
-  const promise = new Promise<T>((res, rej) => {
-    resolve = res
-    reject = rej
-  })
-  return { promise, resolve, reject }
-}
-
-/**
- * Successful generated Remote result for programmable domain fakes.
- * @param value - the value the Host answers with.
- * @returns the success branch of a Remote result.
- */
-export function ok<T>(value: T): RemoteResult<T> {
-  return { ok: true, value }
-}
-
-/**
- * Failed generated Remote result carrying the owner's declared failure.
- * @param error - the owner-declared failure.
- * @returns the failure branch of a Remote result.
- */
-export function err<T>(error: RemoteFailure): RemoteResult<T> {
-  return { ok: false, error }
-}
-
-type ValueStreamItem<F> =
-  | { kind: 'frame'; value: F; delivered?: () => void }
-  | { kind: 'end' }
-  | { kind: 'fail'; error: unknown }
-
-interface ValueStreamConn<F> {
-  feed(item: ValueStreamItem<F>): void
-}
-
-interface OpenValueStream<F> {
-  readonly values: AsyncGenerator<F>
-  dispose(): void
-}
-
-/**
- * Commands Remote double: the generated face delivers the carrier's outcome, so
- * a test that programs nothing sees an empty catalog and an unmatched line.
- * @returns the Remote namespaces the session cluster calls.
- */
-export type RuntimeRemotes = SessionRemotes & { readonly workspace: WorkspaceRemote }
-
-export function fakeRemote(api = new FakeApiClient()): RuntimeRemotes {
-  return api.sessionRemotes()
-}
-
-export class FakeApiClient {
-  /** Chronological call record: [method, payload]. */
-  readonly calls: { method: string; payload: unknown }[] = []
-  /** Session ids in physical follow-generation opening order. */
-  readonly followStarts: SessionId[] = []
-
-  // Programmable slots (defaults answer OK-empty); reassign per case.
-  onList: (payload: unknown) => Promise<RemoteResult<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
-  onSearch: (payload: unknown) => Promise<RemoteResult<{ items: SessionSearchItem[]; hasMore: boolean }>> =
-    () => Promise.resolve(ok({ items: [], hasMore: false }))
-  onCreate: (payload: unknown) => Promise<RemoteResult<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
-  onSelectModel: (payload: SessionSelectModelRequest) => Promise<RemoteResult<SessionSelectModelValue>> =
-    payload => Promise.resolve(ok({
-      selected: {
-        provider: payload.provider,
-        model: payload.model,
-        ...(payload.reasoningEffort === undefined
-          ? {}
-          : { reasoningEffort: payload.reasoningEffort }),
-      },
-    }))
-  onRename: (payload: unknown) => Promise<RemoteResult<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
-  onFork: (payload: unknown) => Promise<RemoteResult<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
-  onHistory: (payload: { sessionId: SessionId; throughSeq?: number; beforeSeq?: number; maxMessages?: number })
-  => Promise<RemoteResult<SessionPage & { readonly projections?: SessionProjectionBaseline }>> =
-    () => Promise.resolve(ok({ records: [], hasMore: false }))
-
-  onPrompt: (payload: unknown) => Promise<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
-  onAttachment: (payload: unknown) => Promise<RemoteResult<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
-    () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
-  onUpdateQueue: (payload: unknown) => Promise<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
-  onCancel: (payload: unknown) => Promise<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
-  onOpenWorkspacePath: (payload: unknown) => Promise<RemoteResult<{ opened: true }>> =
-    () => Promise.resolve(ok({ opened: true as const }))
-
-  private readonly followConns = new Map<SessionId, ValueStreamConn<SessionFollowFrame>[]>()
-  private readonly controlConns: ValueStreamConn<SessionControlFrame>[] = []
-  private readonly workspaceConns: ValueStreamConn<WorkspaceFollowFrame>[] = []
-  /** Optional Host opening cursor override for stale-page and reconnect tests. */
-  followCursor: number | undefined
-  controlBaseline: SessionControlBaseline = {
-    queues: {},
-    jobs: {},
-    projections: {},
-  }
-  assistantStreamBaseline: SessionAssistantStreamBaseline = {
-    revision: 0,
-  }
-  workspaceBaseline: Extract<WorkspaceFollowFrame, { type: 'baseline' }>['value'] = {
-    items: [],
-    archivedSessionIds: [],
-  }
-  lastSearchSignal: AbortSignal | undefined
-
-  onSubagentList: (payload: unknown) => Promise<RemoteResult<SubagentCatalog>>
-    = () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
-  onSubagentPrompt: (payload: unknown) => Promise<RemoteResult<SubagentPromptReceipt>>
-    = () => Promise.resolve(ok({ messageId: 'fake-message' as MessageId }))
-
-  onSubagentInterrupt: (payload: unknown) => Promise<RemoteResult<SubagentInterruptReceipt>>
-    = () => Promise.resolve(ok({ accepted: true as const }))
-
-  onWorkspaceCreate: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView; created: boolean }>> =
-    () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true }))
-
-  onWorkspaceRename: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView }>> =
-    () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
-
-  onWorkspaceDelete: (payload: unknown) => Promise<RemoteResult<{ deleted: true }>> =
-    () => Promise.resolve(ok({ deleted: true }))
-
-  onWorkspaceInsertBefore: (payload: unknown) => Promise<RemoteResult<{ workspaceIds: WorkspaceId[] }>> =
-    () => Promise.resolve(ok({ workspaceIds: [] }))
-
-  onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView }>> =
-    () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
-
-  onWorkspaceArchiveSession: (payload: unknown) => Promise<RemoteResult<{ archivedSessionIds: SessionId[] }>> =
-    payload => Promise.resolve(ok({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] }))
-
-  onWorkspaceUnarchiveSession: (payload: unknown) => Promise<RemoteResult<{ archivedSessionIds: SessionId[] }>> =
-    () => Promise.resolve(ok({ archivedSessionIds: [] }))
-
-  /** Remote namespaces bound to this fake's programmable unary slots and stream pumps. */
-  sessionRemotes(): RuntimeRemotes {
-    return {
-      $stream: <Item>(options: RemoteStreamOptions<Item>) => (
-        new RemoteStream(AVAILABLE_STREAM_CONNECTION, options)
-      ),
-      commands: {
-        execute: () => Promise.resolve({ ok: true, value: undefined }),
-      },
-      session: {
-        canOpenWorkspacePath: () => Promise.resolve(ok(true)),
-        list: payload => this.record('session.list', payload, this.onList(payload)),
-        modelCatalog: () => Promise.resolve({
-          ok: true,
-          value: {
-            default: { provider: 'fixture', model: 'fixture' },
-            routableProviders: [],
-            groups: [],
-            failures: [],
-          },
-        }),
-        search: (payload, signal) => {
-          this.lastSearchSignal = signal
-          return this.record('session.search', payload, this.onSearch(payload))
-        },
-        create: payload => this.record('session.create', payload, this.onCreate(payload)),
-        selectModel: payload => this.record(
-          'session.selectModel',
-          payload,
-          this.onSelectModel(payload),
-        ),
-        rename: payload => this.record('session.rename', payload, this.onRename(payload)),
-        fork: payload => this.record('session.fork', payload, this.onFork(payload)),
-        prompt: payload => this.record('session.prompt', payload, this.onPrompt(payload)),
-        attachment: payload => this.record('session.attachment', payload, this.onAttachment(payload)),
-        updateQueue: payload => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
-        cancel: payload => this.record('session.cancel', payload, this.onCancel(payload)),
-        openWorkspacePath: payload => this.record(
-          'session.openWorkspacePath',
-          payload,
-          this.onOpenWorkspacePath(payload),
-        ),
-        page: request => this.page(request),
-        follow: (request, signal) => this.openFollow(request, signal),
-        control: signal => this.openControl(signal),
-      },
-      subagents: {
-        list: parentSessionId => this.record(
-          'subagents.list',
-          parentSessionId,
-          this.onSubagentList(parentSessionId),
-        ),
-        prompt: request => this.record('subagents.prompt', request, this.onSubagentPrompt(request)),
-        interruptByParent: (childSessionId, parentSessionId, mode) => this.record(
-          'subagents.interruptByParent',
-          { childSessionId, parentSessionId, mode },
-          this.onSubagentInterrupt({ childSessionId, parentSessionId, mode }),
-        ),
-      },
-      workspace: {
-        create: payload => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
-        rename: payload => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
-        delete: payload => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)),
-        insertBefore: payload => this.record(
-          'workspace.insertBefore',
-          payload,
-          this.onWorkspaceInsertBefore(payload),
-        ),
-        insertSessionBefore: payload => this.record(
-          'workspace.insertSessionBefore',
-          payload,
-          this.onWorkspaceInsertSessionBefore(payload),
-        ),
-        archiveSession: payload => this.record(
-          'workspace.archiveSession',
-          payload,
-          this.onWorkspaceArchiveSession(payload),
-        ),
-        unarchiveSession: payload => this.record(
-          'workspace.unarchiveSession',
-          payload,
-          this.onWorkspaceUnarchiveSession(payload),
-        ),
-        follow: signal => this.openWorkspace(signal),
-      },
-    }
-  }
-
-  /** Push one live Session event to every follower of that Session. */
-  async pushFollow(
-    sessionId: SessionId,
-    frame: Exclude<SessionFollowFrame, { type: 'snapshot' }>,
-  ): Promise<void> {
-    await Promise.all([...(this.followConns.get(sessionId) ?? [])].map(conn => new Promise<void>((resolve) => {
-      conn.feed({ kind: 'frame', value: frame, delivered: resolve })
-    })))
-  }
-
-  /** Push one Host-wide control update. */
-  pushControl(frame: Exclude<SessionControlFrame, { type: 'baseline' }>): void {
-    for (const conn of [...this.controlConns]) conn.feed({ kind: 'frame', value: frame })
-  }
-
-  /** Push one Workspace projection increment. */
-  pushWorkspace(frame: Exclude<WorkspaceFollowFrame, { type: 'baseline' }>): void {
-    for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'frame', value: frame })
-  }
-
-  /** End (clean close) or fail (throw) every open stream — reconnect-path material. */
-  endStreams(): void {
-    for (const conns of this.followConns.values()) {
-      for (const conn of [...conns]) conn.feed({ kind: 'end' })
-    }
-    for (const conn of [...this.controlConns]) conn.feed({ kind: 'end' })
-    for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'end' })
-  }
-
-  failStreams(error: unknown): void {
-    for (const conns of this.followConns.values()) {
-      for (const conn of [...conns]) conn.feed({ kind: 'fail', error })
-    }
-    for (const conn of [...this.controlConns]) conn.feed({ kind: 'fail', error })
-    for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'fail', error })
-  }
-
-  callsOf(method: string): unknown[] {
-    return this.calls.filter(c => c.method === method).map(c => c.payload)
-  }
-
-  /** Number of currently attached journal generations for one Session. */
-  activeFollows(sessionId: SessionId): number {
-    return this.followConns.get(sessionId)?.length ?? 0
-  }
-
-  private record<T>(method: string, payload: unknown, response: Promise<T>): Promise<T> {
-    this.calls.push({ method, payload })
-    return response
-  }
-
-  private page(request: SessionPageRequest): Promise<RemoteResult<SessionPage>> {
-    return this.fetchPage(request)
-  }
-
-  private async fetchPage(
-    request: SessionPageRequest,
-    response?: Promise<RemoteResult<SessionPage>>,
-  ): Promise<RemoteResult<SessionPage>> {
-    const sessionId = addressSessionId(request.address)
-    const payload = request.address.kind === 'session'
-      ? {
-        sessionId,
-        throughSeq: request.throughSeq,
-        ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq },
-        ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages },
-      }
-      : {
-        parentSessionId: request.address.parentSessionId,
-        childSessionId: request.address.childSessionId,
-        mode: request.address.mode,
-        throughSeq: request.throughSeq,
-        ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq },
-        ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages },
-      }
-    const method = request.address.kind === 'session' ? 'session.history' : 'subagent.history'
-    const result = await this.record(method, payload, response ?? this.onHistory({
-      sessionId,
-      throughSeq: request.throughSeq,
-      ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq },
-      ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages },
-    }))
-    if (!result.ok) return result
-    return {
-      ok: true,
-      value: pageThrough(result.value, request.throughSeq),
-    }
-  }
-
-  private async *openFollow(
-    request: SessionFollowRequest,
-    signal: AbortSignal = new AbortController().signal,
-  ): AsyncGenerator<SessionFollowFrame> {
-    const sessionId = addressSessionId(request.address)
-    this.followStarts.push(sessionId)
-    this.calls.push({ method: 'session.follow', payload: request })
-    const conns = this.followConns.get(sessionId) ?? []
-    if (!this.followConns.has(sessionId)) this.followConns.set(sessionId, conns)
-    const stream = this.openValueStream(conns, signal)
-    try {
-      const response = await this.onHistory({
-        sessionId,
-        maxMessages: request.maxMessages ?? 50,
-      })
-      if (!response.ok) throw response.error
-      const page = response.value
-      yield followSnapshot(page, request, this.followCursor, this.assistantStreamBaseline)
-      yield* stream.values
-    } finally {
-      stream.dispose()
-    }
-  }
-
-  private async *openControl(
-    signal: AbortSignal = new AbortController().signal,
-  ): AsyncGenerator<SessionControlFrame> {
-    const stream = this.openValueStream(this.controlConns, signal)
-    try {
-      yield { type: 'baseline', value: this.controlBaseline }
-      yield* stream.values
-    } finally {
-      stream.dispose()
-    }
-  }
-
-  private async *openWorkspace(
-    signal: AbortSignal = new AbortController().signal,
-  ): AsyncGenerator<WorkspaceFollowFrame> {
-    const stream = this.openValueStream(this.workspaceConns, signal)
-    try {
-      yield { type: 'baseline', value: this.workspaceBaseline }
-      yield* stream.values
-    } finally {
-      stream.dispose()
-    }
-  }
-
-  private openValueStream<F>(
-    registry: ValueStreamConn<F>[],
-    signal: AbortSignal,
-  ): OpenValueStream<F> {
-    const inbox: ValueStreamItem<F>[] = []
-    let wake: (() => void) | null = null
-    let inFlightDelivered: (() => void) | undefined
-    let disposed = false
-    const conn: ValueStreamConn<F> = {
-      feed: (item) => {
-        inbox.push(item)
-        wake?.()
-      },
-    }
-    registry.push(conn)
-    const dispose = (): void => {
-      if (disposed) return
-      disposed = true
-      inFlightDelivered?.()
-      for (const item of inbox) {
-        if (item.kind === 'frame') item.delivered?.()
-      }
-      const index = registry.indexOf(conn)
-      if (index >= 0) registry.splice(index, 1)
-      wake?.()
-    }
-    const values = (async function* (): AsyncGenerator<F> {
-      try {
-        while (!signal.aborted && !disposed) {
-          while (inbox.length > 0) {
-            const item = inbox.shift() as ValueStreamItem<F>
-            if (item.kind === 'end') return
-            if (item.kind === 'fail') throw item.error
-            inFlightDelivered = item.delivered
-            yield item.value
-            inFlightDelivered?.()
-            inFlightDelivered = undefined
-          }
-          await new Promise<void>((resolve) => {
-            wake = resolve
-            signal.addEventListener('abort', () => { resolve() }, { once: true })
-          })
-          wake = null
-        }
-      } finally {
-        dispose()
-      }
-    })()
-    return { values, dispose }
-  }
-
-}

+ 202 - 224
packages/api/session-controller/tests/manager.client.spec.ts

@@ -3,18 +3,27 @@
  * frame routing, and control baselines for uninstantiated sessions.
  */
 
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, vi } from 'vitest'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
 import { SessionSeq } from '@deepseek-ai/dsh-session/types'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
+import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
+import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
+import {
+  createClientTest, type ClientTestFixtures, webApp,
+} from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
 import type {} from '@deepseek-ai/dsh-session-title/client'
 import { SessionManager } from '../src/client/sessions/manager.ts'
-import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
+import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
 import { entries, plainTurn } from './event-script.client.ts'
+import { FOLLOW, err, followScript, sessionWorld } from './remote/session.client.ts'
 
 const S1 = 'fk-m1' as SessionId
 const S2 = 'fk-m2' as SessionId
+/** A SessionManager's Remote methods use the same native mocks as an assembled client. */
+const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
+const it = createClientTest({ roster: API_ROSTER })
 
 type SummaryOver = Partial<{
   updatedAt: number
@@ -29,16 +38,21 @@ function summary(sessionId: SessionId, over: SummaryOver = {}) {
   return { sessionId, updatedAt: 100, running: false, blank: false, ...over }
 }
 
-function makeManager(): SessionManager {
-  const api = new FakeApiClient()
-  return new SessionManager(fakeRemote(api))
+function makeManager(
+  mock: RemoteMock,
+  remote: ClientTestFixtures['remote'],
+  restoredSelection?: SessionId,
+  restoredAddress?: SubagentAddress,
+): SessionManager {
+  mock.load(sessionWorld)
+  // Cases using this helper never open a Session, so they do not need the broader Client Remote's $stream member.
+  return new SessionManager(remote as unknown as SessionRemotes, restoredSelection, restoredAddress)
 }
 
 describe('SessionManager instances', () => {
-  it('lazily builds one resident instance per id and syncs the running bit from the list', async () => {
-    const api = new FakeApiClient()
-    api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
-    const manager = new SessionManager(fakeRemote(api))
+  it('lazily builds one resident instance per id and syncs the running bit from the list', async ({ mock, remote }) => {
+    remote.session.list.mockResolvedValue(ok({ items: [summary(S1, { running: true })] as never[] }))
+    const manager = makeManager(mock, remote)
     await manager.refreshList()
     const session = manager.get(S1)
     expect(manager.get(S1)).toBe(session) // resident: same instance forever
@@ -48,97 +62,90 @@ describe('SessionManager instances', () => {
 })
 
 describe('list lifecycle', () => {
-  it('single-flights refreshList and preserves the Host baseline order', async () => {
-    const api = new FakeApiClient()
-    const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
-    api.onList = () => gate.promise
-    const manager = new SessionManager(fakeRemote(api))
+  it('single-flights refreshList and preserves the Host baseline order', async ({ mock, remote }) => {
+    const gate = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
+    remote.session.list.mockReturnValue(gate.promise)
+    const manager = makeManager(mock, remote)
     const first = manager.refreshList()
     const second = manager.refreshList()
     expect(manager.getListSnapshot().state).toBe('loading')
     gate.resolve(ok({ items: [summary(S2, { updatedAt: 200 }), summary(S1)] as never[] }))
     await Promise.all([first, second])
-    expect(api.callsOf('session.list')).toHaveLength(1)
+    expect(remote.session.list).toHaveBeenCalledOnce()
     const snapshot = manager.getListSnapshot()
     expect(snapshot.state).toBe('idle')
     expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1])
   })
 
-  it('replays incremental frames over hydration and never batch-reorders established ids', async () => {
-    const api = new FakeApiClient()
-    const first = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
-    api.onList = () => first.promise
-    const manager = new SessionManager(fakeRemote(api))
+  it('replays incremental frames over hydration and never batch-reorders established ids', async ({ mock, remote }) => {
+    const first = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
+    remote.session.list.mockReturnValue(first.promise)
+    const manager = makeManager(mock, remote)
     const hydration = manager.refreshList()
     manager.handleSessionAdded(summary(S2, { blank: true }))
     first.resolve(ok({ items: [summary(S1)] as never[] }))
     await hydration
     expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
 
-    api.onList = () => Promise.resolve(ok({
+    remote.session.list.mockResolvedValue(ok({
       items: [summary(S1, { updatedAt: 900 }), summary(S2, { updatedAt: 800 })] as never[],
     }))
     await manager.refreshList()
     expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
   })
 
-  it('advances list activity from the filtered Host notification', async () => {
-    const api = new FakeApiClient()
-    api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
-    const manager = new SessionManager(fakeRemote(api))
+  it('advances list activity from the filtered Host notification', async ({ mock, remote }) => {
+    remote.session.list.mockResolvedValue(ok({ items: [summary(S1)] as never[] }))
+    const manager = makeManager(mock, remote)
     await manager.refreshList()
 
     manager.handleSessionActivity(S1, 500)
     expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500)
   })
 
-  it('keeps the error in the list snapshot on failure', async () => {
-    const api = new FakeApiClient()
-    api.onList = () => Promise.resolve(err(new RemoteError('gateway/internal', 'boom', {})))
-    const manager = new SessionManager(fakeRemote(api))
+  it('keeps the error in the list snapshot on failure', async ({ mock, remote }) => {
+    remote.session.list.mockResolvedValue(err(new RemoteError('gateway/internal', 'boom', {})))
+    const manager = makeManager(mock, remote)
     await manager.refreshList()
     expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'gateway/internal' } })
     // A failed pull does not step the arrival phase: still pending.
     expect(manager.getListSnapshot().phase).toBe('pending')
   })
 
-  it('phase steps pending → ready on the first successful pull and never returns', async () => {
-    const api = new FakeApiClient()
-    const manager = new SessionManager(fakeRemote(api))
+  it('phase steps pending → ready on the first successful pull and never returns', async ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     expect(manager.getListSnapshot().phase).toBe('pending')
     await manager.refreshList()
     expect(manager.getListSnapshot().phase).toBe('ready')
     // Sticky across later failures: the pull-activity axis reports the error,
     // the arrival phase holds.
-    api.onList = () => Promise.resolve(err(new RemoteError('gateway/internal', 'down', {})))
+    remote.session.list.mockResolvedValue(err(new RemoteError('gateway/internal', 'down', {})))
     await manager.refreshList()
     expect(manager.getListSnapshot()).toMatchObject({ state: 'error', phase: 'ready' })
     // And across an empty re-pull (empty-with-ready = truly no sessions).
-    api.onList = () => Promise.resolve(ok({ items: [] as never[] }))
+    remote.session.list.mockResolvedValue(ok({ items: [] as never[] }))
     await manager.refreshList()
     expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', phase: 'ready' })
     expect(manager.getListSnapshot().items).toEqual([])
   })
 
-  it('merges create into the list immediately without waiting for a refresh', async () => {
-    const api = new FakeApiClient()
-    api.onCreate = () => Promise.resolve(ok({ sessionId: S2 }))
-    const manager = new SessionManager(fakeRemote(api))
+  it('merges create into the list immediately without waiting for a refresh', async ({ mock, remote }) => {
+    remote.session.create.mockResolvedValue(ok({ sessionId: S2 }))
+    const manager = makeManager(mock, remote)
     const result = await manager.create()
     expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
     expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
   })
 
-  it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => {
-    const api = new FakeApiClient()
-    const manager = new SessionManager(fakeRemote(api))
+  it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     const titleFrame = (title: string, seq: number) => {
       manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', value: title, seq })
     }
     titleFrame('Newest', 4)
     titleFrame('Stale', 3)
     titleFrame('Equal', 4)
-    api.onList = () => Promise.resolve(ok({
+    remote.session.list.mockResolvedValue(ok({
       items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[],
     }))
     await manager.refreshList()
@@ -153,14 +160,13 @@ describe('list lifecycle', () => {
     expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
   })
 
-  it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
-    const api = new FakeApiClient()
-    const manager = new SessionManager(fakeRemote(api))
+  it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     // A push frame landed before the list (S2's title is newer than the block's cut).
     manager.handleControlFrame({
       type: 'projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9,
     })
-    api.onList = () => Promise.resolve(ok({
+    remote.session.list.mockResolvedValue(ok({
       items: [
         { ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } },
         { ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } },
@@ -174,10 +180,9 @@ describe('list lifecycle', () => {
     expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed')
   })
 
-  it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => {
-    const api = new FakeApiClient()
-    api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
-    const manager = new SessionManager(fakeRemote(api))
+  it('drops a projection row beyond the subscription baseline before accepting its durable replay', async ({ mock, remote }) => {
+    remote.session.list.mockResolvedValue(ok({ items: [summary(S1)] as never[] }))
+    const manager = makeManager(mock, remote)
     await manager.refreshList()
     const frame = (payload: SessionControlFrame) => { manager.handleControlFrame(payload) }
     frame({ type: 'projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 })
@@ -209,13 +214,12 @@ describe('list lifecycle', () => {
 })
 
 describe('search', () => {
-  it('returns bounded Host results and forwards the caller signal', async () => {
-    const api = new FakeApiClient()
-    api.onSearch = () => Promise.resolve(ok({
+  it('returns bounded Host results and forwards the caller signal', async ({ mock, remote }) => {
+    remote.session.search.mockResolvedValue(ok({
       items: [{ sessionId: S1, snippet: 'matching excerpt' }],
       hasMore: true,
     }))
-    const manager = new SessionManager(fakeRemote(api))
+    const manager = makeManager(mock, remote)
     const signal = new AbortController().signal
 
     await expect(manager.search('exact phrase', signal)).resolves.toEqual({
@@ -225,29 +229,26 @@ describe('search', () => {
         hasMore: true,
       },
     })
-    expect(api.callsOf('session.search')).toEqual([{ query: 'exact phrase' }])
-    expect(api.lastSearchSignal).toBe(signal)
+    expect(remote.session.search).toHaveBeenCalledWith({ query: 'exact phrase' }, signal)
   })
 
-  it('preserves business errors and propagates a non-Remote throw', async () => {
-    const api = new FakeApiClient()
-    const manager = new SessionManager(fakeRemote(api))
-    api.onSearch = () => Promise.resolve(err(new RemoteError('gateway/internal', 'index unavailable', {})))
+  it('preserves business errors and propagates a non-Remote throw', async ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
+    remote.session.search.mockResolvedValue(err(new RemoteError('gateway/internal', 'index unavailable', {})))
     const signal = new AbortController().signal
     await expect(manager.search('first', signal)).resolves.toMatchObject({
       ok: false,
       error: { code: 'gateway/internal', message: 'index unavailable' },
     })
 
-    api.onSearch = () => Promise.reject(new Error('wire down'))
+    remote.session.search.mockRejectedValue(new Error('wire down'))
     await expect(manager.search('second', signal)).rejects.toThrow('wire down')
   })
 })
 
 describe('Host Remote event routing', () => {
-  it('adds/removes/flips sessions and keeps removed instances resident', async () => {
-    const api = new FakeApiClient()
-    const manager = new SessionManager(fakeRemote(api))
+  it('adds/removes/flips sessions and keeps removed instances resident', async ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     manager.handleSessionAdded(summary(S1, { blank: true }))
     manager.handleSessionAdded(summary(S1, { blank: true })) // dup: ignored
     expect(manager.getListSnapshot().items).toHaveLength(1)
@@ -268,20 +269,21 @@ describe('Host Remote event routing', () => {
 })
 
 describe('subagent catalogs', () => {
-  it('keeps a catalog-discovered child address across ordinary selection and status frames', async () => {
-    const api = new FakeApiClient()
-    api.onList = () => Promise.resolve(ok({ items: [
+  it('keeps a catalog-discovered child address across ordinary selection and status frames', async ({ mock, remote, start }) => {
+    remote.session.list.mockResolvedValue(ok({ items: [
       summary(S1),
       summary(S2, { parentSessionId: S1, origin: 'subagent' }),
     ] as never[] }))
-    api.onSubagentList = () => Promise.resolve(ok({
+    remote.subagents.list.mockResolvedValue(ok({
       entries: [{
         kind: 'child', id: S2, mode: 'continuable', label: 'worker',
         activity: 'running', hasChildren: false,
       }] as never[],
       parentAvailable: true,
     }))
-    const manager = new SessionManager(fakeRemote(api))
+    mock.load(sessionWorld)
+    const client = await start()
+    const manager = new SessionManager(client.ctx.remote)
     await manager.refreshList()
     await manager.refreshSubagents(S1)
     manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
@@ -305,7 +307,7 @@ describe('subagent catalogs', () => {
     })
     await manager.get(S2).open()
     await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue')
-    expect(api.callsOf('session.follow')).toEqual([
+    expect(remote.session.follow.mock.calls.map(([request]) => request)).toEqual([
       {
         address: {
           kind: 'subagent', parentSessionId: S1, childSessionId: S2, mode: 'continuable',
@@ -314,8 +316,8 @@ describe('subagent catalogs', () => {
         maxMessages: 50,
       },
     ])
-    expect(api.callsOf('subagent.history')).toEqual([])
-    expect(api.callsOf('subagents.prompt')).toEqual([
+    expect(remote.session.page).not.toHaveBeenCalled()
+    expect(remote.subagents.prompt.mock.calls.map(([request]) => request)).toEqual([
       {
         requestId: expect.any(String) as unknown as string,
         parentSessionId: S1, childSessionId: S2,
@@ -325,14 +327,13 @@ describe('subagent catalogs', () => {
         clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
       },
     ])
-    expect(api.callsOf('session.history')).toEqual([])
-    expect(api.callsOf('session.prompt')).toEqual([])
-    const listCalls = api.callsOf('subagents.list').length
+    expect(remote.session.prompt).not.toHaveBeenCalled()
+    const listCalls = remote.subagents.list.mock.calls.length
     manager.handleSessionStatus(S2, false)
     expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({
       kind: 'child', id: S2, activity: 'inactive',
     })
-    expect(api.callsOf('subagents.list')).toHaveLength(listCalls)
+    expect(remote.subagents.list).toHaveBeenCalledTimes(listCalls)
 
     manager.handleSessionRemoved(S2)
     expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({
@@ -346,33 +347,31 @@ describe('subagent catalogs', () => {
     })
   })
 
-  it('refetches debounced membership only while the parent catalog is open', async () => {
+  it('refetches debounced membership only while the parent catalog is open', async ({ mock, remote }) => {
     vi.useFakeTimers()
     try {
-      const api = new FakeApiClient()
-      const manager = new SessionManager(fakeRemote(api))
+      const manager = makeManager(mock, remote)
       await manager.refreshSubagents(S1)
       manager.setSubagentCatalogOpen(S1, true)
       await Promise.resolve()
-      const baseline = api.callsOf('subagents.list').length
+      const baseline = remote.subagents.list.mock.calls.length
       manager.handleSessionAdded(summary(S2, { parentSessionId: S1 }))
       manager.handleSessionAdded(summary('fk-m3' as SessionId, { parentSessionId: S1 }))
       await vi.advanceTimersByTimeAsync(50)
-      expect(api.callsOf('subagents.list')).toHaveLength(baseline + 1)
+      expect(remote.subagents.list).toHaveBeenCalledTimes(baseline + 1)
 
       manager.setSubagentCatalogOpen(S1, false)
       manager.handleSessionAdded(summary('fk-m4' as SessionId, { parentSessionId: S1 }))
       await vi.advanceTimersByTimeAsync(50)
-      expect(api.callsOf('subagents.list')).toHaveLength(baseline + 1)
+      expect(remote.subagents.list).toHaveBeenCalledTimes(baseline + 1)
     } finally {
       vi.useRealTimers()
     }
   })
 
-  it('marks a loaded parent row expandable only for a direct subagent publication', async () => {
-    const api = new FakeApiClient()
+  it('marks a loaded parent row expandable only for a direct subagent publication', async ({ mock, remote }) => {
     const root = 'fk-root' as SessionId
-    api.onSubagentList = () => Promise.resolve(ok({
+    remote.subagents.list.mockResolvedValue(ok({
       entries: [
         {
           kind: 'child', id: S1, mode: 'continuable', label: 'parent',
@@ -385,7 +384,7 @@ describe('subagent catalogs', () => {
       ] as never[],
       parentAvailable: true,
     }))
-    const manager = new SessionManager(fakeRemote(api))
+    const manager = makeManager(mock, remote)
     await manager.refreshSubagents(root)
 
     manager.handleSessionAdded(summary('fk-grandchild' as SessionId, {
@@ -399,12 +398,11 @@ describe('subagent catalogs', () => {
     ])
   })
 
-  it('preserves a live expandability hint across only the older in-flight catalog response', async () => {
-    const api = new FakeApiClient()
+  it('preserves a live expandability hint across only the older in-flight catalog response', async ({ mock, remote }) => {
     const root = 'fk-root' as SessionId
-    const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
-    api.onSubagentList = () => response.promise
-    const manager = new SessionManager(fakeRemote(api))
+    const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
+    remote.subagents.list.mockReturnValue(response.promise)
+    const manager = makeManager(mock, remote)
     const refresh = manager.refreshSubagents(root)
 
     manager.handleSessionAdded(summary('fk-grandchild' as SessionId, {
@@ -423,7 +421,7 @@ describe('subagent catalogs', () => {
       { kind: 'child', id: S1, hasChildren: true },
     ])
 
-    api.onSubagentList = () => Promise.resolve(ok({
+    remote.subagents.list.mockResolvedValue(ok({
       entries: [{
         kind: 'child', id: S1, mode: 'continuable', label: 'parent',
         activity: 'inactive', hasChildren: false,
@@ -436,12 +434,11 @@ describe('subagent catalogs', () => {
     ])
   })
 
-  it('replays status frames over an older in-flight catalog response', async () => {
-    const api = new FakeApiClient()
+  it('replays status frames over an older in-flight catalog response', async ({ mock, remote }) => {
     const root = 'fk-root' as SessionId
-    const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
-    api.onSubagentList = () => response.promise
-    const manager = new SessionManager(fakeRemote(api))
+    const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
+    remote.subagents.list.mockReturnValue(response.promise)
+    const manager = makeManager(mock, remote)
     const refresh = manager.refreshSubagents(root)
 
     manager.handleSessionStatus(S1, false)
@@ -467,16 +464,15 @@ describe('subagent catalogs', () => {
     ])
   })
 
-  it('marks a detached catalog child inactive without requiring a selected address', async () => {
-    const api = new FakeApiClient()
-    api.onSubagentList = () => Promise.resolve(ok({
+  it('marks a detached catalog child inactive without requiring a selected address', async ({ mock, remote }) => {
+    remote.subagents.list.mockResolvedValue(ok({
       entries: [{
         kind: 'child', id: S2, mode: 'continuable', label: 'worker',
         activity: 'running', hasChildren: false,
       }] as never[],
       parentAvailable: true,
     }))
-    const manager = new SessionManager(fakeRemote(api))
+    const manager = makeManager(mock, remote)
     await manager.refreshSubagents(S1)
 
     manager.handleSessionRemoved(S2)
@@ -486,31 +482,29 @@ describe('subagent catalogs', () => {
     ])
   })
 
-  it('coalesces overlapping catalog reads without scheduling a trailing pull', async () => {
-    const api = new FakeApiClient()
+  it('coalesces overlapping catalog reads without scheduling a trailing pull', async ({ mock, remote }) => {
     const root = 'fk-root' as SessionId
-    const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
-    api.onSubagentList = () => first.promise
-    const manager = new SessionManager(fakeRemote(api))
+    const first = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
+    remote.subagents.list.mockReturnValue(first.promise)
+    const manager = makeManager(mock, remote)
 
     const refresh = manager.refreshSubagents(root)
     expect(manager.refreshSubagents(root)).toBe(refresh)
-    api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
+    remote.subagents.list.mockResolvedValue(ok({ entries: [], parentAvailable: true }))
     first.resolve(ok({ entries: [], parentAvailable: true }))
     await refresh
 
-    expect(api.callsOf('subagents.list')).toHaveLength(1)
+    expect(remote.subagents.list).toHaveBeenCalledOnce()
   })
 
-  it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => {
+  it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async ({ mock, remote }) => {
     vi.useFakeTimers()
     try {
-      const api = new FakeApiClient()
       const root = 'fk-root' as SessionId
-      const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
-      const second = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
-      api.onSubagentList = () => first.promise
-      const manager = new SessionManager(fakeRemote(api), root)
+      const first = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
+      const second = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
+      remote.subagents.list.mockReturnValue(first.promise)
+      const manager = makeManager(mock, remote, root)
       const refresh = manager.refreshSubagents(root)
       manager.setSubagentCatalogOpen(root, true)
 
@@ -520,7 +514,7 @@ describe('subagent catalogs', () => {
       // queue one trailing pull carrying the change.
       manager.handleSessionAdded(summary(S2, { parentSessionId: root }))
       await vi.advanceTimersByTimeAsync(50)
-      api.onSubagentList = () => second.promise
+      remote.subagents.list.mockReturnValueOnce(second.promise)
       first.resolve(ok({
         entries: [{
           kind: 'child', id: S1, mode: 'continuable', label: 'older',
@@ -547,7 +541,7 @@ describe('subagent catalogs', () => {
       // The Remote face resolves one microtask after the response settles.
       await vi.advanceTimersByTimeAsync(0)
 
-      expect(api.callsOf('subagents.list')).toHaveLength(2)
+      expect(remote.subagents.list).toHaveBeenCalledTimes(2)
       expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
         { kind: 'child', id: S1, label: 'older' },
         { kind: 'child', id: S2, label: 'new child' },
@@ -557,16 +551,15 @@ describe('subagent catalogs', () => {
     }
   })
 
-  it('keeps removal invalidation across a stale success and failed trailing pull', async () => {
-    const api = new FakeApiClient()
+  it('keeps removal invalidation across a stale success and failed trailing pull', async ({ mock, remote }) => {
     const root = 'fk-root' as SessionId
     const child = () => ({
       kind: 'child' as const, id: S2, mode: 'continuable' as const, label: 'worker',
       activity: 'inactive' as const, hasChildren: false,
     })
-    const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
-    api.onSubagentList = () => first.promise
-    const manager = new SessionManager(fakeRemote(api))
+    const first = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
+    remote.subagents.list.mockReturnValue(first.promise)
+    const manager = makeManager(mock, remote)
     const refresh = manager.refreshSubagents(root)
     first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
     await refresh
@@ -574,12 +567,12 @@ describe('subagent catalogs', () => {
 
     // The removal lands while a second pull is in flight: the invalidation
     // must survive the pre-removal ok response, so one trailing pull runs.
-    const mid = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
-    api.onSubagentList = () => mid.promise
+    const mid = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
+    remote.subagents.list.mockReturnValueOnce(mid.promise)
     const midRefresh = manager.refreshSubagents(root)
     manager.handleSessionRemoved(root)
-    const trailing = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
-    api.onSubagentList = () => trailing.promise
+    const trailing = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
+    remote.subagents.list.mockReturnValueOnce(trailing.promise)
     mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
     await midRefresh
     expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
@@ -593,23 +586,22 @@ describe('subagent catalogs', () => {
       })
     })
 
-    const rootCalls = api.callsOf('subagents.list').filter(call => call === root)
+    const rootCalls = remote.subagents.list.mock.calls.filter(([call]) => call === root)
     expect(rootCalls).toHaveLength(3)
     expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
     expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
   })
 
-  it('invalidates catalog availability when the owning parent is removed', async () => {
-    const api = new FakeApiClient()
+  it('invalidates catalog availability when the owning parent is removed', async ({ mock, remote }) => {
     const root = 'fk-root' as SessionId
-    api.onSubagentList = () => Promise.resolve(ok({
+    remote.subagents.list.mockResolvedValue(ok({
       entries: [{
         kind: 'child', id: S2, mode: 'continuable', label: 'worker',
         activity: 'inactive', hasChildren: false,
       }] as never[],
       parentAvailable: true,
     }))
-    const manager = new SessionManager(fakeRemote(api))
+    const manager = makeManager(mock, remote)
     await manager.refreshSubagents(root)
     manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
     expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true })
@@ -622,56 +614,51 @@ describe('subagent catalogs', () => {
 })
 
 describe('remaining branches', () => {
-  it('refreshList propagates a non-Remote throw', async () => {
-    const api = new FakeApiClient()
-    api.onList = () => Promise.reject(new Error('list wire down'))
-    const manager = new SessionManager(fakeRemote(api))
+  it('refreshList propagates a non-Remote throw', async ({ mock, remote }) => {
+    remote.session.list.mockRejectedValue(new Error('list wire down'))
+    const manager = makeManager(mock, remote)
     await expect(manager.refreshList()).rejects.toThrow('list wire down')
   })
 
-  it('refreshList pushes running bits down to already-instantiated sessions', async () => {
-    const api = new FakeApiClient()
-    const manager = new SessionManager(fakeRemote(api))
+  it('refreshList pushes running bits down to already-instantiated sessions', async ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     const session = manager.get(S1)
-    api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
+    remote.session.list.mockResolvedValue(ok({ items: [summary(S1, { running: true })] as never[] }))
     await manager.refreshList()
     expect(session.getSnapshot().running).toBe(true)
   })
 
-  it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => {
-    const api = new FakeApiClient()
-    api.onCreate = () => Promise.resolve(ok({ sessionId: S1 }))
-    const manager = new SessionManager(fakeRemote(api))
+  it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async ({ mock, remote }) => {
+    remote.session.create.mockResolvedValue(ok({ sessionId: S1 }))
+    const manager = makeManager(mock, remote)
     await manager.create({ cwd: '/tmp/w', sessionId: S1 })
-    expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }])
+    expect(remote.session.create).toHaveBeenCalledWith({ cwd: '/tmp/w', sessionId: S1 })
     expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
     await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row
     expect(manager.getListSnapshot().items).toHaveLength(1)
-    api.onCreate = () => Promise.reject(new Error('create wire down'))
+    remote.session.create.mockRejectedValue(new Error('create wire down'))
     await expect(manager.create()).rejects.toThrow('create wire down')
     // Business error passes through untouched.
-    api.onCreate = () => Promise.resolve(err(new RemoteError('gateway/internal', 'no', {})))
+    remote.session.create.mockResolvedValue(err(new RemoteError('gateway/internal', 'no', {})))
     expect(await manager.create()).toMatchObject({ ok: false })
   })
 
-  it('publishes a real Ungrouped summary from workspace-attach-failed', async () => {
-    const api = new FakeApiClient()
-    api.onCreate = () => Promise.resolve(err(new RemoteError('session/workspace-attach-failed', 'published but unattached', {
+  it('publishes a real Ungrouped summary from workspace-attach-failed', async ({ mock, remote }) => {
+    remote.session.create.mockResolvedValue(err(new RemoteError('session/workspace-attach-failed', 'published but unattached', {
       sessionId: S1, workspaceId: 'w1',
     })))
-    const manager = new SessionManager(fakeRemote(api))
+    const manager = makeManager(mock, remote)
     const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
     expect(result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } })
     expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })])
     expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd')
   })
 
-  it('reconciles a fork child published before workspace attachment fails', async () => {
-    const api = new FakeApiClient()
-    api.onFork = () => Promise.resolve(err(new RemoteError('session/workspace-attach-failed', 'forked but unattached', {
+  it('reconciles a fork child published before workspace attachment fails', async ({ mock, remote }) => {
+    remote.session.fork.mockResolvedValue(err(new RemoteError('session/workspace-attach-failed', 'forked but unattached', {
       sessionId: S2, workspaceId: 'w1',
     })))
-    const manager = new SessionManager(fakeRemote(api))
+    const manager = makeManager(mock, remote)
     const result = await manager.fork({ sessionId: S1 })
     expect(result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } })
     expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
@@ -681,10 +668,9 @@ describe('remaining branches', () => {
     })])
   })
 
-  it('reconciles a preallocated id after an ordinary transport failure', async () => {
-    const api = new FakeApiClient()
-    api.onCreate = () => Promise.reject(new Error('response lost'))
-    const manager = new SessionManager(fakeRemote(api))
+  it('reconciles a preallocated id after an ordinary transport failure', async ({ mock, remote }) => {
+    remote.session.create.mockRejectedValue(new Error('response lost'))
+    const manager = makeManager(mock, remote)
     await expect(manager.create({ workspaceId: 'w1' as never, sessionId: S1 }))
       .rejects.toThrow('response lost')
     expect(manager.getListSnapshot().items).toEqual([])
@@ -697,9 +683,8 @@ describe('remaining branches', () => {
     expect(manager.getListSnapshot().items).toHaveLength(1)
   })
 
-  it('subscribe notifies on list changes and stops after unsubscribe', async () => {
-    const api = new FakeApiClient()
-    const manager = new SessionManager(fakeRemote(api))
+  it('subscribe notifies on list changes and stops after unsubscribe', async ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     let notified = 0
     const unsubscribe = manager.subscribe(() => { notified++ })
     await manager.refreshList()
@@ -712,17 +697,15 @@ describe('remaining branches', () => {
     expect(notified).toBe(seen)
   })
 
-  it('ignores Host status and error events for sessions without an instance', () => {
-    const api = new FakeApiClient()
-    const manager = new SessionManager(fakeRemote(api))
+  it('ignores Host status and error events for sessions without an instance', ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     manager.handleSessionStatus(S2, true)
     manager.handleSessionError(S2, '无实例')
   })
 
-  it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => {
-    const api = new FakeApiClient()
-    api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
-    const manager = new SessionManager(fakeRemote(api))
+  it('keeps list-entry identity for unchanged rows across an unrelated list change', async ({ mock, remote }) => {
+    remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
+    const manager = makeManager(mock, remote)
     await manager.refreshList()
     const before = manager.getListSnapshot()
     manager.handleSessionStatus(S2, true)
@@ -736,9 +719,8 @@ describe('remaining branches', () => {
     expect(manager.getListSnapshot().items).toBe(after.items)
   })
 
-  it('carries parentSessionId from the added event into the lineage row', () => {
-    const api = new FakeApiClient()
-    const manager = new SessionManager(fakeRemote(api))
+  it('carries parentSessionId from the added event into the lineage row', ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     manager.handleSessionAdded(summary(S1, { blank: true }))
     manager.handleSessionAdded(summary(S2, {
       blank: true, parentSessionId: S1, origin: 'subagent',
@@ -751,34 +733,34 @@ describe('remaining branches', () => {
 })
 
 describe('connected generation', () => {
-  it('refreshes query baselines without rebuilding independently resumed Session sources', async () => {
-    const api = new FakeApiClient()
-    api.onHistory = () => Promise.resolve(ok({
+  it('refreshes query baselines without rebuilding independently resumed Session sources', async ({ mock, remote, start }) => {
+    mock.stream(FOLLOW, followScript(ok({
       records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[],
       hasMore: false,
       modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
-    }))
-    const manager = new SessionManager(fakeRemote(api))
+    })))
+    const client = await start()
+    const manager = new SessionManager(client.ctx.remote)
     const openedSession = manager.get(S1)
     await openedSession.open()
     manager.get(S2) // instantiated but never opened
-    const historyCallsBefore = api.callsOf('session.history').length
+    const historyCallsBefore = remote.session.page.mock.calls.length
     manager.handleConnected()
     await vi.waitFor(() => {
-      expect(api.callsOf('session.list').length).toBe(1)
+      expect(remote.session.list).toHaveBeenCalledOnce()
     })
-    expect(api.callsOf('session.history')).toHaveLength(historyCallsBefore)
+    expect(remote.session.follow).toHaveBeenCalledOnce()
+    expect(remote.session.page).toHaveBeenCalledTimes(historyCallsBefore)
   })
 
-  it('retains the durable parent address and refreshes its catalogs across reconnect', async () => {
-    const api = new FakeApiClient()
+  it('retains the durable parent address and refreshes its catalogs across reconnect', async ({ mock, remote }) => {
     const address = {
       parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const,
     }
-    const parent = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
-    const child = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
-    api.onSubagentList = payload => (payload === S1 ? parent.promise : child.promise)
-    const manager = new SessionManager(fakeRemote(api), S2, address)
+    const parent = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
+    const child = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
+    remote.subagents.list.mockImplementation(payload => (payload === S1 ? parent.promise : child.promise))
+    const manager = makeManager(mock, remote, S2, address)
 
     manager.handleConnected()
     expect(manager.get(S2).getSnapshot().subagent).toEqual({ address })
@@ -786,10 +768,10 @@ describe('connected generation', () => {
     child.resolve(ok({ entries: [], parentAvailable: true }))
 
     await vi.waitFor(() => {
-      expect(api.callsOf('session.list')).toHaveLength(1)
+      expect(remote.session.list).toHaveBeenCalledOnce()
     })
     await vi.waitFor(() => {
-      expect(api.callsOf('subagents.list')).toEqual([S1, S2])
+      expect(remote.subagents.list.mock.calls.map(([parentSessionId]) => parentSessionId)).toEqual([S1, S2])
     })
     expect(manager.get(S2).getSnapshot().subagent).toEqual({
       address,
@@ -809,8 +791,8 @@ describe('completed reminder', () => {
   const entry = (manager: SessionManager, sessionId: SessionId) =>
     manager.getListSnapshot().items.find(item => item.sessionId === sessionId)
 
-  it('arms on a running→idle flip of a non-selected session and clears on select', () => {
-    const manager = makeManager()
+  it('arms on a running→idle flip of a non-selected session and clears on select', ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     added(manager, S1)
     added(manager, S2)
     manager.select(S1)
@@ -823,8 +805,8 @@ describe('completed reminder', () => {
     expect(entry(manager, S2)?.completed).toBe(false)
   })
 
-  it('never arms for the session being watched and re-arms after a switch-away re-run', () => {
-    const manager = makeManager()
+  it('never arms for the session being watched and re-arms after a switch-away re-run', ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     added(manager, S1)
     added(manager, S2)
     manager.select(S2)
@@ -838,8 +820,8 @@ describe('completed reminder', () => {
     expect(entry(manager, S2)?.completed).toBe(true)
   })
 
-  it('a re-run disarms the reminder while running and re-arms on its completion', () => {
-    const manager = makeManager()
+  it('a re-run disarms the reminder while running and re-arms on its completion', ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     added(manager, S1)
     added(manager, S2)
     manager.select(S1)
@@ -853,8 +835,8 @@ describe('completed reminder', () => {
     expect(entry(manager, S2)?.completed).toBe(true)
   })
 
-  it('session-removed drops the reminder and a re-add starts clean', () => {
-    const manager = makeManager()
+  it('session-removed drops the reminder and a re-add starts clean', ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     added(manager, S1)
     added(manager, S2)
     manager.select(S1)
@@ -867,35 +849,32 @@ describe('completed reminder', () => {
     expect(entry(manager, S2)?.completed).toBe(false)
   })
 
-  it('a list refresh carrying the running→idle transition arms the reminder', async () => {
-    const api = new FakeApiClient()
-    api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
-    const manager = new SessionManager(fakeRemote(api))
+  it('a list refresh carrying the running→idle transition arms the reminder', async ({ mock, remote }) => {
+    remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
+    const manager = makeManager(mock, remote)
     await manager.refreshList()
     manager.select(S1)
     expect(entry(manager, S2)?.completed).toBe(false)
-    api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: false })] as never[] }))
+    remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: false })] as never[] }))
     await manager.refreshList()
     expect(entry(manager, S2)?.completed).toBe(true)
   })
 
-  it('never arms for sessions already idle at first observation', async () => {
-    const api = new FakeApiClient()
-    api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
-    const manager = new SessionManager(fakeRemote(api))
+  it('never arms for sessions already idle at first observation', async ({ mock, remote }) => {
+    remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
+    const manager = makeManager(mock, remote)
     await manager.refreshList()
     manager.select(S1)
     expect(entry(manager, S2)?.completed).toBe(false)
-    api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 201 })] as never[] }))
+    remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 201 })] as never[] }))
     await manager.refreshList()
     expect(entry(manager, S2)?.completed).toBe(false)
   })
 
-  it('arms a completion that happened during an in-flight first pull (baseline running, replayed idle)', async () => {
-    const api = new FakeApiClient()
-    const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
-    api.onList = () => gate.promise
-    const manager = new SessionManager(fakeRemote(api))
+  it('arms a completion that happened during an in-flight first pull (baseline running, replayed idle)', async ({ mock, remote }) => {
+    const gate = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
+    remote.session.list.mockReturnValue(gate.promise)
+    const manager = makeManager(mock, remote)
     const refresh = manager.refreshList()
     // The session finishes while the first pull is still in flight; the pull
     // response recorded it as running at pull time.
@@ -905,11 +884,10 @@ describe('completed reminder', () => {
     expect(entry(manager, S2)?.completed).toBe(true)
   })
 
-  it('arms when a session ran and completed entirely between in-flight mutations (baseline idle)', async () => {
-    const api = new FakeApiClient()
-    const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
-    api.onList = () => gate.promise
-    const manager = new SessionManager(fakeRemote(api))
+  it('arms when a session ran and completed entirely between in-flight mutations (baseline idle)', async ({ mock, remote }) => {
+    const gate = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
+    remote.session.list.mockReturnValue(gate.promise)
+    const manager = makeManager(mock, remote)
     const refresh = manager.refreshList()
     // The unknown session starts and finishes while the first pull is in
     // flight; the pull-time baseline recorded it idle, so the running→idle
@@ -933,8 +911,8 @@ describe('background-job mirror', () => {
     type: 'jobs', sessionId, jobs: jobs as never,
   })
 
-  it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => {
-    const manager = makeManager()
+  it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     manager.handleControlFrame(tasksFrame(S1, [view()]))
     manager.handleControlFrame(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })]))
     const first = manager.getListSnapshot().jobsBySession
@@ -946,16 +924,16 @@ describe('background-job mirror', () => {
     expect(manager.getListSnapshot().jobsBySession[S1]).toEqual([view({ status: 'completed' })])
   })
 
-  it('stores an emptied set as an absent key so absence and [] read alike', () => {
-    const manager = makeManager()
+  it('stores an emptied set as an absent key so absence and [] read alike', ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     manager.handleControlFrame(tasksFrame(S1, [view()]))
     expect(S1 in manager.getListSnapshot().jobsBySession).toBe(true)
     manager.handleControlFrame(tasksFrame(S1, []))
     expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
   })
 
-  it('clears the mirror when the next control baseline has no jobs', () => {
-    const manager = makeManager()
+  it('clears the mirror when the next control baseline has no jobs', ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     manager.handleControlFrame(tasksFrame(S1, [view()]))
     manager.handleControlFrame({
       type: 'baseline',
@@ -964,16 +942,16 @@ describe('background-job mirror', () => {
     expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
   })
 
-  it('drops the rows when the session is removed, whichever stream lands first', () => {
-    const manager = makeManager()
+  it('drops the rows when the session is removed, whichever stream lands first', ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     manager.handleSessionAdded(summary(S1, { blank: true }))
     manager.handleControlFrame(tasksFrame(S1, [view()]))
     manager.handleSessionRemoved(S1)
     expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
   })
 
-  it('notifies list subscribers so an open header re-renders without a poll', async () => {
-    const manager = makeManager()
+  it('notifies list subscribers so an open header re-renders without a poll', async ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     const seen = vi.fn()
     manager.subscribe(seen)
     manager.handleControlFrame(tasksFrame(S1, [view()]))

+ 42 - 35
packages/api/session-controller/tests/projection-store.client.spec.ts

@@ -7,14 +7,19 @@
  * seeding, control-stream projection routing pre- and post-instantiation, the
  * list rows' title projection).
  */
-import { describe, expect, it } from 'vitest'
+import { describe, expect } from 'vitest'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
 import { SessionSeq } from '@deepseek-ai/dsh-session/types'
+import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
+import {
+  createClientTest, type ClientTestFixtures, webApp,
+} from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
 import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts'
-import { Session } from '../src/client/sessions/session.ts'
 import { SessionManager } from '../src/client/sessions/manager.ts'
-import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts'
+import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
 import { entries, plainTurn } from './event-script.client.ts'
+import { sessionBench } from './remote/bench.client.ts'
+import { FOLLOW, followScript, sessionWorld } from './remote/session.client.ts'
 
 // Test-domain keys merged into the projection map (the Service Definition package's
 // pure-type outlet), the same way domain host plugins merge theirs.
@@ -25,6 +30,15 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
 }
 
 const SID = 'fk-s1' as SessionId
+/** 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 })
+
+function makeManager(mock: RemoteMock, remote: ClientTestFixtures['remote']): SessionManager {
+  mock.load(sessionWorld)
+  // Manager-routing cases never open a Session, so they do not need the broader Client Remote's $stream member.
+  return new SessionManager(remote as unknown as SessionRemotes)
+}
 
 describe('Session projection value semantics', () => {
   it('reads undefined until a value lands (capability absence)', () => {
@@ -102,34 +116,31 @@ describe('Session projection value semantics', () => {
 })
 
 describe('Session tail-page seeding', () => {
-  it('seeds the store from a history response carrying a projections block', async () => {
-    const api = new FakeApiClient()
-    const session = new Session(SID, fakeRemote(api))
-    api.onHistory = () => Promise.resolve(ok({
+  it('seeds the store from a history response carrying a projections block', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
+    mock.stream(FOLLOW, followScript(ok({
       records: entries(plainTurn(SessionSeq(0), 0, '问', '答')) as never[], hasMore: false,
       projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } },
-    } as never))
+    } as never)))
     await session.open()
     expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] })
   })
 
-  it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => {
-    const api = new FakeApiClient()
-    const session = new Session(SID, fakeRemote(api))
-    api.onHistory = () => Promise.resolve(ok({
+  it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
+    mock.stream(FOLLOW, followScript(ok({
       records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false,
       projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
-    } as never))
+    } as never)))
     await session.open()
     session.projections.apply('test/marks', { marks: ['pushed-9'] }, SessionSeq(9))
     await session.resync()
     expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed-9'] })
   })
 
-  it('treats a blockless response as no reset: pushed values survive', async () => {
-    const api = new FakeApiClient()
-    const session = new Session(SID, fakeRemote(api))
-    api.onHistory = () => Promise.resolve(ok({ records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false }))
+  it('treats a blockless response as no reset: pushed values survive', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
+    mock.stream(FOLLOW, followScript(ok({ records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false })))
     await session.open()
     session.projections.apply('test/marks', { marks: ['pushed'] }, SessionSeq(9))
     await session.resync()
@@ -140,9 +151,8 @@ describe('Session tail-page seeding', () => {
 describe('manager frame routing', () => {
   const sid = (s: string): SessionId => s as SessionId
 
-  it('lands projection frames before instantiation and the Session adopts the same store', async () => {
-    const api = new FakeApiClient()
-    const manager = new SessionManager(fakeRemote(api))
+  it('lands projection frames before instantiation and the Session adopts the same store', ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     manager.handleControlFrame({
       type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7,
     })
@@ -155,12 +165,11 @@ describe('manager frame routing', () => {
     expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] })
   })
 
-  it('projects the title key into list rows and truncates phantom rows on the control baseline', async () => {
-    const api = new FakeApiClient()
-    const manager = new SessionManager(fakeRemote(api))
-    api.onList = () => Promise.resolve(ok({
+  it('projects the title key into list rows and truncates phantom rows on the control baseline', async ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
+    remote.session.list.mockResolvedValue(ok({
       items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
-    }) as never)
+    }))
     await manager.refreshList()
     manager.handleControlFrame({
       type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4,
@@ -180,10 +189,9 @@ describe('manager frame routing', () => {
     expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
   })
 
-  it('projects every retained value into list rows with stable snapshot identity', async () => {
-    const api = new FakeApiClient()
-    const manager = new SessionManager(fakeRemote(api))
-    api.onList = () => Promise.resolve(ok({
+  it('projects every retained value into list rows with stable snapshot identity', async ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
+    remote.session.list.mockResolvedValue(ok({
       items: [{
         sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
         projections: {
@@ -191,7 +199,7 @@ describe('manager frame routing', () => {
           values: { 'test/marks': { marks: ['baseline'] } },
         },
       }],
-    }) as never)
+    }))
     await manager.refreshList()
     const baseline = manager.getListSnapshot().items[0]?.projectionValues
     expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } })
@@ -207,12 +215,11 @@ describe('manager frame routing', () => {
     expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
   })
 
-  it('drops the projection store with the removed session', async () => {
-    const api = new FakeApiClient()
-    const manager = new SessionManager(fakeRemote(api))
-    api.onList = () => Promise.resolve(ok({
+  it('drops the projection store with the removed session', async ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
+    remote.session.list.mockResolvedValue(ok({
       items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
-    }) as never)
+    }))
     await manager.refreshList()
     manager.handleControlFrame({
       type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4,

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

@@ -1,4 +1,4 @@
-/** Pure history responses shared by the local Remote fake and assembled Session tests. */
+/** Pure history response builders for assembled Session tests. */
 import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types'
 import type {
   SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, SessionPage, SessionProjectionBaseline,

+ 17 - 7
packages/api/session-controller/tests/remote/session.client.ts

@@ -1,6 +1,6 @@
 /**
  * The Remote side of one Session under test: default answers for every
- * `session/*` and `subagents/*` endpoint a `Session` calls, builders for
+ * `session/*` and `subagents/*` endpoint a `Session` or its manager 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
@@ -13,8 +13,8 @@ import { ok, type RemoteMock, type RemoteTable, type StreamScript, type UnaryRul
 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,
+  SessionAssistantStreamBaseline, 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'
@@ -73,10 +73,16 @@ export async function pushEvent(mock: RemoteMock, event: SessionEvent): Promise<
  * 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.
+ * @param options - snapshot cursor and initial Assistant stream state; the latter may be read per opening.
  * @returns the script.
  */
-export function followScript(history: HistorySource<SessionFollowRequest>, options: { cursor?: number } = {}): StreamScript {
+export function followScript(
+  history: HistorySource<SessionFollowRequest>,
+  options: {
+    cursor?: number
+    assistantStream?: SessionAssistantStreamBaseline | (() => SessionAssistantStreamBaseline)
+  } = {},
+): StreamScript {
   return async ([request], stream) => {
     const follow = request as SessionFollowRequest
     const result = await answer(history, follow)
@@ -84,7 +90,10 @@ export function followScript(history: HistorySource<SessionFollowRequest>, optio
       stream.fail(result.error)
       return
     }
-    stream.push(followSnapshot(result.value, follow, options.cursor))
+    const assistantStream = typeof options.assistantStream === 'function'
+      ? options.assistantStream()
+      : options.assistantStream
+    stream.push(followSnapshot(result.value, follow, options.cursor, assistantStream))
   }
 }
 
@@ -167,7 +176,7 @@ export function queueFrame(
   return { type: 'queue', sessionId, items: items.map(queueItem) }
 }
 
-/** Default answers: every command accepted, an empty history, one attachment of one zero byte. */
+/** Default answers: every command accepted, empty history and subagent catalog, one attachment of one zero byte. */
 export const sessionWorld: RemoteTable = {
   unary: {
     'session/prompt': ok({ accepted: true }),
@@ -179,6 +188,7 @@ export const sessionWorld: RemoteTable = {
       data: 'AA==',
     }),
     'session/page': pageRule(ok({ records: [], hasMore: false })),
+    'subagents/list': ok({ entries: [], parentAvailable: true }),
     'subagents/prompt': ok({ messageId: 'fake-message' }),
     'subagents/interruptByParent': ok({ accepted: true }),
   },

+ 249 - 221
packages/api/session-controller/tests/sessions-service.client.spec.ts

@@ -7,39 +7,50 @@
  * projection, create.
  */
 import { Context } from '@deepseek-ai/cordis'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, vi } from 'vitest'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { LlmAttemptId } from '@deepseek-ai/dsh-llm'
 import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
 import { SESSION_FORMAT_VERSION, SessionSeq } from '@deepseek-ai/dsh-session/types'
+import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
+import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
 import { ClientSessions, SessionCreateError } from '../src/client/sessions/service.ts'
 import { scopeOf } from '../src/client/scope.ts'
-import type { SessionFollowFrame } from '../src/types.ts'
-import {
-  FakeApiClient,
-  deferred,
-  err,
-  fakeRemote,
-  ok,
-  type RuntimeRemotes,
-} from './fake-api.client.ts'
+import type {
+  SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest,
+} from '../src/types.ts'
+import { FOLLOW, err, followScript, sessionWorld } from './remote/session.client.ts'
 
 const sid = (s: string): SessionId => s as SessionId
+/** ClientSessions uses the Gateway client for stream supervision and the native Remote mocks for responses. */
+const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
 
 interface Bench {
   ctx: Context
-  api: FakeApiClient
+  mock: RemoteMock
   svc: ClientSessions
 }
 
-function bench(configureRemote?: (remote: RuntimeRemotes) => RuntimeRemotes): Bench {
-  const ctx = new Context()
-  const api = new FakeApiClient()
-  const remote = fakeRemote(api)
-  const svc = new ClientSessions(ctx, configureRemote?.(remote) ?? remote)
-  return { ctx, api, svc }
-}
+type BenchFactory = () => Bench
+
+const it = createClientTest({ roster: API_ROSTER }).extend<{ bench: BenchFactory }>({
+  bench: async ({ mock, start }, use) => {
+    mock.load(sessionWorld)
+    const client = await start()
+    const contexts: Context[] = []
+    try {
+      await use(() => {
+        const ctx = new Context()
+        contexts.push(ctx)
+        const svc = new ClientSessions(ctx, client.ctx.remote)
+        return { ctx, mock, svc }
+      })
+    } finally {
+      await Promise.all(contexts.map(ctx => ctx.fiber.dispose()))
+    }
+  },
+})
 
 /** Refresh the manager list from programmable rows and flush the microtask batch. */
 type FeedRow = {
@@ -53,7 +64,7 @@ type FeedRow = {
 }
 
 async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
-  b.api.onList = () => Promise.resolve(ok({
+  b.mock.remote.session.list.mockResolvedValue(ok({
     items: rows.map(r => ({
       sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false,
       ...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
@@ -69,7 +80,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
 }
 
 describe('list store projection', () => {
-  it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => {
+  it('projects durable titles separately from cwd/id display fallbacks and parent links', async ({ bench }) => {
     const b = bench()
     b.svc.handleControlFrame({
       type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Durable title', seq: 2,
@@ -87,7 +98,7 @@ describe('list store projection', () => {
     expect(state.byId[sid('s2')]?.title).toBeUndefined()
   })
 
-  it('reprojects a blank session from the generic agent-preset projection', async () => {
+  it('reprojects a blank session from the generic agent-preset projection', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1', blank: true, projections: { agentPreset: 'standard' } }])
     expect(b.svc.list.getSnapshot().byId[sid('s1')]?.projectionValues?.agentPreset).toBe('standard')
@@ -100,7 +111,7 @@ describe('list store projection', () => {
     expect(b.svc.list.getSnapshot().byId[sid('s1')]?.projectionValues?.agentPreset).toBe('minimal')
   })
 
-  it('reflects live increments (host stream via manager) into the store', async () => {
+  it('reflects live increments (host stream via manager) into the store', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1' }])
     b.svc.handleSessionAdded({
@@ -112,15 +123,16 @@ describe('list store projection', () => {
 })
 
 describe('search', () => {
-  it('delegates transient content search without changing the list snapshot', async () => {
+  it('delegates transient content search without changing the list snapshot', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1' }])
     const before = b.svc.list.getSnapshot()
-    b.api.onSearch = () => Promise.resolve(ok({
+    b.mock.remote.session.search.mockResolvedValue(ok({
       items: [{ sessionId: sid('s1'), snippet: 'matching excerpt' }],
       hasMore: false,
     }))
     const signal = new AbortController().signal
+    const call = vi.spyOn(b.mock.rpc, 'call')
 
     await expect(b.svc.search('needle', signal)).resolves.toEqual({
       ok: true,
@@ -129,13 +141,13 @@ describe('search', () => {
         hasMore: false,
       },
     })
-    expect(b.api.lastSearchSignal).toBe(signal)
+    expect(call.mock.calls.find(([, endpoint]) => endpoint === 'session/search')?.[3]).toBe(signal)
     expect(b.svc.list.getSnapshot()).toBe(before)
   })
 })
 
 describe('scope tree', () => {
-  it('publishes transient Assistant chunks and the named durable v2 settlement through one event source', async () => {
+  it('publishes transient Assistant chunks and the named durable v2 settlement through one event source', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1' }])
     b.svc.open(sid('s1'))
@@ -168,14 +180,15 @@ describe('scope tree', () => {
       publications.push(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
     })
 
-    await b.api.pushFollow(sid('s1'), {
+    b.mock.streams.push(FOLLOW, {
       type: 'assistant-stream',
       frame: {
         type: 'start', attemptId, revision: 1, startedAfterSeq: -1,
         turn: 1, step: 1,
       },
     })
-    await b.api.pushFollow(sid('s1'), {
+    await b.mock.streams.drained(FOLLOW)
+    b.mock.streams.push(FOLLOW, {
       type: 'assistant-stream',
       frame: {
         type: 'chunk', attemptId, revision: 2, index: 0,
@@ -183,20 +196,23 @@ describe('scope tree', () => {
         chunk: { type: 'text-delta', index: 0, text: 'live' },
       },
     })
+    await b.mock.streams.drained(FOLLOW)
     await vi.waitFor(() => {
       expect(binding.eventSource.getSnapshot().entries).toHaveLength(1)
     })
-    await b.api.pushFollow(sid('s1'), durableMessage)
+    b.mock.streams.push(FOLLOW, durableMessage)
+    await b.mock.streams.drained(FOLLOW)
     await Promise.resolve()
     expect(binding.eventSource.getSnapshot().entries).toHaveLength(1)
 
-    await b.api.pushFollow(sid('s1'), {
+    b.mock.streams.push(FOLLOW, {
       type: 'assistant-stream',
       frame: {
         type: 'end', attemptId, revision: 3, index: 1,
         outcome: { kind: 'committed', eventType: 'assistant/message', seq: 0 },
       },
     })
+    await b.mock.streams.drained(FOLLOW)
     await vi.waitFor(() => {
       expect(binding.eventSource.getSnapshot().entries).toHaveLength(1)
     })
@@ -208,12 +224,11 @@ describe('scope tree', () => {
     dispose()
   })
 
-  it('replaces an active assistant baseline on reconnect without duplicate chunks', async () => {
+  it('replaces an active assistant baseline on reconnect without duplicate chunks', async ({ bench }) => {
     const b = bench()
     const attemptId = LlmAttemptId('reconnect-attempt')
     let records: never[] = []
-    b.api.onHistory = () => Promise.resolve(ok({ records, hasMore: false }))
-    b.api.assistantStreamBaseline = {
+    let assistantStreamBaseline: SessionAssistantStreamBaseline = {
       revision: 2,
       activeAttempt: {
         attemptId, startedAfterSeq: -1, turn: 1, step: 1,
@@ -221,6 +236,10 @@ describe('scope tree', () => {
         stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['a'] }],
       },
     }
+    b.mock.stream(FOLLOW, followScript(
+      () => ok({ records, hasMore: false }),
+      { assistantStream: () => assistantStreamBaseline },
+    ))
     await feedList(b, [{ id: 's1' }])
     b.svc.open(sid('s1'))
     const binding = b.svc.binding(sid('s1'))
@@ -230,7 +249,7 @@ describe('scope tree', () => {
     })
 
     records = []
-    b.api.assistantStreamBaseline = {
+    assistantStreamBaseline = {
       revision: 3,
       activeAttempt: {
         attemptId, startedAfterSeq: -1, turn: 1, step: 1,
@@ -238,9 +257,9 @@ describe('scope tree', () => {
         stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [1], texts: ['a', 'b'] }],
       },
     }
-    b.api.failStreams(new RemoteStreamCarrierError('lost'))
+    b.mock.streams.fail(FOLLOW, new RemoteStreamCarrierError('lost'))
     await vi.waitFor(() => {
-      expect(b.api.followStarts.filter(id => id === sid('s1'))).toHaveLength(2)
+      expect(b.mock.log.requests(FOLLOW)).toHaveLength(2)
       expect(binding.eventSource.getSnapshot().entries).toHaveLength(2)
     })
 
@@ -251,7 +270,7 @@ describe('scope tree', () => {
     ))).toEqual(['a', 'b'])
   })
 
-  it('stages a post-opening assistant settlement behind its exact active attempt', async () => {
+  it('stages a post-opening assistant settlement behind its exact active attempt', async ({ bench }) => {
     const b = bench()
     const attemptId = LlmAttemptId('reconnect-settlement-attempt')
     const priorMessage = {
@@ -290,11 +309,11 @@ describe('scope tree', () => {
         surfaceOp: 'append' as const,
       },
     }
-    b.api.onHistory = () => Promise.resolve(ok({
+    const history = ok({
       records: [priorMessage] as never[],
       hasMore: false,
-    }))
-    b.api.assistantStreamBaseline = {
+    })
+    const assistantStreamBaseline: SessionAssistantStreamBaseline = {
       revision: 2,
       activeAttempt: {
         attemptId,
@@ -305,6 +324,7 @@ describe('scope tree', () => {
         stream: currentMessage.event.data.stream,
       },
     }
+    b.mock.stream(FOLLOW, followScript(history, { assistantStream: assistantStreamBaseline }))
     await feedList(b, [{ id: 's1' }])
     b.svc.open(sid('s1'))
     const binding = b.svc.binding(sid('s1'))
@@ -317,18 +337,20 @@ describe('scope tree', () => {
       .toEqual(['assistant/message', 'assistant/live-chunk'])
     expect(binding.eventSource.getSnapshot().entries[0]?.event).toBe(priorMessage.event)
 
-    await b.api.pushFollow(sid('s1'), currentMessage)
+    b.mock.streams.push(FOLLOW, currentMessage)
+    await b.mock.streams.drained(FOLLOW)
     await Promise.resolve()
     expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
       .toEqual(['assistant/message', 'assistant/live-chunk'])
 
-    await b.api.pushFollow(sid('s1'), {
+    b.mock.streams.push(FOLLOW, {
       type: 'assistant-stream',
       frame: {
         type: 'end', attemptId, revision: 3, index: 1,
         outcome: { kind: 'committed', eventType: 'assistant/message', seq: 1 },
       },
     })
+    await b.mock.streams.drained(FOLLOW)
     await vi.waitFor(() => {
       expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
         .toEqual(['assistant/message', 'assistant/message'])
@@ -338,7 +360,7 @@ describe('scope tree', () => {
     })
   })
 
-  it('replaces an invalid settlement with the authoritative post-end baseline', async () => {
+  it('replaces an invalid settlement with the authoritative post-end baseline', async ({ bench }) => {
     const b = bench()
     const attemptId = LlmAttemptId('reconnect-end-index-attempt')
     const prior = {
@@ -364,11 +386,7 @@ describe('scope tree', () => {
       },
     }
     let records = [prior] as never[]
-    b.api.onHistory = () => Promise.resolve(ok({
-      records,
-      hasMore: false,
-    }))
-    b.api.assistantStreamBaseline = {
+    let assistantStreamBaseline: SessionAssistantStreamBaseline = {
       revision: 2,
       activeAttempt: {
         attemptId,
@@ -379,6 +397,10 @@ describe('scope tree', () => {
         stream: message.event.data.stream,
       },
     }
+    b.mock.stream(FOLLOW, followScript(
+      () => ok({ records, hasMore: false }),
+      { assistantStream: () => assistantStreamBaseline },
+    ))
     await feedList(b, [{ id: 's1' }])
     b.svc.open(sid('s1'))
     const binding = b.svc.binding(sid('s1'))
@@ -389,29 +411,31 @@ describe('scope tree', () => {
     })
     const openingRevision = binding.eventSource.getSnapshot().revision
 
-    await b.api.pushFollow(sid('s1'), message)
+    b.mock.streams.push(FOLLOW, message)
+    await b.mock.streams.drained(FOLLOW)
     await Promise.resolve()
     expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
       .toEqual(['turn/start', 'assistant/live-chunk'])
     records = [prior, message] as never[]
-    b.api.assistantStreamBaseline = { revision: 3 }
-    await b.api.pushFollow(sid('s1'), {
+    assistantStreamBaseline = { revision: 3 }
+    b.mock.streams.push(FOLLOW, {
       type: 'assistant-stream',
       frame: {
         type: 'end', attemptId, revision: 3, index: 0,
         outcome: { kind: 'committed', eventType: 'assistant/message', seq: 0 },
       },
     })
+    await b.mock.streams.drained(FOLLOW)
     await vi.waitFor(() => {
-      expect(b.api.followStarts.filter(id => id === sid('s1'))).toHaveLength(2)
-      expect(b.api.activeFollows(sid('s1'))).toBe(1)
+      expect(b.mock.log.requests(FOLLOW)).toHaveLength(2)
+      expect(b.mock.log.streams(FOLLOW).filter(stream => stream.state === 'open')).toHaveLength(1)
       expect(binding.eventSource.getSnapshot().revision).toBeGreaterThan(openingRevision)
       expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
         .toEqual(['turn/start', 'assistant/message'])
     })
   })
 
-  it('retains a Host-addressed scope until the first Session baseline owns pruning', async () => {
+  it('retains a Host-addressed scope until the first Session baseline owns pruning', async ({ bench }) => {
     const b = bench()
     const scoped = b.svc.resolveAgentScope(sid('s-early'))
     expect(scopeOf(scoped)).toBe('s-early')
@@ -427,7 +451,7 @@ describe('scope tree', () => {
     expect(b.svc.scope(sid('s-early'))).toBeUndefined()
   })
 
-  it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => {
+  it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1' }])
     expect(b.svc.scope(sid('unknown'))).toBeUndefined()
@@ -442,7 +466,7 @@ describe('scope tree', () => {
     expect(binding?.ctx).toBe(scoped)
   })
 
-  it('tears down an off-stage removed session but defers the staged one until the stage moves', async () => {
+  it('tears down an off-stage removed session but defers the staged one until the stage moves', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1' }, { id: 's2' }])
     const ctx1 = b.svc.scope(sid('s1'))
@@ -460,7 +484,7 @@ describe('scope tree', () => {
     expect(b.svc.scope(sid('s1'))).toBeUndefined()
   })
 
-  it('keeps the scope when the session merely stops running (frozen ≠ removed)', async () => {
+  it('keeps the scope when the session merely stops running (frozen ≠ removed)', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1', running: true }])
     const scoped = b.svc.scope(sid('s1'))
@@ -468,7 +492,7 @@ describe('scope tree', () => {
     expect(b.svc.scope(sid('s1'))).toBe(scoped)
   })
 
-  it('cancels a deferred teardown when the id reappears in the list', async () => {
+  it('cancels a deferred teardown when the id reappears in the list', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1' }])
     const scoped = b.svc.scope(sid('s1'))
@@ -479,13 +503,17 @@ describe('scope tree', () => {
     expect(b.svc.scope(sid('s1'))).toBe(scoped)
   })
 
-  it('closes an opened journal when its removed scope drops', async () => {
+  it('closes an opened journal when its removed scope drops', async ({ bench }) => {
     const b = bench()
+    const follows = () => b.mock.log.streams(FOLLOW).filter(({ args }) => {
+      const request = args[0] as SessionFollowRequest
+      return request.address.kind === 'session' && request.address.sessionId === sid('s1')
+    })
     await feedList(b, [{ id: 's1' }])
     b.svc.open(sid('s1'))
     const session = b.svc.binding(sid('s1'))?.session
     if (session === undefined) throw new Error('expected the selected Session binding')
-    await vi.waitFor(() => { expect(b.api.activeFollows(sid('s1'))).toBe(1) })
+    await vi.waitFor(() => { expect(follows().filter(stream => stream.state === 'open')).toHaveLength(1) })
     const notified = vi.fn()
     session.subscribe(notified)
 
@@ -493,20 +521,24 @@ describe('scope tree', () => {
     await feedList(b, [{ id: 's2' }])
     b.svc.open(sid('s2'))
 
-    await vi.waitFor(() => { expect(b.api.activeFollows(sid('s1'))).toBe(0) })
+    await vi.waitFor(() => { expect(follows().filter(stream => stream.state === 'open')).toHaveLength(0) })
     notified.mockClear()
-    await b.api.pushFollow(sid('s1'), {
+    expect(b.mock.streams.push(FOLLOW, {
       type: 'event',
       event: { seq: 0, timestamp: 0, type: 'turn/start', data: { turn: 0 } } as never,
-    })
+    }, ([request]) => {
+      const address = (request as SessionFollowRequest).address
+      return address.kind === 'session' && address.sessionId === sid('s1')
+    })).toBe(0)
+    await b.mock.streams.drained(FOLLOW)
     await Promise.resolve()
-    expect(b.api.followStarts.filter(id => id === sid('s1'))).toHaveLength(1)
+    expect(follows()).toHaveLength(1)
     expect(notified).not.toHaveBeenCalled()
   })
 })
 
 describe('Agent scope disposal lifecycle', () => {
-  it('root disposal runs Agent scope effects', async () => {
+  it('root disposal runs Agent scope effects', async ({ bench }) => {
     const b = bench()
     const readiness = b.ctx.plugin(() => undefined)
     await readiness
@@ -525,59 +557,54 @@ describe('Agent scope disposal lifecycle', () => {
     expect(b.svc.sessionOf(scoped)).toBeUndefined()
   })
 
-  it('root disposal waits for an opened Session source to finish closing', async () => {
-    const closeGate = deferred<undefined>()
+  it('root disposal waits for an opened Session source to finish closing', async ({ bench }) => {
+    const closeGate = Promise.withResolvers<undefined>()
     const abortObserved = vi.fn()
     let followSignal: AbortSignal | undefined
-    const b = bench(remote => ({
-      ...remote,
-      session: {
-        ...remote.session,
-        follow: (request, signal) => {
-          if (signal === undefined) throw new Error('fixture requires a signal')
-          followSignal = signal
-          let opened = false
-          return {
-            [Symbol.asyncIterator]: () => ({
-              next: () => {
-                if (!opened) {
-                  opened = true
-                  return Promise.resolve({
-                    done: false,
-                    value: {
-                      type: 'snapshot',
-                      header: {
-                        version: SESSION_FORMAT_VERSION,
-                        id: request.address.kind === 'session'
-                          ? request.address.sessionId
-                          : request.address.childSessionId,
-                        createdAt: 0,
-                        isSeeded: false,
-                      },
-                      cursor: -1,
-                      records: [],
-                      hasMore: false,
-                      projections: { asOfSeq: -1, values: {} },
-                      assistantStream: { revision: 0 },
-                    } as const,
-                  })
-                }
-                return new Promise((_resolve, reject) => {
-                  signal.addEventListener('abort', () => {
-                    abortObserved()
-                    void closeGate.promise.then(() => {
-                      reject(signal.reason instanceof Error
-                        ? signal.reason
-                        : new Error(String(signal.reason)))
-                    })
-                  }, { once: true })
+    const b = bench()
+    b.mock.remote.session.follow.mockImplementation((request, signal) => {
+      if (signal === undefined) throw new Error('fixture requires a signal')
+      followSignal = signal
+      let opened = false
+      return {
+        [Symbol.asyncIterator]: () => ({
+          next: () => {
+            if (!opened) {
+              opened = true
+              return Promise.resolve({
+                done: false,
+                value: {
+                  type: 'snapshot',
+                  header: {
+                    version: SESSION_FORMAT_VERSION,
+                    id: request.address.kind === 'session'
+                      ? request.address.sessionId
+                      : request.address.childSessionId,
+                    createdAt: 0,
+                    isSeeded: false,
+                  },
+                  cursor: -1,
+                  records: [],
+                  hasMore: false,
+                  projections: { asOfSeq: -1, values: {} },
+                  assistantStream: { revision: 0 },
+                } as const,
+              })
+            }
+            return new Promise((_resolve, reject) => {
+              signal.addEventListener('abort', () => {
+                abortObserved()
+                void closeGate.promise.then(() => {
+                  reject(signal.reason instanceof Error
+                    ? signal.reason
+                    : new Error(String(signal.reason)))
                 })
-              },
-            }),
-          }
-        },
-      },
-    }))
+              }, { once: true })
+            })
+          },
+        }),
+      }
+    })
     const readiness = b.ctx.plugin(() => undefined)
     await readiness
     await feedList(b, [{ id: 's1' }])
@@ -599,55 +626,50 @@ describe('Agent scope disposal lifecycle', () => {
     expect(settled).toHaveBeenCalledOnce()
   })
 
-  it('root disposal joins every Session drop already started by pruning under load', async () => {
-    const closeGates = new Map<SessionId, ReturnType<typeof deferred<undefined>>>()
+  it('root disposal joins every Session drop already started by pruning under load', async ({ bench }) => {
+    const closeGates = new Map<SessionId, PromiseWithResolvers<undefined>>()
     const aborted = new Set<SessionId>()
-    const b = bench(remote => ({
-      ...remote,
-      session: {
-        ...remote.session,
-        follow: (request, signal) => {
-          if (signal === undefined) throw new Error('fixture requires a signal')
-          const sessionId = request.address.kind === 'session'
-            ? request.address.sessionId
-            : request.address.childSessionId
-          const closeGate = deferred<undefined>()
-          closeGates.set(sessionId, closeGate)
-          let opened = false
-          return {
-            [Symbol.asyncIterator]: () => ({
-              next: () => {
-                if (!opened) {
-                  opened = true
-                  return Promise.resolve({
-                    done: false,
-                    value: {
-                      type: 'snapshot',
-                      header: { version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 0, isSeeded: false },
-                      cursor: -1,
-                      records: [],
-                      hasMore: false,
-                      projections: { asOfSeq: -1, values: {} },
-                      assistantStream: { revision: 0 },
-                    } as const,
-                  })
-                }
-                return new Promise<IteratorResult<SessionFollowFrame>>((_resolve, reject) => {
-                  signal.addEventListener('abort', () => {
-                    aborted.add(sessionId)
-                    void closeGate.promise.then(() => {
-                      reject(signal.reason instanceof Error
-                        ? signal.reason
-                        : new Error(String(signal.reason)))
-                    })
-                  }, { once: true })
+    const b = bench()
+    b.mock.remote.session.follow.mockImplementation((request, signal) => {
+      if (signal === undefined) throw new Error('fixture requires a signal')
+      const sessionId = request.address.kind === 'session'
+        ? request.address.sessionId
+        : request.address.childSessionId
+      const closeGate = Promise.withResolvers<undefined>()
+      closeGates.set(sessionId, closeGate)
+      let opened = false
+      return {
+        [Symbol.asyncIterator]: () => ({
+          next: () => {
+            if (!opened) {
+              opened = true
+              return Promise.resolve({
+                done: false,
+                value: {
+                  type: 'snapshot',
+                  header: { version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 0, isSeeded: false },
+                  cursor: -1,
+                  records: [],
+                  hasMore: false,
+                  projections: { asOfSeq: -1, values: {} },
+                  assistantStream: { revision: 0 },
+                } as const,
+              })
+            }
+            return new Promise<IteratorResult<SessionFollowFrame>>((_resolve, reject) => {
+              signal.addEventListener('abort', () => {
+                aborted.add(sessionId)
+                void closeGate.promise.then(() => {
+                  reject(signal.reason instanceof Error
+                    ? signal.reason
+                    : new Error(String(signal.reason)))
                 })
-              },
-            }),
-          }
-        },
-      },
-    }))
+              }, { once: true })
+            })
+          },
+        }),
+      }
+    })
     const readiness = b.ctx.plugin(() => undefined)
     await readiness
     const sessionIds = Array.from({ length: 24 }, (_, index) => sid(`load-${String(index)}`))
@@ -691,7 +713,7 @@ describe('Agent scope disposal lifecycle', () => {
 describe('current selection (migrated from ui-layout, arbitrated into the list snapshot)', () => {
   afterEach(() => { vi.unstubAllGlobals() })
 
-  it('open() writes list.current; unknown ids fail loud', async () => {
+  it('open() writes list.current; unknown ids fail loud', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1' }])
     expect(b.svc.list.getSnapshot().current).toBeUndefined()
@@ -701,7 +723,7 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
     expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone
   })
 
-  it('clear() blanks list.current and the persisted selection', async () => {
+  it('clear() blanks list.current and the persisted selection', async ({ bench }) => {
     const storage = new Map<string, string>()
     vi.stubGlobal('localStorage', {
       getItem: (k: string) => storage.get(k) ?? null,
@@ -721,7 +743,7 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
     expect(again.svc.list.getSnapshot().current).toBeUndefined()
   })
 
-  it('masks (not destroys) the selection while its session is off the list', async () => {
+  it('masks (not destroys) the selection while its session is off the list', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1' }, { id: 's2' }])
     b.svc.open(sid('s1'))
@@ -731,7 +753,7 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
     expect(b.svc.list.getSnapshot().current).toBe('s1')
   })
 
-  it('persists the selection under dsh.sessions.current and rehydrates it into a fresh service', async () => {
+  it('persists the selection under dsh.sessions.current and rehydrates it into a fresh service', async ({ bench }) => {
     const storage = new Map<string, string>()
     vi.stubGlobal('localStorage', {
       getItem: (k: string) => storage.get(k) ?? null,
@@ -749,7 +771,7 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
 })
 
 describe('binding and stage lifecycle', () => {
-  it('binding() is pure resolution: no staging, no deferred sweep', async () => {
+  it('binding() is pure resolution: no staging, no deferred sweep', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1' }, { id: 's2' }])
     b.svc.open(sid('s1')) // staged
@@ -758,10 +780,13 @@ describe('binding and stage lifecycle', () => {
     expect(b.svc.scope(sid('s1'))).toBeDefined()
   })
 
-  it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async () => {
+  it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1' }, { id: 's2' }])
-    const followStarts = () => b.api.followStarts.map(String)
+    const followStarts = () => b.mock.log.requests(FOLLOW).map((request) => {
+      const address = (request as SessionFollowRequest).address
+      return String(address.kind === 'session' ? address.sessionId : address.childSessionId)
+    })
     // Resolution is addressing, not staging: no window pull.
     b.svc.scope(sid('s1'))
     b.svc.binding(sid('s1'))
@@ -780,7 +805,7 @@ describe('binding and stage lifecycle', () => {
     })
   })
 
-  it('startup restore: a persisted selection validated by the first projection opens its window unprompted', async () => {
+  it('startup restore: a persisted selection validated by the first projection opens its window unprompted', async ({ bench }) => {
     const storage = new Map<string, string>([
       ['dsh.sessions.current', JSON.stringify({ sessionId: 's1' })],
     ])
@@ -790,10 +815,13 @@ describe('binding and stage lifecycle', () => {
     })
     try {
       const b = bench()
-      expect(b.api.followStarts).toEqual([])
+      expect(b.mock.log.requests(FOLLOW)).toEqual([])
       await feedList(b, [{ id: 's1' }]) // projection validates the persisted id → current lands → stage follows
       await vi.waitFor(() => {
-        expect(b.api.followStarts.map(String)).toEqual(['s1'])
+        expect(b.mock.log.requests(FOLLOW).map((request) => {
+          const address = (request as SessionFollowRequest).address
+          return String(address.kind === 'session' ? address.sessionId : address.childSessionId)
+        })).toEqual(['s1'])
       })
     } finally {
       vi.unstubAllGlobals()
@@ -802,10 +830,10 @@ describe('binding and stage lifecycle', () => {
 })
 
 describe('catalog-addressed navigation', () => {
-  it('uses catalog labels for a listed addressed route', async () => {
+  it('uses catalog labels for a listed addressed route', async ({ bench }) => {
     const b = bench()
-    b.api.onSubagentList = (payload) => {
-      const parentSessionId = payload as SessionId
+    b.mock.remote.subagents.list.mockImplementation((payload) => {
+      const parentSessionId = payload
       if (parentSessionId === sid('root')) {
         return Promise.resolve(ok({
           entries: [{
@@ -825,7 +853,7 @@ describe('catalog-addressed navigation', () => {
         }))
       }
       return Promise.resolve(ok({ entries: [], parentAvailable: false }))
-    }
+    })
     await feedList(b, [
       { id: 'root' },
       { id: 'child', cwd: '/summary-child', parentId: 'root', origin: 'subagent' },
@@ -841,10 +869,10 @@ describe('catalog-addressed navigation', () => {
     expect(b.svc.list.getSnapshot().byId[sid('grandchild')]?.displayTitle).toBe('Grandchild')
   })
 
-  it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async () => {
+  it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async ({ bench }) => {
     const b = bench()
-    b.api.onSubagentList = (payload) => {
-      const parentSessionId = payload as SessionId
+    b.mock.remote.subagents.list.mockImplementation((payload) => {
+      const parentSessionId = payload
       if (parentSessionId === sid('root')) {
         return Promise.resolve(ok({
           entries: [{
@@ -864,7 +892,7 @@ describe('catalog-addressed navigation', () => {
         }))
       }
       return Promise.resolve(ok({ entries: [], parentAvailable: false }))
-    }
+    })
     await feedList(b, [{ id: 'root' }])
     await b.svc.refreshSubagents(sid('root'))
     await b.svc.refreshSubagents(sid('child'))
@@ -888,12 +916,12 @@ describe('catalog-addressed navigation', () => {
 })
 
 describe('create', () => {
-  it('passes a preallocated id and preserves it on ordinary failure', async () => {
+  it('passes a preallocated id and preserves it on ordinary failure', async ({ bench }) => {
     const b = bench()
-    b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') }))
+    b.mock.remote.session.create.mockResolvedValue(ok({ sessionId: sid('fresh') }))
     await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh')
-    expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/w', sessionId: 'fresh' }])
-    b.api.onCreate = () => Promise.resolve(err(new RemoteError('gateway/internal', '爆了', {})))
+    expect(b.mock.remote.session.create).toHaveBeenCalledWith({ cwd: '/w', sessionId: 'fresh' })
+    b.mock.remote.session.create.mockResolvedValue(err(new RemoteError('gateway/internal', '爆了', {})))
     const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error)
     expect(failure).toBeInstanceOf(SessionCreateError)
     expect(failure).toMatchObject({
@@ -902,9 +930,9 @@ describe('create', () => {
     })
   })
 
-  it('resolves with the session already listed and binding-resolvable (no flush wait)', async () => {
+  it('resolves with the session already listed and binding-resolvable (no flush wait)', async ({ bench }) => {
     const b = bench()
-    b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('born') }))
+    b.mock.remote.session.create.mockResolvedValue(ok({ sessionId: sid('born') }))
     const born = await b.svc.create({ workspaceId: 'ws' as never })
     // Synchronously after resolution — the draft hand-off contract: the
     // create echo IS the entity entering the client's view (blank row +
@@ -914,9 +942,9 @@ describe('create', () => {
     expect(b.svc.scope(born)).toBeDefined()
   })
 
-  it('lists the published id after Workspace attachment fails (publication precedes attachment)', async () => {
+  it('lists the published id after Workspace attachment fails (publication precedes attachment)', async ({ bench }) => {
     const b = bench()
-    b.api.onCreate = () => Promise.resolve(err(new RemoteError(
+    b.mock.remote.session.create.mockResolvedValue(err(new RemoteError(
       'session/workspace-attach-failed',
       'ledger unavailable',
       { sessionId: sid('published'), workspaceId: 'ws' },
@@ -936,29 +964,29 @@ describe('create', () => {
 })
 
 describe('fork', () => {
-  it.each([
+  it.for([
     ['Roadmap', 'Roadmap (1)'],
     ['Roadmap (1)', 'Roadmap (2)'],
     ['计划(1)', '计划(2)'],
     ['计划 (9)', '计划 (10)'],
-  ])('increments the durable title %j after the child is published', async (sourceTitle, childTitle) => {
+  ] as const)('increments the durable title %j after the child is published', async ([sourceTitle, childTitle], { bench }) => {
     const b = bench()
     b.svc.handleControlFrame({
       type: 'projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2,
     })
     await feedList(b, [{ id: 'source', cwd: '/work' }])
-    b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
-    b.api.onRename = (payload) => {
+    b.mock.remote.session.fork.mockResolvedValue(ok({ sessionId: sid('child') }))
+    b.mock.remote.session.rename.mockImplementation((payload) => {
       const { title } = payload as { title: string }
       return Promise.resolve(ok({ title, seq: 3 }))
-    }
+    })
 
     await expect(b.svc.fork({
       sessionId: sid('source'), atSeq: 7, increaseTitle: true,
     })).resolves.toBe('child')
 
-    expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 7 }])
-    expect(b.api.callsOf('session.rename')).toEqual([{ sessionId: 'child', title: childTitle }])
+    expect(b.mock.remote.session.fork).toHaveBeenCalledWith({ sessionId: 'source', atSeq: 7 })
+    expect(b.mock.remote.session.rename).toHaveBeenCalledWith({ sessionId: 'child', title: childTitle })
     await Promise.resolve()
     expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({
       title: childTitle,
@@ -967,37 +995,37 @@ describe('fork', () => {
     })
   })
 
-  it('floors a fractional anchor to the real event seq the wire accepts', async () => {
+  it('floors a fractional anchor to the real event seq the wire accepts', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 'source', cwd: '/work' }])
-    b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
+    b.mock.remote.session.fork.mockResolvedValue(ok({ sessionId: sid('child') }))
 
     // The frozen node of an interrupted turn carries turnEnd.seq - 0.9.
     await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 41.1 })).resolves.toBe('child')
 
-    expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 41 }])
+    expect(b.mock.remote.session.fork).toHaveBeenCalledWith({ sessionId: 'source', atSeq: 41 })
   })
 
-  it('does not rename without the title policy or a durable source title', async () => {
+  it('does not rename without the title policy or a durable source title', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 'source', cwd: '/work' }])
-    b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
+    b.mock.remote.session.fork.mockResolvedValue(ok({ sessionId: sid('child') }))
     await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })).resolves.toBe('child')
-    expect(b.api.callsOf('session.rename')).toEqual([])
+    expect(b.mock.remote.session.rename).not.toHaveBeenCalled()
 
-    b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child-2') }))
+    b.mock.remote.session.fork.mockResolvedValue(ok({ sessionId: sid('child-2') }))
     await expect(b.svc.fork({ sessionId: sid('source') })).resolves.toBe('child-2')
-    expect(b.api.callsOf('session.rename')).toEqual([])
+    expect(b.mock.remote.session.rename).not.toHaveBeenCalled()
   })
 
-  it('rejects when child rename fails while keeping the published child addressable', async () => {
+  it('rejects when child rename fails while keeping the published child addressable', async ({ bench }) => {
     const b = bench()
     b.svc.handleControlFrame({
       type: 'projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2,
     })
     await feedList(b, [{ id: 'source' }])
-    b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
-    b.api.onRename = () => Promise.resolve(err(new RemoteError('session/title-invalid', 'rejected', { sessionId: sid('child') })))
+    b.mock.remote.session.fork.mockResolvedValue(ok({ sessionId: sid('child') }))
+    b.mock.remote.session.rename.mockResolvedValue(err(new RemoteError('session/title-invalid', 'rejected', { sessionId: sid('child') })))
 
     await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true }))
       .rejects.toThrow('fork child rename failed: session/title-invalid: rejected')
@@ -1006,7 +1034,7 @@ describe('fork', () => {
 })
 
 describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => {
-  it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async () => {
+  it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async ({ bench }) => {
     const b = bench()
     await feedList(b, [])
     expect(b.svc.scope(sid('s-new'))).toBeUndefined() // not in view: no scope, no exceptions
@@ -1024,7 +1052,7 @@ describe('scope lifecycle rides the list mirror (entity parity: no client-side p
 })
 
 describe('blank mirror', () => {
-  it('flips blank=false from the running:true status frame (cross-client conversion)', async () => {
+  it('flips blank=false from the running:true status frame (cross-client conversion)', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1', blank: true }])
     expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true })
@@ -1035,13 +1063,13 @@ describe('blank mirror', () => {
     expect(b.svc.binding(sid('s1'))?.session.getSnapshot().blank).toBe(false)
   })
 
-  it('flips blank=false on prompt ACCEPTANCE, not on the attempt', async () => {
+  it('flips blank=false on prompt ACCEPTANCE, not on the attempt', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }])
     const session = b.svc.binding(sid('s1'))!.session
     expect(session.getSnapshot().blank).toBe(true)
-    const gate = deferred<Awaited<ReturnType<FakeApiClient['onPrompt']>>>()
-    b.api.onPrompt = () => gate.promise
+    const gate = Promise.withResolvers<Awaited<ReturnType<typeof b.mock.remote.session.prompt>>>()
+    b.mock.remote.session.prompt.mockReturnValue(gate.promise)
     const send = session.prompt([{ type: 'text', text: 'hi' }], 'queue')
     // In flight: still blank (the flip point is the success response, which
     // proves the user message reached the host log).
@@ -1053,11 +1081,11 @@ describe('blank mirror', () => {
     expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false })
   })
 
-  it('keeps a rejected first prompt blank: hidden and still reusable', async () => {
+  it('keeps a rejected first prompt blank: hidden and still reusable', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }])
     const session = b.svc.binding(sid('s1'))!.session
-    b.api.onPrompt = () => Promise.resolve(err(new RemoteError('gateway/internal', 'agent busy', {})))
+    b.mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('gateway/internal', 'agent busy', {})))
     const result = await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
     expect(result.ok).toBe(false)
     // No flip on failure: local stays aligned with the host authority
@@ -1067,7 +1095,7 @@ describe('blank mirror', () => {
     expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true })
   })
 
-  it('takes session-added blank=true as the hidden birth and list blank as reconnect authority', async () => {
+  it('takes session-added blank=true as the hidden birth and list blank as reconnect authority', async ({ bench }) => {
     const b = bench()
     await feedList(b, [])
     b.svc.handleSessionAdded({
@@ -1080,7 +1108,7 @@ describe('blank mirror', () => {
     expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: false })
   })
 
-  it('never re-blanks: a stale blank=true summary cannot hide an engaged session', async () => {
+  it('never re-blanks: a stale blank=true summary cannot hide an engaged session', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1', blank: true }])
     const session = b.svc.binding(sid('s1'))!.session
@@ -1094,7 +1122,7 @@ describe('blank mirror', () => {
 })
 
 describe('coverage tails (branch duals)', () => {
-  it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => {
+  it('displayTitleOf falls back to the id for empty and separator-only cwd', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 'no-base', cwd: '///' }, { id: 'empty-cwd', cwd: '' }])
     const { byId } = b.svc.list.getSnapshot()
@@ -1103,7 +1131,7 @@ describe('coverage tails (branch duals)', () => {
     expect(byId[sid('no-base')]?.title).toBeUndefined()
   })
 
-  it('binding for an unknown session returns undefined and leaves the staged scope intact', async () => {
+  it('binding for an unknown session returns undefined and leaves the staged scope intact', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1' }])
     b.svc.open(sid('s1'))
@@ -1113,20 +1141,20 @@ describe('coverage tails (branch duals)', () => {
     expect(b.svc.scope(sid('s1'))).toBeDefined()
   })
 
-  it('a masked current gap holds the stage (no teardown, no re-open) until the stage moves', async () => {
+  it('a masked current gap holds the stage (no teardown, no re-open) until the stage moves', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1' }])
     b.svc.open(sid('s1'))
-    await vi.waitFor(() => { expect(b.api.followStarts).toHaveLength(1) })
+    await vi.waitFor(() => { expect(b.mock.log.requests(FOLLOW)).toHaveLength(1) })
     await feedList(b, []) // removed while staged: current masks to undefined, stage holds → deferred
     expect(b.svc.scope(sid('s1'))).toBeDefined()
     // Resurfacing re-projects current = s1: same stage occupant, no second pull.
     await feedList(b, [{ id: 's1' }])
-    expect(b.api.followStarts).toHaveLength(1)
+    expect(b.mock.log.requests(FOLLOW)).toHaveLength(1)
     expect(b.svc.list.getSnapshot().current).toBe('s1')
   })
 
-  it('sweep hits both deferral edges: staged-id skip and an already-vacated scope record', async () => {
+  it('sweep hits both deferral edges: staged-id skip and an already-vacated scope record', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 'a' }, { id: 'b' }])
     b.svc.scope(sid('a'))