Kaynağa Gözat

feat(remote-mock): expose typed native mocks and controlled streams

imccyu 1 ay önce
ebeveyn
işleme
3d6fa12a6d

+ 1 - 0
THIRD_PARTY_NOTICES.md

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 22 - 0
pnpm-lock.yaml

@@ -10113,6 +10113,28 @@ importers:
         specifier: workspace:^
         version: link:../../core/session
 
+  packages/test-support/remote-mock:
+    dependencies:
+      '@vitest/spy':
+        specifier: ^4.1.8
+        version: 4.1.8
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-api-settings-controller':
+        specifier: workspace:^
+        version: link:../../api/settings-controller
+      '@deepseek-ai/dsh-client-connection':
+        specifier: workspace:^
+        version: link:../../client/connection
+      '@deepseek-ai/dsh-typert-protocol':
+        specifier: workspace:^
+        version: link:../../typert/protocol
+      typescript:
+        specifier: ^6.0.3
+        version: 6.0.3
+
   packages/test-support/session-snapshot:
     dependencies:
       '@agentclientprotocol/sdk':

+ 1 - 0
tsconfig.base.json

@@ -336,6 +336,7 @@
       "@deepseek-ai/dsh-plan-mode/invariant": ["./packages/plan/plan-mode/src/invariant.ts"],
       "@deepseek-ai/dsh-plugin-package-inventory-deepseek": ["./packages/llm/plugin-package-inventory-deepseek/src"],
       "@deepseek-ai/dsh-pwsh-sandbox": ["./packages/shell/pwsh-sandbox/src"],
+      "@deepseek-ai/dsh-remote-mock": ["./packages/test-support/remote-mock/src"],
       "@deepseek-ai/dsh-repeat-tool-reminder": ["./packages/guard/repeat-tool-reminder/src"],
       "@deepseek-ai/dsh-sandbox": ["./packages/sandbox/sandbox/src"],
       "@deepseek-ai/dsh-sandbox-local": ["./packages/sandbox/sandbox-local/src"],

+ 1 - 0
tsconfig.client.json

@@ -68,6 +68,7 @@
     { "path": "./packages/extensions/cordis-client-runner" },
     { "path": "./packages/extensions/ui-cordis" },
     { "path": "./packages/test-support/client-runtime" },
+    { "path": "./packages/test-support/remote-mock" },
     { "path": "./packages/client/ui-layout" },
     { "path": "./packages/client/ui-sidebar" },
     { "path": "./packages/client/ui-brand-official" },