Explorar o código

Merge pull request #852 from deepseek-harness/worktree-fe-docs

Client test runtime: outward service interfaces + single-slot DOM snapshots
Tianyi Cui hai 2 meses
pai
achega
d533e225ad
Modificáronse 51 ficheiros con 3150 adicións e 734 borrados
  1. 1 0
      docs/config-catalog.md
  2. 6 0
      docs/module-graph.md
  3. 56 0
      packages/client/runtime/src/client/contract/session.ts
  4. 47 0
      packages/client/runtime/src/client/contract/sessions-port.ts
  5. 64 0
      packages/client/runtime/src/client/contract/sessions.ts
  6. 65 0
      packages/client/runtime/src/client/contract/workspaces.ts
  7. 11 2
      packages/client/runtime/src/client/index.ts
  8. 190 0
      packages/client/runtime/src/client/sessions/provide.ts
  9. 29 129
      packages/client/runtime/src/client/sessions/service.ts
  10. 5 3
      packages/client/runtime/src/client/sessions/session.ts
  11. 6 5
      packages/client/runtime/src/client/workspaces/service.ts
  12. 6 0
      packages/client/test-runtime/README.i18n.yaml
  13. 24 0
      packages/client/test-runtime/README.md
  14. 24 0
      packages/client/test-runtime/README.zh.md
  15. 54 0
      packages/client/test-runtime/package.json
  16. 82 0
      packages/client/test-runtime/src/fixtures.ts
  17. 372 0
      packages/client/test-runtime/src/index.ts
  18. 32 0
      packages/client/test-runtime/src/invariant.ts
  19. 390 0
      packages/client/test-runtime/src/sessions.ts
  20. 89 0
      packages/client/test-runtime/src/snapshot.ts
  21. 147 0
      packages/client/test-runtime/src/workspaces.ts
  22. 37 0
      packages/client/test-runtime/tests/__snapshots__/runtime.spec.tsx.snap
  23. 12 0
      packages/client/test-runtime/tests/invariant.spec.ts
  24. 546 0
      packages/client/test-runtime/tests/runtime.spec.tsx
  25. 27 0
      packages/client/test-runtime/tsconfig.json
  26. 4 4
      packages/client/ui-command/src/client/service.ts
  27. 4 3
      packages/client/ui-conversation/src/client/apply.ts
  28. 3 3
      packages/client/ui-conversation/src/client/index.ts
  29. 5 6
      packages/client/ui-conversation/src/client/input/hub.ts
  30. 3 3
      packages/client/ui-conversation/src/client/queue/store.ts
  31. 40 15
      packages/client/ui-conversation/src/client/service.ts
  32. 128 142
      packages/client/ui-conversation/tests/apply-inject.spec.tsx
  33. 29 64
      packages/client/ui-conversation/tests/chat-apply.spec.tsx
  34. 56 169
      packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
  35. 0 149
      packages/client/ui-conversation/tests/selection-survival.spec.ts
  36. 109 0
      packages/client/ui-conversation/tests/selection-survival.spec.tsx
  37. 34 31
      packages/client/ui-conversation/tests/service-orchestration.spec.ts
  38. 4 2
      packages/client/ui-layout/src/client/index.ts
  39. 16 1
      packages/client/ui-layout/src/client/service.ts
  40. 1 0
      packages/client/ui-sidebar/package.json
  41. 131 0
      packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.spec.tsx.snap
  42. 51 0
      packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx
  43. 2 1
      packages/client/ui-slash/src/client/index.ts
  44. 2 2
      packages/client/ui-slash/src/client/service.ts
  45. 1 0
      packages/client/web/package.json
  46. 61 0
      packages/client/web/tests/app-shell.spec.tsx
  47. 65 0
      packages/client/web/tests/app.spec.tsx
  48. 76 0
      pnpm-lock.yaml
  49. 1 0
      scripts/verify-package-readme-model-experience.ts
  50. 1 0
      tsconfig.base.json
  51. 1 0
      tsconfig.client.json

+ 1 - 0
docs/config-catalog.md

@@ -2230,6 +2230,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
 - `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts))
 - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts))
 - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts))
+- `@deepseek-ai/dsh-client-test-runtime` ([`packages/client/test-runtime/src/index.ts`](../packages/client/test-runtime/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts))
 - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts))

+ 6 - 0
docs/module-graph.md

@@ -141,6 +141,7 @@ flowchart TD
     pkg_client_locale["client-locale"]
     pkg_client_modules["client-modules"]
     pkg_client_runtime["client-runtime"]
+    pkg_client_test_runtime["client-test-runtime"]
     pkg_client_ui_command["client-ui-command"]
     pkg_client_ui_conversation["client-ui-conversation"]
     pkg_client_ui_layout["client-ui-layout"]
@@ -276,6 +277,10 @@ flowchart TD
   pkg_client_locale --> pkg_client_ui_primitives
   pkg_client_locale --> pkg_client_ui_slots
   pkg_client_locale --> pkg_invariants
+  pkg_client_test_runtime --> pkg_client_runtime
+  pkg_client_test_runtime --> pkg_client_ui_slots
+  pkg_client_test_runtime --> pkg_client_web_react
+  pkg_client_test_runtime --> pkg_invariants
   pkg_client_ui_models --> pkg_client_runtime
   pkg_client_ui_models --> pkg_client_ui_slots
   pkg_client_ui_models --> pkg_invariants
@@ -946,6 +951,7 @@ flowchart TD
 | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
 | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
 | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
+| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
 | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |

+ 56 - 0
packages/client/runtime/src/client/contract/session.ts

@@ -0,0 +1,56 @@
+/**
+ * The outward session face. Feature packages never see the concrete Session
+ * class: components read conversation state through `useSession` (the
+ * ObservableSnapshot half), and orchestration code calls the behavior verbs
+ * below — nothing else. Widening this interface is the explicit act of
+ * widening what features may do to a session (and what every test fixture
+ * must stub); runtime-internal entry points (history staging, wire-frame
+ * dispatch) stay on the class, invisible out here.
+ */
+import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
+import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
+import type { ConversationSnapshot } from '../sessions/conversation.ts'
+import type { ObservableSnapshot } from './store.ts'
+
+/** Key-addressed projection read face (the useProjection resolution path; see ProjectionValueStore). */
+export interface ProjectionsFace {
+  /**
+   * The identity-stable bare observable for one projection key (absence is
+   * an `undefined` snapshot, never a missing face).
+   * @param key - projection key.
+   * @returns the key's value face.
+   */
+  faceOf(key: string): ObservableSnapshot<unknown>
+}
+
+/** Identity plus the behavior verbs features may invoke on a session. */
+export interface ISession {
+  /** The session's host identity (agent id — same axis). */
+  readonly sessionId: SessionId
+  /** Host-computed projection values by key (the useProjection seat). */
+  readonly projections: ProjectionsFace
+  /**
+   * Send a prompt into the session.
+   * @param content - model-facing content blocks.
+   * @param mode - 'queue' appends a turn; 'steer' interrupts the running one.
+   * @returns acceptance, or the business error (also mirrored into snapshot.promptError).
+   */
+  prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
+  /**
+   * Cancel the running turn.
+   * @returns acceptance, or the business error.
+   */
+  cancel(): Promise<RpcResult<{ accepted: true }>>
+  /**
+   * Extend the history window backwards (older messages pagination).
+   * @returns completion; failures land in snapshot.openState/loadingOlder.
+   */
+  loadOlder(): Promise<void>
+}
+
+/**
+ * The full outward face: behavior verbs plus the conversation read side
+ * (the `useSession` hook source). This is the type carried by
+ * `SessionBinding.session` and the provide channel.
+ */
+export type SessionFace = ISession & ObservableSnapshot<ConversationSnapshot>

+ 47 - 0
packages/client/runtime/src/client/contract/sessions-port.ts

@@ -0,0 +1,47 @@
+/**
+ * Cross-domain sessions face: the contract surface sibling domains (today:
+ * workspaces) consume instead of the sessions implementation. The sessions
+ * domain satisfies it structurally — SessionsService is assignable, checked
+ * wherever the assembly layer or a test injects the real service — so
+ * widening this face is the explicit act of widening the inter-domain
+ * dependency.
+ */
+
+import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
+import type { ObservableSnapshot } from './store.ts'
+
+/** Session-list row facts sibling domains read: recency, blank-reuse eligibility, and its cwd canon. */
+export interface SessionsPortSummary {
+  id: SessionId
+  /** Empty-log bit (blank sessions are reused by New Session instead of minting another). */
+  blank: boolean
+  cwd?: string
+  updatedAt: number
+}
+
+/** Session-list facts sibling domains read: readiness, selection, and the row map. */
+export interface SessionsPortList {
+  ids: SessionId[]
+  byId: Record<SessionId, SessionsPortSummary>
+  current: SessionId | undefined
+  phase: 'pending' | 'ready'
+}
+
+/** The sessions-service face injected into sibling domains. */
+export interface SessionsPort {
+  /** Observable list snapshot (read face only; writes stay inside the sessions domain). */
+  readonly list: ObservableSnapshot<SessionsPortList>
+  /**
+   * Create a session on the host.
+   * @param opts - target workspace.
+   * @returns the new session id.
+   */
+  create(opts: { workspaceId: WorkspaceId }): Promise<SessionId>
+  /**
+   * Select a session as current.
+   * @param id - session id (must exist in the list store).
+   */
+  open(id: SessionId): void
+  /** Clear the current selection into the no-session view state. */
+  clear(): void
+}

+ 64 - 0
packages/client/runtime/src/client/contract/sessions.ts

@@ -0,0 +1,64 @@
+/**
+ * The outward sessions-service face — what `ctx.sessions` exposes to feature
+ * packages and the renderer host, and therefore exactly what the test
+ * runtime's sessions double must implement. Wire-pump entry points
+ * (handleMuxEnvelope/handleConnected/refresh) and runtime internals stay on
+ * the concrete class; cross-domain consumers keep the narrower
+ * [SessionsPort](./sessions-port.ts). Widening this interface is the
+ * explicit act of widening what features may do to the sessions domain.
+ */
+import type { Context } from 'cordis'
+import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
+import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
+import type {
+  SessionBinding, SessionListState, SessionProvideDescriptor,
+} from '../sessions/service.ts'
+import type { SessionFace } from './session.ts'
+import type { ObservableSnapshot } from './store.ts'
+
+/** The sessions-service face injected as `ctx.sessions`. */
+export interface ISessions {
+  /** The useSessions standard feed (list rows + current selection; read face — writes stay inside the domain). */
+  readonly list: ObservableSnapshot<SessionListState>
+  /** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */
+  readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
+  /**
+   * Select a session as current.
+   * @param id - session id (must exist in the list; unknown ids fail loud).
+   */
+  open(id: SessionId): void
+  /** Clear the current selection into the no-session view state. */
+  clear(): void
+  /**
+   * Register a per-session standard-props provider (hooks become `use<Name>`
+   * selector hooks on the render side; props spread verbatim).
+   * @param descriptor - static member roster plus per-session resolver.
+   * @returns disposer removing the provider.
+   */
+  provide(descriptor: SessionProvideDescriptor): () => void
+  /**
+   * Resolve an Agent-scoped context view (use-and-discard).
+   * @param id - session id.
+   * @returns scoped ctx, or undefined for a session neither listed nor already scoped.
+   */
+  scope(id: SessionId): Context | undefined
+  /**
+   * Read the Agent scope tag off a context (service-method seam: fetch
+   * bundles must reach scope resolution through ctx.sessions).
+   * @param ctx - any client context.
+   * @returns the session id, or undefined on root contexts.
+   */
+  scopeOf(ctx: Context): SessionId | undefined
+  /**
+   * Resolve the session face behind an Agent-scoped context.
+   * @param ctx - an Agent-scoped context.
+   * @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
+   */
+  sessionOf(ctx: Context): SessionFace | undefined
+  /**
+   * Resolve the stable session binding (scope-addressed assembly feed).
+   * @param id - session id.
+   * @returns binding, or undefined for a session neither listed nor already scoped.
+   */
+  binding(id: SessionId): SessionBinding | undefined
+}

+ 65 - 0
packages/client/runtime/src/client/contract/workspaces.ts

@@ -0,0 +1,65 @@
+/**
+ * The outward workspaces-service face — what `ctx.workspaces` exposes to
+ * feature packages and the renderer host, and therefore exactly what the
+ * test runtime's workspaces double must implement. Wire-pump entry points
+ * (handleHostEnvelope/handleConnected/refresh/startInitialSelection) stay on
+ * the concrete class. Widening this interface is the explicit act of
+ * widening what features may do to the workspaces domain.
+ */
+import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
+import type { WorkspaceListState } from '../workspaces/service.ts'
+import type { ObservableSnapshot } from './store.ts'
+
+/** The workspaces-service face injected as `ctx.workspaces`. */
+export interface IWorkspaces {
+  /** The useWorkspaces standard feed (read face — writes stay inside the domain). */
+  readonly list: ObservableSnapshot<WorkspaceListState>
+  /**
+   * Connect a Workspace to its reusable or freshly created blank session.
+   * @param workspaceId - target workspace.
+   * @returns the connected session id.
+   */
+  connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId>
+  /**
+   * The New Session flow: connect the target (or recent) Workspace and open
+   * the resulting session; failures surface on the session list state.
+   * @param workspaceId - explicit target; omitted uses the recency projection.
+   */
+  startSession(workspaceId?: WorkspaceId): void
+  /**
+   * Create a Workspace by name or register an existing path.
+   * @param input - exactly one Host create spelling.
+   * @returns the created or idempotently resolved Workspace.
+   */
+  create(input: { name: string } | { path: string }): Promise<WorkspaceView>
+  /**
+   * Open the Host's native directory picker.
+   * @returns the selected path, or null when the user cancelled.
+   */
+  pickDirectory(): Promise<string | null>
+  /**
+   * Open a filesystem path with the Host operating system's default application.
+   * @param path - absolute or host-resolvable path.
+   */
+  openPath(path: string): Promise<void>
+  /**
+   * Rename a Workspace.
+   * @param workspaceId - target workspace.
+   * @param title - the new display title.
+   * @returns the updated Workspace view.
+   */
+  rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView>
+  /**
+   * Delete a Workspace (its sessions fall back to the unaccounted group).
+   * @param workspaceId - target workspace.
+   */
+  delete(workspaceId: WorkspaceId): Promise<void>
+  /**
+   * Move an accounted session within/into a Workspace's ordered list.
+   * @param workspaceId - target workspace.
+   * @param sessionId - accounted session to move.
+   * @param beforeSessionId - accounted anchor to insert before; omitted appends.
+   * @returns the updated Workspace view.
+   */
+  insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView>
+}

+ 11 - 2
packages/client/runtime/src/client/index.ts

@@ -12,10 +12,17 @@ import type { UseProjection } from './sessions/projection-store.ts'
 export { SlotsService } from './slots.ts'
 export type { RootOwnerProps } from './slots.ts'
 export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
+// The provide channel is shared with the client test runtime (one
+// materialization/projection implementation; no test-side mirror to drift).
+export { SessionProvideChannel } from './sessions/provide.ts'
+export type { SessionProvideChannelHost } from './sessions/provide.ts'
 export { createScope } from './agents/scope.ts'
 export type { AgentScopeHandle } from './agents/scope.ts'
 export { WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
 export type { Session } from './sessions/session.ts'
+export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
+export type { ISessions } from './contract/sessions.ts'
+export type { IWorkspaces } from './contract/workspaces.ts'
 export type {
   SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
 } from './sessions/service.ts'
@@ -109,8 +116,10 @@ declare module 'cordis' {
   }
   interface Context {
     slots: import('./slots.ts').SlotsService
-    sessions: import('./sessions/service.ts').SessionsService
-    workspaces: import('./workspaces/service.ts').WorkspacesService
+    /** The outward face only; the concrete service stays inside the runtime. */
+    sessions: import('./contract/sessions.ts').ISessions
+    /** The outward face only; the concrete service stays inside the runtime. */
+    workspaces: import('./contract/workspaces.ts').IWorkspaces
   }
 }
 

+ 190 - 0
packages/client/runtime/src/client/sessions/provide.ts

@@ -0,0 +1,190 @@
+/**
+ * The session standard-props provide channel: provider roster, bundle
+ * materialization (fail-loud on undeclared/missing/duplicate members), the
+ * static no-session projection, and the atomic current-session projection
+ * observable. One implementation — SessionsService drives it from wire
+ * truth, the test runtime's sessions double drives it from fixtures — so
+ * the materialization rules and the projection semantics cannot drift
+ * between production and the test bench.
+ */
+import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SessionBinding, SessionProvideDescriptor } from './service.ts'
+
+/** The owner-side hooks: how the channel reaches the owner's live bundles and current selection. */
+export interface SessionProvideChannelHost {
+  /**
+   * Re-materialize every already-materialized bundle against the new roster
+   * (call {@link SessionProvideChannel.materializeInfo} per live binding).
+   * Lazily-materialized sessions pick the new roster up on first resolve.
+   */
+  rebuildBundles(): void
+  /** Resolve the current selection's bundle (the owner's maybe-provide lookup). */
+  resolveCurrent(): SessionMaybeProvideInfo
+}
+
+/**
+ * Provider roster + materialization + current projection. The channel owns
+ * every rule a provider contribution must satisfy; owners keep only their
+ * per-session bundle storage and the definition of "current".
+ */
+export class SessionProvideChannel {
+  private readonly providers: SessionProvideDescriptor[] = []
+  private maybeInfoCache: SessionMaybeProvideInfo
+  /** Latest published current bundle (identity comparison dedupes republish). */
+  private currentSnapshot: SessionMaybeProvideInfo
+  /** Projection subscribers (plain cell: bundles hold live session sources, so no store freeze may touch them). */
+  private readonly listeners = new Set<() => void>()
+
+  /**
+   * Atomic current-session provide projection: selection changes and
+   * provider-roster changes publish through this one source, so a roster
+   * change under a stable current id republishes the bundle instead of
+   * stranding mounted entries.
+   */
+  readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
+
+  /**
+   * @param host - owner-side bundle storage and current-selection resolution.
+   */
+  constructor(private readonly host: SessionProvideChannelHost) {
+    // The runtime's own contribution comes first: useSession rides the same
+    // provide channel every plugin uses (no renderer special case).
+    this.providers.push({
+      hooks: ['session'],
+      resolve: binding => ({ hooks: { session: binding.session } }),
+    })
+    this.maybeInfoCache = this.materializeMaybeInfo()
+    this.currentSnapshot = this.maybeInfoCache
+    this.currentProvideInfo = {
+      getSnapshot: () => this.currentSnapshot,
+      subscribe: (fn) => {
+        this.listeners.add(fn)
+        return () => { this.listeners.delete(fn) }
+      },
+    }
+  }
+
+  /** The static no-session projection under the current roster (declared names present, values undefined). */
+  get maybeInfo(): SessionMaybeProvideInfo {
+    return this.maybeInfoCache
+  }
+
+  /**
+   * Register a per-session standard-props provider (see
+   * SessionsService.provide for the product contract). Live bundles rebuild
+   * immediately; misdeclared providers fail loud here, at the registration
+   * edge, and the registration rolls back — the channel never stays on a
+   * roster it cannot materialize.
+   * @param descriptor - static member roster plus per-session resolver.
+   * @returns disposer removing the provider.
+   */
+  provide(descriptor: SessionProvideDescriptor): () => void {
+    this.providers.push(descriptor)
+    try {
+      this.applyRosterChange()
+    } catch (error) {
+      this.providers.splice(this.providers.indexOf(descriptor), 1)
+      // Restore the previous (valid) roster's bundles; cannot rethrow — the
+      // pre-push roster materialized successfully before.
+      this.applyRosterChange()
+      throw error
+    }
+    return () => {
+      const at = this.providers.indexOf(descriptor)
+      if (at >= 0) this.providers.splice(at, 1)
+      this.applyRosterChange()
+    }
+  }
+
+  /**
+   * Re-derive the current selection's bundle and publish it when it changed.
+   * Bundles are identity-stable per (scope, roster) materialization, so an
+   * identity compare is exact; synchronous notify — call sites (the owner's
+   * list subscription, provide()) already sit behind their own batching or
+   * registration edges.
+   */
+  publishCurrent(): void {
+    const next = this.host.resolveCurrent()
+    if (next === this.currentSnapshot) return
+    this.currentSnapshot = next
+    for (const fn of [...this.listeners]) {
+      try {
+        fn()
+      } catch (error) {
+        // Contain subscriber failures: this notify runs inside the list
+        // notification, where a throwing render-side subscriber would starve
+        // later listeners and abort the projection pass that scheduled it.
+        console.error('sessions.currentProvideInfo subscriber failed:', error)
+      }
+    }
+  }
+
+  /**
+   * Materialize the standard-props bundle for one session (fails loud on
+   * undeclared, missing, and duplicate member names).
+   * @param binding - session assembly handle fed to every resolver.
+   * @returns the materialized bundle (identity-stable until the next materialization).
+   */
+  materializeInfo(binding: SessionBinding): SessionProvideInfo {
+    const hooks: Record<string, HostObservable<unknown>> = {}
+    const props: Record<string, unknown> = {}
+    for (const descriptor of this.providers) {
+      const contribution = descriptor.resolve(binding)
+      const contributedHooks = contribution.hooks ?? {}
+      const contributedProps = contribution.props ?? {}
+      for (const name of Object.keys(contributedHooks)) {
+        if (!(descriptor.hooks ?? []).includes(name)) {
+          throw new Error(`sessions.provide: undeclared hook "${name}"`)
+        }
+      }
+      for (const name of Object.keys(contributedProps)) {
+        if (!(descriptor.props ?? []).includes(name)) {
+          throw new Error(`sessions.provide: undeclared prop "${name}"`)
+        }
+      }
+      for (const name of descriptor.hooks ?? []) {
+        const source = contributedHooks[name]
+        if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
+        if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
+        hooks[name] = source
+      }
+      for (const name of descriptor.props ?? []) {
+        if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
+        if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
+        props[name] = contributedProps[name]
+      }
+    }
+    return {
+      sessionId: binding.sessionId,
+      hooks,
+      props,
+      // The useProjection seat: key-addressed bare value faces off the
+      // session's projection store (open key space — never a static roster member).
+      projections: { faceOf: key => binding.session.projections.faceOf(key) },
+    }
+  }
+
+  /** Rebuild the static projection and the owner's live bundles, then republish the current one. */
+  private applyRosterChange(): void {
+    this.maybeInfoCache = this.materializeMaybeInfo()
+    this.host.rebuildBundles()
+    this.publishCurrent()
+  }
+
+  /** Build the static no-session kit and reject duplicate declared names. */
+  private materializeMaybeInfo(): SessionMaybeProvideInfo {
+    const hooks: Record<string, undefined> = {}
+    const props: Record<string, undefined> = {}
+    for (const descriptor of this.providers) {
+      for (const name of descriptor.hooks ?? []) {
+        if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
+        hooks[name] = undefined
+      }
+      for (const name of descriptor.props ?? []) {
+        if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
+        props[name] = undefined
+      }
+    }
+    return { sessionId: undefined, hooks, props } // no projections face: every key reads absent without a session
+  }
+}

+ 29 - 129
packages/client/runtime/src/client/sessions/service.ts

@@ -22,9 +22,12 @@ import type {
 } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SnapshotStore } from '../contract/store.ts'
 import { createSnapshotStore } from '../contract/store.ts'
+import type { SessionFace } from '../contract/session.ts'
+import type { ISessions } from '../contract/sessions.ts'
 import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
 import { SessionManager } from './manager.ts'
 import type { SessionListPhase } from './manager.ts'
+import { SessionProvideChannel } from './provide.ts'
 import type { Session } from './session.ts'
 
 /** Session list row projected from the host list RPC plus live stream increments. */
@@ -79,7 +82,8 @@ export class SessionCreateError extends Error {
 /** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
 export interface SessionBinding {
   readonly sessionId: SessionId
-  readonly session: Session
+  /** The outward session face only — feature code never sees the concrete class. */
+  readonly session: SessionFace
   readonly ctx: Context
 }
 
@@ -119,6 +123,8 @@ interface ScopeRecord {
   fiber: Fiber
   ctx: Context
   binding: SessionBinding
+  /** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */
+  session: Session
   /** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */
   provideInfo: SessionProvideInfo
 }
@@ -146,7 +152,7 @@ export interface SessionProvideDescriptor {
 }
 
 /** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
-export class SessionsService {
+export class SessionsService implements ISessions {
   /** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
   readonly list: SnapshotStore<SessionListState>
   /** The object-layer instance cluster and frame dispatch entry. */
@@ -170,14 +176,8 @@ export class SessionsService {
   private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
 
   private readonly scopes = new Map<SessionId, ScopeRecord>()
-  /** Registered per-session standard-props providers, in registration order. */
-  private readonly providers: SessionProvideDescriptor[] = []
-  /** Static no-session projection, rebuilt only when the provider roster changes. */
-  private maybeInfo: SessionMaybeProvideInfo
-  /** Latest published {@link SessionsService.currentProvideInfo} bundle (identity comparison dedupes republish). */
-  private currentProvideInfoSnapshot: SessionMaybeProvideInfo
-  /** currentProvideInfo subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */
-  private readonly currentProvideInfoListeners = new Set<() => void>()
+  /** The provide channel (roster, materialization rules, current projection) — shared with the test runtime's double. */
+  private readonly provideChannel: SessionProvideChannel
   /**
    * The staged session id — follows `list.current` exactly, holding its last
    * defined value across masked gaps (a transiently absent selection blanks
@@ -212,23 +212,17 @@ export class SessionsService {
     // The current-provide projection follows the same current writes.
     this.list.subscribe(() => {
       this.followCurrent()
-      this.updateCurrentProvideInfo()
+      this.provideChannel.publishCurrent()
     })
-    // The runtime's own contribution comes first: useSession rides the same
-    // provide channel every plugin uses (no renderer special case).
-    this.providers.push({
-      hooks: ['session'],
-      resolve: binding => ({ hooks: { session: binding.session } }),
-    })
-    this.maybeInfo = this.materializeMaybeProvideInfo()
-    this.currentProvideInfoSnapshot = this.maybeInfo
-    this.currentProvideInfo = {
-      getSnapshot: () => this.currentProvideInfoSnapshot,
-      subscribe: (fn) => {
-        this.currentProvideInfoListeners.add(fn)
-        return () => { this.currentProvideInfoListeners.delete(fn) }
+    this.provideChannel = new SessionProvideChannel({
+      rebuildBundles: () => {
+        for (const record of this.scopes.values()) {
+          record.provideInfo = this.provideChannel.materializeInfo(record.binding)
+        }
       },
-    }
+      resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current),
+    })
+    this.currentProvideInfo = this.provideChannel.currentProvideInfo
     rootCtx.reflect.provide('sessions', this, undefined)
   }
 
@@ -243,105 +237,10 @@ export class SessionsService {
    * @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops).
    */
   provide(descriptor: SessionProvideDescriptor): () => void {
-    this.providers.push(descriptor)
     // Scopes may already exist (boot order: the list lands and resolves
-    // scopes before later plugins register) — their bundles must include
-    // every provider by first render, so re-materialize on roster change.
-    this.rematerializeProvideBundles()
-    return () => {
-      const at = this.providers.indexOf(descriptor)
-      if (at >= 0) this.providers.splice(at, 1)
-      this.rematerializeProvideBundles()
-    }
-  }
-
-  /** Rebuild every live scope's standard-props bundle after a provider roster change. */
-  private rematerializeProvideBundles(): void {
-    this.maybeInfo = this.materializeMaybeProvideInfo()
-    for (const record of this.scopes.values()) {
-      record.provideInfo = this.materializeProvideInfo(record.binding)
-    }
-    this.updateCurrentProvideInfo()
-  }
-
-  /**
-   * Re-derive the current selection's provide bundle and publish it when it
-   * changed. Bundles are identity-stable per (scope, roster)
-   * materialization, so an identity compare is exact; synchronous notify —
-   * both call sites (list.subscribe, provide()) already sit behind their own
-   * batching or registration edges.
-   */
-  private updateCurrentProvideInfo(): void {
-    const next = this.maybeProvideInfo(this.list.getSnapshot().current)
-    if (next === this.currentProvideInfoSnapshot) return
-    this.currentProvideInfoSnapshot = next
-    for (const fn of [...this.currentProvideInfoListeners]) {
-      try {
-        fn()
-      } catch (error) {
-        // Contain subscriber failures: this notify runs inside the list
-        // notification, where a throwing render-side subscriber would starve
-        // later listeners and abort the projection pass that scheduled it.
-        console.error('sessions.currentProvideInfo subscriber failed:', error)
-      }
-    }
-  }
-
-  /** Build the static no-session kit and reject duplicate declared names. */
-  private materializeMaybeProvideInfo(): SessionMaybeProvideInfo {
-    const hooks: Record<string, undefined> = {}
-    const props: Record<string, undefined> = {}
-    for (const descriptor of this.providers) {
-      for (const name of descriptor.hooks ?? []) {
-        if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
-        hooks[name] = undefined
-      }
-      for (const name of descriptor.props ?? []) {
-        if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
-        props[name] = undefined
-      }
-    }
-    return { sessionId: undefined, hooks, props } // no projections face: every key reads absent without a session
-  }
-
-  /** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
-  private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo {
-    const hooks: Record<string, HostObservable<unknown>> = {}
-    const props: Record<string, unknown> = {}
-    for (const descriptor of this.providers) {
-      const contribution = descriptor.resolve(binding)
-      const contributedHooks = contribution.hooks ?? {}
-      const contributedProps = contribution.props ?? {}
-      for (const name of Object.keys(contributedHooks)) {
-        if (!(descriptor.hooks ?? []).includes(name)) {
-          throw new Error(`sessions.provide: undeclared hook "${name}"`)
-        }
-      }
-      for (const name of Object.keys(contributedProps)) {
-        if (!(descriptor.props ?? []).includes(name)) {
-          throw new Error(`sessions.provide: undeclared prop "${name}"`)
-        }
-      }
-      for (const name of descriptor.hooks ?? []) {
-        const source = contributedHooks[name]
-        if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
-        if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
-        hooks[name] = source
-      }
-      for (const name of descriptor.props ?? []) {
-        if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
-        if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
-        props[name] = contributedProps[name]
-      }
-    }
-    return {
-      sessionId: binding.sessionId,
-      hooks,
-      props,
-      // The useProjection seat: key-addressed bare value faces off the
-      // session's projection store (open key space — never a static roster member).
-      projections: { faceOf: key => binding.session.projections.faceOf(key) },
-    }
+    // scopes before later plugins register) — the channel rebuilds their
+    // bundles through the host hooks so every provider lands by first render.
+    return this.provideChannel.provide(descriptor)
   }
 
   /**
@@ -439,9 +338,9 @@ export class SessionsService {
    * `agent.session`). Same service-method seam as
    * {@link SessionsService.scopeOf}.
    * @param ctx - an Agent-scoped context.
-   * @returns the Session, or undefined when the ctx is untagged or its scope was pruned.
+   * @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
    */
-  sessionOf(ctx: Context): Session | undefined {
+  sessionOf(ctx: Context): SessionFace | undefined {
     const id = scopeTagOf(ctx)
     if (id === undefined) return undefined
     return this.scopes.get(id)?.binding.session
@@ -473,7 +372,7 @@ export class SessionsService {
    * return the static no-session projection rather than removing hook props.
    */
   private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
-    return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
+    return (id === undefined ? undefined : this.provideInfo(id)) ?? this.provideChannel.maybeInfo
   }
 
   /**
@@ -497,7 +396,7 @@ export class SessionsService {
      * validates and the projection masks absent selections), so resolve
      * cannot miss; kept so a future current writer cannot crash the notify. */
     if (record !== undefined) {
-      void record.binding.session.open()
+      void record.session.open()
     }
   }
 
@@ -540,8 +439,9 @@ export class SessionsService {
       fiber,
       ctx,
       binding,
+      session,
       // Sources are bare observables; React binds selector hooks at its own seam.
-      provideInfo: this.materializeProvideInfo(binding),
+      provideInfo: this.provideChannel.materializeInfo(binding),
     }
     this.scopes.set(id, record)
     return record
@@ -608,7 +508,7 @@ export class SessionsService {
     void record.fiber.dispose()
     // Release the Session's dispatch point with the scope it belongs to (a
     // surviving instance — the live Intent — rebinds when resolve re-mints).
-    record.binding.session.unbindScope()
+    record.session.unbindScope()
     // Optional lookup: slots and sessions are sibling services with no
     // declared dependency; a slots-less boot (object-layer tests) skips.
     this.rootCtx.get('slots')?.pruneStoreScope(id)

+ 5 - 3
packages/client/runtime/src/client/sessions/session.ts

@@ -10,7 +10,7 @@ import type {
 // Value import from the inline-safe wire layer (not the connection plugin):
 // plugin-to-plugin value imports are a bundle purity error.
 import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
-import type { ObservableSnapshot } from '../contract/store.ts'
+import type { SessionFace } from '../contract/session.ts'
 import type {
   CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
   PromptError, QueuedMessage, RunningToolCall,
@@ -67,9 +67,11 @@ function queuePreviewOf(content: readonly ContentBlock[]): string {
 
 /**
  * Owns a session's event window, derived conversation state, and observable
- * snapshot. React bindings remain outside this data layer.
+ * snapshot. React bindings remain outside this data layer. Features see only
+ * the {@link SessionFace} slice (ISession verbs + the snapshot source); the
+ * remaining public members are manager/runtime entry points.
  */
-export class Session implements ObservableSnapshot<ConversationSnapshot> {
+export class Session implements SessionFace {
   // ---- Window and derived state (all private; the snapshot is the only read surface) ----
   private events: SessionEvent[] = []
   /** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).

+ 6 - 5
packages/client/runtime/src/client/workspaces/service.ts

@@ -6,7 +6,8 @@ import type {
 } from '@deepseek-ai/dsh-client-connection/client'
 import type { SnapshotStore } from '../contract/store.ts'
 import { createSnapshotStore } from '../contract/store.ts'
-import type { SessionsService } from '../sessions/service.ts'
+import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts'
+import type { IWorkspaces } from '../contract/workspaces.ts'
 import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts'
 
 /** Workspace list plus the two-baseline readiness and default-target projection. */
@@ -30,7 +31,7 @@ export class WorkspaceCreateError extends Error {
 }
 
 /** Real Workspace object layer and Host actions. */
-export class WorkspacesService {
+export class WorkspacesService implements IWorkspaces {
   /** UI-facing immutable projection; the manager remains wire truth. */
   readonly list: SnapshotStore<WorkspaceListState>
   /** Workspace baseline and frame owner. */
@@ -43,9 +44,9 @@ export class WorkspacesService {
   /**
    * @param ctx - client root context.
    * @param api - shared wire client.
-   * @param sessions - lower-level Session service used for recency and blank-session reuse.
+   * @param sessions - cross-domain sessions face used for recency and blank-session reuse.
    */
-  constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsService) {
+  constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsPort) {
     this.manager = new WorkspaceManager(api)
     this.list = createSnapshotStore<WorkspaceListState>({
       items: [], state: 'idle', phase: 'pending', error: null,
@@ -271,7 +272,7 @@ export class WorkspacesService {
 /** Stable tie-breaking follows Host Workspace order. */
 function recentWorkspace(
   workspaces: readonly WorkspaceView[],
-  sessions: ReturnType<SessionsService['list']['getSnapshot']>['byId'],
+  sessions: SessionsPortList['byId'],
 ): WorkspaceId | undefined {
   let selected: WorkspaceId | undefined
   let selectedTime = Number.NEGATIVE_INFINITY

+ 6 - 0
packages/client/test-runtime/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/client/test-runtime/README.md
+README.md: 883d71224139dc409229fdfb35362d040e810cc7
+README.zh.md: a3daf112940b03b585d44bc5fd1317e43ebd35fe

+ 24 - 0
packages/client/test-runtime/README.md

@@ -0,0 +1,24 @@
+# @deepseek-ai/dsh-client-test-runtime
+
+English | [中文](README.zh.md)
+
+jsdom slot test runtime for client feature specs: a real Cordis `Context`, the production `SlotsService` and web-react renderer, assembled around typed session/workspace doubles. Feature suites exercise declaration, registration, scope, store, inject, rendering, updates, and disposal without hand-building the machinery per suite — and without a second implementation of any production logic.
+
+The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionsService`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face.
+
+Local DOM snapshots: `declare(children)` registers an auto frame whose per-key `<div data-slot>` wrappers are snapshot roots; `renderSlot(key, owner)` returns the slot-local view (container, scoped Testing Library queries, in-place `update(owner)`); a registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3` → `frame`) to keep `.snap` files structural and collapses `<svg>` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead; `mount(plugin)` runs a real fiber with fail-loud service prechecks, and `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis.
+
+Not part of the product plugin graph (no `dshClient`); feature packages depend on it in `devDependencies` only.
+
+## Model Experience
+
+None, as this package is browser-side test infrastructure; nothing here reaches a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **Consumed through repository source aliases only.** Specs resolve the package through tsconfig `paths` to `src`; the built `lib/` artifact re-exports `@deepseek-ai/dsh-client-runtime/client`, whose bundle is a browser loader script with no Node ESM exports, so `lib/index.js` is not importable under plain Node. Acceptable while every consumer is an in-repo Vitest suite; a Node-compatible runtime entry is deferred until an out-of-repo consumer exists.
+- **Conversation snapshots are fixture data, not replayed history.** `updateSnapshot` writes the snapshot store directly; the wire-to-snapshot computation stays covered by the runtime package's own tests and the replay e2e. A fixture can therefore express states the production fold would never produce.

+ 24 - 0
packages/client/test-runtime/README.zh.md

@@ -0,0 +1,24 @@
+# @deepseek-ai/dsh-client-test-runtime
+
+[English](README.md) | 中文
+
+面向 client feature 测试的 jsdom slot 测试运行时:真实 Cordis `Context`、生产 `SlotsService` 与 web-react 渲染器,围绕带类型的 session/workspace 测试替身组装。feature 套件无需逐套件手搭机器即可测遍声明、注册、scope、store、inject、渲染、更新与销毁——且不存在任何生产逻辑的第二份实现。
+
+替身实现的正是 feature 经 ctx 拿到的对外面(`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`),生产面一旦改形,测试台在编译期即断,而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写)、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。
+
+局部 DOM 快照:`declare(children)` 注册自动 frame,逐 key 的 `<div data-slot>` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图(container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3` → `frame`)保持 `.snap` 只含结构,并把 `<svg>` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)`;`mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。
+
+不属于产品插件图(无 `dshClient`);feature 包仅以 `devDependencies` 依赖之。
+
+## Model Experience
+
+无;本包是浏览器侧测试基础设施,无一物到达模型请求。
+
+#### KV Cache effect
+
+无;本包既不组装也不发送 provider 请求。
+
+## Known Limitations and Deferred Work
+
+- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。当前所有消费方都是仓内 Vitest 套件,可接受;Node 兼容的运行时入口待出现仓外消费方再补。
+- **会话快照是 fixture 数据,不是重放历史。** `updateSnapshot` 直写快照 store;wire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产折叠永不产出的状态。

+ 54 - 0
packages/client/test-runtime/package.json

@@ -0,0 +1,54 @@
+{
+  "name": "@deepseek-ai/dsh-client-test-runtime",
+  "description": "jsdom slot test runtime: real Cordis Context + SlotsService + web-react renderer with test-owned session/workspace doubles for feature specs",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "license": "BSD-3-Clause",
+  "dependencies": {
+    "@testing-library/dom": "^10.4.1",
+    "@testing-library/react": "^16.3.2",
+    "vitest": "^4.1.8"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/dsh-client-runtime": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
+    "@deepseek-ai/dsh-client-web-react": "^0.0.1",
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "cordis": "^4.0.0-rc.7",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-client-web-react": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@types/react": "~18.3.1",
+    "@types/react-dom": "~18.3.0",
+    "cordis": "^4.0.0-rc.7",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ]
+}

+ 82 - 0
packages/client/test-runtime/src/fixtures.ts

@@ -0,0 +1,82 @@
+/** Session/workspace fixture shapes and snapshot defaults for the test runtime. */
+import type {
+  ConversationSnapshot, ISession, SessionId, SessionSummary, WorkspaceListState,
+} from '@deepseek-ai/dsh-client-runtime/client'
+
+/**
+ * Fixture overrides for the session behavior face: any subset of the
+ * production ISession verbs (typed against it, so a face change surfaces
+ * here at compile time), plus extra members feature-specific casts consume.
+ * The open Record tail means a misnamed EXTRA member is not caught by the
+ * compiler (it grafts as dead weight); the ISession verbs stay safe — a
+ * misnamed verb leaves the fail-loud stub in place, which names itself at
+ * the first call.
+ */
+export type SessionBehaviorOverrides = Partial<ISession> & Record<string, unknown>
+
+/**
+ * act-wrapped mutation runner shared by every runtime object: public mutators
+ * funnel through it so tests never handle SlotCore microtask batching or
+ * React act themselves.
+ */
+export type Stabilizer = (fn: () => void | Promise<void>) => Promise<void>
+
+/**
+ * Session fixture accepted by {@link TestSessions.add}: identity plus optional
+ * snapshot/list-row overrides and the session behavior face the feature under
+ * test actually calls (kept open — the runtime never fakes methods a test did
+ * not supply, so an unstubbed call fails loud at the call site).
+ */
+export interface SessionFixture {
+  id: string
+  /** Overrides merged over {@link conversationSnapshot} (sessionId comes from `id`). */
+  snapshot?: Partial<Omit<ConversationSnapshot, 'sessionId'>>
+  /** List-row overrides merged over the defaults derived from `id`. */
+  summary?: Partial<Omit<SessionSummary, 'id'>>
+  /** Session behavior face: exactly the methods the feature under test calls (ISession subset + extras). */
+  session?: SessionBehaviorOverrides
+}
+
+/**
+ * A complete quiescent conversation snapshot (open window, no traffic).
+ * @param sessionId - owning session id.
+ * @returns the snapshot; spread fixture overrides on top.
+ */
+export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot {
+  return {
+    sessionId,
+    nodes: [],
+    foldDegraded: false,
+    partial: null,
+    runningCalls: [],
+    codeDispatches: new Map(),
+    pending: [],
+    queue: [],
+    running: false,
+    composerPhase: 'active',
+    removed: false,
+    openState: 'open',
+    openError: null,
+    hasMore: false,
+    loadingOlder: false,
+    promptError: null,
+    blank: false,
+    lastAgentError: null,
+  }
+}
+
+/**
+ * A ready workspace list with no workspaces (the shape WorkspacesService
+ * projects after both baselines land).
+ * @returns the initial state of the test workspaces store.
+ */
+export function workspaceListState(): WorkspaceListState {
+  return {
+    items: [],
+    state: 'idle',
+    phase: 'ready',
+    error: null,
+    baselinesReady: true,
+    recentWorkspaceId: undefined,
+  }
+}

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

@@ -0,0 +1,372 @@
+/**
+ * jsdom slot test runtime: a real small runtime — Cordis `Context`, the
+ * runtime `SlotsService`, and the web-react renderer — assembled around
+ * test-owned session/workspace doubles, so feature specs exercise
+ * declaration, registration, scope, store, inject, rendering, updates, and
+ * disposal without hand-building the machinery per suite.
+ *
+ * Not part of the product plugin graph (no `dshClient`); feature packages
+ * depend on it in devDependencies only. It copies no SlotCore/renderer/store
+ * machinery — everything mounts the production implementations.
+ * @module @deepseek-ai/dsh-client-test-runtime
+ */
+/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
+ * `keyof SlotMap & string` is the declare-merge key pattern (see ui-slots):
+ * this compilation unit sees only the runtime's 'root' row, but consumer
+ * programs merge their own keys in; the rule fires on the narrow-map view. */
+import { Context, Inject } from 'cordis'
+import type { Fiber, Plugin } from 'cordis'
+import { createElement, Fragment, useSyncExternalStore } from 'react'
+import type { ReactNode } from 'react'
+import { act, render, within } from '@testing-library/react'
+import type { RenderResult } from '@testing-library/react'
+import type { queries } from '@testing-library/dom'
+import type { BoundFunctions } from '@testing-library/dom'
+import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
+import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
+import type {
+  ChildrenDecl, ComposedProps, OwnerOf, SlotComponent, SlotMap, SlotRendererHost, StoreInstanceLike,
+} from '@deepseek-ai/dsh-client-ui-slots'
+import { registerDomSnapshotSerializer } from './snapshot.ts'
+import { TestSessions } from './sessions.ts'
+import { TestWorkspaces } from './workspaces.ts'
+import type { Stabilizer } from './fixtures.ts'
+
+export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts'
+export { FixtureSession, TestSessions } from './sessions.ts'
+export { TestWorkspaces } from './workspaces.ts'
+export { conversationSnapshot, workspaceListState } from './fixtures.ts'
+export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
+
+/** Erased register face for the internal root call (the public declare seam holds the typing). */
+type ErasedRegister = (options: object, component: unknown) => () => void
+
+/**
+ * One rendered slot's local view, from {@link SlotTestRuntime.renderSlot}:
+ * the `data-slot` wrapper is the snapshot root (`expect(view.container)
+ * .toMatchSnapshot()` captures exactly this slot's output), Testing Library
+ * queries are bound inside it, and `update` re-renders with new owner props.
+ */
+export interface SlotView<K extends keyof SlotMap & string> {
+  /** The `<div data-slot="<key>">` wrapper around the slot's rendered output. */
+  readonly container: HTMLElement
+  /** Testing Library queries scoped to {@link SlotView.container}. */
+  readonly view: BoundFunctions<typeof queries>
+  /**
+   * Replace the owner props and flush the re-render (the render-site update:
+   * in production the owner recomputes the share and React re-renders).
+   * @param owner - the next owner props share.
+   */
+  update(owner: OwnerOf<K>): void
+}
+
+/**
+ * Mounted feature plugin handle: the live fiber plus an act-wrapped,
+ * idempotent dispose (unload cascade: entries, declared child slots, store
+ * instances, and provided services all fall together).
+ */
+export interface FeatureHandle {
+  /** The plugin's live Cordis fiber (state assertions, escape hatch). */
+  readonly fiber: Fiber
+  /**
+   * Dispose the plugin fiber inside React act; repeated calls no-op.
+   * @returns completion of the unload cascade.
+   */
+  dispose(): Promise<void>
+}
+
+/**
+ * Owner-props cell behind the auto frame: one external store the frame
+ * subscribes to, so {@link SlotTestRuntime.renderSlot} and
+ * {@link SlotView.update} drive React through the standard uSES seam.
+ */
+class OwnerPropsCell {
+  private readonly owners = new Map<string, object>()
+  private readonly listeners = new Set<() => void>()
+  private version = 0
+
+  /** Snapshot version for uSES pairing (bumped on every set). */
+  readonly getVersion = (): number => this.version
+
+  /**
+   * Subscribe to owner-props changes.
+   * @param fn - change callback.
+   * @returns unsubscribe.
+   */
+  readonly subscribe = (fn: () => void): (() => void) => {
+    this.listeners.add(fn)
+    return () => { this.listeners.delete(fn) }
+  }
+
+  /**
+   * Install or replace one key's owner props and notify (synchronous; the
+   * caller wraps in act).
+   * @param key - slot key.
+   * @param owner - owner props share.
+   */
+  set(key: string, owner: object): void {
+    this.owners.set(key, owner)
+    this.version += 1
+    for (const fn of [...this.listeners]) fn()
+  }
+
+  /** Keys with supplied owner props, in first-supply order. */
+  entries(): readonly (readonly [string, object])[] {
+    return [...this.owners.entries()]
+  }
+}
+
+/**
+ * The test-owned 'root' occupant: declares the child slots a suite needs
+ * through the REAL `slots.register`, with a caller-supplied minimal frame —
+ * the runtime never guesses a feature's page structure.
+ */
+export class TestRoot {
+  private disposeEntry: (() => void) | undefined
+
+  /**
+   * @param slots - the runtime SlotsService.
+   * @param stabilize - the owning runtime's act wrapper.
+   */
+  constructor(private readonly slots: SlotsService, private readonly stabilize: Stabilizer) {}
+
+  /**
+   * Register the root frame, declaring (and thereby claiming) the child
+   * slots. One declaration per runtime — a second call fails loud in the
+   * core ('root' is a single slot).
+   * @param children - child-slot declaration table (declaration + render authorization + runtime spec).
+   * @param frame - minimal frame component; its props derive from the declared keys (composed-props contract).
+   * @returns completion of the act-wrapped registration.
+   */
+  async declare<const D extends ChildrenDecl>(
+    children: D,
+    frame: SlotComponent<ComposedProps<'root', keyof NoInfer<D> & keyof SlotMap & string, undefined, object>>,
+  ): Promise<void> {
+    await this.stabilize(() => {
+      // Erased hop (same pattern as SlotsService's own implementation arm);
+      // the declare signature above is the typed seam.
+      this.disposeEntry = (this.slots.register as unknown as ErasedRegister)({ name: 'root', children }, frame)
+    })
+  }
+
+  /** Remove the root registration and collapse its declarations (runtime dispose path). */
+  release(): void {
+    this.disposeEntry?.()
+    this.disposeEntry = undefined
+  }
+}
+
+/**
+ * The assembled test runtime. Obtain via {@link SlotTestRuntime.create};
+ * dispose with {@link SlotTestRuntime.dispose} (afterEach). Public mutators
+ * are act-wrapped throughout — tests never handle SlotCore microtask
+ * batching or React act themselves.
+ */
+export class SlotTestRuntime {
+  /** The runtime's Cordis root (escape hatch: extra services via `ctx.provide`, raw `ctx.plugin` mounts). */
+  readonly ctx: Context
+  /** The production SlotsService mounted on {@link SlotTestRuntime.ctx}. */
+  readonly slots: SlotsService
+  /** The test-owned 'root' occupant. */
+  readonly root: TestRoot
+  /** Sessions double (list/current observable, cells, scopes, behavior faces). */
+  readonly sessions: TestSessions
+  /** Workspaces double (list observable, recorded intent actions). */
+  readonly workspaces: TestWorkspaces
+
+  private readonly stabilizer: Stabilizer = async (fn) => {
+    await act(async () => { await fn() })
+  }
+
+  private host: SlotRendererHost | undefined
+  private readonly views: RenderResult[] = []
+  private readonly handles: FeatureHandle[] = []
+  private disposed = false
+  /** Auto-frame state ({@link SlotTestRuntime.declare} / {@link SlotTestRuntime.renderSlot}). */
+  private readonly ownerCell = new OwnerPropsCell()
+  private readonly autoDeclared = new Set<string>()
+  private autoRootView: RenderResult | undefined
+
+  private constructor(ctx: Context, slots: SlotsService) {
+    this.ctx = ctx
+    this.slots = slots
+    this.root = new TestRoot(slots, this.stabilizer)
+    this.sessions = new TestSessions(this.stabilizer, ctx)
+    this.workspaces = new TestWorkspaces(this.stabilizer)
+    ctx.provide('sessions', this.sessions)
+    ctx.provide('workspaces', this.workspaces)
+    // Capturing install: the production renderer does the rendering; the
+    // wrapper only takes the host face for storeOf (no machinery copied).
+    const renderer = createSlotRenderer()
+    slots.install({
+      renderRoot: (host, ownerProps) => {
+        this.host = host
+        return renderer.renderRoot(host, ownerProps)
+      },
+    })
+  }
+
+  /**
+   * Assemble a runtime: real Context, mounted SlotsService, installed
+   * renderer, and the session/workspace doubles provided as services.
+   * @returns the ready runtime.
+   */
+  static async create(): Promise<SlotTestRuntime> {
+    registerDomSnapshotSerializer()
+    const ctx = new Context()
+    const fiber = ctx.plugin(SlotsService)
+    await fiber.await()
+    return new SlotTestRuntime(ctx, ctx.get('slots') as SlotsService)
+  }
+
+  /**
+   * Provide an extra service the feature under test injects (e.g. a layout
+   * fake). Sugar over `ctx.provide`, typed against the Context declaration
+   * merge: for a declared service name the fake must be a subset of that
+   * service's outward face (Partial — supply only what the feature calls),
+   * so a production face change breaks the fake at compile time. Undeclared
+   * names stay unchecked (ad-hoc test services).
+   * @param name - service name.
+   * @param value - service implementation (test double).
+   */
+  provide<K extends string>(name: K, value: K extends keyof Context ? Partial<Context[K]> : unknown): void {
+    this.ctx.provide(name, value)
+  }
+
+  /**
+   * Mount a feature plugin on a real fiber. Required services are prechecked
+   * so a missing provider fails loud instead of suspending the fiber forever
+   * (deliberate load-order suspension tests use `ctx.plugin` directly).
+   * @param plugin - plugin value (function, class, or `{ inject, apply }` object).
+   * @returns handle owning the fiber's explicit disposal.
+   */
+  async mount(plugin: Plugin): Promise<FeatureHandle> {
+    const required = Object.keys(Inject.resolve((plugin as { inject?: Inject }).inject))
+    const missing = required.filter(name => this.ctx.get(name) === undefined)
+    if (missing.length > 0) {
+      throw new Error(`mount would suspend: missing service(s) ${missing.join(', ')} — provide() them first`)
+    }
+    const fiber = this.ctx.plugin(plugin)
+    await this.stabilizer(async () => {
+      await fiber.await()
+    })
+    let disposed = false
+    const handle: FeatureHandle = {
+      fiber,
+      dispose: async () => {
+        if (disposed) return
+        disposed = true
+        await this.stabilizer(() => fiber.dispose())
+      },
+    }
+    this.handles.push(handle)
+    return handle
+  }
+
+  /**
+   * Render the root slot tree through the ctx-level entry (the shell's own
+   * seam): `ctx.slots.renderSlot('root', {})` under Testing Library.
+   * @returns the Testing Library view.
+   */
+  renderRoot(): RenderResult {
+    const view = render(createElement(Fragment, null, this.slots.renderSlot('root', {})))
+    this.views.push(view)
+    return view
+  }
+
+  /**
+   * Declare child slots under an auto-generated root frame — the single-slot
+   * mounting path for local DOM snapshots. Each key later supplied through
+   * {@link SlotTestRuntime.renderSlot} renders inside its own
+   * `<div data-slot="<key>">` wrapper (the snapshot root). Mutually exclusive
+   * with {@link TestRoot.declare} ('root' is a single slot); one call per
+   * runtime.
+   * @param children - child-slot declaration table (same contract as TestRoot.declare).
+   * @returns completion of the act-wrapped registration.
+   */
+  async declare(children: ChildrenDecl): Promise<void> {
+    for (const key of Object.keys(children)) this.autoDeclared.add(key)
+    const cell = this.ownerCell
+    const AutoFrame = (props: { renderSlot: (key: string, owner: object) => ReactNode }) => {
+      useSyncExternalStore(cell.subscribe, cell.getVersion)
+      return createElement(Fragment, null, cell.entries().map(([key, owner]) =>
+        createElement('div', { 'data-slot': key, key }, props.renderSlot(key, owner))))
+    }
+    await this.root.declare(children as never, AutoFrame as never)
+  }
+
+  /**
+   * Render one declared slot with its owner props and return the local view.
+   * The whole root tree mounts through the production assembly path
+   * (renderer, scope providers, store axis); only this key's output lands in
+   * the returned container. Call again with another key to view a sibling
+   * slot of the same tree.
+   * @param key - a key declared through {@link SlotTestRuntime.declare}.
+   * @param owner - owner props share for the render site.
+   * @returns the slot-local view (snapshot container, scoped queries, owner updates).
+   */
+  renderSlot<K extends keyof SlotMap & string>(key: K, owner: OwnerOf<K>): SlotView<K> {
+    if (!this.autoDeclared.has(key)) {
+      throw new Error(`renderSlot('${key}') without declare() — declare the key first (or use root.declare for a custom frame)`)
+    }
+    const install = (next: object): void => {
+      // Synchronous cell write inside act: the frame re-renders through uSES.
+      act(() => {
+        this.ownerCell.set(key, next)
+      })
+    }
+    install(owner)
+    this.autoRootView ??= this.renderRoot()
+    const container = this.autoRootView.container.querySelector(`[data-slot="${key}"]`)
+    if (!(container instanceof HTMLElement)) {
+      throw new Error(`renderSlot('${key}'): the auto frame rendered no wrapper — was the runtime already disposed?`)
+    }
+    return { container, view: within(container), update: install }
+  }
+
+  /**
+   * Resolve the store instance the renderer would hand a slot's component
+   * (identity assertions, action-driven writes). Requires a prior
+   * {@link SlotTestRuntime.renderRoot} — the host face exists only inside the
+   * installed renderer, exactly as in production.
+   * @param key - slot key whose first entry declares the store.
+   * @param scopeKey - session id for session-scope slots; omit for root scope.
+   * @returns the live store instance.
+   */
+  storeOf(key: keyof SlotMap & string, scopeKey?: string): StoreInstanceLike {
+    if (this.host === undefined) {
+      throw new Error('storeOf before renderRoot() — the host face exists only inside the installed renderer')
+    }
+    const entry = this.host.entriesOf(key)[0]
+    if (entry === undefined) throw new Error(`storeOf('${key}'): no registration on the ledger`)
+    const instance = this.host.storeOf(entry, scopeKey)
+    if (instance === undefined) throw new Error(`storeOf('${key}'): the entry declares no store`)
+    return instance
+  }
+
+  /**
+   * Flush pending ledger/store notifications inside act — for mutations made
+   * outside the runtime's own methods (e.g. a direct `slots.register`).
+   * @returns completion of the act pass.
+   */
+  async flush(): Promise<void> {
+    await this.stabilizer(() => {})
+  }
+
+  /**
+   * Tear down: unmount React trees first, then dispose feature fibers, the
+   * root registration, minted session scopes, and persisted test state.
+   * Idempotent.
+   * @returns completion of the teardown.
+   */
+  async dispose(): Promise<void> {
+    if (this.disposed) return
+    this.disposed = true
+    this.autoRootView = undefined
+    for (const view of this.views.splice(0)) view.unmount()
+    for (const handle of this.handles.splice(0)) await handle.dispose()
+    this.root.release()
+    await this.sessions.disposeScopes()
+    localStorage.clear()
+  }
+}

+ 32 - 0
packages/client/test-runtime/src/invariant.ts

@@ -0,0 +1,32 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-client-test-runtime`.
+ * @module @deepseek-ai/dsh-client-test-runtime/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-client-test-runtime'
+
+/** Cordis companion plugin name. */
+export const name = 'client-test-runtime-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: this test-support package owns no production event
+ * stream or mutable data — it assembles the runtime SlotsService and renderer
+ * (whose packages own their invariants) around test doubles; its own behavior
+ * is exercised by its package tests.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 390 - 0
packages/client/test-runtime/src/sessions.ts

@@ -0,0 +1,390 @@
+/** Test-owned sessions face: the SlotsService host contract over declarative fixtures. */
+import type { Context } from 'cordis'
+import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import type {
+  ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
+  SessionListState, SessionProvideDescriptor, SessionSummary, SnapshotStore,
+} from '@deepseek-ai/dsh-client-runtime/client'
+import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
+import { conversationSnapshot } from './fixtures.ts'
+import type { SessionFixture, Stabilizer } from './fixtures.ts'
+
+/**
+ * The fixture-backed session face: conversation reads delegate to the
+ * fixture's snapshot store; ISession verbs are fail-loud stubs unless the
+ * fixture supplies them (the runtime never fakes behavior a test did not
+ * declare — an unstubbed call names itself instead of half-working). Extra
+ * fixture methods are grafted verbatim for feature-side casts.
+ */
+export class FixtureSession implements SessionFace {
+  /**
+   * The useProjection seat: identity-stable per-key faces over the fixture's
+   * projection values (set via {@link TestSessions.setProjection}).
+   */
+  readonly projections: ProjectionsFace & { set(key: string, value: unknown): void }
+
+  /**
+   * @param sessionId - host identity (branded view of the fixture id).
+   * @param store - conversation snapshot store (updateSnapshot writes it).
+   * @param overrides - fixture-declared behavior face, grafted over the stubs.
+   */
+  constructor(
+    readonly sessionId: SessionId,
+    private readonly store: SnapshotStore<ConversationSnapshot>,
+    overrides: Record<string, unknown>,
+  ) {
+    const values = new Map<string, unknown>()
+    const listeners = new Map<string, Set<() => void>>()
+    const faces = new Map<string, ObservableSnapshot<unknown>>()
+    this.projections = {
+      faceOf: (key: string) => {
+        let face = faces.get(key)
+        if (face === undefined) {
+          face = {
+            getSnapshot: () => values.get(key),
+            subscribe: (fn: () => void) => {
+              const set = listeners.get(key) ?? new Set()
+              set.add(fn)
+              listeners.set(key, set)
+              return () => { set.delete(fn) }
+            },
+          }
+          faces.set(key, face)
+        }
+        return face
+      },
+      set: (key: string, value: unknown) => {
+        values.set(key, value)
+        for (const fn of [...(listeners.get(key) ?? [])]) fn()
+      },
+    }
+    Object.assign(this, overrides)
+  }
+
+  /** @returns the fixture conversation snapshot (useSession read side). */
+  getSnapshot(): ConversationSnapshot {
+    return this.store.getSnapshot()
+  }
+
+  /**
+   * Subscribe to fixture snapshot changes.
+   * @param fn - change callback.
+   * @returns unsubscribe.
+   */
+  subscribe(fn: () => void): () => void {
+    return this.store.subscribe(fn)
+  }
+
+  /**
+   * Fail-loud stub; supply `prompt` on the fixture's session face to exercise it.
+   * @returns never — always throws.
+   */
+  prompt(): never {
+    throw new Error(`test session "${this.sessionId}": prompt is not stubbed — supply it on the fixture's session face`)
+  }
+
+  /**
+   * Fail-loud stub; supply `cancel` on the fixture's session face to exercise it.
+   * @returns never — always throws.
+   */
+  cancel(): never {
+    throw new Error(`test session "${this.sessionId}": cancel is not stubbed — supply it on the fixture's session face`)
+  }
+
+  /**
+   * Fail-loud stub; supply `loadOlder` on the fixture's session face to exercise it.
+   * @returns never — always throws.
+   */
+  loadOlder(): never {
+    throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`)
+  }
+}
+
+/** One live test session: fixture-derived stores plus its minted scope state. */
+interface SessionRecord {
+  summary: SessionSummary
+  snapshot: SnapshotStore<ConversationSnapshot>
+  session: FixtureSession
+  scope: Context | undefined
+  scopeFiber: { dispose(): Promise<void> } | undefined
+  /** Materialized standard-props bundle (identity-stable per session; invalidated on roster change). */
+  provideInfo: SessionProvideInfo | undefined
+}
+
+/** Test binding shape handed to provider resolvers and feature injects (a SessionBinding whose session is the fixture face). */
+export interface TestSessionBinding {
+  readonly sessionId: SessionId
+  readonly session: FixtureSession
+  readonly ctx: Context
+}
+
+/**
+ * Sessions test double behind the renderer host and feature injects: owns the
+ * list/current observable, the standard-props provide channel (the runtime's
+ * `useSession` contribution included), scope minting through the production
+ * `createScope`, and the session behavior face supplied per fixture.
+ *
+ * Implements the same ISessions face features receive as `ctx.sessions`, so
+ * a production face change breaks this double at compile time; the extra
+ * members (add/updateSnapshot/setCurrent/remove/behavior/calls and the
+ * legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
+ */
+export class TestSessions implements ISessions {
+  /** The useSessions standard feed (list rows + current selection). */
+  readonly list: SnapshotStore<SessionListState>
+  /**
+   * Atomic current-session provide projection (production SessionsService
+   * mirror): selection changes and provider-roster changes publish through
+   * this one source — the member the SlotsService host face hands the
+   * renderer's SessionProvider.
+   */
+  readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
+  private readonly records = new Map<SessionId, SessionRecord>()
+  /** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */
+  private readonly channel: SessionProvideChannel
+
+  /** Calls observed on the service-level face (open/clear), newest last. */
+  readonly calls: { method: 'open' | 'clear'; args: unknown[] }[] = []
+
+  /**
+   * @param stabilize - the owning runtime's act wrapper.
+   * @param rootCtx - the runtime's Cordis root; scope fibers mount under it.
+   */
+  constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) {
+    this.list = createSnapshotStore<SessionListState>({
+      ids: [], byId: {}, current: undefined, phase: 'ready',
+    })
+    this.channel = new SessionProvideChannel({
+      rebuildBundles: () => {
+        for (const record of this.records.values()) {
+          if (record.provideInfo !== undefined) {
+            record.provideInfo = this.channel.materializeInfo(this.bindingOf(record.session.sessionId, record))
+          }
+        }
+      },
+      resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current),
+    })
+    this.currentProvideInfo = this.channel.currentProvideInfo
+    // The projection follows every current write, as in production.
+    this.list.subscribe(() => { this.channel.publishCurrent() })
+  }
+
+  /**
+   * Add a session from a fixture and (by default) make it current.
+   * @param fixture - identity + snapshot/summary overrides + behavior face.
+   * @param opts - pass `current: false` to add without selecting.
+   * @returns the stable session id (branded view of `fixture.id`).
+   */
+  async add(fixture: SessionFixture, opts?: { current?: boolean }): Promise<SessionId> {
+    const id = fixture.id as SessionId
+    if (this.records.has(id)) throw new Error(`test session "${id}" already added`)
+    const summary: SessionSummary = {
+      id,
+      displayTitle: fixture.id,
+      running: false,
+      blank: false,
+      updatedAt: this.records.size + 1,
+      ...fixture.summary,
+    }
+    const snapshot = createSnapshotStore<ConversationSnapshot>({
+      ...conversationSnapshot(id),
+      ...fixture.snapshot,
+    })
+    this.records.set(id, {
+      summary,
+      snapshot,
+      session: new FixtureSession(id, snapshot, fixture.session ?? {}),
+      scope: undefined,
+      scopeFiber: undefined,
+      provideInfo: undefined,
+    })
+    await this.stabilize(() => {
+      this.list.update((draft) => {
+        draft.ids.push(id)
+        draft.byId[id] = summary
+        if (opts?.current !== false) draft.current = id
+      })
+    })
+    return id
+  }
+
+  /**
+   * Update a session's conversation snapshot through an immer draft (the
+   * live-stream stand-in: components subscribed via useSession re-render).
+   * @param id - session id.
+   * @param mutate - draft mutator.
+   */
+  async updateSnapshot(id: string, mutate: (draft: ConversationSnapshot) => void): Promise<void> {
+    const record = this.require(id)
+    await this.stabilize(() => { record.snapshot.update(mutate) })
+  }
+
+  /**
+   * Switch the current selection (undefined = the no-session empty state).
+   * @param id - session id to select, or undefined to clear.
+   */
+  async setCurrent(id: string | undefined): Promise<void> {
+    if (id !== undefined) this.require(id)
+    await this.stabilize(() => {
+      this.list.update((draft) => { draft.current = id as SessionId | undefined })
+    })
+  }
+
+  /**
+   * Remove a session: list row, scope fiber, and per-session store instances
+   * (with persisted state) die together — the same single lifecycle axis the
+   * production SessionsService drives on session death, minus staging.
+   * @param id - session id.
+   */
+  async remove(id: string): Promise<void> {
+    const record = this.require(id)
+    this.records.delete(id as SessionId)
+    await this.stabilize(async () => {
+      this.list.update((draft) => {
+        draft.ids = draft.ids.filter(existing => existing !== id)
+        const { [id as SessionId]: _dead, ...rest } = draft.byId
+        draft.byId = rest
+        if (draft.current === id) draft.current = undefined
+      })
+      if (record.scopeFiber !== undefined) await record.scopeFiber.dispose()
+      this.rootCtx.get('slots')?.pruneStoreScope(id)
+    })
+  }
+
+  /**
+   * Register a per-session standard-props provider (production `provide`
+   * contract: hooks become `use<Name>` selector hooks on the render side,
+   * props spread verbatim; duplicate names fail loud at materialization).
+   * @param descriptor - static member roster plus per-session resolver.
+   * @returns disposer removing the provider.
+   */
+  provide(descriptor: SessionProvideDescriptor): () => void {
+    return this.channel.provide(descriptor)
+  }
+
+  /**
+   * Resolve the definite per-session standard-props bundle (host face member).
+   * @param id - session id.
+   * @returns the identity-stable bundle, or undefined for unknown sessions.
+   */
+  provideInfo(id: string): SessionProvideInfo | undefined {
+    const record = this.records.get(id as SessionId)
+    if (record === undefined) return undefined
+    record.provideInfo ??= this.channel.materializeInfo(this.bindingOf(id as SessionId, record))
+    return record.provideInfo
+  }
+
+  /**
+   * Resolve the current-session-optional standard kit (host face member):
+   * unknown or absent ids return the static no-session projection.
+   * @param id - current session id, when selected.
+   * @returns a definite or no-session provide bundle.
+   */
+  maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
+    return (id === undefined ? undefined : this.provideInfo(id)) ?? this.channel.maybeInfo
+  }
+
+  /**
+   * Resolve (mint on first touch) the session-scoped Cordis context through
+   * the production `createScope`, so real `scopeOf`/scope-addressed services
+   * resolve it.
+   * @param id - session id.
+   * @returns the scoped context, or undefined for unknown sessions.
+   */
+  scope(id: string): Context | undefined {
+    const record = this.records.get(id as SessionId)
+    if (record === undefined) return undefined
+    if (record.scope === undefined) {
+      const handle = createScope(this.rootCtx, id as SessionId)
+      record.scope = handle.ctx
+      record.scopeFiber = handle.fiber
+    }
+    return record.scope
+  }
+
+  /**
+   * Session assembly binding (inject factories and provide resolvers receive it).
+   * @param id - session id.
+   * @returns sessionId + behavior face + scoped ctx, or undefined when unknown.
+   */
+  binding(id: string): TestSessionBinding | undefined {
+    const record = this.records.get(id as SessionId)
+    if (record === undefined) return undefined
+    return this.bindingOf(id as SessionId, record)
+  }
+
+  /**
+   * Read the session scope tag off a context (service-method seam mirror).
+   * @param ctx - any client context.
+   * @returns the session id, or undefined on root contexts.
+   */
+  scopeOf(ctx: Context): SessionId | undefined {
+    return scopeOf(ctx)
+  }
+
+  /**
+   * Resolve the scoped session face off a context (production `sessionOf`
+   * mirror).
+   * @param ctx - any client context.
+   * @returns the fixture session face, or undefined off-scope.
+   */
+  sessionOf(ctx: Context): SessionFace | undefined {
+    const id = scopeOf(ctx)
+    if (id === undefined) return undefined
+    return this.records.get(id)?.session
+  }
+
+  /**
+   * Service-level selection call (recorded, then applied to the list store
+   * synchronously — inject callbacks call this outside any act window; the
+   * store notify is microtask-batched so the next stabilized step observes it).
+   * @param id - session id.
+   */
+  open(id: SessionId): void {
+    this.calls.push({ method: 'open', args: [id] })
+    this.require(id)
+    this.list.update((draft) => { draft.current = id })
+  }
+
+  /** Clear the current selection (recorded; the production no-session flow). */
+  clear(): void {
+    this.calls.push({ method: 'clear', args: [] })
+    this.list.update((draft) => { draft.current = undefined })
+  }
+
+  /**
+   * The session face of a fixture (typed view for assertions; fixture
+   * behavior methods are grafted onto it).
+   * @param id - session id.
+   * @returns the FixtureSession the binding and provide channel carry.
+   */
+  behavior(id: string): FixtureSession {
+    return this.require(id).session
+  }
+
+  /** Dispose minted scope fibers (runtime dispose path). */
+  async disposeScopes(): Promise<void> {
+    for (const record of this.records.values()) {
+      if (record.scopeFiber !== undefined) {
+        await record.scopeFiber.dispose()
+        record.scope = undefined
+        record.scopeFiber = undefined
+      }
+    }
+  }
+
+  private bindingOf(id: SessionId, record: SessionRecord): TestSessionBinding {
+    const ctx = this.scope(id)
+    /* v8 ignore next 2 -- bindingOf only runs for a live record, whose scope
+     * always resolves; kept so a future caller cannot mint a ctx-less binding. */
+    if (ctx === undefined) throw new Error(`test session "${id}" resolved no scope`)
+    return { sessionId: id, session: record.session, ctx }
+  }
+
+  private require(id: string): SessionRecord {
+    const record = this.records.get(id as SessionId)
+    if (record === undefined) throw new Error(`test session "${id}" is not added`)
+    return record
+  }
+}

+ 89 - 0
packages/client/test-runtime/src/snapshot.ts

@@ -0,0 +1,89 @@
+/**
+ * DOM snapshot hygiene: a vitest snapshot serializer that keeps `.snap`
+ * files structural. Two normalizations, both on a clone (the live DOM is
+ * untouched, so class/tag queries keep working):
+ *
+ * - CSS-module scoped class names (`_frame_334d2d`, this repo's
+ *   `_[local]_[hash]` shape) fold back to their semantic local (`frame`), so
+ *   CSS edits do not churn snapshots.
+ * - `<svg>` internals collapse to a `data-content` fingerprint on the svg
+ *   element: path geometry is print noise, but the fingerprint still flips
+ *   when an icon's artwork actually changes.
+ */
+import { expect } from 'vitest'
+import type { SnapshotSerializer } from 'vitest'
+
+/** One scoped class token: `_<local>_<hash>` (local may itself contain underscores). */
+const SCOPED_CLASS = /^_(.+)_[a-z0-9]+$/
+
+/** Fold scoped tokens in one class attribute value; foreign tokens pass through. */
+function normalizeClassValue(value: string): string {
+  return value
+    .split(/\s+/)
+    .filter(token => token !== '')
+    .map(token => token.replace(SCOPED_CLASS, '$1'))
+    .join(' ')
+}
+
+/** FNV-1a 32-bit over the svg markup: deterministic, dependency-free fingerprint. */
+function fingerprint(markup: string): string {
+  let hash = 0x811c9dc5
+  for (let i = 0; i < markup.length; i++) {
+    hash ^= markup.charCodeAt(i)
+    hash = Math.imul(hash, 0x01000193)
+  }
+  return (hash >>> 0).toString(16).padStart(8, '0')
+}
+
+/** svg elements of a subtree, the root included when it is one. */
+function svgsOf(root: Element): Element[] {
+  const svgs: Element[] = [...root.querySelectorAll('svg')]
+  if (root.tagName.toLowerCase() === 'svg') svgs.unshift(root)
+  return svgs
+}
+
+/** Whether serializing this subtree needs a normalized clone. */
+function needsNormalization(root: Element): boolean {
+  const scoped = [root, ...root.querySelectorAll('[class]')].some((el) => {
+    const value = el.getAttribute('class')
+    return value !== null && value.split(/\s+/).some(token => SCOPED_CLASS.test(token))
+  })
+  return scoped || svgsOf(root).some(svg => svg.childNodes.length > 0)
+}
+
+/**
+ * The serializer plugin. Matches DOM elements whose subtree carries a scoped
+ * class or svg internals; serializes a normalized clone, which no longer
+ * matches, so printing falls through to the built-in DOM element serializer.
+ */
+export const domSnapshotSerializer: SnapshotSerializer = {
+  test(value: unknown): boolean {
+    return typeof Element !== 'undefined' && value instanceof Element && needsNormalization(value)
+  },
+  serialize(value, config, indentation, depth, refs, printer): string {
+    const clone = (value as Element).cloneNode(true) as Element
+    for (const el of [clone, ...clone.querySelectorAll('[class]')]) {
+      const raw = el.getAttribute('class')
+      if (raw !== null) el.setAttribute('class', normalizeClassValue(raw))
+    }
+    for (const svg of svgsOf(clone)) {
+      if (svg.childNodes.length === 0) continue
+      svg.setAttribute('data-content', fingerprint(svg.innerHTML))
+      svg.replaceChildren()
+    }
+    return printer(clone, config, indentation, depth, refs)
+  },
+}
+
+let registered = false
+
+/**
+ * Register {@link domSnapshotSerializer} with vitest's expect (idempotent).
+ * SlotTestRuntime.create() calls this; specs that snapshot DOM outside the
+ * runtime import and call it themselves.
+ */
+export function registerDomSnapshotSerializer(): void {
+  if (registered) return
+  registered = true
+  expect.addSnapshotSerializer(domSnapshotSerializer)
+}

+ 147 - 0
packages/client/test-runtime/src/workspaces.ts

@@ -0,0 +1,147 @@
+/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import type {
+  IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
+} from '@deepseek-ai/dsh-client-runtime/client'
+import { workspaceListState } from './fixtures.ts'
+import type { Stabilizer } from './fixtures.ts'
+
+/**
+ * Workspaces test double. Implements the same IWorkspaces face features
+ * receive as `ctx.workspaces`, so a production face change breaks this
+ * double at compile time. Every action records into {@link
+ * TestWorkspaces.calls}; defaults are inert echoes — feature tests needing
+ * richer behavior replace them via {@link TestWorkspaces.stub}.
+ */
+export class TestWorkspaces implements IWorkspaces {
+  /** The useWorkspaces standard feed. */
+  readonly list: SnapshotStore<WorkspaceListState>
+
+  /** Calls observed on the action face, newest last. */
+  readonly calls: { method: string; args: unknown[] }[] = []
+
+  /** Replaceable action seat: feature tests may stub richer behavior. */
+  private readonly stubs = new Map<string, (...args: unknown[]) => unknown>()
+
+  /**
+   * @param stabilize - the owning runtime's act wrapper.
+   */
+  constructor(private readonly stabilize: Stabilizer) {
+    this.list = createSnapshotStore<WorkspaceListState>(workspaceListState())
+  }
+
+  /**
+   * Update the workspace list state through an immer draft.
+   * @param mutate - draft mutator.
+   */
+  async update(mutate: (draft: WorkspaceListState) => void): Promise<void> {
+    await this.stabilize(() => { this.list.update(mutate) })
+  }
+
+  /**
+   * Replace an action's behavior (the recorded call is still appended first).
+   * @param method - action name (e.g. 'connectWorkspace').
+   * @param impl - replacement behavior.
+   */
+  stub(method: string, impl: (...args: unknown[]) => unknown): void {
+    this.stubs.set(method, impl)
+  }
+
+  /**
+   * Connect a workspace to its reusable/new blank session (recorded). The
+   * default resolves the workspace id back as the session id; stub for
+   * cross-session flows.
+   * @param workspaceId - target workspace.
+   * @returns the connected session id.
+   */
+  async connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId> {
+    this.calls.push({ method: 'connectWorkspace', args: [workspaceId] })
+    const stub = this.stubs.get('connectWorkspace')
+    if (stub !== undefined) return await (stub(workspaceId) as Promise<SessionId>)
+    return `session-of-${workspaceId}` as SessionId
+  }
+
+  /**
+   * New-session flow (recorded; stubbed behavior runs when installed).
+   * @param workspaceId - optional explicit workspace target.
+   */
+  startSession(workspaceId?: WorkspaceId): void {
+    this.calls.push({ method: 'startSession', args: [workspaceId] })
+    this.stubs.get('startSession')?.(workspaceId)
+  }
+
+  /**
+   * Create a Workspace (recorded). The default echoes a view derived from
+   * the input; stub for failure or list-coupled flows.
+   * @param input - exactly one Host create spelling.
+   * @returns the created Workspace view.
+   */
+  async create(input: { name: string } | { path: string }): Promise<WorkspaceView> {
+    this.calls.push({ method: 'create', args: [input] })
+    const stub = this.stubs.get('create')
+    if (stub !== undefined) return await (stub(input) as Promise<WorkspaceView>)
+    const title = 'name' in input ? input.name : input.path
+    return {
+      workspaceId: `ws-${title}` as WorkspaceId,
+      title,
+      path: 'path' in input ? input.path : `/${input.name}`,
+      sessionIds: [],
+    } as unknown as WorkspaceView
+  }
+
+  /**
+   * Open a path with the host OS default application (recorded; default no-op).
+   * @param path - host-resolvable path.
+   */
+  async openPath(path: string): Promise<void> {
+    this.calls.push({ method: 'openPath', args: [path] })
+    await (this.stubs.get('openPath')?.(path) as Promise<void> | undefined)
+  }
+
+  /**
+   * Directory picker (recorded). The default cancels (null); stub to select.
+   * @returns the picked path, or null.
+   */
+  async pickDirectory(): Promise<string | null> {
+    this.calls.push({ method: 'pickDirectory', args: [] })
+    const stub = this.stubs.get('pickDirectory')
+    if (stub !== undefined) return await (stub() as Promise<string | null>)
+    return null
+  }
+
+  /**
+   * Rename a Workspace (recorded). The default echoes a minimal view.
+   * @param workspaceId - target workspace.
+   * @param title - new title.
+   * @returns the updated view.
+   */
+  async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
+    this.calls.push({ method: 'rename', args: [workspaceId, title] })
+    const stub = this.stubs.get('rename')
+    if (stub !== undefined) return await (stub(workspaceId, title) as Promise<WorkspaceView>)
+    return { workspaceId, title, path: `/${title}`, sessionIds: [] } as unknown as WorkspaceView
+  }
+
+  /**
+   * Delete a Workspace (recorded; default no-op).
+   * @param workspaceId - target workspace.
+   */
+  async delete(workspaceId: WorkspaceId): Promise<void> {
+    this.calls.push({ method: 'delete', args: [workspaceId] })
+    await (this.stubs.get('delete')?.(workspaceId) as Promise<void> | undefined)
+  }
+
+  /**
+   * Move an accounted session (recorded). The default echoes a minimal view.
+   * @param workspaceId - target workspace.
+   * @param sessionId - session to move.
+   * @param beforeSessionId - anchor; omitted appends.
+   * @returns the updated view.
+   */
+  async insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView> {
+    this.calls.push({ method: 'insertSessionBefore', args: [workspaceId, sessionId, beforeSessionId] })
+    const stub = this.stubs.get('insertSessionBefore')
+    if (stub !== undefined) return await (stub(workspaceId, sessionId, beforeSessionId) as Promise<WorkspaceView>)
+    return { workspaceId, title: '', path: '', sessionIds: [sessionId] } as unknown as WorkspaceView
+  }
+}

+ 37 - 0
packages/client/test-runtime/tests/__snapshots__/runtime.spec.tsx.snap

@@ -0,0 +1,37 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`single-slot mounting (declare + renderSlot) > folds class hashes and collapses svg internals in snapshots, leaving the live DOM alone 1`] = `
+<div
+  data-slot="trt.panel"
+>
+  <div
+    class="frame plain"
+  >
+    <span
+      class="label"
+    >
+      styled
+    </span>
+    <svg
+      aria-hidden="true"
+      data-content="2bfa09dc"
+      viewBox="0 0 16 16"
+    />
+  </div>
+</div>
+`;
+
+exports[`single-slot mounting edge arms > serializes childless svg untouched next to scoped classes 1`] = `
+<div
+  data-slot="trt.panel"
+>
+  <div
+    class="frame"
+  >
+    <svg
+      aria-hidden="true"
+      viewBox="0 0 1 1"
+    />
+  </div>
+</div>
+`;

+ 12 - 0
packages/client/test-runtime/tests/invariant.spec.ts

@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import * as TestRuntimeInvariant from '@deepseek-ai/dsh-client-test-runtime/invariant'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+
+describe('invariant companion', () => {
+  it('registers under the package name with an empty installer', async () => {
+    const ctx = new Context()
+    await ctx.plugin(InvariantService, { enabled: true })
+    await expect(ctx.plugin(TestRuntimeInvariant).await()).resolves.toBeDefined()
+  })
+})

+ 546 - 0
packages/client/test-runtime/tests/runtime.spec.tsx

@@ -0,0 +1,546 @@
+// @vitest-environment jsdom
+/**
+ * SlotTestRuntime behavior: root declaration + rendering, session
+ * add/update/switch/remove through the real renderer, shared store identity
+ * and scope pruning, feature mount/dispose cascade, and runtime disposal
+ * idempotence. All through the production SlotsService + createSlotRenderer
+ * stack — this suite is the fixture the migrated feature specs rely on.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup } from '@testing-library/react'
+import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
+import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
+import type { PropsRenderSlots, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
+import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface SlotMap {
+    'trt.panel': { kind: 'single'; scope: 'root'; owner: { label?: string } }
+    'trt.chat': { kind: 'single'; scope: 'session' }
+    'trt.rows': { kind: 'list'; scope: 'root' }
+  }
+}
+
+afterEach(cleanup)
+
+type FrameProps = PropsRenderSlots<'trt.panel' | 'trt.chat' | 'trt.rows'>
+
+/** Root frame declaring all three suite slots (render sites for each kind). */
+function Frame({ renderSlot, SessionProvider }: FrameProps) {
+  return (
+    <>
+      {renderSlot('trt.panel', { label: 'from-owner' }, { fallback: <i>no panel</i> })}
+      <SessionProvider empty={() => <i>no session</i>}>
+        {() => renderSlot('trt.chat', {})}
+      </SessionProvider>
+      {renderSlot('trt.rows', {})}
+    </>
+  )
+}
+
+const CHILDREN = {
+  'trt.panel': { kind: 'single', scope: 'root' },
+  'trt.chat': { kind: 'single', scope: 'session' },
+  'trt.rows': { kind: 'list', scope: 'root' },
+} as const
+
+async function runtimeWithFrame() {
+  const runtime = await SlotTestRuntime.create()
+  await runtime.root.declare(CHILDREN, Frame)
+  return runtime
+}
+
+describe('root declaration and rendering', () => {
+  it('renders declared slots through the real renderer: fallback, then a live registration, then unload', async () => {
+    const runtime = await runtimeWithFrame()
+    const view = runtime.renderRoot()
+    expect(view.container.textContent).toContain('no panel')
+
+    let dispose = (): void => {}
+    await runtime.flush() // no-op guard: flush outside mutations is safe
+    await (async () => {
+      dispose = runtime.slots.register(
+        { name: 'trt.panel' },
+        ({ label }: { label?: string }) => <b>panel:{label}</b>)
+      await runtime.flush()
+    })()
+    expect(view.container.textContent).toContain('panel:from-owner')
+    dispose()
+    await runtime.flush()
+    expect(view.container.textContent).toContain('no panel')
+    await runtime.dispose()
+  })
+
+  it('fails loud when rendering with no root declaration (production boot-order check)', async () => {
+    const runtime = await SlotTestRuntime.create()
+    expect(() => runtime.renderRoot()).toThrow(/'root' has no registration/)
+    await runtime.dispose()
+  })
+})
+
+describe('sessions', () => {
+  it('drives SessionProvider: empty state, current session, switch, live snapshot updates', async () => {
+    const runtime = await runtimeWithFrame()
+    runtime.slots.register({ name: 'trt.chat' }, (props: SessionStandardProps) => {
+      const running = props.useSession(s => s.running)
+      return <span>chat:{props.sessionId}:{String(running)}</span>
+    })
+    const view = runtime.renderRoot()
+    expect(view.container.textContent).toContain('no session')
+
+    await runtime.sessions.add({ id: 's1' })
+    expect(view.container.textContent).toContain('chat:s1:false')
+
+    await runtime.sessions.updateSnapshot('s1', (draft) => { draft.running = true })
+    expect(view.container.textContent).toContain('chat:s1:true')
+
+    await runtime.sessions.add({ id: 's2' }) // becomes current by default
+    expect(view.container.textContent).toContain('chat:s2:false')
+
+    await runtime.sessions.setCurrent(undefined)
+    expect(view.container.textContent).toContain('no session')
+    await runtime.sessions.setCurrent('s1')
+    expect(view.container.textContent).toContain('chat:s1:true')
+    await runtime.dispose()
+  })
+
+  it('add with current:false keeps the selection; unknown ids fail loud on the mutators', async () => {
+    const runtime = await runtimeWithFrame()
+    await runtime.sessions.add({ id: 's1' })
+    await runtime.sessions.add({ id: 's2' }, { current: false })
+    expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
+    expect(runtime.sessions.list.getSnapshot().ids).toEqual(['s1', 's2'])
+    await expect(runtime.sessions.add({ id: 's1' })).rejects.toThrow(/already added/)
+    await expect(runtime.sessions.setCurrent('ghost')).rejects.toThrow(/not added/)
+    await expect(runtime.sessions.updateSnapshot('ghost', () => {})).rejects.toThrow(/not added/)
+    await expect(runtime.sessions.remove('ghost')).rejects.toThrow(/not added/)
+    expect(() => runtime.sessions.behavior('ghost')).toThrow(/not added/)
+    await runtime.dispose()
+  })
+
+  it('mints REAL-tag scopes lazily and resolves them through the production scopeOf; bindings expose the behavior face', async () => {
+    const runtime = await runtimeWithFrame()
+    const prompt = vi.fn()
+    await runtime.sessions.add({ id: 's1', session: { prompt } })
+
+    expect(runtime.sessions.provideInfo('ghost')).toBeUndefined()
+    expect(runtime.sessions.scope('ghost')).toBeUndefined()
+    expect(runtime.sessions.binding('ghost')).toBeUndefined()
+
+    const scope = runtime.sessions.scope('s1')!
+    expect(runtime.sessions.scope('s1')).toBe(scope) // stable per session
+    expect(runtime.sessions.scopeOf(scope)).toBe('s1')
+    expect(runtime.sessions.scopeOf(runtime.ctx)).toBeUndefined()
+    // sessionOf resolves the behavior face off the scope tag.
+    expect(runtime.sessions.sessionOf(scope)).toBe(runtime.sessions.behavior('s1'))
+    expect(runtime.sessions.sessionOf(runtime.ctx)).toBeUndefined()
+
+    const binding = runtime.sessions.binding('s1')!
+    expect(binding.sessionId).toBe('s1')
+    expect(binding.ctx).toBe(scope)
+    ;(binding.session as { prompt: () => void }).prompt()
+    expect(prompt).toHaveBeenCalledOnce()
+    expect(runtime.sessions.behavior('s1')).toBe(binding.session)
+    // The binding's session doubles as the conversation observable face.
+    expect((binding.session as { getSnapshot(): { sessionId: string } }).getSnapshot().sessionId).toBe('s1')
+
+    // A scoped service resolves through the scope ctx (scope-addressed pattern).
+    runtime.provide('probe', { hello: 'world' })
+    expect(scope.get('probe')).toEqual({ hello: 'world' })
+    await runtime.dispose()
+  })
+
+  it('materializes provide bundles: built-in session hook, custom providers, no-session projection', async () => {
+    const runtime = await runtimeWithFrame()
+    await runtime.sessions.add({ id: 's1' })
+
+    const info = runtime.sessions.provideInfo('s1')!
+    expect(info.sessionId).toBe('s1')
+    expect(info.hooks['session']).toBeDefined() // the built-in useSession source
+    expect(runtime.sessions.provideInfo('s1')).toBe(info) // identity-stable
+
+    // A feature provider (the ui-conversation input pattern): declared names
+    // materialize per session and land in the no-session roster as undefined.
+    const off = runtime.sessions.provide({
+      hooks: ['probe'],
+      props: ['probeActions'],
+      resolve: binding => ({
+        hooks: { probe: { getSnapshot: () => binding.sessionId, subscribe: () => () => {} } },
+        props: { probeActions: { poke: () => {} } },
+      }),
+    })
+    const enriched = runtime.sessions.provideInfo('s1')!
+    expect(enriched.hooks['probe']?.getSnapshot()).toBe('s1')
+    expect(enriched.props['probeActions']).toBeDefined()
+    const maybe = runtime.sessions.maybeProvideInfo(undefined)
+    expect(maybe.sessionId).toBeUndefined()
+    expect(Object.keys(maybe.hooks)).toEqual(['session', 'probe'])
+    expect(runtime.sessions.maybeProvideInfo('s1')).toBe(runtime.sessions.provideInfo('s1'))
+    expect(runtime.sessions.maybeProvideInfo('ghost').sessionId).toBeUndefined()
+
+    // Misdeclared providers fail loud AT REGISTRATION (the production
+    // channel rebuilds live bundles eagerly and rolls the roster back):
+    // missing hook, missing prop, duplicate hook, duplicate prop.
+    expect(() => runtime.sessions.provide({ hooks: ['void'], resolve: () => ({}) }))
+      .toThrow(/missing hook "void"/)
+    expect(() => runtime.sessions.provide({ props: ['void'], resolve: () => ({}) }))
+      .toThrow(/missing prop "void"/)
+    expect(() => runtime.sessions.provide({
+      hooks: ['session'],
+      resolve: () => ({ hooks: { session: { getSnapshot: () => 0, subscribe: () => () => {} } } }),
+    })).toThrow(/duplicate hook "session"/)
+    const propA = runtime.sessions.provide({ props: ['twice'], resolve: () => ({ props: { twice: 1 } }) })
+    expect(() => runtime.sessions.provide({ props: ['twice'], resolve: () => ({ props: { twice: 2 } }) }))
+      .toThrow(/duplicate prop "twice"/)
+    propA()
+    // The rejected registrations rolled back: the roster still materializes.
+    expect(runtime.sessions.provideInfo('s1')).toBeDefined()
+    off()
+    off() // disposer is idempotent
+    expect(Object.keys(runtime.sessions.maybeProvideInfo(undefined).hooks)).toEqual(['session'])
+    await runtime.dispose()
+  })
+
+  it('records service-face calls; open() moves the selection and clear() empties it', async () => {
+    const runtime = await runtimeWithFrame()
+    await runtime.sessions.add({ id: 's1' })
+    await runtime.sessions.add({ id: 's2' })
+    runtime.sessions.open('s1' as SessionId)
+    await runtime.flush()
+    expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
+    runtime.sessions.clear()
+    await runtime.flush()
+    expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
+    expect(runtime.sessions.calls).toEqual([
+      { method: 'open', args: ['s1'] },
+      { method: 'clear', args: [] },
+    ])
+    await runtime.dispose()
+  })
+})
+
+describe('stores', () => {
+  const createSuiteStore = () => defineStore({
+    init: () => ({ note: '' }),
+    persist: 'trt.store',
+    actions: { setNote: (d, note: string) => { d.note = note } },
+  })
+
+  it('resolves per-session instances via the host face: shared identity, isolation, action-driven re-render', async () => {
+    const runtime = await runtimeWithFrame()
+    const handle = createSuiteStore()
+    runtime.slots.register(
+      { name: 'trt.chat', store: handle },
+      (props: SessionStandardProps & { useStore: <S>(sel: (s: { note: string }) => S) => S }) =>
+        <span>note:{props.useStore(s => s.note)}</span>)
+    const view = runtime.renderRoot()
+    await runtime.sessions.add({ id: 's1' })
+
+    expect(() => runtime.storeOf('trt.panel')).toThrow(/no registration/)
+    const store = runtime.storeOf('trt.chat', 's1')
+    await runtime.flush()
+    ;(store.actions['setNote'] as (note: string) => void)('hello')
+    await runtime.flush()
+    expect(view.container.textContent).toContain('note:hello')
+    expect(runtime.storeOf('trt.chat', 's1')).toBe(store) // cached per scope key
+
+    await runtime.sessions.add({ id: 's2' })
+    const other = runtime.storeOf('trt.chat', 's2')
+    expect(other).not.toBe(store)
+    expect(other.getSnapshot()).toEqual({ note: '' })
+    await runtime.dispose()
+  })
+
+  it('storeOf guards: before renderRoot, and for storeless entries', async () => {
+    const runtime = await runtimeWithFrame()
+    runtime.slots.register({ name: 'trt.panel' }, () => null)
+    expect(() => runtime.storeOf('trt.panel')).toThrow(/before renderRoot/)
+    runtime.renderRoot()
+    expect(() => runtime.storeOf('trt.panel')).toThrow(/declares no store/)
+    await runtime.dispose()
+  })
+
+  it('remove() prunes the session store scope: persisted state clears, a re-added session starts fresh', async () => {
+    const runtime = await runtimeWithFrame()
+    const handle = createSuiteStore()
+    runtime.slots.register({ name: 'trt.chat', store: handle }, () => null)
+    runtime.renderRoot()
+    await runtime.sessions.add({ id: 's1' })
+
+    const doomed = runtime.storeOf('trt.chat', 's1')
+    ;(doomed.actions['setNote'] as (note: string) => void)('buried')
+    expect(localStorage.getItem('trt.store.s1')).not.toBeNull()
+
+    await runtime.sessions.remove('s1')
+    expect(localStorage.getItem('trt.store.s1')).toBeNull()
+    expect(runtime.sessions.list.getSnapshot().ids).toEqual([])
+    expect(runtime.sessions.provideInfo('s1')).toBeUndefined()
+
+    await runtime.sessions.add({ id: 's1' })
+    const reborn = runtime.storeOf('trt.chat', 's1')
+    expect(reborn).not.toBe(doomed)
+    expect(reborn.getSnapshot()).toEqual({ note: '' })
+    await runtime.dispose()
+  })
+
+  it('remove() also disposes a minted scope fiber; removing a non-current session keeps the selection', async () => {
+    const runtime = await runtimeWithFrame()
+    await runtime.sessions.add({ id: 's1' })
+    await runtime.sessions.add({ id: 's2' }, { current: false })
+    const scope = runtime.sessions.scope('s1')!
+    await runtime.sessions.remove('s2')
+    expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
+    await runtime.sessions.remove('s1')
+    expect(scope.fiber.uid).toBeNull() // disposed fiber loses its uid
+    expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
+    await runtime.dispose()
+  })
+})
+
+describe('workspaces', () => {
+  it('feeds useWorkspaces and records/stubs intent actions', async () => {
+    const runtime = await runtimeWithFrame()
+    runtime.slots.register(
+      { name: 'trt.panel' },
+      (props: { useWorkspaces: <S>(sel: (s: { phase: string }) => S) => S }) =>
+        <span>ws:{props.useWorkspaces(s => s.phase)}</span>)
+    const view = runtime.renderRoot()
+    expect(view.container.textContent).toContain('ws:ready')
+
+    await runtime.workspaces.update((draft) => { draft.phase = 'pending' })
+    expect(view.container.textContent).toContain('ws:pending')
+
+    runtime.workspaces.startSession('w1' as WorkspaceId)
+    await expect(runtime.workspaces.connectWorkspace('w2' as WorkspaceId)).resolves.toBe('session-of-w2')
+    expect(runtime.workspaces.calls).toEqual([
+      { method: 'startSession', args: ['w1'] },
+      { method: 'connectWorkspace', args: ['w2'] },
+    ])
+    const stub = vi.fn(() => Promise.resolve('other' as never))
+    runtime.workspaces.stub('connectWorkspace', stub)
+    await expect(runtime.workspaces.connectWorkspace('w3' as WorkspaceId)).resolves.toBe('other')
+    expect(stub).toHaveBeenCalledOnce()
+    await runtime.dispose()
+  })
+})
+
+describe('feature mount and disposal', () => {
+  it('mounts a plugin on a real fiber; dispose() cascades entries, declared children, and services', async () => {
+    const runtime = await runtimeWithFrame()
+    runtime.provide('layout', { openDetails: vi.fn() })
+    const feature = await runtime.mount({
+      inject: ['slots', 'layout'],
+      apply: (ctx: typeof runtime.ctx) => {
+        ctx.provide('feature-service', { ok: true })
+        ctx.slots.register({
+          name: 'trt.rows',
+          id: 'row-1',
+          children: { 'trt.rows.hole': { kind: 'single', scope: 'root' } },
+        } as never, ((props: { renderSlot: (key: string, owner: object) => unknown }) =>
+          <div data-testid="row">{props.renderSlot('trt.rows.hole', {}) as React.ReactNode}</div>) as never)
+      },
+    })
+    const view = runtime.renderRoot()
+    expect(view.getByTestId('row')).toBeTruthy()
+    expect(runtime.ctx.get('feature-service')).toEqual({ ok: true })
+    expect(runtime.slots.entries('trt.rows')).toHaveLength(1)
+
+    await feature.dispose()
+    await feature.dispose() // idempotent
+    expect(runtime.slots.entries('trt.rows')).toHaveLength(0)
+    expect(runtime.slots.spec('trt.rows.hole' as never)).toBeUndefined()
+    expect(runtime.ctx.get('feature-service')).toBeUndefined()
+    expect(view.queryByTestId('row')).toBeNull()
+    await runtime.dispose()
+  })
+
+  it('mount fails loud on missing services instead of suspending forever', async () => {
+    const runtime = await runtimeWithFrame()
+    await expect(runtime.mount({ inject: ['slots', 'absent-service'], apply: () => {} }))
+      .rejects.toThrow(/missing service\(s\) absent-service/)
+    await runtime.dispose()
+  })
+
+  it('runtime dispose is idempotent, unmounts views, disposes mounted features, and clears persisted state', async () => {
+    const runtime = await runtimeWithFrame()
+    const feature = await runtime.mount({
+      inject: ['slots'],
+      apply: (ctx: typeof runtime.ctx) => { ctx.slots.register({ name: 'trt.panel' }, () => <b>p</b>) },
+    })
+    const view = runtime.renderRoot()
+    expect(view.container.textContent).toContain('p')
+    localStorage.setItem('trt.leftover', 'x')
+
+    await runtime.dispose()
+    expect(view.container.innerHTML).toBe('')
+    expect(feature.fiber.uid).toBeNull()
+    expect(localStorage.getItem('trt.leftover')).toBeNull()
+    await runtime.dispose() // idempotent
+    await expect(runtime.dispose()).resolves.toBeUndefined()
+  })
+})
+
+describe('single-slot mounting (declare + renderSlot)', () => {
+  it('renders one slot inside its data-slot wrapper and updates owner props in place', async () => {
+    const runtime = await SlotTestRuntime.create()
+    await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
+    runtime.slots.register(
+      { name: 'trt.panel' },
+      ({ label }: { label?: string }) => <b data-testid="panel">{label ?? 'none'}</b>)
+    const slot = runtime.renderSlot('trt.panel', { label: 'first' })
+    expect(slot.container.getAttribute('data-slot')).toBe('trt.panel')
+    expect(slot.view.getByTestId('panel').textContent).toBe('first')
+
+    const panel = slot.view.getByTestId('panel')
+    slot.update({ label: 'second' })
+    expect(slot.view.getByTestId('panel').textContent).toBe('second')
+    // In-place re-render: the element identity survived the owner flip.
+    expect(slot.view.getByTestId('panel')).toBe(panel)
+    await runtime.dispose()
+  })
+
+  it('views sibling slots of one tree separately and rejects undeclared keys', async () => {
+    const runtime = await SlotTestRuntime.create()
+    await runtime.declare({
+      'trt.panel': { kind: 'single', scope: 'root' },
+      'trt.rows': { kind: 'list', scope: 'root' },
+    })
+    runtime.slots.register({ name: 'trt.panel' }, () => <b>panel</b>)
+    runtime.slots.register({ name: 'trt.rows', id: 'r1' }, () => <i>row</i>)
+    const panel = runtime.renderSlot('trt.panel', {})
+    const rows = runtime.renderSlot('trt.rows', {})
+    expect(panel.container.textContent).toBe('panel')
+    expect(rows.container.textContent).toBe('row')
+    expect(() => runtime.renderSlot('trt.chat', {})).toThrow(/without declare\(\)/)
+    await runtime.dispose()
+  })
+
+  it('folds class hashes and collapses svg internals in snapshots, leaving the live DOM alone', async () => {
+    const runtime = await SlotTestRuntime.create()
+    await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
+    runtime.slots.register({ name: 'trt.panel' }, () => (
+      <div className="_frame_a1b2c3 plain">
+        <span className="_label_ff00aa">styled</span>
+        <svg viewBox="0 0 16 16" aria-hidden="true">
+          <path d="M0 0L16 16" fill="currentColor" />
+        </svg>
+      </div>
+    ))
+    const slot = runtime.renderSlot('trt.panel', {})
+    expect(slot.container).toMatchSnapshot()
+    // The serializer works on a clone: the live DOM keeps hashes and paths.
+    expect(slot.container.querySelector('div')!.className).toBe('_frame_a1b2c3 plain')
+    expect(slot.container.querySelector('svg path')).not.toBeNull()
+    await runtime.dispose()
+  })
+})
+
+describe('fixture session face', () => {
+  it('fail-loud stubs name the missing verb; supplied overrides run instead', async () => {
+    const runtime = await SlotTestRuntime.create()
+    await runtime.sessions.add({ id: 's1' })
+    const bare = runtime.sessions.behavior('s1')
+    expect(() => bare.prompt()).toThrow(/prompt is not stubbed/)
+    expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
+    expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
+    await runtime.dispose()
+  })
+
+  it('projections faces are identity-stable per key, read absent, and notify on set', async () => {
+    const runtime = await SlotTestRuntime.create()
+    await runtime.sessions.add({ id: 's1' })
+    const session = runtime.sessions.behavior('s1')
+    const face = session.projections.faceOf('todos')
+    expect(session.projections.faceOf('todos')).toBe(face)
+    expect(face.getSnapshot()).toBeUndefined()
+    const seen: unknown[] = []
+    const off = face.subscribe(() => { seen.push(face.getSnapshot()) })
+    session.projections.set('todos', [1, 2])
+    expect(seen).toEqual([[1, 2]])
+    off()
+    session.projections.set('todos', [3])
+    expect(seen).toEqual([[1, 2]]) // unsubscribed
+    // A never-subscribed key sets without listeners (the empty-notify arm).
+    session.projections.set('untouched', 1)
+    // The provide bundle hands the same store to the render side.
+    const info = runtime.sessions.provideInfo('s1')!
+    expect(info.projections?.faceOf('todos').getSnapshot()).toEqual([3])
+    // A roster change rebuilds the ALREADY-materialized bundle eagerly
+    // (production channel semantics: mounted entries must see the provider)
+    // and skips never-materialized records (they pick the roster up lazily).
+    await runtime.sessions.add({ id: 's-lazy' }, { current: false })
+    const offProbe = runtime.sessions.provide({
+      hooks: ['probe2'],
+      resolve: () => ({ hooks: { probe2: { getSnapshot: () => 1, subscribe: () => () => {} } } }),
+    })
+    const rebuilt = runtime.sessions.provideInfo('s1')!
+    expect(rebuilt).not.toBe(info)
+    expect(rebuilt.hooks['probe2']).toBeDefined()
+    offProbe()
+    await runtime.dispose()
+  })
+})
+
+describe('workspaces action face', () => {
+  it('records every IWorkspaces verb with inert defaults and honors stubs', async () => {
+    const runtime = await SlotTestRuntime.create()
+    const ws = runtime.workspaces
+    const created = await ws.create({ name: 'alpha' })
+    expect(created.title).toBe('alpha')
+    const registered = await ws.create({ path: '/tmp/beta' })
+    expect(registered.path).toBe('/tmp/beta')
+    await expect(ws.pickDirectory()).resolves.toBeNull()
+    const renamed = await ws.rename('w1' as WorkspaceId, 'Renamed')
+    expect(renamed.title).toBe('Renamed')
+    await ws.delete('w1' as WorkspaceId)
+    await ws.openPath('/proj/file.ts')
+    const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId)
+    expect(moved.sessionIds).toEqual(['s1'])
+    expect(ws.calls.map(c => c.method)).toEqual(
+      ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore'])
+
+    ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never))
+    ws.stub('pickDirectory', () => Promise.resolve('/picked'))
+    ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never))
+    ws.stub('delete', () => Promise.resolve())
+    ws.stub('openPath', () => Promise.resolve())
+    ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never))
+    expect((await ws.create({ name: 'y' })).title).toBe('X')
+    await expect(ws.pickDirectory()).resolves.toBe('/picked')
+    expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S')
+    await ws.delete('w1' as WorkspaceId)
+    await ws.openPath('/other')
+    expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([])
+    await runtime.dispose()
+  })
+})
+
+describe('single-slot mounting edge arms', () => {
+  it('renderSlot fails loud after dispose and after an external unmount', async () => {
+    const runtime = await SlotTestRuntime.create()
+    await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
+    runtime.slots.register({ name: 'trt.panel' }, () => <b>p</b>)
+    runtime.renderSlot('trt.panel', {})
+    // RTL cleanup empties the mounted tree behind the runtime's back: the
+    // wrapper lookup names the state instead of returning a dead container.
+    cleanup()
+    expect(() => runtime.renderSlot('trt.panel', {})).toThrow(/rendered no wrapper/)
+    await runtime.dispose()
+    // After dispose the root registration is gone: the production boot-order
+    // check fires before any wrapper lookup.
+    expect(() => runtime.renderSlot('trt.panel', {})).toThrow(/'root' has no registration/)
+  })
+
+  it('serializes childless svg untouched next to scoped classes', async () => {
+    const runtime = await SlotTestRuntime.create()
+    await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
+    runtime.slots.register({ name: 'trt.panel' }, () => (
+      <div className="_frame_a1b2c3">
+        <svg viewBox="0 0 1 1" aria-hidden="true" />
+      </div>
+    ))
+    const slot = runtime.renderSlot('trt.panel', {})
+    expect(slot.container).toMatchSnapshot()
+    await runtime.dispose()
+  })
+})

+ 27 - 0
packages/client/test-runtime/tsconfig.json

@@ -0,0 +1,27 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../ui-slots"
+    },
+    {
+      "path": "../web-react"
+    },
+    {
+      "path": "../runtime"
+    },
+    {
+      "path": "../../support/invariants"
+    }
+  ]
+}

+ 4 - 4
packages/client/ui-command/src/client/service.ts

@@ -9,10 +9,10 @@
 import { Service } from 'cordis'
 import type { Context } from 'cordis'
 import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
-import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
 import type {
   CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
-  SlashServiceContract, SubmitOutcome,
+  SubmitOutcome,
 } from '@deepseek-ai/dsh-client-ui-slash/client'
 import type { CommandContribution, CommandServiceContract } from './contract.ts'
 import type { CommandDescriptor } from './directory.ts'
@@ -46,7 +46,7 @@ export class CommandService extends Service implements CommandServiceContract {
       if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`)
       return result.value.commands
     })
-    const slash = ctx.get('slash') as SlashServiceContract | undefined
+    const slash = ctx.get('slash')
     if (slash === undefined) throw new Error('ui-command: slash service unavailable')
     ctx.effect(() => slash.registerSource({
       trigger: '/',
@@ -292,7 +292,7 @@ export class CommandService extends Service implements CommandServiceContract {
     return this.sessions().scope(id)
   }
 
-  private sessions(): SessionsService {
+  private sessions(): ISessions {
     const sessions = this.ctx.get('sessions')
     if (sessions === undefined) throw new Error('ui-command: sessions service unavailable')
     return sessions

+ 4 - 3
packages/client/ui-conversation/src/client/apply.ts

@@ -1,7 +1,7 @@
 /** Registers the conversation components, shared store, and service callbacks. */
 import type { Context } from 'cordis'
 import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
-import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
 import type { ViewTab } from './contract/views.ts'
 import type {
@@ -10,6 +10,7 @@ import type {
 import { resolveToolPath } from './contract/tool-call-model.ts'
 import { createChatStore } from './stores.ts'
 import { ConversationService } from './service.ts'
+import type { IConversation } from './service.ts'
 import { InputHub } from './input/hub.ts'
 import { InputBar } from './skeleton/InputBar.tsx'
 import { ChatView } from './chat/ChatView.tsx'
@@ -24,8 +25,8 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
 /** Services required by the conversation plugin. */
 export const inject = ['slots', 'layout', 'sessions', 'workspaces']
 
-/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
-function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
+/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
+function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
   const scoped = sessions.scope(id)
   if (scoped === undefined) throw new Error(`ui-conversation: session "${id}" resolved no scope`)
   const conversation = scoped.get('conversation')

+ 3 - 3
packages/client/ui-conversation/src/client/index.ts

@@ -3,10 +3,9 @@
  * between the independently implemented skeleton and chat domains; `apply.ts`
  * owns their slot assembly.
  */
-import type { ConversationService } from './service.ts'
-
 export { apply, inject } from './apply.ts'
 export { ConversationService } from './service.ts'
+export type { IConversation } from './service.ts'
 
 export type {
   CallId, ChatStoreState, SelectionTarget, ViewTab,
@@ -22,6 +21,7 @@ export type {
 
 declare module 'cordis' {
   interface Context {
-    conversation: ConversationService
+    /** The outward face only; the concrete service stays inside this plugin. */
+    conversation: import('./service.ts').IConversation
   }
 }

+ 5 - 6
packages/client/ui-conversation/src/client/input/hub.ts

@@ -8,9 +8,8 @@
  * bail events) and owns the default-sink choreography: every session is a
  * real host entity, so the sink is one unconditional prompt path.
  */
-import type { ClientContext, Session, SessionBinding, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
-import type { SlashController, SlashServiceContract } from '@deepseek-ai/dsh-client-ui-slash/client'
-import type {} from '@deepseek-ai/dsh-client-ui-slash/client'
+import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
+import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client'
 import { queueReadFaceOf } from '../queue/store.ts'
 import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
 import type { PopupDismissFace } from './facade.ts'
@@ -113,7 +112,7 @@ export class InputHub implements InputService {
    * exactly one path; a failed first prompt is an ordinary prompt failure
    * (error strip via promptError, draft restored only while untouched).
    */
-  private sink(session: Session, text: string, mode: 'queue' | 'steer'): void {
+  private sink(session: SessionFace, text: string, mode: 'queue' | 'steer'): void {
     if (text === '') return
     const shell = this.shells.get(session.sessionId)
     // Commit, not an editable clear: undo must not resurrect sent content.
@@ -129,7 +128,7 @@ export class InputHub implements InputService {
   }
 
   private controller(actx: ClientContext): SlashController | undefined {
-    const slash = this.rootCtx.get('slash') as SlashServiceContract | undefined
+    const slash = this.rootCtx.get('slash')
     return slash?.sessionOf(actx)
   }
 
@@ -138,7 +137,7 @@ export class InputHub implements InputService {
     return command?.popupFor(actx)
   }
 
-  private sessions(): SessionsService {
+  private sessions(): ISessions {
     const sessions = this.rootCtx.get('sessions')
     if (sessions === undefined) throw new Error('conversation.input: sessions service unavailable')
     return sessions

+ 3 - 3
packages/client/ui-conversation/src/client/queue/store.ts

@@ -5,7 +5,7 @@
  * reference-stable across unrelated snapshot swaps, so this is a pure
  * projection — no second store, no copy.
  */
-import type { ObservableSnapshot, Session } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ObservableSnapshot, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
 import type { QueuedMessage } from '../input/contract.ts'
 
 /**
@@ -13,10 +13,10 @@ import type { QueuedMessage } from '../input/contract.ts'
  * The wiring layer (T5) overlays this onto InputState.queue; the runtime
  * QueuedMessage and the input-contract QueuedMessage are structurally the
  * same frozen shape ({key, preview}).
- * @param session - the resident session instance.
+ * @param session - the resident session face.
  * @returns the queue read face (snapshot reference stable while the queue is unchanged).
  */
-export function queueReadFaceOf(session: Session): ObservableSnapshot<readonly QueuedMessage[]> {
+export function queueReadFaceOf(session: SessionFace): ObservableSnapshot<readonly QueuedMessage[]> {
   return {
     getSnapshot: () => session.getSnapshot().queue,
     subscribe: fn => session.subscribe(fn),

+ 40 - 15
packages/client/ui-conversation/src/client/service.ts

@@ -12,24 +12,50 @@ import type { Context } from 'cordis'
 // Type-only imports: a plugin-to-plugin value import is a bundle purity
 // error, so scope resolution goes through the sessions service (scopeOf
 // method) instead of the standalone helper.
-import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
-import { InputHub } from './input/hub.ts'
+import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
+import type { InputService } from './input/contract.ts'
+
+/**
+ * The outward conversation face (`ctx.conversation`): the scope-addressed
+ * verbs and the input registry other plugins may reach — and exactly what a
+ * test fake must supply.
+ */
+export interface IConversation {
+  /** The per-session input machine registry (InputService face). */
+  readonly input: InputService
+  /**
+   * Send a prompt into the caller scope's session.
+   * @param text - prompt text, sent verbatim as one text block.
+   * @param mode - queue after the current turn, or steer into it.
+   * @returns completion; business failures reject (and land in promptError).
+   */
+  send(text: string, mode: 'queue' | 'steer'): Promise<void>
+  /**
+   * Cancel the scoped session's in-flight turn.
+   * @returns completion; failures reject as in send.
+   */
+  cancel(): Promise<void>
+  /**
+   * Pull one older history page for the scoped session.
+   * @returns completion of the page pull.
+   */
+  loadOlder(): Promise<void>
+}
 
 /** Scope-addressed conversation service (root singleton, provided as `conversation`). */
-export class ConversationService extends Service {
+export class ConversationService extends Service implements IConversation {
   /** The per-session input machine registry (InputService face, design §5.2). */
-  readonly input: InputHub
+  readonly input: InputService
 
   /**
    * @param ctx - owning root context (the plugin apply context; the service
    * registers itself and follows that fiber's lifetime).
-   * @param config - the shared InputHub constructed by the plugin apply
-   * (shared with the slot inject factories); absent = own instance
-   * (object-layer tests that never touch slots).
+   * @param config - carries the InputService instance constructed by the
+   * plugin apply (the same InputHub the slot inject factories close over).
    */
-  constructor(ctx: Context, config?: { input?: InputHub }) {
+  constructor(ctx: Context, config: { input: InputService }) {
     super(ctx, 'conversation')
-    this.input = config?.input ?? new InputHub(ctx)
+    this.input = config.input
   }
 
   /**
@@ -57,8 +83,8 @@ export class ConversationService extends Service {
     await this.scopedSession('loadOlder').loadOlder()
   }
 
-  /** Resolve the caller scope's Session or throw on root contexts. */
-  private scopedSession(op: string): Session {
+  /** Resolve the caller scope's session face or throw on root contexts. */
+  private scopedSession(op: string): SessionFace {
     const id = this.scopeId(op)
     const binding = this.requireSessions().binding(id)
     if (binding === undefined) throw new Error(`conversation.${op}: session "${id}" resolved no binding`)
@@ -74,10 +100,9 @@ export class ConversationService extends Service {
     return id
   }
 
-  private requireSessions(): SessionsService {
-    // ctx.get instead of ctx.sessions: the typed Context merge is suspended
-    // while the client/host `sessions` declaration collision awaits
-    // arbitration (see the runtime package's Context merge note).
+  private requireSessions(): ISessions {
+    // Strict ctx.get, not the injection proxy: the scope-addressed pattern
+    // reads the service off whatever context the tracker rebound.
     const sessions = this.ctx.get('sessions')
     if (sessions === undefined) throw new Error('conversation: sessions service unavailable')
     return sessions

+ 128 - 142
packages/client/ui-conversation/tests/apply-inject.spec.tsx

@@ -1,144 +1,73 @@
 // @vitest-environment jsdom
 // apply inject factories exercised end to end against the terminal thin
-// shape: the conversation surface (views triple, send choreography incl.
-// optimistic clear + failure restore THROUGH the declared store actions,
-// openDetails = select action + layout orchestration, sessions.open
-// navigation), and the closeDetails details surface. Complements
-// chat-apply.spec.tsx (registration)
-// and selection-survival.spec.ts (store axis). History opening is NOT an
-// inject concern anymore — the runtime sessions service opens on watch
-// (sessions-service.spec.ts owns that behavior).
+// shape: the strict session surface (views triple, draft mirror), the
+// provide-channel input face (machine-sink submit choreography incl.
+// optimistic clear + failure restore), the resident surface (selectWorkspace
+// draft carrying), the composer-bar stop face, openDetails = select action +
+// layout orchestration, and the closeDetails details surface. Complements
+// chat-apply.spec.tsx (registration) and selection-survival.spec.tsx (store
+// axis). History opening is NOT an inject concern — the runtime sessions
+// service opens on watch (sessions-service.spec.ts owns that behavior).
+//
+// The inject surfaces are read off the ledger entries deliberately (typed at
+// this spec's own contract): these cases pin factory choreography the UI
+// guards would mask. Rendering-path acceptance lives in
+// chat-toolview-slot.spec.tsx.
 
-import { Context } from 'cordis'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { cleanup } from '@testing-library/react'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
-import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
-import type {
-  SessionId, SessionListState, WorkspaceListState,
-} from '@deepseek-ai/dsh-client-runtime/client'
-import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
+import { describe, expect, it, vi } from 'vitest'
+import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
+import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
+import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {
   ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { createChatStore } from '../src/client/stores.ts'
 
-afterEach(cleanup)
-
 const ROOT = 'root-1' as SessionId
 
 type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
 type ChatActions = ChatInstance['actions']
 
-const SCOPE_TAG: symbol = (() => {
-  const recorded: (string | symbol)[] = []
-  const spy = new Proxy(new Context(), {
-    get(target, prop, receiver) {
-      recorded.push(prop)
-      // Reflect.get is typed any; the probe only records property names.
-      // eslint-disable-next-line @typescript-eslint/no-unsafe-return
-      return Reflect.get(target, prop, receiver)
-    },
-  })
-  void scopeOf(spy)
-  const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
-  if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
-  return symbol
-})()
+/** ISession verb mocks, typed against the production face (['prompt'] etc. keep vitest mock ergonomics). */
+function sessionFakeFor() {
+  return {
+    open: vi.fn(() => Promise.resolve()),
+    loadOlder: vi.fn<ISession['loadOlder']>(() => Promise.resolve()),
+    prompt: vi.fn<ISession['prompt']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
+    cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
+  } satisfies SessionBehaviorOverrides
+}
 
 async function bench() {
-  const ctx = new Context()
-  const slotsFiber = ctx.plugin(SlotsService)
-  await slotsFiber.await()
-  const slots = ctx.get('slots') as SlotsService
-
-  const listStore = createSnapshotStore<SessionListState>({
-    ids: [ROOT],
-    byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, blank: false, updatedAt: 1 } },
-    current: ROOT,
-    phase: 'ready',
-  })
-  const sessionFake = {
-    sessionId: ROOT,
-    open: vi.fn(() => Promise.resolve()),
-    loadOlder: vi.fn(() => Promise.resolve()),
-    prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
-      () => Promise.resolve({ ok: true, value: { accepted: true } })),
-    cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
-      () => Promise.resolve({ ok: true, value: { accepted: true } })),
-    // Observable face (the input machine's queue read face rides it).
-    getSnapshot: () => ({ queue: [] }),
-    subscribe: () => () => {},
-  }
-  const scopes = new Map<SessionId, Context>()
-  const mint = (id: SessionId): Context => {
-    let scoped = scopes.get(id)
-    if (scoped === undefined) {
-      scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id })
-      scopes.set(id, scoped)
-    }
-    return scoped
-  }
-  type TestProvider = {
-    resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): {
-      hooks?: Record<string, unknown>
-      props?: Record<string, unknown>
-    }
-  }
-  const providers: TestProvider[] = []
-  const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
-  const sessionsFake = {
-    list: listStore,
-    binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
-    scope: (id: SessionId) => mint(id),
-    provideInfo: () => undefined,
-    currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
-    provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
-    scopeOf,
-    sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),
-    open: vi.fn(),
-  }
-  ctx.provide('sessions', sessionsFake)
-  const workspaceStore = createSnapshotStore<WorkspaceListState>({
-    items: [], state: 'idle', phase: 'ready', error: null,
-    baselinesReady: true, recentWorkspaceId: undefined,
+  const runtime = await SlotTestRuntime.create()
+  const sessionFake = sessionFakeFor()
+  await runtime.sessions.add({
+    id: ROOT,
+    summary: { title: 'R', displayTitle: 'R', cwd: '/proj' },
+    session: sessionFake,
   })
-  const workspacesFake = {
-    list: workspaceStore,
-    connectWorkspace: vi.fn(async () => ROOT),
-    openPath: vi.fn(async () => {}),
-  }
-  ctx.provide('workspaces', workspacesFake)
   const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
-  ctx.provide('layout', layoutFake)
-  ctx.provide('locale', { bind: () => (key: string) => key })
+  runtime.provide('layout', layoutFake)
 
-  // The AppFrame role: the three conversation-package slots must be declared
-  // by a live entry before apply can contribute into them (the stand-in
-  // consumes renderSlot to satisfy the declare-means-render check).
-  slots.register({
-    name: 'root',
-    children: {
-      'conversation': { kind: 'single', scope: 'session-maybe' },
-      'details': { kind: 'single', scope: 'session' },
-    },
+  // The AppFrame role: the conversation-package slots must be declared by a
+  // live entry before apply can contribute into them.
+  await runtime.root.declare({
+    'conversation': { kind: 'single', scope: 'session-maybe' },
+    'details': { kind: 'single', scope: 'session' },
   }, (_p: { renderSlot?: unknown }) => null)
 
-  const fiber = ctx.plugin({ inject: [...inject], apply })
-  await fiber.await()
+  const feature = await runtime.mount({ inject: [...inject], apply })
 
-  // Reach the render-side entry view (inject + store handle) the way the
-  // renderer does: through the host face.
-  let host: SlotRendererHost | undefined
-  slots.install({ renderRoot: (h) => { host = h; return null } })
-  slots.renderSlot('root', {})
-  const hostFace = host!
-  const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') => hostFace.entriesOf(key)[0]!
+  // The host face (store resolution) exists only inside the installed
+  // renderer, so materialize it the way the shell does.
+  runtime.renderRoot()
+  const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
+    runtime.slots.entries(key)[0]!
   /** Resolve store instance + call the inject the way the outlet would. */
   const conversationSurface = (id: SessionId) => {
     const entry = entryOf('conversation.session')
-    const instance = hostFace.storeOf(entry, id) as ChatInstance
+    const instance = runtime.storeOf('conversation.session', id) as ChatInstance
     const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionInjected)(
       id, instance.actions)
     return { instance, injected }
@@ -154,27 +83,28 @@ async function bench() {
   /** Same resolution for the chat entry riding the view ring. */
   const chatViewSurface = (id: SessionId) => {
     const entry = entryOf('conversation.view')
-    const instance = hostFace.storeOf(entry, id) as ChatInstance
+    const instance = runtime.storeOf('conversation.view', id) as ChatInstance
     const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)(
       id, instance.actions)
     return { instance, injected }
   }
   /** Materialize the input provide contribution the way the runtime does. */
   const inputSurface = (id: SessionId) => {
-    const contribution = providers[0]!.resolve(sessionsFake.binding(id))
-    const state = contribution.hooks!['input'] as {
+    const info = runtime.sessions.provideInfo(id)!
+    const state = info.hooks['input'] as {
       getSnapshot: () => { draft: string }
       subscribe: (fn: () => void) => () => void
     }
-    const actions = contribution.props!['inputActions'] as {
+    const actions = info.props['inputActions'] as {
       setDraft: (text: string) => void
       submit: (mode?: 'queue' | 'steer') => void
     }
     return { state, actions }
   }
   return {
-    ctx, slots, hostFace, entryOf, conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
-    sessionFake, sessionsFake, workspacesFake, layoutFake, mint,
+    runtime, feature, slots: runtime.slots, entryOf,
+    conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
+    sessionFake, layoutFake,
   }
 }
 
@@ -190,6 +120,7 @@ describe('conversation slot inject surface', () => {
     const chatView = b.chatViewSurface(ROOT)
     chatView.injected.loadOlder()
     expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
+    await b.runtime.dispose()
   })
 
   it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => {
@@ -208,14 +139,14 @@ describe('conversation slot inject surface', () => {
     await Promise.resolve()
     expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
     // Failure: restored (draft still empty when the rejection lands).
-    b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
+    b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
     actions.setDraft('retry me')
     actions.submit('queue')
     await vi.waitFor(() => {
       expect(state.getSnapshot().draft).toBe('retry me')
     })
     // Failure landing after new typing: no clobber (restore fills empty only).
-    b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
+    b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
     actions.submit('queue')
     actions.setDraft('typed during flight')
     await new Promise(r => setTimeout(r, 0))
@@ -229,23 +160,25 @@ describe('conversation slot inject surface', () => {
     expect(mirrored).toEqual(['mirrored text'])
     unbind()
     // Stop failure is swallowed (promptError owns the surface).
-    b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
+    b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
     b.composerSurface(ROOT).stop()
     await new Promise(r => setTimeout(r, 0))
     expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
+    await b.runtime.dispose()
   })
 
-  it('inject fails loud when the session resolves no scope or the scope lacks the service', async () => {
+  it('inject fails loud when the session resolves no binding or the scope lacks the service', async () => {
     const b = await bench()
     const entry = b.entryOf('conversation.composer.bar')
     const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
-    // Unknown session: sessions.scope answers nothing.
-    ;(b.sessionsFake.scope as unknown) = () => undefined
-    expect(() => { injectFn(ROOT).stop() }).toThrow(/resolved no scope/)
-    // A scope minted outside the service tree: no conversation service on it.
-    const foreign = new Context()
-    ;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({})
-    expect(() => { injectFn(ROOT).stop() }).toThrow(/unavailable through the session scope/)
+    // Unknown session: the keyboard face's binding resolution answers nothing.
+    expect(() => { injectFn('ghost' as SessionId).stop() }).toThrow(/resolved no binding/)
+    // A scope whose service tree lost 'conversation' (the feature fiber
+    // unloaded while a retained inject closure re-runs): fails loud too.
+    const stop = injectFn(ROOT).stop
+    await b.feature.dispose()
+    expect(() => { stop() }).toThrow(/unavailable through the session scope/)
+    await b.runtime.dispose()
   })
 
   it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
@@ -258,6 +191,7 @@ describe('conversation slot inject surface', () => {
     // writes land where the skeleton and details read.
     const conv = b.conversationSurface(ROOT)
     expect(conv.instance).toBe(instance)
+    await b.runtime.dispose()
   })
 
   it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
@@ -265,8 +199,9 @@ describe('conversation slot inject surface', () => {
     const { injected } = b.chatViewSurface(ROOT)
     injected.openFile('src/a.ts')
     await vi.waitFor(() => {
-      expect(b.workspacesFake.openPath).toHaveBeenCalledWith('/proj/src/a.ts')
+      expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] })
     })
+    await b.runtime.dispose()
   })
 
   it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => {
@@ -274,23 +209,73 @@ describe('conversation slot inject surface', () => {
     const { injected } = b.conversationSurface(ROOT)
     const resident = b.residentSurface(ROOT)
     injected.open(ROOT)
-    expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
+    expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
     // Same-session connect (the picked workspace resolves to this session):
     // no draft movement, plain re-open.
+    b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
     const { state, actions } = b.inputSurface(ROOT)
     actions.setDraft('carry me')
     void resident.selectWorkspace('workspace-1' as never)
-    await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) })
-    expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1')
+    await vi.waitFor(() => {
+      expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(2)
+    })
+    expect(b.runtime.workspaces.calls).toContainEqual({ method: 'connectWorkspace', args: ['workspace-1'] })
     expect(state.getSnapshot().draft).toBe('carry me')
     // Cross-session connect: the draft MOVES — the old machine empties, the
     // new session's machine receives the text, then navigation lands there.
     const OTHER = 'other-1' as SessionId
-    b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER)
+    await b.runtime.sessions.add({ id: OTHER }, { current: false })
+    b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
     void resident.selectWorkspace('workspace-2' as never)
-    await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) })
+    await vi.waitFor(() => {
+      expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
+    })
     expect(state.getSnapshot().draft).toBe('')
     expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
+    await b.runtime.dispose()
+  })
+
+  it('selectWorkspace edge arms: no-session resident, empty-draft move, connect failure retryable', async () => {
+    const b = await bench()
+    // No-session resident (hero before any session): connect resolves and
+    // navigation proceeds without any draft choreography.
+    const noSession = b.residentSurface(undefined)
+    b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
+    void noSession.selectWorkspace('workspace-0' as never)
+    await vi.waitFor(() => {
+      expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
+    })
+
+    // Cross-session connect with an EMPTY draft: no move, no clearing.
+    const OTHER = 'b9-other' as SessionId
+    await b.runtime.sessions.add({ id: OTHER }, { current: false })
+    const resident = b.residentSurface(ROOT)
+    const { state } = b.inputSurface(ROOT)
+    expect(state.getSnapshot().draft).toBe('')
+    b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
+    void resident.selectWorkspace('workspace-3' as never)
+    await vi.waitFor(() => {
+      expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
+    })
+    expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('')
+
+    // Connect failure: the rejection propagates to the caller (the view owns
+    // the rollback) and no further navigation happens.
+    const opens = b.runtime.sessions.calls.filter(c => c.method === 'open').length
+    b.runtime.workspaces.stub('connectWorkspace', () => Promise.reject(new Error('offline')))
+    await expect(resident.selectWorkspace('workspace-4' as never)).rejects.toThrow('offline')
+    expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(opens)
+    await b.runtime.dispose()
+  })
+
+  it('scopedConversation fails loud when the session resolves no scope', async () => {
+    const b = await bench()
+    // The chat-view inject resolves the scoped conversation service at inject
+    // time: an unlisted session hits the scope() === undefined throw directly.
+    const entry = b.entryOf('conversation.view')
+    const injectFn = entry.inject as unknown as (sessionId: SessionId, actions: unknown) => unknown
+    expect(() => injectFn('never-listed' as SessionId, {})).toThrow(/resolved no scope/)
+    await b.runtime.dispose()
   })
 
   it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
@@ -313,6 +298,7 @@ describe('conversation slot inject surface', () => {
     off()
     off2()
     unsub()
+    await b.runtime.dispose()
   })
 })
 
@@ -325,9 +311,9 @@ describe('details inject surface', () => {
     injected.closeDetails()
     expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1)
     // The shared handle: details resolves the SAME instance conversation writes.
-    const conv = b.hostFace.storeOf(b.entryOf('conversation.session'), ROOT)
-    const details = b.hostFace.storeOf(entry, ROOT)
+    const conv = b.runtime.storeOf('conversation.session', ROOT)
+    const details = b.runtime.storeOf('details', ROOT)
     expect(details).toBe(conv)
+    await b.runtime.dispose()
   })
-
 })

+ 29 - 64
packages/client/ui-conversation/tests/chat-apply.spec.tsx

@@ -1,89 +1,53 @@
 // @vitest-environment jsdom
 // apply wiring: the conversation service provided, the chat view registered
 // as the first 'conversation.view' ring entry declaring the keyed toolview
-// hole, the three slot registrations land against a root entry's children
-// declarations (the AppFrame role), the shared store handle rides all session
-// entries, and the bash sample mounts through the load-order seam as a keyed
-// entry. Full-chain rendering belongs to the machinery spec
-// (chat-toolview-slot.spec.tsx) and the shell e2e; this spec stops at the
-// assembly surface.
+// hole, the slot registrations land against a root entry's children
+// declarations (the AppFrame role), the shared store handle rides all strict
+// session entries, and the bash sample + todo row mount through the
+// load-order seam as keyed entries. Full-chain rendering belongs to the
+// machinery spec (chat-toolview-slot.spec.tsx) and the shell e2e; this spec
+// stops at the assembly surface.
 
-import { Context } from 'cordis'
 import { describe, expect, it, vi } from 'vitest'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
-import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
-import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
+import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
+import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
 
 const ROOT = 'root-1' as SessionId
 const CHILD = 'child-1' as SessionId
 
 async function bench() {
-  const ctx = new Context()
-  const slotsFiber = ctx.plugin(SlotsService)
-  await slotsFiber.await()
+  const runtime = await SlotTestRuntime.create()
+  await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false })
+  await runtime.sessions.add(
+    { id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false })
+  runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
 
-  const listStore = createSnapshotStore<SessionListState>({
-    ids: [ROOT, CHILD],
-    byId: {
-      [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, blank: false, updatedAt: 1 },
-      [CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, blank: false, updatedAt: 2 },
-    },
-    current: undefined,
-    phase: 'ready',
-  })
-  const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
-  const sessionsFake = {
-    list: listStore,
-    binding: vi.fn(),
-    scope: () => undefined,
-    provideInfo: () => undefined,
-    currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
-    provide: vi.fn(() => () => {}),
-    create: vi.fn(),
-    open: vi.fn(),
-    updateIntent: vi.fn(),
-  }
-  ctx.provide('sessions', sessionsFake)
-  ctx.provide('workspaces', {
-    startSession: vi.fn(),
-    sendSession: vi.fn(),
-    openPath: vi.fn(async () => {}),
-  })
-  ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
-  ctx.provide('locale', { bind: () => (key: string) => key })
-
-  // Declared by ui-layout's root entry in production; a stand-in root
-  // occupant declares them here so the contributions land (it consumes
-  // renderSlot to satisfy the declare-means-render check).
-  const slots = ctx.get('slots') as SlotsService
-  slots.register({
-    name: 'root',
-    children: {
-      'conversation': { kind: 'single', scope: 'session-maybe' },
-      'details': { kind: 'single', scope: 'session' },
-    },
+  // Declared by ui-layout's root entry in production; the test root declares
+  // them here so the contributions land.
+  await runtime.root.declare({
+    'conversation': { kind: 'single', scope: 'session-maybe' },
+    'details': { kind: 'single', scope: 'session' },
   }, (_p: { renderSlot?: unknown }) => null)
 
-  const fiber = ctx.plugin({ inject: [...inject], apply })
-  return { ctx, fiber, slots }
+  const feature = await runtime.mount({ inject: [...inject], apply })
+  return { runtime, feature, slots: runtime.slots }
 }
 
 /** First stored entry for a key (inject/store live directly on StoredEntry). */
-function renderEntryOf(slots: SlotsService, key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') {
+function renderEntryOf(slots: Awaited<ReturnType<typeof bench>>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') {
   return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
 }
 
 describe('apply wiring', () => {
   it('provides the conversation service', async () => {
     const b = await bench()
-    await b.fiber.await()
-    expect(b.ctx.get('conversation')).toBeDefined()
+    expect(b.runtime.ctx.get('conversation')).toBeDefined()
+    await b.runtime.dispose()
   })
 
   it('registers the chat view as the first ring entry, declaring the keyed toolview hole', async () => {
     const b = await bench()
-    await b.fiber.await()
     const entries = b.slots.entries('conversation.view')
     expect(entries.map(e => e.options.id)).toEqual(['chat'])
     expect(entries[0]?.options.label).toBe('Chat')
@@ -91,11 +55,11 @@ describe('apply wiring', () => {
     // Declaring is claiming: the chat entry's registration put the hole on
     // the ledger with the contract's kind/scope.
     expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
+    await b.runtime.dispose()
   })
 
   it('occupies the slots + the ring; session entries share one store handle', async () => {
     const b = await bench()
-    await b.fiber.await()
     const conversation = renderEntryOf(b.slots, 'conversation')
     const conversationSession = renderEntryOf(b.slots, 'conversation.session')
     const chatView = renderEntryOf(b.slots, 'conversation.view')
@@ -111,21 +75,21 @@ describe('apply wiring', () => {
     // The hero workspace picker hole rides the conversation entry's children
     // declaration (the empty-state occupant is gone).
     expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
+    await b.runtime.dispose()
   })
 
   it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => {
     const b = await bench()
-    await b.fiber.await()
     // Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
     // service being present implies the chat entry declared the hole first.
     const entries = b.slots.entries('conversation.chat.toolview')
     expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write'])
+    await b.runtime.dispose()
   })
 
   it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {
     const b = await bench()
-    await b.fiber.await()
-    await b.fiber.dispose()
+    await b.feature.dispose()
     expect(b.slots.entries('conversation')).toHaveLength(0)
     // The declared ring collapses with its declaring entry, and the chat
     // entry's keyed hole (with the sample's registration) collapses with it.
@@ -133,6 +97,7 @@ describe('apply wiring', () => {
     expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
     expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
     expect(b.slots.entries('details')).toHaveLength(0)
-    expect(b.ctx.get('conversation')).toBeUndefined()
+    expect(b.runtime.ctx.get('conversation')).toBeUndefined()
+    await b.runtime.dispose()
   })
 })

+ 56 - 169
packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx

@@ -1,7 +1,7 @@
 // @vitest-environment jsdom
 // The dissolved tool ring's acceptance chain on the REAL machinery stack:
-// cordis Context + SlotsService ledger + the web-react renderer + this
-// package's own apply — no outlet twins. Proves the keyed
+// SlotTestRuntime (cordis Context + SlotsService ledger + the web-react
+// renderer) + this package's own apply — no outlet twins. Proves the keyed
 // 'conversation.chat.toolview' hole end to end: registered rows dispatch by
 // entryKey (the bash sample lands through its plugin), unregistered tools
 // fall back to GenericToolCard at the render site, live registration/unload
@@ -10,23 +10,16 @@
 // inject: ['slots', 'conversation'] load-order seam suspends on real fiber
 // semantics until the service (and with it the hole declaration) is present.
 
-import { Context } from 'cordis'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { act, cleanup, fireEvent, render } from '@testing-library/react'
-import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
-import type {
-  ConversationSnapshot, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
-} from '@deepseek-ai/dsh-client-runtime/client'
-import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
+import { cleanup, fireEvent } from '@testing-library/react'
+import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
 import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
+import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 
 const SID = 's1' as SessionId
 
-/** Identity-stable no-session bundle (uSES getSnapshot contract). */
-const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
-
 afterEach(cleanup)
 // The chat store persists under its declared key; clear between cases.
 beforeEach(() => {
@@ -40,117 +33,38 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
   content: [], isError: false, callView: null, resultView: null,
 })
 
-function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
-  return {
-    sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
-    pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
-    hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
-  }
-}
-
 /** Test-owned AppFrame role: declares and renders the resident conversation area. */
 type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
 function AppRoot({ renderSlot }: AppRootProps) {
   return <>{renderSlot('conversation', {})}</>
 }
 
+const LAYOUT_CHILDREN = {
+  'conversation': { kind: 'single', scope: 'session-maybe' },
+  'details': { kind: 'single', scope: 'session' },
+} as const
+
 /**
- * Real-stack bench: SlotsService plugin, renderer installed, sessions/layout
- * fakes at the service seams only (external boundaries), the package apply on
- * its own fiber, and the test AppFrame occupying 'root'.
+ * Real-stack bench: SlotTestRuntime with the session/layout doubles at the
+ * service seams only (external boundaries), the package apply on its own
+ * fiber, and the test AppFrame occupying 'root'.
  */
 async function bench(nodes: ToolResultNode[]) {
-  const ctx = new Context()
-  const slotsFiber = ctx.plugin(SlotsService)
-  await slotsFiber.await()
-  const slots = ctx.get('slots') as SlotsService
-
-  const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
-  const list = createSnapshotStore<SessionListState>({
-    ids: [SID],
-    byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
-    current: SID,
-    phase: 'ready',
-  })
-  // Identity-stable provide bundle: the renderer caches hooks per source and
-  // inject results per bundle, both by object identity. Registered providers
-  // (the package's input contribution) materialize into it lazily, once.
-  const providers: ((binding: object) => { hooks?: object; props?: object })[] = []
-  let info: { sessionId: SessionId; hooks: object; props: object } | undefined
-  const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
+  const runtime = await SlotTestRuntime.create()
   const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
-  const actxFake = { get: () => scoped, effect: () => {}, on: () => () => {} }
-  const bindingOf = (id: SessionId) => ({
-    sessionId: id,
-    ctx: actxFake,
+  runtime.provide('layout', layout)
+  await runtime.sessions.add({
+    id: SID,
+    summary: { title: 'S', displayTitle: 'S' },
+    snapshot: { nodes },
     session: {
-      sessionId: id,
-      loadOlder: vi.fn(),
-      prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })),
-      // Observable face for the input machine's queue read face.
-      getSnapshot: () => session.getSnapshot(),
-      subscribe: (fn: () => void) => session.subscribe(fn),
+      loadOlder: vi.fn<ISession['loadOlder']>(),
+      prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
     },
   })
-  const provideInfo = (id: string) => {
-    if (id !== SID) return undefined
-    if (info === undefined) {
-      const hooks: Record<string, unknown> = { session }
-      const props: Record<string, unknown> = {}
-      for (const provider of providers) {
-        const c = provider(bindingOf(SID))
-        Object.assign(hooks, c.hooks ?? {})
-        Object.assign(props, c.props ?? {})
-      }
-      info = { sessionId: SID, hooks, props }
-    }
-    return info
-  }
-  ctx.provide('sessions', {
-    list,
-    binding: bindingOf,
-    scope: () => actxFake,
-    provideInfo,
-    currentProvideInfo: {
-      getSnapshot: () => provideInfo(SID),
-      subscribe: () => () => {},
-    },
-    provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
-    scopeOf: () => SID,
-    create: vi.fn(),
-    open: vi.fn(),
-    updateIntent: vi.fn(),
-  })
-  const workspaces = {
-    list: createSnapshotStore<WorkspaceListState>({
-      items: [], state: 'idle', phase: 'ready', error: null,
-      baselinesReady: true, recentWorkspaceId: undefined,
-    }),
-    startSession: vi.fn(),
-    sendSession: vi.fn(),
-    openPath: vi.fn(async () => {}),
-  }
-  ctx.provide('workspaces', workspaces)
-  ctx.provide('layout', layout)
-  ctx.provide('locale', { bind: () => (key: string) => key })
-
-  slots.install(createSlotRenderer())
-  slots.register({
-    name: 'root',
-    children: {
-      'conversation': { kind: 'single', scope: 'session-maybe' },
-      'details': { kind: 'single', scope: 'session' },
-    },
-  }, AppRoot)
-
-  const fiber = ctx.plugin({ inject: [...inject], apply })
-  await fiber.await()
-  return { ctx, slots, fiber, session, list, layout, workspaces }
-}
-
-/** Render the whole tree through the ctx-level root seam (the shell's own entry). */
-function mountApp(slots: SlotsService) {
-  return render(<>{slots.renderSlot('root', {})}</>)
+  await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
+  const feature = await runtime.mount({ inject: [...inject], apply })
+  return { runtime, slots: runtime.slots, feature, layout }
 }
 
 describe('keyed toolview hole through the real machinery', () => {
@@ -159,7 +73,7 @@ describe('keyed toolview hole through the real machinery', () => {
       toolResult(3, 'c1', 'bash'),
       toolResult(4, 'c2', 'mystery', '{"n":1}'),
     ])
-    const view = mountApp(b.slots)
+    const view = b.runtime.renderRoot()
     // bash: the sample plugin's keyed registration took the row (root
     // session → global arm, decided inside the component off useSessions).
     expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
@@ -167,6 +81,7 @@ describe('keyed toolview hole through the real machinery', () => {
     expect(view.getByText('Build')).toBeTruthy()
     // mystery: no registration under that key → render-site fallback.
     expect(view.getByText('Tool call')).toBeTruthy()
+    await b.runtime.dispose()
   })
 
   it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => {
@@ -176,7 +91,7 @@ describe('keyed toolview hole through the real machinery', () => {
       toolResult(4, 'cordis-2', 'cordis_mount', JSON.stringify({ code })),
       toolResult(5, 'cordis-3', 'cordis_unmount', '{"id":"dyn-2"}'),
     ])
-    const view = mountApp(b.slots)
+    const view = b.runtime.renderRoot()
 
     expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
     const mounted = view.container.querySelector('[data-variant="code"]')
@@ -186,42 +101,46 @@ describe('keyed toolview hole through the real machinery', () => {
 
     fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
     expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
+    await b.runtime.dispose()
   })
 
   it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => {
     const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')])
-    const view = mountApp(b.slots)
+    const view = b.runtime.renderRoot()
     view.getByText('src/a.ts').click()
     expect(b.layout.openDetails).not.toHaveBeenCalled()
     await vi.waitFor(() => {
-      expect(b.workspaces.openPath).toHaveBeenCalledWith('src/a.ts')
+      expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['src/a.ts'] })
     })
+    await b.runtime.dispose()
   })
 
   it('bash summary clicks do not open details or host paths', async () => {
     const b = await bench([toolResult(3, 'c1', 'bash')])
-    const view = mountApp(b.slots)
+    const view = b.runtime.renderRoot()
     view.getByText('Build').click()
     expect(b.layout.openDetails).not.toHaveBeenCalled()
-    expect(b.workspaces.openPath).not.toHaveBeenCalled()
+    expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false)
+    await b.runtime.dispose()
   })
 
   it('a live keyed registration takes over its tool row and unload reverts to the fallback', async () => {
     const b = await bench([toolResult(3, 'c2', 'mystery', '{"n":1}')])
-    const view = mountApp(b.slots)
+    const view = b.runtime.renderRoot()
     expect(view.getByText('Tool call')).toBeTruthy()
     let dispose = (): void => {}
-    await act(async () => {
-      dispose = b.slots.register(
-        { name: 'conversation.chat.toolview', key: 'mystery' },
-        () => <div data-testid="mystery-row" />)
-    })
+    dispose = b.slots.register(
+      { name: 'conversation.chat.toolview', key: 'mystery' },
+      () => <div data-testid="mystery-row" />)
+    await b.runtime.flush()
     // Per-key version tick: the row flipped without a remount of the view.
     expect(view.getByTestId('mystery-row')).toBeTruthy()
     expect(view.queryByText('Tool call')).toBeNull()
-    await act(async () => { dispose() })
+    dispose()
+    await b.runtime.flush()
     expect(view.queryByTestId('mystery-row')).toBeNull()
     expect(view.getByText('Tool call')).toBeTruthy()
+    await b.runtime.dispose()
   })
 
   it('a duplicate key registration fails loud at load', async () => {
@@ -230,6 +149,7 @@ describe('keyed toolview hole through the real machinery', () => {
       { name: 'conversation.chat.toolview', key: 'bash' },
       () => null,
     )).toThrow(/key "bash"/)
+    await b.runtime.dispose()
   })
 
   it('the inject channel feeds (sessionId) => I into the row component', async () => {
@@ -247,66 +167,33 @@ describe('keyed toolview hole through the real machinery', () => {
     }, ({ mark, poke }: ToolRowProps & { mark: string; poke: () => void }) => (
       <button data-testid="probe-row" onClick={poke}>{mark}</button>
     ))
-    const view = mountApp(b.slots)
+    const view = b.runtime.renderRoot()
     const row = view.getByTestId('probe-row')
     expect(row.textContent).toBe(`for:${SID}`)
     row.click()
     expect(poked).toEqual([SID])
+    await b.runtime.dispose()
   })
 })
 
 describe('registrant load-order seam', () => {
   it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
-    const ctx = new Context()
-    const slotsFiber = ctx.plugin(SlotsService)
-    await slotsFiber.await()
-    const slots = ctx.get('slots') as SlotsService
-    ctx.provide('sessions', {
-      list: createSnapshotStore<SessionListState>({
-        ids: [], byId: {}, current: undefined, phase: 'ready',
-      }),
-      binding: () => undefined,
-      scope: () => undefined,
-      provideInfo: () => undefined,
-      currentProvideInfo: {
-        getSnapshot: () => ABSENT_INFO,
-        subscribe: () => () => {},
-      },
-      provide: () => () => {},
-      create: vi.fn(),
-      open: vi.fn(),
-      updateIntent: vi.fn(),
-    })
-    ctx.provide('workspaces', {
-      list: createSnapshotStore<WorkspaceListState>({
-        items: [], state: 'idle', phase: 'ready', error: null,
-        baselinesReady: true, recentWorkspaceId: undefined,
-      }),
-      startSession: vi.fn(),
-      sendSession: vi.fn(),
-      openPath: vi.fn(async () => {}),
-    })
-    ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
-    ctx.provide('locale', { bind: () => (key: string) => key })
-    slots.register({
-      name: 'root',
-      children: {
-        'conversation': { kind: 'single', scope: 'session-maybe' },
-        'details': { kind: 'single', scope: 'session' },
-      },
-    }, AppRoot)
+    const runtime = await SlotTestRuntime.create()
+    runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
+    await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
 
     // Third-party posture, mounted BEFORE ui-conversation: real fiber inject
     // semantics hold it — apply must not run while 'conversation' is absent.
-    // (Plain arrow, not vi.fn: mock functions carry a prototype and trip the
-    // fiber's isConstructor branch.)
+    // Uses ctx.plugin directly (the deliberate-suspension escape hatch; mount()
+    // would fail loud on the missing service). (Plain arrow, not vi.fn: mock
+    // functions carry a prototype and trip the fiber's isConstructor branch.)
     let applyRuns = 0
-    const registrantApply = (registrantCtx: Context): void => {
+    const registrantApply = (registrantCtx: typeof runtime.ctx): void => {
       applyRuns += 1
       registrantCtx.slots.register(
         { name: 'conversation.chat.toolview', key: 'late' }, () => null)
     }
-    const late = ctx.plugin({
+    const late = runtime.ctx.plugin({
       name: 'late-registrant',
       inject: ['slots', 'conversation'],
       apply: registrantApply,
@@ -317,11 +204,11 @@ describe('registrant load-order seam', () => {
     // Mounting the package resolves the seam: service present ⟹ the chat
     // entry (and its hole declaration) is already on the ledger, so the
     // suspended registrant lands without an undeclared-slot throw.
-    const fiber = ctx.plugin({ inject: [...inject], apply })
-    await fiber.await()
+    await runtime.mount({ inject: [...inject], apply })
     await late.await()
     expect(applyRuns).toBe(1)
-    expect(slots.entries('conversation.chat.toolview').map(e => e.options.key))
+    expect(runtime.slots.entries('conversation.chat.toolview').map(e => e.options.key))
       .toEqual(expect.arrayContaining(['bash', 'late']))
+    await runtime.dispose()
   })
 })

+ 0 - 149
packages/client/ui-conversation/tests/selection-survival.spec.ts

@@ -1,149 +0,0 @@
-// @vitest-environment jsdom
-/**
- * Exercises selection persistence through the real SlotsService store axis;
- * component stubs cannot prove per-session identity or disposal.
- */
-import { Context } from 'cordis'
-import { beforeEach, describe, expect, it } from 'vitest'
-import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
-import type { SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
-import { createChatStore } from '../src/client/stores.ts'
-
-const sid = (s: string): SessionId => s as SessionId
-
-/** Identity-stable no-session bundle (uSES getSnapshot contract). */
-const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
-
-interface Bench {
-  slots: SlotsService
-  chat: ReturnType<typeof createChatStore>
-}
-
-function bench(): Bench {
-  const ctx = new Context()
-  ctx.provide('sessions', {
-    list: createSnapshotStore<SessionListState>({
-      ids: [], byId: {}, current: undefined, phase: 'ready',
-    }),
-    provideInfo: () => undefined,
-    currentProvideInfo: {
-      getSnapshot: () => ABSENT_INFO,
-      subscribe: () => () => {},
-    },
-    provide: () => () => {},
-  })
-  ctx.provide('workspaces', {
-    list: createSnapshotStore<WorkspaceListState>({
-      items: [], state: 'idle', phase: 'ready', error: null,
-      baselinesReady: true, recentWorkspaceId: undefined,
-    }),
-  })
-  // Service self-registers as ctx 'slots' (cordis Service constructor).
-  const slots = new SlotsService(ctx)
-  const chat = createChatStore()
-  // The apply.ts shape: one shared handle across both session-slot
-  // registrations. 'conversation'/'details' must first exist in the ledger —
-  // register a root occupant declaring them (the AppFrame role; the stand-in
-  // consumes renderSlot to satisfy the declare-means-render check).
-  slots.register({
-    name: 'root',
-    children: {
-      'conversation': { kind: 'single', scope: 'session-maybe' },
-      'conversation.session': { kind: 'single', scope: 'session' },
-      'details': { kind: 'single', scope: 'session' },
-    },
-  }, (_p: { renderSlot?: unknown }) => null)
-  // apply.ts mounts the shared chat handle only under session-scope slots
-  // (the session-maybe 'conversation' shell carries no store).
-  slots.register({ name: 'conversation.session', store: chat }, () => null)
-  slots.register({ name: 'details', store: chat }, () => null)
-  return { slots, chat }
-}
-
-/** Resolve the store instance the renderer would hand a slot's component for a session. */
-function storeFor(b: Bench, slot: 'conversation.session' | 'details', sessionId: SessionId) {
-  const host = renderHost(b)
-  const entry = host.entriesOf(slot)[0]!
-  return host.storeOf(entry, sessionId)! as ReturnType<ReturnType<typeof createChatStore>['create']>
-}
-
-/** The host face is only built at renderSlot time; install a stub renderer once to reach it. */
-function renderHost(b: Bench): import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost {
-  const captured = (b as unknown as { _host?: import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost })
-  if (captured._host === undefined) {
-    b.slots.install({
-      renderRoot: (host) => {
-        captured._host = host
-        return null
-      },
-    })
-    b.slots.renderSlot('root', {})
-  }
-  return captured._host!
-}
-
-beforeEach(() => {
-  localStorage.clear()
-})
-
-describe('selection survives on the store seat', () => {
-  it('one session, two slots: conversation writes, details reads the SAME instance', () => {
-    const b = bench()
-
-    const conv = storeFor(b, 'conversation.session', sid('s1'))
-    const details = storeFor(b, 'details', sid('s1'))
-    conv.actions.select({ turnSeq: 3, callId: 'c1' })
-    expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
-    // Identity, not just value: the shared handle resolves one instance per scope key.
-    expect(details).toBe(conv)
-  })
-
-  it('sessions are isolated: s2 selection never bleeds into s1', () => {
-    const b = bench()
-
-    const one = storeFor(b, 'conversation.session', sid('s1'))
-    const two = storeFor(b, 'conversation.session', sid('s2'))
-    expect(two).not.toBe(one)
-    one.actions.select({ turnSeq: 1, callId: 'a' })
-    two.actions.select({ turnSeq: 9, callId: 'z' })
-    expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' })
-    expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
-  })
-
-  it('a list-projection update keeps instance identity and the selection value', () => {
-    const b = bench()
-    const id = sid('s1')
-    const projection = createSnapshotStore({ displayTitle: 's1' })
-
-    const store = storeFor(b, 'conversation.session', id)
-    store.actions.select({ turnSeq: 3, callId: 'c1' })
-    store.actions.setDraft('half-typed')
-
-    projection.set({ displayTitle: 'proj-a' })
-    expect(projection.getSnapshot().displayTitle).toBe('proj-a')
-
-    const after = storeFor(b, 'conversation.session', id)
-    expect(after).toBe(store)
-    expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
-    expect(after.store.getSnapshot().draft).toBe('half-typed')
-  })
-
-  it('session death buries the instance and its persisted draft', () => {
-    const b = bench()
-
-    const doomed = storeFor(b, 'conversation.session', sid('s1'))
-    doomed.actions.setDraft('to be buried')
-    doomed.actions.select({ turnSeq: 1 })
-    expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
-
-    // SessionsService calls this public slot lifecycle seam when the scope dies.
-    b.slots.pruneStoreScope(sid('s1'))
-
-    // Persisted residue is gone with the session...
-    expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
-    // ...and a re-created same-id session starts from a FRESH instance.
-    const reborn = storeFor(b, 'conversation.session', sid('s1'))
-    expect(reborn).not.toBe(doomed)
-    expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
-  })
-})

+ 109 - 0
packages/client/ui-conversation/tests/selection-survival.spec.tsx

@@ -0,0 +1,109 @@
+// @vitest-environment jsdom
+/**
+ * Exercises selection persistence through the real SlotsService store axis;
+ * component stubs cannot prove per-session identity or disposal.
+ */
+import { beforeEach, describe, expect, it } from 'vitest'
+import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
+import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
+import { createChatStore } from '../src/client/stores.ts'
+
+const sid = (s: string): SessionId => s as SessionId
+
+type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
+
+async function bench() {
+  const runtime = await SlotTestRuntime.create()
+  const chat = createChatStore()
+  // The apply.ts shape: one shared handle across both strict-session slot
+  // registrations ('conversation.session'/'details'); the session-maybe
+  // 'conversation' shell carries no store by design. The slots must first
+  // exist in the ledger — the test root declares them (the AppFrame role).
+  await runtime.root.declare({
+    'conversation': { kind: 'single', scope: 'session-maybe' },
+    'conversation.session': { kind: 'single', scope: 'session' },
+    'details': { kind: 'single', scope: 'session' },
+  }, (_p: { renderSlot?: unknown }) => null)
+  runtime.slots.register({ name: 'conversation.session', store: chat }, () => null)
+  runtime.slots.register({ name: 'details', store: chat }, () => null)
+  runtime.renderRoot() // materializes the host face storeOf resolves through
+  return { runtime, chat }
+}
+
+/** Resolve the store instance the renderer would hand a slot's component for a session. */
+function storeFor(b: Awaited<ReturnType<typeof bench>>, slot: 'conversation.session' | 'details', sessionId: SessionId) {
+  return b.runtime.storeOf(slot, sessionId) as ChatInstance
+}
+
+beforeEach(() => {
+  localStorage.clear()
+})
+
+describe('selection survives on the store seat', () => {
+  it('one session, two slots: conversation writes, details reads the SAME instance', async () => {
+    const b = await bench()
+
+    const conv = storeFor(b, 'conversation.session', sid('s1'))
+    const details = storeFor(b, 'details', sid('s1'))
+    conv.actions.select({ turnSeq: 3, callId: 'c1' })
+    expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
+    // Identity, not just value: the shared handle resolves one instance per scope key.
+    expect(details).toBe(conv)
+    await b.runtime.dispose()
+  })
+
+  it('sessions are isolated: s2 selection never bleeds into s1', async () => {
+    const b = await bench()
+
+    const one = storeFor(b, 'conversation.session', sid('s1'))
+    const two = storeFor(b, 'conversation.session', sid('s2'))
+    expect(two).not.toBe(one)
+    one.actions.select({ turnSeq: 1, callId: 'a' })
+    two.actions.select({ turnSeq: 9, callId: 'z' })
+    expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' })
+    expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
+    await b.runtime.dispose()
+  })
+
+  it('a list-projection update keeps instance identity and the selection value', async () => {
+    const b = await bench()
+    const id = sid('s1')
+
+    const store = storeFor(b, 'conversation.session', id)
+    store.actions.select({ turnSeq: 3, callId: 'c1' })
+    store.actions.setDraft('half-typed')
+
+    // A projection churn elsewhere (list rows re-projected) must not touch
+    // store identity: drive the runtime's own list observable.
+    await b.runtime.sessions.add({ id, summary: { displayTitle: 'proj-a' } })
+    expect(b.runtime.sessions.list.getSnapshot().byId[id]?.displayTitle).toBe('proj-a')
+
+    const after = storeFor(b, 'conversation.session', id)
+    expect(after).toBe(store)
+    expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
+    expect(after.store.getSnapshot().draft).toBe('half-typed')
+    await b.runtime.dispose()
+  })
+
+  it('session death buries the instance and its persisted draft', async () => {
+    const b = await bench()
+    await b.runtime.sessions.add({ id: 's1' })
+
+    const doomed = storeFor(b, 'conversation.session', sid('s1'))
+    doomed.actions.setDraft('to be buried')
+    doomed.actions.select({ turnSeq: 1 })
+    expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
+
+    // TestSessions.remove drives the same public slot lifecycle seam the
+    // production SessionsService calls when the scope dies (pruneStoreScope).
+    await b.runtime.sessions.remove('s1')
+
+    // Persisted residue is gone with the session...
+    expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
+    // ...and a re-created same-id session starts from a FRESH instance.
+    const reborn = storeFor(b, 'conversation.session', sid('s1'))
+    expect(reborn).not.toBe(doomed)
+    expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
+    await b.runtime.dispose()
+  })
+})

+ 34 - 31
packages/client/ui-conversation/tests/service-orchestration.spec.ts

@@ -1,39 +1,32 @@
 // @vitest-environment jsdom
+// ConversationService scope addressing over the runtime's real scope tag:
+// TestSessions mints tagged scopes through the production createScope, so the
+// service's scopeOf/binding path runs against production resolution (no local
+// tag probe).
 import { Context } from 'cordis'
 import { describe, expect, it, vi } from 'vitest'
-import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
-import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
+import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
 import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { InputHub } from '../src/client/input/hub.ts'
 
-const sid = (id: string) => id as SessionId
-const SCOPE_TAG: symbol = (() => {
-  const reads: (string | symbol)[] = []
-  const proxy = new Proxy(new Context(), {
-    get(target, property, receiver): unknown {
-      reads.push(property)
-      return Reflect.get(target, property, receiver)
-    },
-  })
-  void scopeOf(proxy)
-  return reads.find((value): value is symbol => typeof value === 'symbol')!
-})()
-
-async function bench(withSessions = true) {
-  const ctx = new Context()
+async function bench() {
+  const runtime = await SlotTestRuntime.create()
   const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
   const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
   const loadOlder = vi.fn(() => Promise.resolve())
-  const sessions = {
-    binding: (sessionId: SessionId) => ({
-      sessionId, session: { prompt, cancel, loadOlder },
-    }),
-    scopeOf,
-  } as unknown as SessionsService
-  if (withSessions) ctx.provide('sessions', sessions)
-  await ctx.plugin(ConversationService).await()
-  const root = ctx.get('conversation') as ConversationService
-  const scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: sid('s1') }).get('conversation') as ConversationService
-  return { root, scoped, prompt, cancel, loadOlder }
+  await runtime.sessions.add({
+    id: 's1',
+    session: { prompt, cancel, loadOlder },
+  })
+  // config.input is required (the apply shares its hub with the inject
+  // factories); the bench passes its own instance explicitly.
+  const fiber = runtime.ctx.plugin(ConversationService, {
+    input: new InputHub(runtime.ctx),
+  })
+  await fiber.await()
+  const root = runtime.ctx.get('conversation') as ConversationService
+  const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
+  return { runtime, root, scoped, prompt, cancel, loadOlder }
 }
 
 describe('ConversationService', () => {
@@ -45,6 +38,7 @@ describe('ConversationService', () => {
     expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer')
     expect(b.cancel).toHaveBeenCalledOnce()
     expect(b.loadOlder).toHaveBeenCalledOnce()
+    await b.runtime.dispose()
   })
 
   it('folds Session business failures into callback rejections', async () => {
@@ -53,12 +47,21 @@ describe('ConversationService', () => {
     await expect(b.scoped.send('x', 'queue')).rejects.toThrow('conversation.send failed: agent-busy: busy')
     b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never)
     await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope')
+    await b.runtime.dispose()
   })
 
-  it('fails loudly from the root scope or without SessionsService', async () => {
+  it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
     const b = await bench()
     await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
-    const missing = await bench(false)
-    await expect(missing.root.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/)
+    await b.runtime.sessions.remove('s1')
+    await expect(b.scoped.send('x', 'queue')).rejects.toThrow(/resolved no binding/)
+    await b.runtime.dispose()
+    // No SessionsService at all: a bare context (the runtime always provides one).
+    const bare = new Context()
+    await bare.plugin(ConversationService, {
+      input: new InputHub(bare),
+    }).await()
+    const orphan = bare.get('conversation') as ConversationService
+    await expect(orphan.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/)
   })
 })

+ 4 - 2
packages/client/ui-layout/src/client/index.ts

@@ -17,14 +17,16 @@ import { ThemePresenter } from './theme-presenter.ts'
 
 // Contract surface only (export-convergence rule: cross-package consumers
 // keep a symbol exported; test-only/package-internal symbols live off /src).
-// LayoutService: the ctx.layout service class (consumers type against it).
+// ILayout: the ctx.layout face consumers and test fakes type against.
 // OwnerShare contracts below are the render-side halves registrants compose
 // against; the frame components and the store factory are package-internal.
 export { LayoutService } from './service.ts'
+export type { ILayout } from './service.ts'
 
 declare module 'cordis' {
   interface Context {
-    layout: LayoutService
+    /** The outward face only; the concrete service stays inside this plugin. */
+    layout: import('./service.ts').ILayout
   }
 }
 

+ 16 - 1
packages/client/ui-layout/src/client/service.ts

@@ -14,8 +14,23 @@ import type { createLayoutStore } from './stores.ts'
 /** The layout store's bound action set (framework-baked, draft params peeled). */
 export type PanelActions = BoundActions<ReturnType<typeof createLayoutStore>>
 
+/**
+ * The outward layout face (`ctx.layout`): the panel transitions other
+ * plugins may trigger — and exactly what a test fake must supply. The
+ * attachPanels wiring hook stays on the concrete class (root-entry assembly
+ * only).
+ */
+export interface ILayout {
+  /** Toggle the sidebar panel (closed ⟷ contract default width). */
+  toggleSidebar(): void
+  /** Open the details panel (no-op when already open). */
+  openDetails(): void
+  /** Close the details panel. */
+  closeDetails(): void
+}
+
 /** Cross-plugin panel-action face (ctx.layout). */
-export class LayoutService {
+export class LayoutService implements ILayout {
   #panels: PanelActions | undefined
 
   /**

+ 1 - 0
packages/client/ui-sidebar/package.json

@@ -47,6 +47,7 @@
   },
   "devDependencies": {
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",

+ 131 - 0
packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.spec.tsx.snap

@@ -0,0 +1,131 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`sidebar shell snapshots > renders the collapsed rail after the crossfade settles, in place 1`] = `
+<div
+  data-slot="sidebar"
+>
+  <div
+    class="root collapsed railIn"
+    style=""
+  >
+    <div
+      class="logoRow"
+    >
+      <button
+        aria-label="Open sidebar"
+        class="iconButton toggle"
+        type="button"
+      >
+        <svg
+          aria-hidden="true"
+          class="railFish"
+          data-content="965fe321"
+          fill="none"
+          height="17.6580310880829"
+          viewBox="0 0 23.16 17.04"
+          width="24"
+        />
+        <svg
+          class="panelIcon"
+          data-content="35f95b0c"
+          fill="none"
+          height="18"
+          viewBox="0 0 16 16"
+          width="18"
+          xmlns="http://www.w3.org/2000/svg"
+        />
+      </button>
+    </div>
+    <button
+      aria-label="New session"
+      class="newSession"
+      type="button"
+    >
+      <svg
+        data-content="c0ce4dcc"
+        fill="none"
+        height="18"
+        viewBox="0 0 16 16"
+        width="18"
+        xmlns="http://www.w3.org/2000/svg"
+      />
+    </button>
+    <div
+      class="regionArea"
+    />
+    <div
+      class="footArea"
+    />
+  </div>
+</div>
+`;
+
+exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsule, empty holes) 1`] = `
+<div
+  data-slot="sidebar"
+>
+  <div
+    class="root"
+    style="width: 300px;"
+  >
+    <div
+      class="logoRow"
+    >
+      <button
+        aria-label="New session"
+        class="brand wide"
+        type="button"
+      >
+        <svg
+          aria-hidden="true"
+          data-content="951274b9"
+          fill="none"
+          height="24"
+          viewBox="0 0 182 24"
+          width="182"
+        />
+      </button>
+      <button
+        aria-label="Collapse sidebar"
+        class="iconButton toggle"
+        type="button"
+      >
+        <svg
+          class="panelIcon"
+          data-content="35f95b0c"
+          fill="none"
+          height="16"
+          viewBox="0 0 16 16"
+          width="16"
+          xmlns="http://www.w3.org/2000/svg"
+        />
+      </button>
+    </div>
+    <button
+      aria-label="New session"
+      class="newSession"
+      type="button"
+    >
+      <svg
+        data-content="c0ce4dcc"
+        fill="none"
+        height="14"
+        viewBox="0 0 16 16"
+        width="14"
+        xmlns="http://www.w3.org/2000/svg"
+      />
+      <span
+        class="newSessionLabel wide"
+      >
+        New Session
+      </span>
+    </button>
+    <div
+      class="regionArea"
+    />
+    <div
+      class="footArea"
+    />
+  </div>
+</div>
+`;

+ 51 - 0
packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx

@@ -0,0 +1,51 @@
+// @vitest-environment jsdom
+/**
+ * Local DOM snapshots of the sidebar shell through the real assembly path:
+ * SlotTestRuntime mounts the package apply on its own fiber, the auto frame
+ * supplies the layout's owner share at the render site, and the snapshot
+ * captures exactly the 'sidebar' slot's output (CSS-module class names
+ * folded to their semantic locals by the runtime's serializer). The child
+ * holes (sidebar.workspaces / sidebar.settings) have no registrant here, so
+ * the snapshots pin the shell chrome itself.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, waitFor } from '@testing-library/react'
+import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
+import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
+
+afterEach(cleanup)
+
+async function bench() {
+  const runtime = await SlotTestRuntime.create()
+  runtime.provide('layout', { toggleSidebar: vi.fn() })
+  await runtime.declare({ 'sidebar': { kind: 'single', scope: 'root' } })
+  await runtime.mount({ inject: [...inject], apply })
+  return runtime
+}
+
+describe('sidebar shell snapshots', () => {
+  it('renders the expanded column (wordmark, capsule, empty holes)', async () => {
+    const runtime = await bench()
+    const slot = runtime.renderSlot('sidebar', { collapsed: false, width: 300 })
+    // Wordmark + capsule both start a session in the expanded state.
+    expect(slot.view.getAllByRole('button', { name: 'New session' })).toHaveLength(2)
+    expect(slot.container).toMatchSnapshot()
+    await runtime.dispose()
+  })
+
+  it('renders the collapsed rail after the crossfade settles, in place', async () => {
+    const runtime = await bench()
+    const slot = runtime.renderSlot('sidebar', { collapsed: false, width: 300 })
+    const shell = slot.container.firstElementChild
+    slot.update({ collapsed: true, width: 56 })
+    // The wide content (wordmark shortcut) unmounts at the 150ms settle;
+    // only the rail's capsule remains a New-session button.
+    await waitFor(() => {
+      expect(slot.view.getAllByRole('button', { name: 'New session' })).toHaveLength(1)
+    })
+    expect(slot.container).toMatchSnapshot()
+    // Same tree position: the owner flip re-rendered the shell in place.
+    expect(slot.container.firstElementChild).toBe(shell)
+    await runtime.dispose()
+  })
+})

+ 2 - 1
packages/client/ui-slash/src/client/index.ts

@@ -24,7 +24,8 @@ export type { SlashServiceContract } from './contract.ts'
 
 declare module 'cordis' {
   interface Context {
-    slash: SlashService
+    /** The outward face only; the concrete service stays inside this plugin. */
+    slash: import('./contract.ts').SlashServiceContract
   }
 }
 

+ 2 - 2
packages/client/ui-slash/src/client/service.ts

@@ -7,7 +7,7 @@
  */
 import { Service } from 'cordis'
 import type { Context } from 'cordis'
-import type { ClientContext, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SlashSource } from '../types.ts'
 import { SlashController } from './controller.ts'
 import type { SlashServiceContract } from './contract.ts'
@@ -99,7 +99,7 @@ export class SlashService extends Service implements SlashServiceContract {
     return controller
   }
 
-  private sessions(): SessionsService {
+  private sessions(): ISessions {
     const sessions = this.ctx.get('sessions')
     if (sessions === undefined) throw new Error('ui-slash: sessions service unavailable')
     return sessions

+ 1 - 0
packages/client/web/package.json

@@ -31,6 +31,7 @@
   "devDependencies": {
     "@cordisjs/plugin-loader": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@types/react": "~18.3.1",
     "@types/react-dom": "~18.3.0",

+ 61 - 0
packages/client/web/tests/app-shell.spec.tsx

@@ -0,0 +1,61 @@
+// @vitest-environment jsdom
+/**
+ * App-shell assembly plugin on the real machinery: bare Context + production
+ * SlotsService + the test-runtime session/workspace doubles. Deliberately NOT
+ * mounted through SlotTestRuntime — its create() installs the capturing
+ * renderer and install() is boot-once; app-shell IS the production installer,
+ * so this bench hands it the uninstalled service exactly as boot does.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { act, cleanup, render } from '@testing-library/react'
+import { Context } from 'cordis'
+import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
+import { TestSessions, TestWorkspaces } from '@deepseek-ai/dsh-client-test-runtime'
+import type { Stabilizer } from '@deepseek-ai/dsh-client-test-runtime'
+import * as AppShell from '@deepseek-ai/dsh-client-web/src/app-shell.ts'
+
+afterEach(cleanup)
+
+const stabilize: Stabilizer = async (fn) => { await act(async () => { await fn() }) }
+
+async function bench() {
+  const ctx = new Context()
+  await ctx.plugin(SlotsService).await()
+  const slots = ctx.get('slots') as SlotsService
+  ctx.provide('sessions', new TestSessions(stabilize, ctx))
+  ctx.provide('workspaces', new TestWorkspaces(stabilize))
+  ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
+  const fiber = ctx.plugin({ inject: [...AppShell.inject], apply: AppShell.apply })
+  await fiber.await()
+  return { ctx, slots, fiber }
+}
+
+describe('app-shell assembly plugin', () => {
+  it('installs the renderer and provides the assembled appShell face', async () => {
+    const { ctx, slots } = await bench()
+    slots.register({ name: 'root' }, () => <div data-testid="root-probe" />)
+    const shell = ctx.get('appShell')
+    expect(shell).toBeDefined()
+    const view = render(<>{shell!.renderApp()}</>)
+    expect(view.getByTestId('root-probe')).toBeTruthy()
+  })
+
+  it('assembles once: repeated renderApp calls reuse the built closure', async () => {
+    const { ctx, slots } = await bench()
+    slots.register({ name: 'root' }, () => <div data-testid="root-probe" />)
+    const shell = ctx.get('appShell')!
+    const first = render(<>{shell.renderApp()}</>)
+    expect(first.getByTestId('root-probe')).toBeTruthy()
+    first.unmount()
+    // Second call rides the cached closure (renderApp ??=) and still renders.
+    const second = render(<>{shell.renderApp()}</>)
+    expect(second.getByTestId('root-probe')).toBeTruthy()
+  })
+
+  it('fiber dispose retracts the service and uninstalls the renderer', async () => {
+    const { ctx, slots, fiber } = await bench()
+    await stabilize(() => fiber.dispose())
+    expect(ctx.get('appShell')).toBeUndefined()
+    expect(() => slots.renderSlot('root', {})).toThrow('not installed')
+  })
+})

+ 65 - 0
packages/client/web/tests/app.spec.tsx

@@ -0,0 +1,65 @@
+// @vitest-environment jsdom
+/**
+ * buildRenderApp on SlotTestRuntime: the fail-loud sessions precondition, the
+ * one ctx-level renderSlot('root') call, and the document-title projection
+ * arms over the real slot stack.
+ */
+import { afterEach, describe, expect, it } from 'vitest'
+import { cleanup, render } from '@testing-library/react'
+import { Context } from 'cordis'
+import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
+import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
+import { buildRenderApp } from '@deepseek-ai/dsh-client-web/src/app.tsx'
+
+let runtime: SlotTestRuntime | undefined
+
+afterEach(async () => {
+  cleanup()
+  await runtime?.dispose()
+  runtime = undefined
+  document.title = ''
+})
+
+async function bench() {
+  runtime = await SlotTestRuntime.create()
+  await runtime.root.declare({}, () => <div data-testid="frame" />)
+  return { runtime, renderApp: buildRenderApp({ ctx: runtime.ctx }) }
+}
+
+describe('buildRenderApp', () => {
+  it('fails loud when the sessions service is unavailable', () => {
+    expect(() => buildRenderApp({ ctx: new Context() })).toThrow('sessions service unavailable')
+  })
+
+  it('renders the root slot tree through the one ctx-level renderSlot call', async () => {
+    const b = await bench()
+    const view = render(<>{b.renderApp()}</>)
+    expect(view.getByTestId('frame')).toBeTruthy()
+  })
+
+  it('projects the current session durable title and falls back to the product title', async () => {
+    document.title = 'Product'
+    const b = await bench()
+    render(<>{b.renderApp()}</>)
+    // No current session: the product title stands.
+    expect(document.title).toBe('Product')
+    await b.runtime.sessions.add({ id: 's1', summary: { title: 'First' } })
+    expect(document.title).toBe('First — Product')
+    await b.runtime.sessions.setCurrent(undefined)
+    expect(document.title).toBe('Product')
+    // A session without a durable title keeps the product title.
+    await b.runtime.sessions.add({ id: 's2' })
+    expect(document.title).toBe('Product')
+  })
+
+  it('a current id without a list row falls back (selection/list arbitration transient)', async () => {
+    document.title = 'Product'
+    const b = await bench()
+    await b.runtime.sessions.add({ id: 's1', summary: { title: 'First' } })
+    render(<>{b.renderApp()}</>)
+    expect(document.title).toBe('First — Product')
+    b.runtime.sessions.list.update((draft) => { draft.current = 'ghost' as SessionId })
+    await b.runtime.flush()
+    expect(document.title).toBe('Product')
+  })
+})

+ 76 - 0
pnpm-lock.yaml

@@ -924,6 +924,46 @@ importers:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
 
+  packages/client/test-runtime:
+    dependencies:
+      '@testing-library/dom':
+        specifier: ^10.4.1
+        version: 10.4.1
+      '@testing-library/react':
+        specifier: ^16.3.2
+        version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      vitest:
+        specifier: ^4.1.8
+        version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
+    devDependencies:
+      '@deepseek-ai/dsh-client-runtime':
+        specifier: workspace:^
+        version: link:../runtime
+      '@deepseek-ai/dsh-client-ui-slots':
+        specifier: workspace:^
+        version: link:../ui-slots
+      '@deepseek-ai/dsh-client-web-react':
+        specifier: workspace:^
+        version: link:../web-react
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      '@types/react':
+        specifier: ~18.3.1
+        version: 18.3.31
+      '@types/react-dom':
+        specifier: ~18.3.0
+        version: 18.3.7(@types/react@18.3.31)
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
+      react-dom:
+        specifier: ^18.2.0
+        version: 18.3.1(react@18.3.1)
+
   packages/client/ui-command:
     dependencies:
       clsx:
@@ -1250,6 +1290,9 @@ importers:
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
         version: link:../runtime
+      '@deepseek-ai/dsh-client-test-runtime':
+        specifier: workspace:^
+        version: link:../test-runtime
       '@deepseek-ai/dsh-client-ui-layout':
         specifier: workspace:^
         version: link:../ui-layout
@@ -1467,6 +1510,9 @@ importers:
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
         version: link:../runtime
+      '@deepseek-ai/dsh-client-test-runtime':
+        specifier: workspace:^
+        version: link:../test-runtime
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../support/invariants
@@ -15933,6 +15979,36 @@ snapshots:
     transitivePeerDependencies:
       - msw
 
+  vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)):
+    dependencies:
+      '@vitest/expect': 4.1.8
+      '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
+      '@vitest/pretty-format': 4.1.8
+      '@vitest/runner': 4.1.8
+      '@vitest/snapshot': 4.1.8
+      '@vitest/spy': 4.1.8
+      '@vitest/utils': 4.1.8
+      es-module-lexer: 2.1.0
+      expect-type: 1.3.0
+      magic-string: 0.30.21
+      obug: 2.1.3
+      pathe: 2.0.3
+      picomatch: 4.0.4
+      std-env: 4.1.0
+      tinybench: 2.9.0
+      tinyexec: 1.2.4
+      tinyglobby: 0.2.17
+      tinyrainbow: 3.1.0
+      vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)
+      why-is-node-running: 2.3.0
+    optionalDependencies:
+      '@opentelemetry/api': 1.9.1
+      '@types/node': 25.9.3
+      '@vitest/coverage-v8': 4.1.8(vitest@4.1.8)
+      jsdom: 29.1.1
+    transitivePeerDependencies:
+      - msw
+
   vscode-jsonrpc@5.0.1: {}
 
   vscode-jsonrpc@9.0.1: {}

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

@@ -47,6 +47,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
   'packages/client/hmr': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
   'packages/client/modules': { kind: 'none', reason: 'Browser-side module-loading kernel machinery; registers no model surface.' },
+  'packages/client/test-runtime': { kind: 'none', reason: 'Browser-side test infrastructure (jsdom bench); registers no model surface.' },
   'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
   'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
   'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },

+ 1 - 0
tsconfig.base.json

@@ -118,6 +118,7 @@
       "@deepseek-ai/dsh-client-modules": ["./packages/client/modules/src"],
       "@deepseek-ai/dsh-client-runtime": ["./packages/client/runtime/src"],
       "@deepseek-ai/dsh-client-runtime/client": ["./packages/client/runtime/src/client"],
+      "@deepseek-ai/dsh-client-test-runtime": ["./packages/client/test-runtime/src"],
       "@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"],
       "@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"],
       "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"],

+ 1 - 0
tsconfig.client.json

@@ -34,6 +34,7 @@
     { "path": "./packages/client/hmr" },
     { "path": "./packages/client/connection" },
     { "path": "./packages/client/runtime" },
+    { "path": "./packages/client/test-runtime" },
     { "path": "./packages/client/ui-layout" },
     { "path": "./packages/client/ui-sidebar" },
     { "path": "./packages/client/ui-conversation" },