Переглянути джерело

refactor(interaction): move Approval and Question into UI owners

imccyu 1 місяць тому
батько
коміт
049170c6d0
32 змінених файлів з 1707 додано та 691 видалено
  1. 0 144
      packages/client/runtime/src/client/sessions/pending.ts
  2. 90 0
      packages/client/ui-approval/package.json
  3. 74 0
      packages/client/ui-approval/src/client/ApprovalPanel.module.css
  4. 55 0
      packages/client/ui-approval/src/client/ApprovalPanel.tsx
  5. 138 0
      packages/client/ui-approval/src/client/contract/slots.ts
  6. 79 0
      packages/client/ui-approval/src/client/index.ts
  7. 22 0
      packages/client/ui-approval/src/client/locales.ts
  8. 4 0
      packages/client/ui-approval/src/css-modules.d.ts
  9. 4 0
      packages/client/ui-approval/src/index.ts
  10. 23 0
      packages/client/ui-approval/src/invariant.ts
  11. 345 0
      packages/client/ui-approval/tests/ui-approval.client.spec.tsx
  12. 48 0
      packages/client/ui-approval/tsconfig.json
  13. 3 0
      packages/client/ui-approval/tsdown.config.ts
  14. 8 4
      packages/client/ui-plan/package.json
  15. 4 1
      packages/client/ui-plan/src/client/index.ts
  16. 2 2
      packages/client/ui-plan/tests/browser-plugin.client.spec.ts
  17. 1 1
      packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx
  18. 6 3
      packages/client/ui-plan/tsconfig.json
  19. 1 1
      packages/client/ui-tool/tests/ask-question-row.client.spec.tsx
  20. 20 6
      packages/client/ui-user-questions/package.json
  21. 3 4
      packages/client/ui-user-questions/src/client/QuestionComposer.tsx
  22. 98 50
      packages/client/ui-user-questions/src/client/contract/slots.ts
  23. 41 50
      packages/client/ui-user-questions/src/client/index.ts
  24. 176 117
      packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts
  25. 132 69
      packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx
  26. 169 118
      packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
  27. 17 2
      packages/client/ui-user-questions/tsconfig.json
  28. 18 7
      packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts
  29. 29 56
      packages/interaction/user-questions/src/index.ts
  30. 1 1
      packages/interaction/user-questions/src/types.ts
  31. 77 44
      packages/interaction/user-questions/tests/user-questions.spec.ts
  32. 19 11
      packages/plan/plan-mode/tests/plan-mode.spec.ts

+ 0 - 144
packages/client/runtime/src/client/sessions/pending.ts

@@ -1,144 +0,0 @@
-// PendingWait: the legacy render-facing carrier retained until UI owners consume Remote events directly.
-
-import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
-import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
-
-/** One selectable answer offered by the legacy question renderer. */
-export interface PendingQuestionOption {
-  readonly label: string
-  readonly description?: string
-}
-
-/** One question rendered by the legacy question composer. */
-export interface PendingQuestionItem {
-  readonly id: string
-  readonly question: string
-  readonly detail?: string
-  readonly header?: string
-  readonly options?: readonly PendingQuestionOption[]
-  readonly multiSelect?: boolean
-  readonly intent?: { readonly kind: 'plan-review'; readonly approve: string }
-}
-
-/** Structured answer returned by the legacy question composer. */
-export interface PendingQuestionAnswer {
-  answers: {
-    id: string
-    selected: string[]
-    custom?: string
-  }[]
-}
-
-/** Kind-keyed payload map: the requested frame's domain fields (envelope fields stripped). */
-export interface PendingPayloads {
-  approval: {
-    readonly approvalId: string
-    readonly toolName: string
-    readonly callId?: string
-    readonly reason?: string
-  }
-  question: { readonly questions: readonly PendingQuestionItem[] }
-}
-
-interface PendingResponseValues {
-  approval: {
-    readonly sessionId: SessionId
-    readonly approvalId: string
-    readonly outcome: 'allowed-once' | 'rejected'
-  }
-  question: { readonly sessionId: SessionId; readonly answer: PendingQuestionAnswer }
-}
-
-type PendingInteractionResult<K extends PendingKind> =
-  | { readonly ok: true; readonly value: PendingResponseValues[K] }
-  | {
-    readonly ok: false
-    readonly error: { readonly code: string; readonly message: string; readonly details: Readonly<Record<string, unknown>> }
-  }
-
-/** Receipt returned by the legacy response carrier. */
-export interface PendingRespondReceipt {
-  readonly accepted: boolean
-  readonly reason?: string
-}
-
-interface PendingRespondRequest<K extends PendingKind> {
-  readonly interactionId: string
-  readonly result: PendingInteractionResult<K>
-}
-
-/** Pending-interaction discriminant (the keys of PendingPayloads). */
-export type PendingKind = keyof PendingPayloads
-
-/** Session-list summary of the user action currently blocking progress. */
-export type PendingInteractionStatus = 'approval' | 'plan-review' | 'question'
-
-/** Kind-discriminated union of concrete waits: narrowing on `kind` types `payload`. */
-export type PendingInteraction = { [K in PendingKind]: PendingWait<K> }[PendingKind]
-
-/** Key prefixes, one per kind (the key doubles as the Session pending-map key). */
-const KEY_PREFIX: Record<PendingKind, string> = { approval: 'a', question: 'q' }
-
-/**
- * One pending host-owned interaction wait: an immutable render face
- * (kind/key/sessionId/payload) plus the response carrier. respond() addresses
- * the Host's opaque interaction identity. Settlement is expressed only by pending-list
- * membership (the settled flag is a fail-loud guard, not a render input).
- */
-export class PendingWait<K extends PendingKind = PendingKind> {
-  /** Interaction kind (union discriminant). */
-  readonly kind: K
-  /** Opaque render identity, stable across baseline replay and usable as a React key. */
-  readonly key: string
-  /** Owning session. */
-  readonly sessionId: SessionId
-  /** The requested frame's domain fields, verbatim. */
-  readonly payload: PendingPayloads[K]
-  #settled = false
-  readonly #interactionId: string
-  readonly #respond: (request: PendingRespondRequest<K>) => Promise<RemoteResult<PendingRespondReceipt>>
-
-  /**
-   * Minted by Session on a requested frame (public construction is the test-fixture path).
-   * @param kind - interaction kind.
-   * @param interactionId - the Host-minted stable interaction identity.
-   * @param sessionId - owning session.
-   * @param payload - the requested frame's domain fields.
-   * @param respond - Session Controller response method.
-   */
-  constructor(
-    kind: K, interactionId: string, sessionId: SessionId, payload: PendingPayloads[K],
-    respond: (request: PendingRespondRequest<K>) => Promise<RemoteResult<PendingRespondReceipt>>,
-  ) {
-    this.kind = kind
-    this.key = `${KEY_PREFIX[kind]}:${interactionId}`
-    this.sessionId = sessionId
-    this.payload = payload
-    this.#interactionId = interactionId
-    this.#respond = respond
-  }
-
-  /**
-   * Send a result for this wait. Throws synchronously once settled and rejects
-   * when the generated Remote call itself fails.
-   * @param result - the result shell (ok value / error envelope), domain-encoded by the caller.
-   * @returns the carrier receipt.
-   */
-  respond(result: PendingInteractionResult<K>): Promise<PendingRespondReceipt> {
-    if (this.#settled) throw new Error(`pending wait ${this.key} is already settled`)
-    return this.send(result)
-  }
-
-  private async send(result: PendingInteractionResult<K>): Promise<PendingRespondReceipt> {
-    const response = await this.#respond({ interactionId: this.#interactionId, result })
-    if (!response.ok) {
-      throw new Error(`session interaction response failed: ${response.error.code}: ${response.error.message}`)
-    }
-    return response.value
-  }
-
-  /** Session-only settlement mark (the authoritative resolved frame arrived); respond() throws afterwards. */
-  markSettled(): void {
-    this.#settled = true
-  }
-}

+ 90 - 0
packages/client/ui-approval/package.json

@@ -0,0 +1,90 @@
+{
+  "name": "@deepseek-ai/dsh-client-ui-approval",
+  "description": "Approval composer takeover over the scoped Remote Event waterfall",
+  "version": "0.1.1-rc.2",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/client/ui-approval"
+  },
+  "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"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dsh": {
+    "client": {
+      "external": [
+        "@deepseek-ai/dsh-api-session-controller/client",
+        "@deepseek-ai/dsh-client-ui-conversation/client"
+      ],
+      "inject": [
+        "@deepseek-ai/dsh-api-remotes",
+        "@deepseek-ai/dsh-api-session-controller",
+        "@deepseek-ai/dsh-client-locale",
+        "@deepseek-ai/dsh-client-ui-conversation",
+        "@deepseek-ai/dsh-client-ui-renderer",
+        "@deepseek-ai/dsh-client-ui-session"
+      ],
+      "platform": "web"
+    }
+  },
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
+  "license": "MIT",
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
+    "@types/react": "~18.3.1",
+    "react": "^18.2.0"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts"
+  ]
+}

+ 74 - 0
packages/client/ui-approval/src/client/ApprovalPanel.module.css

@@ -0,0 +1,74 @@
+.root {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 8px calc(var(--dsh-composer-side-clearance) + 16px) 12px;
+}
+
+.card {
+  overflow: hidden;
+  width: 100%;
+  max-width: var(--dsh-chat-content-width);
+  border: 1px solid var(--dsw-alias-state-warn-secondary);
+  border-radius: 20px;
+  background: var(--dsw-specific-input-major);
+  box-shadow: var(--dsw-shadow-lv2);
+  --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
+  --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
+}
+
+.strip {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 10px 16px;
+  background: var(--dsw-alias-state-warn-tertiary);
+  color: var(--dsw-alias-state-warn-primary);
+  font-size: 13px;
+  line-height: 18px;
+}
+
+.dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: var(--dsw-alias-state-warn-primary);
+}
+
+.body {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  box-sizing: border-box;
+  max-height: var(--dsh-composer-text-max-height);
+  overflow-y: auto;
+  padding: 12px 16px 0;
+}
+
+.headline {
+  color: var(--dsw-alias-label-primary);
+  font-size: 15px;
+  font-weight: 500;
+  line-height: 24px;
+}
+
+.command {
+  color: var(--dsw-alias-label-tertiary);
+  font-family: var(--ds-font-family-code);
+  font-size: 13px;
+  line-height: 20px;
+  word-break: break-all;
+}
+
+.actionRow {
+  display: flex;
+  justify-content: flex-end;
+  gap: 8px;
+  padding: 14px 16px;
+}
+
+.reject:hover:not(:disabled) {
+  background: var(--dsw-alias-interactive-bg-hover-danger);
+  color: var(--dsw-alias-state-error-primary);
+  border-color: transparent;
+}

+ 55 - 0
packages/client/ui-approval/src/client/ApprovalPanel.tsx

@@ -0,0 +1,55 @@
+/** Composer takeover for one pending approval waterfall. */
+import { useState, type ReactNode } from 'react'
+import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ApprovalComposerProps, PendingApproval } from './contract/slots.ts'
+import css from './ApprovalPanel.module.css'
+
+/**
+ * Render one pending approval and its optional Tool-owned detail.
+ * @param props - selector-matched request and standard Slot props.
+ * @returns The approval composer takeover.
+ */
+export function ApprovalPanel(props: ApprovalComposerProps) {
+  const approval = props.matched
+  const detail = approval.callId === undefined
+    ? null
+    : props.renderSlot('conversation.approval.detail', { callId: approval.callId })
+  return <ApprovalFlow key={approval.key} pending={approval} detail={detail} t={props.t} />
+}
+
+function ApprovalFlow({ pending, detail, t }: {
+  pending: PendingApproval
+  detail: ReactNode
+  t: ApprovalComposerProps['t']
+}) {
+  const [answered, setAnswered] = useState(false)
+  const answer = (outcome: 'allowed-once' | 'rejected'): void => {
+    setAnswered(true)
+    void pending.answer(outcome).catch(() => { setAnswered(false) })
+  }
+  return (
+    <div className={css.root} data-approval-key={pending.key}>
+      <div className={css.card}>
+        <div className={css.strip}><span className={css.dot} />{t('waiting')}</div>
+        <div
+          className={css.body}
+          data-approval-scroll=""
+          tabIndex={0}
+          role="group"
+          aria-label={t('detail.aria')}
+        >
+          <div className={css.headline}>{pending.reason ?? t('escalation', { toolName: pending.toolName })}</div>
+          {detail !== null && <div className={css.command}>{detail}</div>}
+        </div>
+        <div className={css.actionRow}>
+          <Button variant="outline" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
+            {t('reject')}
+          </Button>
+          <Button variant="primary" disabled={answered} onClick={() => { answer('allowed-once') }}>
+            {t('allowOnce')}
+          </Button>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 138 - 0
packages/client/ui-approval/src/client/contract/slots.ts

@@ -0,0 +1,138 @@
+/** Approval composer and optional correlated-detail contracts. */
+import type { CallId } from '@deepseek-ai/dsh-llm'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type {
+  PropsLocale, PropsRenderSlots, PropsRuntime,
+} from '@deepseek-ai/dsh-client-ui-slots'
+import { settlePendingComposer } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { ApprovalKey } from '../locales.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-session/client' {
+  interface SessionPendingInteractionMap {
+    /** Pending approval request. */
+    approval: PendingApproval
+  }
+}
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    /** Approval prompt copy. */
+    approval: ApprovalKey
+  }
+
+  interface SlotMap {
+    /** Optional detail for the Tool call correlated with an approval request. */
+    'conversation.approval.detail': {
+      kind: 'single'
+      scope: 'session'
+      owner: ApprovalDetailOwnerProps
+    }
+  }
+}
+
+/** Stable identity handed to an optional approval-detail renderer. */
+export interface ApprovalDetailOwnerProps {
+  /** Tool call correlated with the request. */
+  callId: CallId
+}
+
+/** Client-visible fields of an approval request projected through Remote Events. */
+export interface ApprovalPresentationRequest {
+  /** Tool requesting the decision. */
+  readonly toolName: string
+  /** Tool call correlated with the request. */
+  readonly callId?: CallId
+  /** Human-readable reason supplied by the requester. */
+  readonly reason?: string
+  /** Cancellation projected from the Host waterfall. */
+  readonly signal?: AbortSignal
+}
+
+/** Decisions this interactive Client presentation can return. */
+export type ApprovalDecision = 'allowed-once' | 'rejected'
+
+let nextApprovalKey = 0
+
+/** One answerable Client presentation of a pending Host waterfall. */
+export class PendingApproval {
+  /** Domain discriminator used by Session pending-interaction consumers. */
+  readonly kind = 'approval' as const
+  /** Opaque render identity and one-shot remount axis. */
+  readonly key: string
+  /** Tool requesting the decision. */
+  readonly toolName: string
+  /** Correlated Tool call, when supplied by the asker. */
+  readonly callId: CallId | undefined
+  /** Human-readable reason supplied by the asker. */
+  readonly reason: string | undefined
+  /** Result returned by the Remote Event listener to the Host waterfall. */
+  readonly result: Promise<ApprovalDecision>
+
+  readonly #resolve: (outcome: ApprovalDecision) => void
+  readonly #reject: (reason: unknown) => void
+  readonly #signal: AbortSignal | undefined
+  readonly #onAbort: (() => void) | undefined
+  #settled = false
+
+  /**
+   * @param sessionId - Agent/Session identity owning the scoped request.
+   * @param request - Host approval request projected through the Remote Event.
+   */
+  constructor(readonly sessionId: SessionId, request: ApprovalPresentationRequest) {
+    nextApprovalKey += 1
+    this.key = `approval:${String(nextApprovalKey)}`
+    this.toolName = request.toolName
+    this.callId = request.callId
+    this.reason = request.reason
+    const completion = Promise.withResolvers<ApprovalDecision>()
+    this.result = completion.promise
+    this.#resolve = completion.resolve
+    this.#reject = completion.reject
+    this.#signal = request.signal
+    if (request.signal === undefined) {
+      this.#onAbort = undefined
+      return
+    }
+    const onAbort = (): void => {
+      this.abort(request.signal?.reason ?? new Error('approval request was aborted'))
+    }
+    this.#onAbort = onAbort
+    request.signal.addEventListener('abort', onAbort, { once: true })
+    if (request.signal.aborted) onAbort()
+  }
+
+  /**
+   * Resolve the Host waterfall with the user's decision.
+   * @param outcome - supported interactive decision.
+   */
+  answer(outcome: ApprovalDecision): Promise<void> {
+    return settlePendingComposer(() => {
+      this.finish(() => { this.#resolve(outcome) })
+    }, 'pending approval settlement failed')
+  }
+
+  /**
+   * End an unanswered presentation when its transport, scope, or plugin lifetime ends.
+   * @param reason - rejection exposed to the waiting Remote Event listener.
+   */
+  abort(reason: unknown): void {
+    if (this.#settled) return
+    this.finish(() => { this.#reject(reason) })
+  }
+
+  private finish(settle: () => void): void {
+    if (this.#settled) throw new Error(`pending approval ${this.key} is already settled`)
+    this.#settled = true
+    if (this.#signal !== undefined && this.#onAbort !== undefined) {
+      this.#signal.removeEventListener('abort', this.#onAbort)
+    }
+    settle()
+  }
+}
+
+/** Full props of the approval composer takeover. */
+export type ApprovalComposerProps =
+  PropsRuntime<'conversation.composer'>
+  & PropsRenderSlots<'conversation.approval.detail'>
+  & { matched: PendingApproval }
+  & PropsLocale<'approval'>

+ 79 - 0
packages/client/ui-approval/src/client/index.ts

@@ -0,0 +1,79 @@
+/** Browser approval consumer over the existing scoped Remote Event waterfall. */
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-api-remotes/client'
+import type {} from '@deepseek-ai/dsh-api-session-controller/client'
+import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+import type { TypertClientEventListener } from '@deepseek-ai/dsh-typert-protocol'
+import type {} from '@deepseek-ai/dsh-client-locale/client'
+import { ApprovalPanel } from './ApprovalPanel.tsx'
+import { PendingApproval } from './contract/slots.ts'
+import { en, zh } from './locales.ts'
+
+export { PendingApproval } from './contract/slots.ts'
+export type {
+  ApprovalComposerProps,
+  ApprovalDecision,
+  ApprovalDetailOwnerProps,
+  ApprovalPresentationRequest,
+} from './contract/slots.ts'
+export type { ApprovalKey } from './locales.ts'
+
+/** Required services: Agent scopes, Remote Events, Session UI, Slot registry, and copy. */
+export const inject = ['sessions', 'remote', 'uiSession', 'slots', 'locale']
+
+const NS = 'approval'
+
+type ApprovalListener = TypertClientEventListener<'approval/request'>
+type ClientApprovalRequest = Parameters<ApprovalListener>[0]
+type ClientApprovalNext = Parameters<ApprovalListener>[1]
+type ClientApprovalOutcome = Awaited<ReturnType<ApprovalListener>>
+
+/** Present one request until the user answers or its lifetime ends. */
+async function answerApproval(
+  ctx: ClientContext,
+  owner: ClientContext,
+  request: ClientApprovalRequest,
+  next: ClientApprovalNext,
+  attend: (pending: PendingApproval) => () => void,
+): Promise<ClientApprovalOutcome> {
+  const sessionId = ctx.sessions.scopeOf(owner)
+  if (sessionId === undefined) return next()
+  const pending = new PendingApproval(sessionId, {
+    toolName: request.toolName,
+    ...(request.callId === undefined
+      ? {}
+      : { callId: request.callId }),
+    ...(request.reason === undefined ? {} : { reason: request.reason }),
+    ...(request.signal === undefined ? {} : { signal: request.signal }),
+  })
+  const remove = attend(pending)
+  try {
+    return await pending.result
+  } finally {
+    remove()
+  }
+}
+
+/**
+ * Install approval copy and the scoped waterfall consumer.
+ * @param ctx - Client root context.
+ */
+export function apply(ctx: ClientContext): void {
+  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-approval: dictionaries')
+  const attend = ctx.uiSession.attend<PendingApproval>(() => 0)
+  ctx.slots.inject('conversation.composer', () => ctx.slots.register({
+    name: 'conversation.composer',
+    priority: 1,
+    select: ({ pendingInteraction }: ComposerChainProps): PendingApproval | null =>
+      pendingInteraction instanceof PendingApproval ? pendingInteraction : null,
+    locale: NS,
+    children: {
+      'conversation.approval.detail': { kind: 'single', scope: 'session' },
+    },
+  }, ApprovalPanel))
+  ctx.remote.$on('approval/request', function (request, next) {
+    return answerApproval(ctx, this, request, next, attend)
+  })
+}

+ 22 - 0
packages/client/ui-approval/src/client/locales.ts

@@ -0,0 +1,22 @@
+/** `approval` namespace dictionaries. */
+
+/** Simplified Chinese dictionary and key-set source of truth. */
+export const zh = {
+  waiting: '等待审批',
+  'detail.aria': '审批详情',
+  escalation: '工具 {toolName} 请求越权执行',
+  reject: '拒绝',
+  allowOnce: '允许一次',
+} satisfies Record<string, string>
+
+/** Approval dictionary key union. */
+export type ApprovalKey = keyof typeof zh
+
+/** English dictionary, checked against the Chinese key set. */
+export const en = {
+  waiting: 'Waiting for approval',
+  'detail.aria': 'Approval details',
+  escalation: 'Tool {toolName} requests privileged execution',
+  reject: 'Reject',
+  allowOnce: 'Allow once',
+} satisfies Record<ApprovalKey, string>

+ 4 - 0
packages/client/ui-approval/src/css-modules.d.ts

@@ -0,0 +1,4 @@
+declare module '*.module.css' {
+  const classes: Readonly<Record<string, string>>
+  export default classes
+}

+ 4 - 0
packages/client/ui-approval/src/index.ts

@@ -0,0 +1,4 @@
+/** Browser-only approval presentation plugin; the Host capability is composed independently. */
+
+/** Node plugin body. */
+export function apply(): void {}

+ 23 - 0
packages/client/ui-approval/src/invariant.ts

@@ -0,0 +1,23 @@
+/** Package-owned invariant companion for the approval presentation plugin. */
+/* jscpd:ignore-start */
+import type { Context } from '@deepseek-ai/cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-approval'
+
+/** Cordis companion plugin name. */
+export const name = 'client-ui-approval-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/** No runtime invariant: registries own and observe the Remote listener and temporary Slot entry. */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns The installed registration's disposer.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 345 - 0
packages/client/ui-approval/tests/ui-approval.client.spec.tsx

@@ -0,0 +1,345 @@
+// @vitest-environment jsdom
+import { Context } from '@deepseek-ai/cordis'
+import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
+import InvariantRegistry from '@deepseek-ai/dsh-invariants'
+import type { CallId } from '@deepseek-ai/dsh-llm'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { ApprovalPanel } from '../src/client/ApprovalPanel.tsx'
+import type { ApprovalComposerProps } from '../src/client/contract/slots.ts'
+import { PendingApproval } from '../src/client/contract/slots.ts'
+import { apply, inject } from '../src/client/index.ts'
+import { apply as nodeApply } from '../src/index.ts'
+import * as ApprovalInvariant from '../src/invariant.ts'
+
+type ApprovalListener = (
+  this: Context,
+  request: {
+    toolName: string
+    callId?: string
+    reason?: string
+    signal?: AbortSignal
+  },
+  next: () => Promise<'unavailable'>,
+) => Promise<unknown>
+
+interface PluginBench {
+  readonly ctx: Context
+  readonly listener: ApprovalListener
+  readonly pending: { getSnapshot(): readonly PendingApproval[] }
+  readonly attend: ReturnType<typeof vi.fn>
+  readonly disposeSlot: ReturnType<typeof vi.fn>
+  readonly disposeLocale: ReturnType<typeof vi.fn>
+  readonly register: ReturnType<typeof vi.fn>
+  readonly injectSlot: ReturnType<typeof vi.fn>
+  registration(): {
+    options: {
+      select(props: { pendingInteraction: PendingApproval | undefined }): PendingApproval | null
+    }
+    component: unknown
+  }
+}
+
+function setupPlugin(): PluginBench {
+  const ctx = new Context()
+  let listener: ApprovalListener | undefined
+  let registration: {
+    options: {
+      select(props: { pendingInteraction: PendingApproval | undefined }): PendingApproval | null
+    }
+    component: unknown
+  } | undefined
+  const disposeSlot = vi.fn()
+  const disposeLocale = vi.fn()
+  let pending: readonly PendingApproval[] = []
+  const attend = vi.fn((_precedence: (value: PendingApproval) => number) => (
+    value: PendingApproval,
+  ) => {
+    pending = [...pending, value]
+    return () => { pending = pending.filter(candidate => candidate !== value) }
+  })
+  const register = vi.fn((
+    options: NonNullable<typeof registration>['options'],
+    component: unknown,
+  ) => {
+    registration = { options, component }
+    return disposeSlot
+  })
+  const injectSlot = vi.fn((_name: string, mount: () => () => void) => {
+    const dispose = ctx.effect(() => mount())
+    return () => { void dispose() }
+  })
+  ctx.provide('remote', {
+    $on: (_event: string, callback: ApprovalListener) => {
+      listener = callback
+      return () => {}
+    },
+  } as never)
+  ctx.provide('sessions', { scopeOf } as never)
+  ctx.provide('uiSession', { attend } as never)
+  ctx.provide('slots', { inject: injectSlot, register } as never)
+  ctx.provide('locale', {
+    register: vi.fn(() => disposeLocale),
+  } as never)
+
+  apply(ctx)
+  if (listener === undefined) throw new Error('approval listener was not registered')
+  return {
+    ctx,
+    listener,
+    pending: { getSnapshot: () => pending },
+    attend,
+    disposeSlot,
+    disposeLocale,
+    register,
+    injectSlot,
+    registration: () => {
+      if (registration === undefined) throw new Error('approval slot was not registered')
+      return registration
+    },
+  }
+}
+
+const id = (value: string): SessionId => value as SessionId
+
+afterEach(() => {
+  cleanup()
+  vi.restoreAllMocks()
+})
+
+describe('PendingApproval', () => {
+  it('resolves once, removes its abort listener, and ignores later abort cleanup', async () => {
+    const controller = new AbortController()
+    const remove = vi.spyOn(controller.signal, 'removeEventListener')
+    const pending = new PendingApproval(id('s1'), {
+      toolName: 'bash',
+      callId: 'call-1' as CallId,
+      reason: 'needs access',
+      signal: controller.signal,
+    })
+
+    await pending.answer('allowed-once')
+
+    await expect(pending.result).resolves.toBe('allowed-once')
+    expect(pending.sessionId).toBe(id('s1'))
+    expect(pending.toolName).toBe('bash')
+    expect(pending.callId).toBe('call-1')
+    expect(pending.reason).toBe('needs access')
+    expect(remove).toHaveBeenCalledWith('abort', expect.any(Function))
+    expect(() => { pending.abort(new Error('late')) }).not.toThrow()
+    await expect(pending.answer('rejected')).rejects.toThrow(/already settled/)
+  })
+
+  it('rejects with an already-aborted signal reason', async () => {
+    const controller = new AbortController()
+    const reason = new Error('host cancelled')
+    controller.abort(reason)
+
+    const pending = new PendingApproval(id('s1'), {
+      toolName: 'read',
+      signal: controller.signal,
+    })
+
+    await expect(pending.result).rejects.toBe(reason)
+  })
+
+  it('uses a stable fallback when an abort signal supplies no reason', async () => {
+    const signal = {
+      aborted: true,
+      reason: undefined,
+      addEventListener: vi.fn(),
+      removeEventListener: vi.fn(),
+    } as unknown as AbortSignal
+
+    const pending = new PendingApproval(id('s1'), { toolName: 'read', signal })
+
+    await expect(pending.result).rejects.toThrow('approval request was aborted')
+  })
+
+  it('rejects an unanswered request explicitly without an AbortSignal', async () => {
+    const pending = new PendingApproval(id('s1'), { toolName: 'write' })
+    const reason = new Error('scope released')
+
+    pending.abort(reason)
+
+    await expect(pending.result).rejects.toBe(reason)
+  })
+
+  it('wraps a non-Error answer settlement failure with its cause', async () => {
+    const failure = 'resolve failed'
+    const completion = Promise.withResolvers<'allowed-once' | 'rejected'>()
+    const withResolvers = vi.spyOn(Promise, 'withResolvers').mockImplementationOnce(() => ({
+      promise: completion.promise,
+      resolve: () => { throw failure },
+      reject: completion.reject,
+    }))
+    const pending = new PendingApproval(id('s1'), { toolName: 'write' })
+    withResolvers.mockRestore()
+
+    const settlement = await pending.answer('allowed-once').catch((error: unknown) => error)
+
+    expect(settlement).toBeInstanceOf(Error)
+    expect(settlement).toMatchObject({
+      message: 'pending approval settlement failed',
+      cause: failure,
+    })
+    completion.resolve('allowed-once')
+    await expect(pending.result).resolves.toBe('allowed-once')
+  })
+})
+
+describe('approval Remote Event consumer', () => {
+  it('delegates an event that has no Agent scope', async () => {
+    const bench = setupPlugin()
+    const next = vi.fn(() => Promise.resolve<'unavailable'>('unavailable'))
+
+    await expect(bench.listener.call(bench.ctx, { toolName: 'bash' }, next))
+      .resolves.toBe('unavailable')
+    expect(next).toHaveBeenCalledOnce()
+    expect(bench.pending.getSnapshot()).toEqual([])
+    expect(bench.register).toHaveBeenCalledOnce()
+  })
+
+  it('publishes one scoped takeover, returns the answer, and keeps stable registrations', async () => {
+    const bench = setupPlugin()
+    const scope = createScope(bench.ctx, id('s1'))
+    await scope.fiber.await()
+    const controller = new AbortController()
+    const next = vi.fn(() => Promise.resolve<'unavailable'>('unavailable'))
+    const result = bench.listener.call(scope.ctx, {
+      toolName: 'bash',
+      callId: 'call-1',
+      reason: 'needs access',
+      signal: controller.signal,
+    }, next)
+    const pending = bench.pending.getSnapshot()[0]!
+    const { options, component } = bench.registration()
+
+    expect(component).toBe(ApprovalPanel)
+    expect(options.select({ pendingInteraction: undefined })).toBeNull()
+    expect(options.select({ pendingInteraction: pending })).toBe(pending)
+    expect(pending).toMatchObject({
+      kind: 'approval',
+      sessionId: id('s1'),
+      toolName: 'bash',
+      callId: 'call-1',
+      reason: 'needs access',
+    })
+
+    await pending.answer('allowed-once')
+
+    await expect(result).resolves.toBe('allowed-once')
+    expect(next).not.toHaveBeenCalled()
+    expect(bench.pending.getSnapshot()).toEqual([])
+    expect(bench.register).toHaveBeenCalledOnce()
+    expect(bench.disposeSlot).not.toHaveBeenCalled()
+    await scope.fiber.dispose()
+  })
+
+  it('propagates request cancellation after removing the pending object', async () => {
+    const bench = setupPlugin()
+    const scope = createScope(bench.ctx, id('s1'))
+    await scope.fiber.await()
+    const controller = new AbortController()
+    const reason = new Error('cancelled by host')
+    const result = bench.listener.call(scope.ctx, {
+      toolName: 'bash',
+      signal: controller.signal,
+    }, () => Promise.resolve('unavailable'))
+    expect(bench.pending.getSnapshot()).toHaveLength(1)
+
+    controller.abort(reason)
+
+    await expect(result).rejects.toBe(reason)
+    expect(bench.pending.getSnapshot()).toEqual([])
+    expect(bench.disposeSlot).not.toHaveBeenCalled()
+    await scope.fiber.dispose()
+  })
+
+  it('removes stable registrations with the plugin lifetime', async () => {
+    const bench = setupPlugin()
+    await bench.ctx.fiber.dispose()
+    expect(bench.disposeSlot).toHaveBeenCalledOnce()
+    expect(bench.disposeLocale).toHaveBeenCalledOnce()
+  })
+})
+
+function panelProps(
+  pending: PendingApproval,
+  renderSlot: ApprovalComposerProps['renderSlot'] = vi.fn(() => null),
+): ApprovalComposerProps {
+  const messages: Record<string, string> = {
+    waiting: 'Waiting',
+    'detail.aria': 'Approval details',
+    escalation: `Tool ${pending.toolName} asks`,
+    reject: 'Reject',
+    allowOnce: 'Allow once',
+  }
+  return {
+    matched: pending,
+    renderSlot,
+    t: (key: string) => messages[key] ?? key,
+  } as unknown as ApprovalComposerProps
+}
+
+describe('ApprovalPanel', () => {
+  it('renders fallback copy without detail and returns rejection', async () => {
+    const pending = new PendingApproval(id('s1'), { toolName: 'bash' })
+    const props = panelProps(pending)
+    render(<ApprovalPanel {...props} />)
+
+    expect(screen.getByText('Tool bash asks')).toBeTruthy()
+    expect(screen.getByRole('group', { name: 'Approval details' })).toBeTruthy()
+    expect(props.renderSlot).not.toHaveBeenCalled()
+    fireEvent.click(screen.getByRole('button', { name: 'Reject' }))
+
+    await expect(pending.result).resolves.toBe('rejected')
+    expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Reject' }).disabled).toBe(true)
+    expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Allow once' }).disabled).toBe(true)
+  })
+
+  it('renders correlated detail and returns allow-once', async () => {
+    const pending = new PendingApproval(id('s1'), {
+      toolName: 'bash',
+      callId: 'call-1' as CallId,
+      reason: 'Run this exact command',
+    })
+    const renderSlot = vi.fn(() => <code>pnpm test</code>)
+    render(<ApprovalPanel {...panelProps(pending, renderSlot)} />)
+
+    expect(screen.getByText('Run this exact command')).toBeTruthy()
+    expect(screen.getByText('pnpm test')).toBeTruthy()
+    expect(renderSlot).toHaveBeenCalledWith('conversation.approval.detail', {
+      callId: 'call-1',
+    })
+    fireEvent.click(screen.getByRole('button', { name: 'Allow once' }))
+
+    await expect(pending.result).resolves.toBe('allowed-once')
+  })
+
+  it('re-enables actions when answering fails', async () => {
+    const pending = new PendingApproval(id('s1'), { toolName: 'bash' })
+    vi.spyOn(pending, 'answer').mockRejectedValue(new Error('transport closed'))
+    render(<ApprovalPanel {...panelProps(pending)} />)
+
+    fireEvent.click(screen.getByRole('button', { name: 'Allow once' }))
+    expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Allow once' }).disabled).toBe(true)
+    await waitFor(() => {
+      expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Allow once' }).disabled).toBe(false)
+    })
+    pending.abort(new Error('test cleanup'))
+    await pending.result.catch(() => {})
+  })
+})
+
+describe('package entries', () => {
+  it('declares its service edges, keeps the Host half inert, and registers its invariant', async () => {
+    expect(inject).toEqual(['sessions', 'remote', 'uiSession', 'slots', 'locale'])
+    expect(() => { nodeApply() }).not.toThrow()
+    const ctx = new Context()
+    await ctx.plugin(InvariantRegistry, { enabled: true })
+
+    await expect(ctx.plugin(ApprovalInvariant).await()).resolves.toBeDefined()
+  })
+})

+ 48 - 0
packages/client/ui-approval/tsconfig.json

@@ -0,0 +1,48 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../api/remotes/tsconfig.client.json"
+    },
+    {
+      "path": "../../api/session-controller/tsconfig.client.json"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../runtime-diagnostics/invariants"
+    },
+    {
+      "path": "../../typert/protocol"
+    },
+    {
+      "path": "../locale"
+    },
+    {
+      "path": "../ui-conversation"
+    },
+    {
+      "path": "../ui-primitives"
+    },
+    {
+      "path": "../ui-renderer"
+    },
+    {
+      "path": "../ui-session"
+    },
+    {
+      "path": "../ui-slots"
+    }
+  ]
+}

+ 3 - 0
packages/client/ui-approval/tsdown.config.ts

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-ui-approval', ['lib/types/index.js', 'lib/types/invariant.js'])

+ 8 - 4
packages/client/ui-plan/package.json

@@ -47,16 +47,17 @@
   "peerDependencies": {
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-plan-mode": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
@@ -65,7 +66,10 @@
     "@deepseek-ai/dsh-plan-mode": "workspace:^",
     "@types/react": "~18.3.1",
     "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "react": "^18.2.0",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 4 - 1
packages/client/ui-plan/src/client/index.ts

@@ -8,13 +8,16 @@
  * plan state.
  */
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
-import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 // Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat).
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 // Type-only: pulls the `plan` SessionProjectionMap merge for useProjection.
 import type {} from '@deepseek-ai/dsh-plan-mode/client'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {} from '@deepseek-ai/dsh-client-ui-session/client'
 import { PlanChip } from './PlanModeControl.tsx'
 import { en, zh, type PlanKey } from './locales.ts'
 

+ 2 - 2
packages/client/ui-plan/tests/browser-plugin.client.spec.ts

@@ -7,8 +7,8 @@
  */
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it, vi } from 'vitest'
-import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
-import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
+import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { PlanChip } from '../src/client/PlanModeControl.tsx'
 import type { PlanChipInjected } from '../src/client/index.ts'

+ 1 - 1
packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx

@@ -7,7 +7,7 @@
  */
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import type { PlanProjection } from '@deepseek-ai/dsh-plan-mode/client'
 import { PlanChip, type PlanChipProps } from '../src/client/PlanModeControl.tsx'

+ 6 - 3
packages/client/ui-plan/tsconfig.json

@@ -14,9 +14,6 @@
     {
       "path": "../../../vendor/cordis"
     },
-    {
-      "path": "../runtime"
-    },
     {
       "path": "../locale"
     },
@@ -26,6 +23,12 @@
     {
       "path": "../ui-primitives"
     },
+    {
+      "path": "../ui-renderer"
+    },
+    {
+      "path": "../ui-session"
+    },
     {
       "path": "../ui-slots"
     },

+ 1 - 1
packages/client/ui-tool/tests/ask-question-row.client.spec.tsx

@@ -9,7 +9,7 @@
  */
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 // Export discipline: packages/client/AGENTS.md.

+ 20 - 6
packages/client/ui-user-questions/package.json

@@ -31,11 +31,16 @@
   },
   "dsh": {
     "client": {
+      "external": [
+        "@deepseek-ai/dsh-client-ui-conversation/client"
+      ],
       "inject": [
         "@deepseek-ai/dsh-api-remotes",
+        "@deepseek-ai/dsh-api-session-controller",
         "@deepseek-ai/dsh-client-locale",
-        "@deepseek-ai/dsh-client-runtime",
-        "@deepseek-ai/dsh-client-ui-conversation"
+        "@deepseek-ai/dsh-client-ui-conversation",
+        "@deepseek-ai/dsh-client-ui-renderer",
+        "@deepseek-ai/dsh-client-ui-session"
       ],
       "platform": "web"
     }
@@ -51,15 +56,21 @@
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^"
+    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
+    "@deepseek-ai/dsh-user-questions": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
@@ -68,10 +79,13 @@
     "@types/react": "~18.3.1",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "react": "^18.2.0",
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
-    "@deepseek-ai/dsh-client-connection": "workspace:^"
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 3 - 4
packages/client/ui-user-questions/src/client/QuestionComposer.tsx

@@ -6,9 +6,10 @@ import {
   IconEditOutline16, MarkdownText,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import {
-  PendingQuestion, planReviewOf,
+  planReviewOf,
   type QuestionAnswer, type QuestionComposerProps,
 } from './contract/slots.ts'
+import type { PendingQuestion } from './contract/slots.ts'
 import { PlanReviewPanel } from './PlanReviewPanel.tsx'
 import css from './QuestionComposer.module.css'
 
@@ -114,9 +115,7 @@ function AnswerField(props: AnswerFieldProps) {
  * @returns The question flow, or the intent's own surface, for this request.
  */
 export function QuestionComposer(props: QuestionComposerProps) {
-  // Domain-face mint rides the carrier's stable identity (never minted in a
-  // select/render dispatch — per-dispatch minting would churn memo identity).
-  const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
+  const question = props.matched
   const review = useMemo(() => planReviewOf(question.questions), [question])
   return review === undefined
     ? <QuestionFlow key={question.key} pending={question} t={props.t} />

+ 98 - 50
packages/client/ui-user-questions/src/client/contract/slots.ts

@@ -1,27 +1,24 @@
-/**
- * Question-composer slot contract: the registrant-side props composition for
- * the conversation-owned `conversation.composer` slot, plus the question
- * domain face over the runtime's carrier object. The carrier (PendingWait)
- * owns envelope transport only; the question protocol — answer value shape,
- * cancelled error encoding, receipt checks — lives HERE, with the package
- * that consumes it.
- */
+/** Question composer props and one pending Remote waterfall response. */
 import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
-// Also pulls ui-conversation's SlotMap merge (the 'conversation.composer'
-// entry) into every program that sees this contract, so PropsRuntime resolves.
-import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
+// The client module declares the conversation.composer SlotMap entry required by PropsRuntime.
+import { settlePendingComposer } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type {
-  PendingQuestionAnswer, PendingWait,
-} from '@deepseek-ai/dsh-client-runtime/client'
+  AskUserQuestionAnswer, AskUserQuestionItem,
+} from '@deepseek-ai/dsh-user-questions'
 
-/** The pending question carrier the owner dispatches into the composer slot. */
-export type QuestionWait = PendingWait<'question'>
+declare module '@deepseek-ai/dsh-client-ui-session/client' {
+  interface SessionPendingInteractionMap {
+    /** Pending question or plan-review request. */
+    question: PendingQuestion
+  }
+}
 
 /** One structured answer batch covering every question of the request. */
-export type QuestionAnswer = PendingQuestionAnswer
+export type QuestionAnswer = AskUserQuestionAnswer
 
-/** One question of the request, as the carrier payload carries it. */
-type QuestionItem = QuestionWait['payload']['questions'][number]
+/** One question of the request. */
+type QuestionItem = AskUserQuestionItem
 
 /** One option the asker offered on a question. */
 type QuestionOption = NonNullable<QuestionItem['options']>[number]
@@ -85,54 +82,105 @@ export function planReviewOf(questions: readonly QuestionItem[]): PlanReview | u
   }
 }
 
-/**
- * Question domain face over the carrier: render identity and questions
- * transparently forwarded; answer/cancel own the wire encoding (the success
- * fields and the cancelled error) and turn a rejected carrier receipt into a
- * thrown error. Components mint one per carrier via useMemo (never inside a
- * select — a per-dispatch mint would churn identity and break memoization).
- */
+let nextQuestionKey = 0
+
+/** Create a wire-preserved user-question rejection. */
+function questionError(message: string, code: 'ASK_ABORTED' | 'ASK_CANCELLED'): Error {
+  const error = new Error(message) as Error & { code: string }
+  error.name = 'UserQuestionError'
+  error.code = code
+  return error
+}
+
+/** One answerable Client presentation of a pending Host waterfall. */
 export class PendingQuestion {
+  /** Presentation discriminator used by Session pending-interaction consumers. */
+  readonly kind: 'question' | 'plan-review'
+  /** Opaque render identity and local-draft remount axis. */
+  readonly key: string
+  /** The request's question list. */
+  readonly questions: readonly AskUserQuestionItem[]
+  /** Result returned by the Remote Event listener to the Host waterfall. */
+  readonly result: Promise<QuestionAnswer>
+
+  readonly #resolve: (answer: QuestionAnswer) => void
+  readonly #reject: (reason: unknown) => void
+  readonly #signal: AbortSignal | undefined
+  readonly #onAbort: (() => void) | undefined
+  #settled = false
+
   /**
-   * @param wait - the runtime carrier for one pending question request.
+   * @param sessionId - Agent/Session identity owning the scoped request.
+   * @param questions - complete question batch.
+   * @param signal - Host request and delivery lifetime.
    */
-  constructor(private readonly wait: QuestionWait) {}
+  constructor(
+    readonly sessionId: SessionId,
+    questions: readonly AskUserQuestionItem[],
+    signal?: AbortSignal,
+  ) {
+    nextQuestionKey += 1
+    this.key = `question:${String(nextQuestionKey)}`
+    this.questions = questions
+    this.kind = planReviewOf(questions) === undefined ? 'question' : 'plan-review'
+    const completion = Promise.withResolvers<QuestionAnswer>()
+    this.result = completion.promise
+    this.#resolve = completion.resolve
+    this.#reject = completion.reject
+    this.#signal = signal
+    if (signal === undefined) {
+      this.#onAbort = undefined
+      return
+    }
+    const onAbort = (): void => {
+      this.abort(questionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
+    }
+    this.#onAbort = onAbort
+    signal.addEventListener('abort', onAbort, { once: true })
+    if (signal.aborted) onAbort()
+  }
 
-  /** Opaque render identity (React key / draft remount axis), forwarded from the carrier. */
-  get key(): string {
-    return this.wait.key
+  /**
+   * Resolve the Host waterfall with the whole answer batch.
+   * @param answer - complete structured answer batch.
+   */
+  answer(answer: QuestionAnswer): Promise<void> {
+    return settlePendingComposer(() => {
+      this.finish(() => { this.#resolve(answer) })
+    }, 'pending question settlement failed')
   }
 
-  /** The request's question list, forwarded from the carrier payload. */
-  get questions(): QuestionWait['payload']['questions'] {
-    return this.wait.payload.questions
+  /** Reject the Host waterfall because the user closed the question. */
+  cancel(): Promise<void> {
+    return settlePendingComposer(() => {
+      this.finish(() => {
+        this.#reject(questionError('the user cancelled ask_user_question', 'ASK_CANCELLED'))
+      })
+    }, 'pending question cancellation failed')
   }
 
   /**
-   * Deliver the whole answer batch; a rejected carrier receipt throws.
-   * @param answer - complete structured answer batch.
+   * End an unanswered presentation when its transport, scope, or plugin lifetime ends.
+   * @param reason - rejection exposed to the waiting Remote Event listener.
    */
-  async answer(answer: QuestionAnswer): Promise<void> {
-    const receipt = await this.wait.respond({
-      ok: true, value: { sessionId: this.wait.sessionId, answer },
-    })
-    if (!receipt.accepted) {
-      throw new Error(`question response rejected: ${receipt.reason}`)
-    }
+  abort(reason: unknown): void {
+    if (this.#settled) return
+    this.finish(() => { this.#reject(reason) })
   }
 
-  /** Reject the whole wait (the host resolves the tool call as cancelled); a rejected receipt throws. */
-  async cancel(): Promise<void> {
-    const receipt = await this.wait.respond({
-      ok: false,
-      error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
-    })
-    if (!receipt.accepted) {
-      throw new Error(`question cancellation rejected: ${receipt.reason}`)
+  private finish(settle: () => void): void {
+    if (this.#settled) throw new Error(`pending question ${this.key} is already settled`)
+    this.#settled = true
+    if (this.#signal !== undefined && this.#onAbort !== undefined) {
+      this.#signal.removeEventListener('abort', this.#onAbort)
     }
+    settle()
   }
 }
 
+/** Pending value returned by the composer-chain selector. */
+export type QuestionWait = PendingQuestion
+
 /**
  * Full component props: the framework runtime share (chain currency +
  * session/global standard kit) plus the chain `matched` share — the entry's

+ 41 - 50
packages/client/ui-user-questions/src/client/index.ts

@@ -12,13 +12,16 @@
  * separate chain entry per shape would race the same carrier, so the shape
  * choice lives inside this entry — see QuestionComposer.
  */
-import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
-import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
-import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
+import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+import type { TypertClientEventListener } from '@deepseek-ai/dsh-typert-protocol'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
 import type {} from '@deepseek-ai/dsh-client-locale/client'
-import { planReviewOf, type QuestionWait } from './contract/slots.ts'
+import type {} from '@deepseek-ai/dsh-api-session-controller/client'
+import { PendingQuestion } from './contract/slots.ts'
 import { QuestionComposer } from './QuestionComposer.tsx'
 import { en, zh, type QuestionKey } from './locales.ts'
 
@@ -38,12 +41,31 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 /** Dictionary namespace owned by this plugin. */
 const NS = 'question'
 
-/** Required services: the slot registry and the question composer's copy. */
-export const inject = ['slots', 'sessions', 'remote', 'conversation', 'locale']
+type QuestionListener = TypertClientEventListener<'user-questions/request'>
+type ClientQuestionRequest = Parameters<QuestionListener>[0]
+type ClientQuestionNext = Parameters<QuestionListener>[1]
+type ClientQuestionAnswer = Awaited<ReturnType<QuestionListener>>
+
+/** Required services: Agent scopes, Remote Events, Session UI, Slot registry, and copy. */
+export const inject = ['sessions', 'remote', 'uiSession', 'slots', 'locale']
 
-/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
-function selectQuestion({ pendingInteraction }: ComposerChainProps): QuestionWait | null {
-  return pendingInteraction?.kind === 'question' ? pendingInteraction : null
+/** Present one request until the user answers, cancels, or its lifetime ends. */
+async function answerQuestion(
+  ctx: ClientContext,
+  owner: ClientContext,
+  request: ClientQuestionRequest,
+  next: ClientQuestionNext,
+  attend: (pending: PendingQuestion) => () => void,
+): Promise<ClientQuestionAnswer> {
+  const sessionId = ctx.sessions.scopeOf(owner)
+  if (sessionId === undefined) return next()
+  const pending = new PendingQuestion(sessionId, request.questions, request.signal)
+  const remove = attend(pending)
+  try {
+    return await pending.result
+  } finally {
+    remove()
+  }
 }
 
 /**
@@ -54,50 +76,19 @@ function selectQuestion({ pendingInteraction }: ComposerChainProps): QuestionWai
  */
 export function apply(ctx: ClientContext): void {
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-user-questions: dictionaries')
-
+  const attend = ctx.uiSession.attend<PendingQuestion>(
+    pending => pending.kind === 'plan-review' ? 2 : 1,
+  )
   ctx.slots.inject('conversation.composer', () => ctx.slots.register(
-    { name: 'conversation.composer', select: selectQuestion, locale: NS },
+    {
+      name: 'conversation.composer',
+      select: ({ pendingInteraction }: ComposerChainProps): PendingQuestion | null =>
+        pendingInteraction instanceof PendingQuestion ? pendingInteraction : null,
+      locale: NS,
+    },
     QuestionComposer,
   ))
-
-  let nextQuestionKey = 0
   ctx.remote.$on('user-questions/request', function (request, next) {
-    const sessionId = ctx.sessions.scopeOf(this)
-    if (sessionId === undefined) return next()
-    nextQuestionKey += 1
-    const interactionId = `remote-${String(nextQuestionKey)}`
-    const completion = Promise.withResolvers<Awaited<ReturnType<typeof next>>>()
-    const wait = new PendingWait('question', interactionId, sessionId, {
-      questions: request.questions,
-    }, (response) => {
-      if (response.result.ok) {
-        completion.resolve(response.result.value.answer)
-      } else {
-        const error = new Error(response.result.error.message) as Error & { code: string }
-        error.name = 'UserQuestionError'
-        error.code = response.result.error.code === 'cancelled'
-          ? 'ASK_CANCELLED'
-          : response.result.error.code
-        completion.reject(error)
-      }
-      return Promise.resolve({ ok: true, value: { accepted: true } })
-    })
-    const status = planReviewOf(request.questions) === undefined ? 'question' : 'plan-review'
-    const remove = ctx.conversation.pendingInteractions.present(
-      wait,
-      status,
-      status === 'plan-review' ? 2 : 1,
-    )
-    const signal = request.signal
-    if (signal === undefined) return completion.promise.finally(remove)
-    const abort = (): void => {
-      completion.reject(signal.reason)
-    }
-    signal.addEventListener('abort', abort, { once: true })
-    if (signal.aborted) abort()
-    return completion.promise.finally(() => {
-      signal.removeEventListener('abort', abort)
-      remove()
-    })
+    return answerQuestion(ctx, this, request, next, attend)
   })
 }

+ 176 - 117
packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts

@@ -1,27 +1,36 @@
 /** Scoped Remote Event wiring for the browser question consumer. */
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it, vi } from 'vitest'
-import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
-import type { PendingWait, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
+import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
 import { PendingQuestion } from '../src/client/contract/slots.ts'
 import { apply, inject } from '../src/client/index.ts'
 
 const SESSION_ID = 'session-question' as SessionId
+const SESSION_SCOPE = Symbol('question-session-scope')
+const QUESTIONS = [{ id: 'mode', question: 'Choose a mode' }] as const
+const PLAN_QUESTIONS: PendingQuestion['questions'] = [{
+  id: 'plan',
+  question: 'Approve this plan?',
+  detail: '# Plan',
+  options: [{ label: 'Approve' }, { label: 'Keep planning' }],
+  intent: { kind: 'plan-review' as const, approve: 'Approve' },
+}]
 const ANSWER = { answers: [{ id: 'mode', selected: ['Fast'] }] }
-const QUESTIONS = [{ id: 'mode', question: 'Choose a mode' }]
 
 type QuestionRequest = {
-  questions: typeof QUESTIONS
+  questions: PendingQuestion['questions']
   signal?: AbortSignal
 }
-type QuestionNext = () => Promise<typeof ANSWER>
+type QuestionAnswer = typeof ANSWER
+type QuestionNext = () => Promise<QuestionAnswer>
 type QuestionListener = (
   this: Context,
   request: QuestionRequest,
   next: QuestionNext,
-) => Promise<typeof ANSWER>
+) => Promise<QuestionAnswer>
 
 async function bench(declare = true) {
   const ctx = new Context()
@@ -33,28 +42,21 @@ async function bench(declare = true) {
       () => null,
     )
   }
-  ctx.provide('locale', new LocaleRuntime(ctx))
-  const owner = ctx.extend()
-  const scopeOf = vi.fn((candidate: Context) => candidate === owner ? SESSION_ID : undefined)
+  const locale = new LocaleRuntime(ctx)
+  ctx.provide('locale', locale)
+  const agent = ctx.extend({ [SESSION_SCOPE]: SESSION_ID })
+  const scopeOf = vi.fn((candidate: Context) => (
+    candidate as Context & { [SESSION_SCOPE]?: SessionId }
+  )[SESSION_SCOPE])
   ctx.provide('sessions', { scopeOf } as never)
-
-  let presented: PendingWait<'question'> | undefined
-  const remove = vi.fn(() => {
-    presented?.markSettled()
-    presented = undefined
-  })
-  const present = vi.fn((wait: PendingWait<'question'>) => {
-    presented = wait
-    return remove
+  let pending: readonly PendingQuestion[] = []
+  const attend = vi.fn((_precedence: (value: PendingQuestion) => number) => (
+    value: PendingQuestion,
+  ) => {
+    pending = [...pending, value]
+    return () => { pending = pending.filter(candidate => candidate !== value) }
   })
-  ctx.provide('conversation', {
-    pendingInteractions: {
-      present,
-      statuses: { getSnapshot: () => new Map(), subscribe: () => () => {} },
-      forSession: () => ({ getSnapshot: () => [], subscribe: () => () => {} }),
-    },
-  } as never)
-
+  ctx.provide('uiSession', { attend } as never)
   let listener: QuestionListener | undefined
   const on = vi.fn((event: string, value: QuestionListener) => {
     expect(event).toBe('user-questions/request')
@@ -64,151 +66,208 @@ async function bench(declare = true) {
   ctx.provide('remote', { $on: on } as never)
   const fiber = ctx.plugin({ inject: [...inject], apply })
   await fiber.await()
-
+  const invoke = (
+    owner: Context,
+    request: QuestionRequest,
+    next: QuestionNext,
+  ): Promise<QuestionAnswer> => {
+    if (listener === undefined) throw new Error('question listener was not installed')
+    return listener.call(owner, request, next)
+  }
   return {
     ctx,
     slots,
-    owner,
+    locale,
+    agent,
     scopeOf,
-    present,
-    remove,
+    pending: { getSnapshot: () => pending },
+    attend,
     on,
     fiber,
-    presented: () => presented,
-    invoke(request: QuestionRequest, next: QuestionNext, target = owner): Promise<typeof ANSWER> {
-      if (listener === undefined) throw new Error('question listener was not installed')
-      return listener.call(target, request, next)
-    },
+    invoke,
   }
 }
 
 describe('apply', () => {
   it('declares the services it binds', () => {
-    expect(inject).toEqual(['slots', 'sessions', 'remote', 'conversation', 'locale'])
+    expect(inject).toEqual(['sessions', 'remote', 'uiSession', 'slots', 'locale'])
   })
 
-  it('installs the Remote Event listener and waits for the composer declaration', async () => {
+  it('installs the Remote Event listener and delegates an unscoped request', async () => {
     const b = await bench(false)
-    expect(b.on).toHaveBeenCalledOnce()
-    expect(b.slots.entries('conversation.composer')).toHaveLength(0)
-
-    b.slots.register(
-      { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
-      () => null,
-    )
-    await Promise.resolve()
-    expect(b.slots.entries('conversation.composer')).toHaveLength(1)
-  })
-
-  it('delegates a request whose Client Context has no Session', async () => {
-    const b = await bench()
     const next = vi.fn(async () => ANSWER)
 
-    await expect(b.invoke({ questions: QUESTIONS }, next, b.ctx)).resolves.toBe(ANSWER)
+    await expect(b.invoke(b.ctx, { questions: QUESTIONS }, next)).resolves.toBe(ANSWER)
 
+    expect(b.on).toHaveBeenCalledOnce()
     expect(next).toHaveBeenCalledOnce()
-    expect(b.present).not.toHaveBeenCalled()
+    expect(b.slots.entries('conversation.composer')).toHaveLength(0)
+    expect(b.pending.getSnapshot()).toEqual([])
   })
 
-  it('publishes one scoped wait and returns its structured answer', async () => {
+  it('projects a scoped request through one stable composer and returns its answer', async () => {
     const b = await bench()
     const next = vi.fn(async () => ANSWER)
-    const result = b.invoke({ questions: QUESTIONS }, next)
-    const wait = b.presented()
-    if (wait === undefined) throw new Error('question wait was not presented')
-    const entry = b.slots.entries('conversation.composer')[0]!
-    const select = entry.select as (
-      owner: { pendingInteraction: PendingWait<'question'> | undefined },
-    ) => PendingWait<'question'> | null
+    const result = b.invoke(b.agent, { questions: QUESTIONS }, next)
+    await Promise.resolve()
 
+    const entry = b.slots.entries('conversation.composer')[0]!
     expect(entry.component).toBe(QuestionComposer)
     expect(entry.inject).toBeUndefined()
     expect(entry.locale).toBe('question')
+    const pending = b.pending.getSnapshot()[0]!
+    const select = entry.select as (
+      owner: { pendingInteraction: PendingQuestion | undefined },
+    ) => PendingQuestion | null
     expect(select({ pendingInteraction: undefined })).toBeNull()
-    expect(select({ pendingInteraction: wait })).toBe(wait)
-    expect(b.present).toHaveBeenCalledWith(wait, 'question', 1)
+    expect(select({ pendingInteraction: pending })).toBe(pending)
+    expect(pending).toMatchObject({ kind: 'question', sessionId: SESSION_ID, questions: QUESTIONS })
 
-    await new PendingQuestion(wait).answer(ANSWER)
+    await pending.answer(ANSWER)
     await expect(result).resolves.toBe(ANSWER)
     expect(next).not.toHaveBeenCalled()
-    expect(b.remove).toHaveBeenCalledOnce()
-    expect(b.presented()).toBeUndefined()
+    expect(b.pending.getSnapshot()).toEqual([])
+    expect(b.slots.entries('conversation.composer')).toHaveLength(1)
   })
 
-  it('uses plan-review precedence and preserves ASK_CANCELLED', async () => {
+  it('preserves ASK_CANCELLED as a rejected waterfall result', async () => {
     const b = await bench()
-    const questions = [{
-      id: 'plan',
-      question: 'Approve?',
-      detail: '# Plan',
-      options: [{ label: 'Approve' }, { label: 'Keep planning' }],
-      intent: { kind: 'plan-review' as const, approve: 'Approve' },
-    }]
-    const result = b.invoke({ questions }, async () => ANSWER)
-    const wait = b.presented()
-    if (wait === undefined) throw new Error('plan review wait was not presented')
-
-    expect(b.present).toHaveBeenCalledWith(wait, 'plan-review', 2)
+    const result = b.invoke(b.agent, { questions: QUESTIONS }, async () => ANSWER)
+    await Promise.resolve()
+    const pending = b.pending.getSnapshot()[0]!
     const rejection = expect(result).rejects.toMatchObject({
       name: 'UserQuestionError',
       code: 'ASK_CANCELLED',
+      message: 'the user cancelled ask_user_question',
     })
-    await new PendingQuestion(wait).cancel()
-    await rejection
-    expect(b.remove).toHaveBeenCalledOnce()
-  })
 
-  it('preserves a non-cancellation question rejection', async () => {
-    const b = await bench()
-    const result = b.invoke({ questions: QUESTIONS }, async () => ANSWER)
-    const wait = b.presented()
-    if (wait === undefined) throw new Error('question wait was not presented')
-
-    const rejection = expect(result).rejects.toMatchObject({
-      name: 'UserQuestionError',
-      code: 'provider-failed',
-      message: 'provider failed',
-    })
-    await wait.respond({
-      ok: false,
-      error: { code: 'provider-failed', message: 'provider failed', details: {} },
-    })
+    await pending.cancel()
     await rejection
-    expect(b.remove).toHaveBeenCalledOnce()
+    expect(b.pending.getSnapshot()).toEqual([])
+    expect(b.slots.entries('conversation.composer')).toHaveLength(1)
   })
 
-  it('removes an aborted request and its signal listener', async () => {
+  it('publishes a plan-review request with its distinct interaction kind', async () => {
     const b = await bench()
-    const controller = new AbortController()
-    const removeEventListener = vi.spyOn(controller.signal, 'removeEventListener')
-    const reason = new DOMException('aborted by Host', 'AbortError')
-    const result = b.invoke({ questions: QUESTIONS, signal: controller.signal }, async () => ANSWER)
-    expect(b.presented()).toBeDefined()
-
-    controller.abort(reason)
+    const result = b.invoke(b.agent, { questions: PLAN_QUESTIONS }, async () => ANSWER)
+    await Promise.resolve()
+    const pending = b.pending.getSnapshot()[0]!
 
-    await expect(result).rejects.toBe(reason)
-    expect(removeEventListener).toHaveBeenCalledWith('abort', expect.any(Function))
-    expect(b.remove).toHaveBeenCalledOnce()
-    expect(b.presented()).toBeUndefined()
+    expect(pending.kind).toBe('plan-review')
+    await pending.answer(ANSWER)
+    await expect(result).resolves.toBe(ANSWER)
+    expect(b.pending.getSnapshot()).toEqual([])
   })
 
-  it('removes a request whose signal was already aborted', async () => {
+  it('removes a cancelled request while preserving the stable composer', async () => {
     const b = await bench()
     const controller = new AbortController()
-    controller.abort()
+    const result = b.invoke(b.agent, { questions: QUESTIONS, signal: controller.signal }, async () => ANSWER)
+    await Promise.resolve()
+    expect(b.pending.getSnapshot()).toHaveLength(1)
 
-    const result = b.invoke({ questions: QUESTIONS, signal: controller.signal }, async () => ANSWER)
+    controller.abort()
 
-    await expect(result).rejects.toBe(controller.signal.reason)
-    expect(b.remove).toHaveBeenCalledOnce()
-    expect(b.presented()).toBeUndefined()
+    await expect(result).rejects.toMatchObject({ code: 'ASK_ABORTED' })
+    expect(b.pending.getSnapshot()).toEqual([])
+    expect(b.slots.entries('conversation.composer')).toHaveLength(1)
   })
 
-  it('teardown unregisters the stable composer entry', async () => {
+  it('removes the stable composer with the plugin lifetime', async () => {
     const b = await bench()
     expect(b.slots.entries('conversation.composer')).toHaveLength(1)
+
     await b.fiber.dispose()
+
     expect(b.slots.entries('conversation.composer')).toHaveLength(0)
   })
 })
+
+describe('PendingQuestion', () => {
+  it('preserves an already-aborted request signal as ASK_ABORTED', async () => {
+    const lifetime = new AbortController()
+    lifetime.abort()
+    const pending = new PendingQuestion(SESSION_ID, QUESTIONS, lifetime.signal)
+
+    await expect(pending.result).rejects.toMatchObject({
+      name: 'UserQuestionError',
+      code: 'ASK_ABORTED',
+      message: 'ask_user_question was aborted before the user answered',
+    })
+  })
+
+  it('rejects on later request cancellation and removes the listener after settlement', async () => {
+    const lifetime = new AbortController()
+    const remove = vi.spyOn(lifetime.signal, 'removeEventListener')
+    const pending = new PendingQuestion(SESSION_ID, QUESTIONS, lifetime.signal)
+    const rejected = expect(pending.result).rejects.toMatchObject({ code: 'ASK_ABORTED' })
+
+    lifetime.abort()
+
+    await rejected
+    expect(remove).toHaveBeenCalledWith('abort', expect.any(Function))
+  })
+
+  it('ignores a lifecycle abort after the answer already settled', async () => {
+    const lifetime = new AbortController()
+    const pending = new PendingQuestion(SESSION_ID, QUESTIONS, lifetime.signal)
+
+    await pending.answer(ANSWER)
+    await expect(pending.result).resolves.toBe(ANSWER)
+    pending.abort(new Error('late disposal'))
+  })
+
+  it('rejects an unanswered request with its caller-owned lifecycle reason', async () => {
+    const pending = new PendingQuestion(SESSION_ID, QUESTIONS)
+    const reason = new Error('scope released')
+    const rejected = expect(pending.result).rejects.toBe(reason)
+
+    pending.abort(reason)
+
+    await rejected
+  })
+
+  it('wraps a non-Error answer settlement failure with its cause', async () => {
+    const failure = 'resolve failed'
+    const completion = Promise.withResolvers<QuestionAnswer>()
+    const withResolvers = vi.spyOn(Promise, 'withResolvers').mockImplementationOnce(() => ({
+      promise: completion.promise,
+      resolve: () => { throw failure },
+      reject: completion.reject,
+    }))
+    const pending = new PendingQuestion(SESSION_ID, QUESTIONS)
+    withResolvers.mockRestore()
+
+    const settlement = await pending.answer(ANSWER).catch((error: unknown) => error)
+
+    expect(settlement).toBeInstanceOf(Error)
+    expect(settlement).toMatchObject({
+      message: 'pending question settlement failed',
+      cause: failure,
+    })
+    completion.resolve(ANSWER)
+    await expect(pending.result).resolves.toBe(ANSWER)
+  })
+
+  it('wraps a non-Error cancellation settlement failure with its cause', async () => {
+    const failure = 'reject failed'
+    const completion = Promise.withResolvers<QuestionAnswer>()
+    const withResolvers = vi.spyOn(Promise, 'withResolvers').mockImplementationOnce(<T>() => ({
+      promise: completion.promise,
+      resolve: completion.resolve as (value: T | PromiseLike<T>) => void,
+      reject: () => { throw failure },
+    }))
+    const pending = new PendingQuestion(SESSION_ID, QUESTIONS)
+    withResolvers.mockRestore()
+
+    const settlement = await pending.cancel().catch((error: unknown) => error)
+
+    expect(settlement).toBeInstanceOf(Error)
+    expect(settlement).toMatchObject({
+      message: 'pending question cancellation failed',
+      cause: failure,
+    })
+    completion.resolve(ANSWER)
+    await expect(pending.result).resolves.toBe(ANSWER)
+  })
+})

+ 132 - 69
packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx

@@ -1,12 +1,10 @@
 // @vitest-environment jsdom
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import type {
-  ConversationSnapshot, SessionId, SessionListState, WorkspaceListState,
-} from '@deepseek-ai/dsh-client-runtime/client'
-import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
-import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
-import { planReviewOf, type QuestionComposerProps, type QuestionWait } from '../src/client/contract/slots.ts'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import {
+  PendingQuestion, planReviewOf, type QuestionComposerProps, type QuestionWait,
+} from '../src/client/contract/slots.ts'
 import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
 import { en, zh } from '../src/client/locales.ts'
 import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
@@ -15,29 +13,113 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
 afterEach(cleanup)
 
 const SID = 's1' as SessionId
-const interactionId = (value: string): string => value
-type QuestionRespond = ConstructorParameters<typeof PendingWait<'question'>>[4]
 
 const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
   (key => dict[key] ?? common[key] ?? key)
 
+type SessionState = Parameters<Parameters<QuestionComposerProps['useSession']>[0]>[0]
+type ConversationState = Parameters<Parameters<QuestionComposerProps['useConversation']>[0]>[0]
+type ChatState = Parameters<Parameters<QuestionComposerProps['useChat']>[0]>[0]
+type TrajectoryState = Parameters<Parameters<QuestionComposerProps['useTrajectory']>[0]>[0]
+type InputState = Parameters<Parameters<QuestionComposerProps['useInput']>[0]>[0]
+type AttentionState = Parameters<Parameters<QuestionComposerProps['useSessionPendingInteraction']>[0]>[0]
+
+const sessionState: SessionState = {
+  sessionId: SID,
+  queue: [],
+  running: false,
+  subagent: null,
+  removed: false,
+  openState: 'open',
+  openError: null,
+  hasMore: false,
+  loadingOlder: false,
+  promptError: null,
+  blank: false,
+  lastAgentError: null,
+  promptAttempted: false,
+  awaitingFirstTurn: false,
+}
+const sessionList = {
+  ids: [SID],
+  byId: { [SID]: { id: SID, displayTitle: 'Session', running: false, blank: false, updatedAt: 0 } },
+  current: SID,
+  phase: 'ready' as const,
+  subagentsByParent: {},
+  jobsBySession: {},
+  currentAddress: undefined,
+}
+const attentionState: AttentionState = new Map()
+const workspaceState = {
+  items: [],
+  archivedSessionIds: [],
+  state: 'idle' as const,
+  phase: 'ready' as const,
+  error: null,
+}
+const conversationState: ConversationState = {
+  views: { get: () => undefined },
+  activeTargets: new Set(),
+}
+const emptyKeys: readonly string[] = []
+const chatState: ChatState = {
+  order: emptyKeys,
+  nodes: { get: () => undefined, values: () => [] },
+  locations: { getTurn: () => emptyKeys, getStep: () => emptyKeys },
+  timeline: { turnOrder: [], turns: new Map() },
+  legacy: {
+    nodes: [],
+    turnTimings: new Map(),
+    turnEnds: new Map(),
+    partial: null,
+    runningCalls: [],
+  },
+}
+const trajectoryState: TrajectoryState = {
+  eventNodes: [],
+  eventLocations: new Map(),
+  requests: [],
+  callSchemas: new Map(),
+  partial: null,
+  runningCalls: [],
+}
+const inputState: InputState = {
+  draft: '',
+  imageIds: [],
+  draftRev: 0,
+  phase: 'plain',
+  occurrences: [],
+  queue: [],
+}
+
 /** Framework standard-kit stubs: the panel consumes only the locale seat. */
-const kit = {
+const kit: Omit<QuestionComposerProps, 'matched'> = {
   sessionId: SID,
   session: undefined,
-  useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
-  useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
-  useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
-  useProjection: (() => undefined) as never,
-  useInput: (() => { throw new Error('unused') }) as never,
-  inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
+  pendingInteraction: undefined,
+  useSession: selector => selector(sessionState),
+  useSessions: selector => selector(sessionList),
+  useSessionPendingInteraction: selector => selector(attentionState),
+  useWorkspaces: selector => selector(workspaceState),
+  useConversation: selector => selector(conversationState),
+  useChat: selector => selector(chatState),
+  useTrajectory: selector => selector(trajectoryState),
+  useProjection: (() => undefined),
+  useInput: selector => selector(inputState),
+  inputActions: {
+    setDraft: () => { throw new Error('unused') },
+    addImages: () => { throw new Error('unused') },
+    removeImage: () => { throw new Error('unused') },
+    pruneImages: () => { throw new Error('unused') },
+    submit: () => { throw new Error('unused') },
+  },
   t: seatOver(zh, commonZh),
 }
 
 const PLAN = '# Ship the picker\n\n- read the store\n- render the rows\n'
 
 /** The plan-mode request shape: one question, the plan as detail, approve named. */
-const questions = (): QuestionWait['payload']['questions'] => [{
+const questions = (): QuestionWait['questions'] => [{
   id: 'plan-review',
   header: 'Plan review',
   question: 'Approve this plan and leave plan mode?',
@@ -49,24 +131,16 @@ const questions = (): QuestionWait['payload']['questions'] => [{
   intent: { kind: 'plan-review', approve: 'Approve' },
 }]
 
-/** Carrier fixture over a scripted respond carrier. */
-function wait(
-  payload: QuestionWait['payload'] = { questions: questions() },
-  respond: QuestionRespond = vi.fn(() => Promise.resolve({
-    ok: true as const,
-    value: { accepted: true as const },
-  })),
-) {
-  return { carrier: new PendingWait('question', interactionId('q-1'), SID, payload, respond), respond }
+/** Pending waterfall fixture with observable Client response methods. */
+function wait(items: QuestionWait['questions'] = questions()) {
+  const carrier = new PendingQuestion(SID, items)
+  const answer = vi.spyOn(carrier, 'answer')
+  const cancel = vi.spyOn(carrier, 'cancel')
+  void carrier.result.catch(() => {})
+  return { carrier, answer, cancel }
 }
 
-/** The Session Controller response request emitted for a decision. */
-function decidedEnvelope(label: string) {
-  return {
-    interactionId: interactionId('q-1'),
-    result: { ok: true, value: { sessionId: SID, answer: { answers: [{ id: 'plan-review', selected: [label] }] } } },
-  }
-}
+const decision = (label: string) => ({ answers: [{ id: 'plan-review', selected: [label] }] })
 
 describe('planReviewOf', () => {
   it('narrows a plan-review request to its decision, options included', () => {
@@ -113,9 +187,9 @@ describe('planReviewOf', () => {
 describe('PlanReviewPanel', () => {
   it('renders the plan under a review strip, with none of the quiz affordances', () => {
     const { carrier } = wait()
-    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
+    render(<QuestionComposer matched={carrier} {...kit} />)
 
-    expect(document.querySelector('[data-plan-review-key="q:q-1"]')).toBeTruthy()
+    expect(document.querySelector('[data-plan-review-key]')?.getAttribute('data-plan-review-key')).toBe(carrier.key)
     expect(screen.getByText(zh['plan.header'])).toBeTruthy()
     // The plan renders as markdown, so its heading is a heading.
     expect(screen.getByRole('heading', { name: 'Ship the picker' })).toBeTruthy()
@@ -131,72 +205,61 @@ describe('PlanReviewPanel', () => {
   })
 
   it('answers with the asker\'s approve label and keeps its description as the tooltip', () => {
-    const { carrier, respond } = wait()
-    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
+    const { carrier, answer } = wait()
+    render(<QuestionComposer matched={carrier} {...kit} />)
 
     const approve = screen.getByRole('button', { name: zh['plan.approve'] })
     expect(approve.getAttribute('title')).toBe('Leave plan mode; the plan is carried out from the next step.')
     fireEvent.click(approve)
-    expect(respond).toHaveBeenCalledWith(decidedEnvelope('Approve'))
+    expect(answer).toHaveBeenCalledWith(decision('Approve'))
     // One-shot: every action locks until the host's resolved frame lands.
     expect(approve.hasAttribute('disabled')).toBe(true)
     expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('disabled')).toBe(true)
     fireEvent.click(approve)
-    expect(respond).toHaveBeenCalledTimes(1)
+    expect(answer).toHaveBeenCalledTimes(1)
   })
 
   it('answers with the asker\'s decline label', () => {
-    const { carrier, respond } = wait()
-    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
+    const { carrier, answer } = wait()
+    render(<QuestionComposer matched={carrier} {...kit} />)
 
     fireEvent.click(screen.getByRole('button', { name: zh['plan.decline'] }))
-    expect(respond).toHaveBeenCalledWith(decidedEnvelope('Keep planning'))
+    expect(answer).toHaveBeenCalledWith(decision('Keep planning'))
   })
 
   it('dismisses the request so the composer returns for a plain message', () => {
-    const { carrier, respond } = wait()
-    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
+    const { carrier, cancel } = wait()
+    render(<QuestionComposer matched={carrier} {...kit} />)
 
     fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
-    expect(respond).toHaveBeenCalledWith({
-      interactionId: interactionId('q-1'),
-      result: {
-        ok: false,
-        error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
-      },
-    })
+    expect(cancel).toHaveBeenCalledWith()
   })
 
   it('omits the tooltip for an option carrying no description', () => {
-    const { carrier } = wait({ questions: [{
+    const { carrier } = wait([{
       ...questions()[0] as object,
       options: [{ label: 'Approve' }, { label: 'Keep planning' }],
-    }] as never })
-    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
+    }] as never)
+    render(<QuestionComposer matched={carrier} {...kit} />)
 
     expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('title')).toBe(false)
     expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('title')).toBe(false)
   })
 
   it('hides the decline action when the asker offered approve alone', () => {
-    const { carrier } = wait({ questions: [{
+    const { carrier } = wait([{
       ...questions()[0] as object, options: [{ label: 'Approve' }],
-    }] as never })
-    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
+    }] as never)
+    render(<QuestionComposer matched={carrier} {...kit} />)
 
     expect(screen.queryByRole('button', { name: zh['plan.decline'] })).toBeNull()
     expect(screen.getByRole('button', { name: zh['plan.approve'] })).toBeTruthy()
   })
 
   it('re-arms the actions and says why when the decision does not land', async () => {
-    const { carrier, respond } = wait(
-      { questions: questions() },
-      vi.fn(() => Promise.resolve({
-        ok: true as const,
-        value: { accepted: false as const, reason: 'not-pending' as const },
-      })),
-    )
-    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
+    const { carrier, answer } = wait()
+    answer.mockRejectedValue(new Error('question response rejected: not-pending'))
+    render(<QuestionComposer matched={carrier} {...kit} />)
 
     fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] }))
     const failure = await screen.findByText('question response rejected: not-pending')
@@ -204,15 +267,15 @@ describe('PlanReviewPanel', () => {
     // Re-armed for the retry: a lost click must not leave a dead card.
     expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('disabled')).toBe(false)
     fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] }))
-    expect(respond).toHaveBeenCalledTimes(2)
+    expect(answer).toHaveBeenCalledTimes(2)
   })
 
   it('reports a non-Error transport failure as its stringified value', async () => {
     // A non-Error rejection is the case under test: a carrier can reject with
     // anything, and the panel must still show the user something.
-    // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercises non-Error rejections
-    const { carrier } = wait({ questions: questions() }, vi.fn(() => Promise.reject('socket gone')))
-    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
+    const { carrier, cancel } = wait()
+    cancel.mockRejectedValue('socket gone')
+    render(<QuestionComposer matched={carrier} {...kit} />)
 
     fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
     expect(await screen.findByText('socket gone')).toBeTruthy()
@@ -220,7 +283,7 @@ describe('PlanReviewPanel', () => {
 
   it('carries the same decision surface in English', () => {
     const { carrier } = wait()
-    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} t={seatOver(en, commonEn)} />)
+    render(<QuestionComposer matched={carrier} {...kit} t={seatOver(en, commonEn)} />)
 
     expect(screen.getByText('Plan review')).toBeTruthy()
     expect(screen.getByRole('button', { name: 'Approve' })).toBeTruthy()

+ 169 - 118
packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx

@@ -1,11 +1,7 @@
 // @vitest-environment jsdom
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import type {
-  ConversationSnapshot, SessionId, SessionListState, WorkspaceListState,
-} from '@deepseek-ai/dsh-client-runtime/client'
-import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
-import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts'
 import { QuestionComposer, parseRecommendedLabel } from '../src/client/QuestionComposer.tsx'
 import { en, zh } from '../src/client/locales.ts'
@@ -15,29 +11,113 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
 afterEach(cleanup)
 
 const SID = 's1' as SessionId
-const interactionId = (value: string): string => value
-type QuestionRespond = ConstructorParameters<typeof PendingWait<'question'>>[4]
 
 const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
   (key => dict[key] ?? common[key] ?? key)
 
+type SessionState = Parameters<Parameters<QuestionComposerProps['useSession']>[0]>[0]
+type ConversationState = Parameters<Parameters<QuestionComposerProps['useConversation']>[0]>[0]
+type ChatState = Parameters<Parameters<QuestionComposerProps['useChat']>[0]>[0]
+type TrajectoryState = Parameters<Parameters<QuestionComposerProps['useTrajectory']>[0]>[0]
+type InputState = Parameters<Parameters<QuestionComposerProps['useInput']>[0]>[0]
+type AttentionState = Parameters<Parameters<QuestionComposerProps['useSessionPendingInteraction']>[0]>[0]
+
+const sessionState: SessionState = {
+  sessionId: SID,
+  queue: [],
+  running: false,
+  subagent: null,
+  removed: false,
+  openState: 'open',
+  openError: null,
+  hasMore: false,
+  loadingOlder: false,
+  promptError: null,
+  blank: false,
+  lastAgentError: null,
+  promptAttempted: false,
+  awaitingFirstTurn: false,
+}
+const sessionList = {
+  ids: [SID],
+  byId: { [SID]: { id: SID, displayTitle: 'Session', running: false, blank: false, updatedAt: 0 } },
+  current: SID,
+  phase: 'ready' as const,
+  subagentsByParent: {},
+  jobsBySession: {},
+  currentAddress: undefined,
+}
+const attentionState: AttentionState = new Map()
+const workspaceState = {
+  items: [],
+  archivedSessionIds: [],
+  state: 'idle' as const,
+  phase: 'ready' as const,
+  error: null,
+}
+const conversationState: ConversationState = {
+  views: { get: () => undefined },
+  activeTargets: new Set(),
+}
+const emptyKeys: readonly string[] = []
+const chatState: ChatState = {
+  order: emptyKeys,
+  nodes: { get: () => undefined, values: () => [] },
+  locations: { getTurn: () => emptyKeys, getStep: () => emptyKeys },
+  timeline: { turnOrder: [], turns: new Map() },
+  legacy: {
+    nodes: [],
+    turnTimings: new Map(),
+    turnEnds: new Map(),
+    partial: null,
+    runningCalls: [],
+  },
+}
+const trajectoryState: TrajectoryState = {
+  eventNodes: [],
+  eventLocations: new Map(),
+  requests: [],
+  callSchemas: new Map(),
+  partial: null,
+  runningCalls: [],
+}
+const inputState: InputState = {
+  draft: '',
+  imageIds: [],
+  draftRev: 0,
+  phase: 'plain',
+  occurrences: [],
+  queue: [],
+}
+
 /** Framework standard-kit stubs: the composer consumes only the locale seat;
  *  the composed props type mandates delivery of the rest (framework hooks are
  *  plain stubs per the client testing discipline). */
-const kit = {
+const kit: Omit<QuestionComposerProps, 'matched'> = {
   session: undefined,
   sessionId: SID,
-  useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
-  useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
-  useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
-  useProjection: (() => undefined) as never,
-  useInput: (() => { throw new Error('unused') }) as never,
-  inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
+  pendingInteraction: undefined,
+  useSession: selector => selector(sessionState),
+  useSessions: selector => selector(sessionList),
+  useSessionPendingInteraction: selector => selector(attentionState),
+  useWorkspaces: selector => selector(workspaceState),
+  useConversation: selector => selector(conversationState),
+  useChat: selector => selector(chatState),
+  useTrajectory: selector => selector(trajectoryState),
+  useProjection: (() => undefined),
+  useInput: selector => selector(inputState),
+  inputActions: {
+    setDraft: () => { throw new Error('unused') },
+    addImages: () => { throw new Error('unused') },
+    removeImage: () => { throw new Error('unused') },
+    pruneImages: () => { throw new Error('unused') },
+    submit: () => { throw new Error('unused') },
+  },
   // The seat's key domain is question ∪ common.
   t: seatOver(zh, commonZh),
 }
 
-const QUESTIONS = [
+const QUESTIONS: PendingQuestion['questions'] = [
   {
     id: 'profile', header: '偏好', question: '选择候选人类型',
     detail: '按当前空缺岗位的优先级选择。',
@@ -55,31 +135,21 @@ const QUESTIONS = [
   },
 ]
 
-/** Carrier fixture: a real PendingWait over a scripted respond carrier. */
-function wait(
-  id = 'question-1',
-  respond: QuestionRespond = vi.fn(() => Promise.resolve({
-    ok: true as const,
-    value: { accepted: true as const },
-  })),
-) {
-  const carrier = new PendingWait(
-    'question', interactionId(id), SID, { questions: QUESTIONS }, respond)
-  return { carrier, respond }
+/** Pending waterfall fixture with observable Client response methods. */
+function wait(questions: PendingQuestion['questions'] = QUESTIONS) {
+  const carrier = new PendingQuestion(SID, questions)
+  const answer = vi.spyOn(carrier, 'answer')
+  const cancel = vi.spyOn(carrier, 'cancel')
+  void carrier.result.catch(() => {})
+  return { carrier, answer, cancel }
 }
 
-/** The Session Controller response request emitted for an answer batch. */
-function answeredEnvelope(id: string, answers: object[]) {
-  return {
-    interactionId: interactionId(id),
-    result: { ok: true, value: { sessionId: SID, answer: { answers } } },
-  }
-}
+const answerBatch = (answers: object[]) => ({ answers })
 
 describe('QuestionComposer', () => {
   it('collects single, custom, and multi-select answers before one batch submit', () => {
-    const { carrier, respond } = wait()
-    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
+    const { carrier, answer } = wait()
+    render(<QuestionComposer matched={carrier} {...kit} />)
 
     expect(screen.getByText('偏好')).toBeTruthy()
     expect(screen.getByText('1 / 3')).toBeTruthy()
@@ -91,7 +161,7 @@ describe('QuestionComposer', () => {
     expect(scrollRegion?.contains(screen.getByRole('radio', { name: /工程落地型/ }))).toBe(true)
     expect(scrollRegion?.contains(screen.getByText('下一题').closest('button'))).toBe(false)
     fireEvent.keyDown(screen.getByRole('radio', { name: /工程落地型/ }), { key: 'Enter' })
-    expect(respond).not.toHaveBeenCalled()
+    expect(answer).not.toHaveBeenCalled()
     fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
 
     expect(screen.getByText('2 / 3')).toBeTruthy()
@@ -118,7 +188,7 @@ describe('QuestionComposer', () => {
     fireEvent.keyDown(multiCustom, { key: 'Enter' })
 
     // The domain face encoded the whole batch into one carrier envelope.
-    expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
+    expect(answer).toHaveBeenCalledWith(answerBatch([
       { id: 'profile', selected: ['工程落地型 (Recommended)'] },
       { id: 'detail', selected: [], custom: '要能独立排查线上问题' },
       { id: 'signals', selected: ['系统设计', '代码质量', '产品判断'], custom: '沟通能力' },
@@ -127,21 +197,13 @@ describe('QuestionComposer', () => {
   })
 
   it('renders plan detail through the shared assistant Markdown primitive', () => {
-    const carrier = new PendingWait(
-      'question',
-      interactionId('markdown-plan'),
-      SID,
-      {
-        questions: [{
-          id: 'plan',
-          question: '批准这个计划吗?',
-          detail: '# 实施计划\n\n- **先验证**现状\n- 修改 `QuestionComposer`',
-          options: [{ label: '批准' }],
-        }],
-      },
-      vi.fn(),
-    )
-    const view = render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
+    const { carrier } = wait([{
+      id: 'plan',
+      question: '批准这个计划吗?',
+      detail: '# 实施计划\n\n- **先验证**现状\n- 修改 `QuestionComposer`',
+      options: [{ label: '批准' }],
+    }])
+    const view = render(<QuestionComposer matched={carrier} {...kit} />)
 
     expect(screen.getByRole('heading', { level: 1, name: '实施计划' })).toBeTruthy()
     expect(view.container.querySelector('strong')?.textContent).toBe('先验证')
@@ -150,8 +212,8 @@ describe('QuestionComposer', () => {
   })
 
   it('skips individual questions without discarding earlier answers', () => {
-    const { carrier, respond } = wait()
-    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
+    const { carrier, answer } = wait()
+    render(<QuestionComposer matched={carrier} {...kit} />)
 
     expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true)
     fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
@@ -160,7 +222,7 @@ describe('QuestionComposer', () => {
     expect(screen.getByText('3 / 3')).toBeTruthy()
     fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
 
-    expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
+    expect(answer).toHaveBeenCalledWith(answerBatch([
       { id: 'profile', selected: ['研究潜力型'] },
       { id: 'detail', selected: [] },
       { id: 'signals', selected: [] },
@@ -168,8 +230,8 @@ describe('QuestionComposer', () => {
   })
 
   it('keeps IME Enter inside the custom input until composition finishes', () => {
-    const { carrier, respond } = wait()
-    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
+    const { carrier, answer } = wait()
+    render(<QuestionComposer matched={carrier} {...kit} />)
 
     fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
     const custom = screen.getByPlaceholderText('输入你的答案')
@@ -177,19 +239,19 @@ describe('QuestionComposer', () => {
 
     fireEvent.keyDown(custom, { key: 'Enter', isComposing: true })
     expect(screen.getByText('2 / 3')).toBeTruthy()
-    expect(respond).not.toHaveBeenCalled()
+    expect(answer).not.toHaveBeenCalled()
 
     fireEvent.keyDown(custom, { key: 'Enter', keyCode: 229 })
     expect(screen.getByText('2 / 3')).toBeTruthy()
-    expect(respond).not.toHaveBeenCalled()
+    expect(answer).not.toHaveBeenCalled()
 
     fireEvent.keyDown(custom, { key: 'Enter' })
     expect(screen.getByText('3 / 3')).toBeTruthy()
   })
 
   it('shows the inline custom input, reports missing answers, and supports pager navigation', () => {
-    const { carrier, respond } = wait()
-    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
+    const { carrier, answer } = wait()
+    render(<QuestionComposer matched={carrier} {...kit} />)
 
     expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy()
     fireEvent.click(screen.getByRole('radio', { name: '工程落地型' }))
@@ -206,12 +268,12 @@ describe('QuestionComposer', () => {
     expect(screen.getByText('2 / 3')).toBeTruthy()
     fireEvent.click(screen.getByLabelText('上一题'))
     expect(screen.getByText('1 / 3')).toBeTruthy()
-    expect(respond).not.toHaveBeenCalled()
+    expect(answer).not.toHaveBeenCalled()
   })
 
   it('answers over multiple lines: both fields grow with the draft and keep Shift+Enter a newline', () => {
-    const { carrier, respond } = wait()
-    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
+    const { carrier, answer } = wait()
+    render(<QuestionComposer matched={carrier} {...kit} />)
 
     // Both question shapes answer into a textarea, so the engine soft-wraps a
     // long answer and Shift+Enter breaks the line natively.
@@ -239,40 +301,39 @@ describe('QuestionComposer', () => {
     fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
     fireEvent.click(screen.getByRole('button', { name: '提交' }))
     // Line breaks reach the model verbatim: nothing along the way flattens them.
-    expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
+    expect(answer).toHaveBeenCalledWith(answerBatch([
       { id: 'profile', selected: [], custom: multiline },
       { id: 'detail', selected: [], custom: multiline },
       { id: 'signals', selected: ['系统设计'] },
     ]))
   })
 
-  it('surfaces cancellation failures: rejected receipt text and raw transport reasons', async () => {
-    const respond = vi.fn()
-      .mockResolvedValueOnce({ ok: true, value: { accepted: false, reason: 'bad-response' } })
+  it('surfaces cancellation failures and re-arms the controls', async () => {
+    const { carrier, cancel } = wait()
+    cancel
+      .mockRejectedValueOnce(new Error('第一次取消失败'))
       .mockRejectedValueOnce(new Error('第二次取消失败'))
-    const { carrier } = wait('question-1', respond)
-    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
+    render(<QuestionComposer matched={carrier} {...kit} />)
 
-    // Receipt rejection surfaces through the domain face's thrown message.
     fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
-    expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy()
+    expect(await screen.findByText('第一次取消失败')).toBeTruthy()
     expect(screen.getByRole<HTMLButtonElement>('button', { name: '跳过本题' }).disabled).toBe(false)
 
     fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
     expect(await screen.findByText('第二次取消失败')).toBeTruthy()
   })
 
-  it('surfaces transport rejection and resets local drafts for a different request', async () => {
-    const respond = vi.fn()
-      .mockRejectedValueOnce(new Error('网络中断'))
-      .mockRejectedValueOnce('字符串错误')
-    const first = wait('first', respond)
-    const view = render(<QuestionComposer matched={first.carrier} pendingInteraction={first.carrier} {...kit} />)
+  it('surfaces answer rejection and resets local drafts for a different request', async () => {
+    const first = wait()
+    const view = render(<QuestionComposer matched={first.carrier} {...kit} />)
 
     fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
     expect(screen.getByText('2 / 3')).toBeTruthy()
-    const second = wait('second', respond)
-    view.rerender(<QuestionComposer matched={second.carrier} pendingInteraction={second.carrier} {...kit} />)
+    const second = wait()
+    second.answer
+      .mockRejectedValueOnce(new Error('网络中断'))
+      .mockRejectedValueOnce('字符串错误')
+    view.rerender(<QuestionComposer matched={second.carrier} {...kit} />)
     expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('false')
 
     fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
@@ -281,7 +342,7 @@ describe('QuestionComposer', () => {
     fireEvent.keyDown(custom, { key: 'Enter' })
     fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
     fireEvent.click(screen.getByRole('button', { name: '提交' }))
-    expect(respond).toHaveBeenNthCalledWith(1, answeredEnvelope('second', [
+    expect(second.answer).toHaveBeenNthCalledWith(1, answerBatch([
       { id: 'profile', selected: ['工程落地型 (Recommended)'] },
       { id: 'detail', selected: [], custom: 'x' },
       { id: 'signals', selected: ['系统设计'] },
@@ -294,64 +355,54 @@ describe('QuestionComposer', () => {
   })
 
   it('renders chrome copy through the English dictionary', () => {
-    const respond = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
-    const carrier = new PendingWait(
-      'question', interactionId('solo'), SID, { questions: [{ id: 'detail', question: '补充你的要求' }] }, respond)
-    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} t={seatOver(en, commonEn)} />)
+    const { carrier } = wait([{ id: 'detail', question: '补充你的要求' }])
+    render(<QuestionComposer matched={carrier} {...kit} t={seatOver(en, commonEn)} />)
     expect(screen.getByLabelText('Dismiss all questions')).toBeTruthy()
     expect(screen.getByRole('button', { name: 'Skip this question' })).toBeTruthy()
     expect(screen.getByPlaceholderText('Type your answer')).toBeTruthy()
   })
 
-  it('same-key carrier replacement (baseline replay) keeps drafts', () => {
-    const first = wait('same-id')
-    const view = render(<QuestionComposer matched={first.carrier} pendingInteraction={first.carrier} {...kit} />)
+  it('keeps drafts when the same pending request rerenders', () => {
+    const pending = wait()
+    const view = render(<QuestionComposer matched={pending.carrier} {...kit} />)
     fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
     expect(screen.getByText('2 / 3')).toBeTruthy()
-    // Replay mints a NEW carrier for the same request; same key = no remount.
-    const replayed = wait('same-id')
-    view.rerender(<QuestionComposer matched={replayed.carrier} pendingInteraction={replayed.carrier} {...kit} />)
+    view.rerender(<QuestionComposer matched={pending.carrier} {...kit} />)
     expect(screen.getByText('2 / 3')).toBeTruthy()
   })
 })
 
 describe('PendingQuestion domain face', () => {
-  it('encodes the answer batch into the ok envelope and throws on a rejected receipt', async () => {
-    const respond = vi.fn()
-      .mockResolvedValueOnce({ ok: true, value: { accepted: true } })
-      .mockResolvedValueOnce({ ok: true, value: { accepted: false, reason: 'not-pending' } })
-    const question = new PendingQuestion(wait('rq', respond).carrier)
+  it('resolves the waterfall result with the answer batch and settles once', async () => {
+    const question = new PendingQuestion(SID, QUESTIONS)
     const batch = { answers: [{ id: 'mode', selected: ['Fast'] }] }
     await expect(question.answer(batch)).resolves.toBeUndefined()
-    expect(respond).toHaveBeenCalledWith(answeredEnvelope('rq', batch.answers))
-    await expect(question.answer(batch)).rejects.toThrow(/question response rejected: not-pending/)
+    await expect(question.result).resolves.toBe(batch)
+    await expect(question.answer(batch)).rejects.toThrow(/already settled/)
   })
 
-  it('encodes cancellation as the cancelled error envelope and throws on a rejected receipt', async () => {
-    const respond = vi.fn()
-      .mockResolvedValueOnce({ ok: true, value: { accepted: true } })
-      .mockResolvedValueOnce({ ok: true, value: { accepted: false, reason: 'bad-response' } })
-    const question = new PendingQuestion(wait('rc', respond).carrier)
+  it('rejects the waterfall result with ASK_CANCELLED and settles once', async () => {
+    const question = new PendingQuestion(SID, QUESTIONS)
+    const result = question.result.catch((error: unknown) => error)
     await expect(question.cancel()).resolves.toBeUndefined()
-    expect(respond).toHaveBeenCalledWith({
-      interactionId: interactionId('rc'),
-      result: {
-        ok: false,
-        error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
-      },
+    await expect(result).resolves.toMatchObject({
+      name: 'UserQuestionError',
+      code: 'ASK_CANCELLED',
+      message: 'the user cancelled ask_user_question',
     })
-    await expect(question.cancel()).rejects.toThrow(/question cancellation rejected: bad-response/)
+    await expect(question.cancel()).rejects.toThrow(/already settled/)
   })
 
-  it('forwards key and questions from the carrier', () => {
-    const question = new PendingQuestion(wait('rk').carrier)
-    expect(question.key).toBe('q:rk')
-    expect(question.questions).toBe(wait('rk').carrier.payload.questions)
+  it('exposes its Client render identity and scoped request values', () => {
+    const question = new PendingQuestion(SID, QUESTIONS)
+    expect(question.key).toMatch(/^question:\d+$/)
+    expect(question.sessionId).toBe(SID)
+    expect(question.questions).toBe(QUESTIONS)
   })
 
   it('collapses the card to the header strip and expands it back', () => {
     const { carrier } = wait()
-    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
+    render(<QuestionComposer matched={carrier} {...kit} />)
     // Expanded: the option list is visible.
     expect(screen.getByRole('radiogroup')).toBeTruthy()
     // Collapse: options leave the tree; the title and minimize toggle stay.
@@ -366,8 +417,8 @@ describe('PendingQuestion domain face', () => {
   })
 
   it('keeps the collapse toggle out of the cancel path and preserves drafts across collapse', () => {
-    const { carrier, respond } = wait()
-    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
+    const { carrier, answer } = wait()
+    render(<QuestionComposer matched={carrier} {...kit} />)
     fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
     // Single-select auto-advances to the second question; collapse and expand
     // must not lose either the picked option or the current position.
@@ -381,7 +432,7 @@ describe('PendingQuestion domain face', () => {
     fireEvent.click(screen.getByLabelText('下一题'))
     fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
     fireEvent.click(screen.getByRole('button', { name: '提交' }))
-    expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
+    expect(answer).toHaveBeenCalledWith(answerBatch([
       { id: 'profile', selected: ['工程落地型 (Recommended)'] },
       { id: 'detail', custom: '要能独立排查线上问题', selected: [] },
       { id: 'signals', selected: ['系统设计'] },

+ 17 - 2
packages/client/ui-user-questions/tsconfig.json

@@ -15,10 +15,19 @@
       "path": "../../../vendor/cordis"
     },
     {
-      "path": "../locale"
+      "path": "../../api/session-controller/tsconfig.client.json"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../interaction/user-questions"
+    },
+    {
+      "path": "../../typert/protocol"
     },
     {
-      "path": "../runtime"
+      "path": "../locale"
     },
     {
       "path": "../ui-conversation"
@@ -29,6 +38,12 @@
     {
       "path": "../ui-slots"
     },
+    {
+      "path": "../ui-renderer"
+    },
+    {
+      "path": "../ui-session"
+    },
     {
       "path": "../../runtime-diagnostics/invariants"
     }

+ 18 - 7
packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts

@@ -4,11 +4,22 @@ import { CallId } from '@deepseek-ai/dsh-llm'
 import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRuntime from '@deepseek-ai/dsh-tools'
-import UserQuestionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-questions'
+import UserQuestionService, {
+  type AskUserQuestionAnswer,
+  type AskUserQuestionRequest,
+} from '@deepseek-ai/dsh-user-questions'
 import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
 
 const testToolSignal = new AbortController().signal
 
+interface QuestionAnswerer {
+  ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
+}
+
+function registerQuestionAnswerer(ctx: Context, answerer: QuestionAnswerer): () => void {
+  return ctx.on('user-questions/request', request => answerer.ask(request))
+}
+
 interface OptionSchemaShape {
   properties: {
     questions: {
@@ -78,7 +89,7 @@ describe('ask_user_question tool', () => {
   it('asks the registered user-questions provider and projects structured answers to text', async () => {
     const ctx = await setup()
     const seen: AskUserQuestionRequest[] = []
-    ctx.userQuestions.registerProvider({
+    registerQuestionAnswerer(ctx, {
       async ask(request) {
         seen.push(request)
         return { answers: [{ id: 'pkg', selected: ['pnpm'] }] }
@@ -114,7 +125,7 @@ describe('ask_user_question tool', () => {
   it('passes recommended option labels through without adding schema fields', async () => {
     const ctx = await setup()
     const seen: AskUserQuestionRequest[] = []
-    ctx.userQuestions.registerProvider({
+    registerQuestionAnswerer(ctx, {
       async ask(request) {
         seen.push(request)
         return { answers: [{ id: 'pkg', selected: ['pnpm (Recommended)'] }] }
@@ -145,7 +156,7 @@ describe('ask_user_question tool', () => {
 
   it('projects custom answers and multi-select choices', async () => {
     const ctx = await setup()
-    ctx.userQuestions.registerProvider({
+    registerQuestionAnswerer(ctx, {
       async ask() {
         return {
           answers: [
@@ -198,7 +209,7 @@ describe('ask_user_question tool', () => {
   it('passes the tool abort signal to the user-questions request', async () => {
     const ctx = await setup()
     const seen: AskUserQuestionRequest[] = []
-    ctx.userQuestions.registerProvider({
+    registerQuestionAnswerer(ctx, {
       async ask(request) {
         seen.push(request)
         return { answers: [{ id: 'continue', selected: ['ok'] }] }
@@ -219,7 +230,7 @@ describe('ask_user_question tool', () => {
   it('passes optional header and a resumed runtime root through to the user-questions request', async () => {
     const ctx = await setup()
     const seen: AskUserQuestionRequest[] = []
-    ctx.userQuestions.registerProvider({
+    registerQuestionAnswerer(ctx, {
       async ask(request) {
         seen.push(request)
         return { answers: [{ id: 'continue', selected: ['ok'] }] }
@@ -259,7 +270,7 @@ describe('ask_user_question tool', () => {
   it('rejects a live runtime-owned agent with a structured DELEGATED_CALLER error', async () => {
     const ctx = await setup()
     const seen: AskUserQuestionRequest[] = []
-    ctx.userQuestions.registerProvider({
+    registerQuestionAnswerer(ctx, {
       async ask(request) {
         seen.push(request)
         return { answers: [{ id: 'continue', selected: ['ok'] }] }

+ 29 - 56
packages/interaction/user-questions/src/index.ts

@@ -1,14 +1,13 @@
 /**
  * Service Definition for the user-questions capability seam (`ctx.userQuestions`): a UI-backed service for
  * pausing an agent tool call until the human answers a question. The model-
- * facing tool lives in `@deepseek-ai/dsh-tool-ask-user`; UI packages provide
- * the single active provider.
+ * facing tool lives in `@deepseek-ai/dsh-tool-ask-user`; UI packages compose
+ * answerers on the Agent-scoped Cordis waterfall.
  *
  * @module @deepseek-ai/dsh-user-questions
  */
 
 import { Context, Service } from '@deepseek-ai/cordis'
-import type { Agent } from '@deepseek-ai/dsh-agent'
 import { HarnessError } from '@deepseek-ai/dsh-llm'
 import { scopeTarget } from '@deepseek-ai/dsh-scope'
 
@@ -19,7 +18,7 @@ declare module '@deepseek-ai/cordis' {
 }
 
 import type {
-  AskUserQuestionAnswer, AskUserQuestionItem,
+  AskUserQuestionAnswer, AskUserQuestionRequestEvent,
 } from './types.ts'
 
 export type {
@@ -28,19 +27,7 @@ export type {
 } from './types.ts'
 
 /** Request for a human answer. */
-export interface AskUserQuestionRequest {
-  /** Questions to display. */
-  questions: AskUserQuestionItem[]
-  /** Exact live calling agent, when the request came from an agent tool call. */
-  agent?: Agent
-  /** Abort signal for the owning tool/step. */
-  signal?: AbortSignal
-}
-
-/** UI-side provider for user questions. */
-export interface UserQuestionProvider {
-  ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
-}
+export interface AskUserQuestionRequest extends AskUserQuestionRequestEvent {}
 
 /** Stable error taxonomy for user-questions failures. */
 export class UserQuestionError extends HarnessError {
@@ -58,35 +45,29 @@ function abortedQuestion(cause?: unknown): UserQuestionError {
   )
 }
 
-/** `ctx.userQuestions`: one active UI provider plus an `ask()` API. */
-export class UserQuestionService extends Service {
-  private provider: UserQuestionProvider | undefined
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
 
-  constructor(ctx: Context) {
-    super(ctx, 'userQuestions')
+function restoreUserQuestionError(reason: unknown): unknown {
+  if (reason instanceof UserQuestionError) return reason
+  if (isRecord(reason)
+    && reason.name === 'UserQuestionError'
+    && typeof reason.message === 'string'
+    && typeof reason.code === 'string') {
+    return new UserQuestionError(reason.message, reason.code, { cause: reason })
   }
+  return reason
+}
 
-  /**
-   * Register the UI provider. Only one provider may be active in a context.
-   *
-   * @param provider UI-side implementation that collects answers.
-   * @returns Disposer that unregisters this provider.
-   */
-  registerProvider(provider: UserQuestionProvider): () => void {
-    const dispose = this.ctx.effect(function* (this: UserQuestionService) {
-      if (this.provider !== undefined) {
-        throw new UserQuestionError('a user-questions provider is already registered', 'DUPLICATE_PROVIDER')
-      }
-      this.provider = provider
-      yield () => {
-        this.provider = undefined
-      }
-    }.bind(this), 'userInteraction.registerProvider()')
-    return () => void dispose()
+/** `ctx.userQuestions`: validation plus the scoped answerer waterfall. */
+export class UserQuestionService extends Service {
+  constructor(ctx: Context) {
+    super(ctx, 'userQuestions')
   }
 
   /**
-   * Ask the active UI provider and wait for the user's answer.
+   * Ask the scoped answerer waterfall and wait for the user's answer.
    *
    * When a caller supplies an agent, human interaction is valid only for the
    * exact live runtime root. Runtime ownership, not durable session lineage,
@@ -145,36 +126,28 @@ export class UserQuestionService extends Service {
           'BAD_INTENT')
       }
     }
-    const askProvider = () => this.provider === undefined
-      ? Promise.reject(new UserQuestionError('no user-questions provider is registered', 'NO_PROVIDER'))
-      : this.provider.ask(request)
+    const noAnswerer = () => Promise.reject(new UserQuestionError(
+      'no user-questions answerer accepted the request',
+      'NO_PROVIDER',
+    ))
     try {
       return await (agent === undefined
-        ? askProvider()
+        ? this.ctx.waterfall('user-questions/request', request, noAnswerer)
         : this.ctx.waterfall(
           scopeTarget(agent, agent),
           'user-questions/request',
           { ...request, agent },
-          askProvider,
+          noAnswerer,
         ))
     } catch (error) {
-      if (error instanceof UserQuestionError) throw error
       const restored = restoreUserQuestionError(error)
-      if (restored !== undefined) throw restored
+      if (restored instanceof UserQuestionError) throw restored
       if (request.signal?.aborted) {
         throw abortedQuestion(error)
       }
-      throw error
+      throw restored
     }
   }
 }
 
-function restoreUserQuestionError(reason: unknown): UserQuestionError | undefined {
-  if (!(reason instanceof Error) || reason.name !== 'UserQuestionError') return undefined
-  const code: unknown = (reason as Error & { readonly code?: unknown }).code
-  return typeof code === 'string'
-    ? new UserQuestionError(reason.message, code, { cause: reason })
-    : undefined
-}
-
 export default UserQuestionService

+ 1 - 1
packages/interaction/user-questions/src/types.ts

@@ -68,7 +68,7 @@ export interface AskUserQuestionRequestEvent {
   /** Questions to display. */
   questions: AskUserQuestionItem[]
   /** Agent identity projected to the corresponding Client Context in transit. */
-  agent: Agent
+  agent?: Agent
   /** Cancellation lifetime of the pending request. */
   signal?: AbortSignal
 }

+ 77 - 44
packages/interaction/user-questions/tests/user-questions.spec.ts

@@ -3,17 +3,27 @@ import { Context } from '@deepseek-ai/cordis'
 import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
 import UserQuestionService, {
   UserQuestionError,
+  type AskUserQuestionAnswer,
   type AskUserQuestionRequest,
-  type UserQuestionProvider,
 } from '@deepseek-ai/dsh-user-questions'
 
-function provider(answer = 'approved'): UserQuestionProvider & { seen: AskUserQuestionRequest[] } {
+interface QuestionAnswerer {
+  ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
+}
+
+function registerAnswerer(ctx: Context, answerer: QuestionAnswerer): () => void {
+  return ctx.on('user-questions/request', request => answerer.ask(request))
+}
+
+function provider(answer = 'approved'): QuestionAnswerer & { seen: AskUserQuestionRequest[] } {
   const seen: AskUserQuestionRequest[] = []
   return {
     seen,
     async ask(request) {
       seen.push(request)
-      return { answers: [{ id: request.questions[0]?.id ?? 'missing', selected: [answer] }] }
+      return {
+        answers: request.questions.map(question => ({ id: question.id, selected: [answer] })),
+      }
     },
   }
 }
@@ -31,12 +41,13 @@ describe('UserQuestionService', () => {
     const ctx = new Context()
     await ctx.plugin(UserQuestionService)
     const p = provider('yes')
-    ctx.userQuestions.registerProvider(p)
+    registerAnswerer(ctx, p)
+    const questions = [{ id: 'confirm', question: 'Proceed?', options: [{ label: 'yes' }] }]
 
-    const result = await ctx.userQuestions.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] })
+    const result = await ctx.userQuestions.ask({ questions })
 
     expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] })
-    expect(p.seen).toEqual([{ questions: [{ id: 'confirm', question: 'Proceed?' }] }])
+    expect(p.seen).toEqual([{ questions }])
   })
 
   it('rejects ask requests when no provider is registered', async () => {
@@ -51,7 +62,7 @@ describe('UserQuestionService', () => {
     const ctx = new Context()
     await ctx.plugin(UserQuestionService)
     const p = provider()
-    const dispose = ctx.userQuestions.registerProvider(p)
+    const dispose = registerAnswerer(ctx, p)
 
     dispose()
     dispose()
@@ -60,20 +71,28 @@ describe('UserQuestionService', () => {
       .rejects.toMatchObject({ code: 'NO_PROVIDER' })
   })
 
-  it('rejects duplicate providers instead of replacing the active UI', async () => {
+  it('delegates through composed answerers', async () => {
     const ctx = new Context()
     await ctx.plugin(UserQuestionService)
-    ctx.userQuestions.registerProvider(provider('first'))
+    const delegated = vi.fn()
+    ctx.on('user-questions/request', (_request, next) => {
+      delegated()
+      return next()
+    })
+    const p = provider('second')
+    registerAnswerer(ctx, p)
 
-    expect(() => ctx.userQuestions.registerProvider(provider('second')))
-      .toThrow(UserQuestionError)
+    await expect(ctx.userQuestions.ask({
+      questions: [{ id: 'confirm', question: 'Proceed?', options: [{ label: 'second' }] }],
+    })).resolves.toEqual({ answers: [{ id: 'confirm', selected: ['second'] }] })
+    expect(delegated).toHaveBeenCalledOnce()
   })
 
   it('fails before reaching the provider when the signal is already aborted', async () => {
     const ctx = new Context()
     await ctx.plugin(UserQuestionService)
     const p = { ask: vi.fn(async () => ({ answers: [{ id: 'confirm', selected: ['too late'] }] })) }
-    ctx.userQuestions.registerProvider(p)
+    registerAnswerer(ctx, p)
     const controller = new AbortController()
     controller.abort()
 
@@ -86,7 +105,7 @@ describe('UserQuestionService', () => {
     const ctx = new Context()
     await ctx.plugin(UserQuestionService)
     const pending = Promise.withResolvers<never>()
-    ctx.userQuestions.registerProvider({ ask: () => pending.promise })
+    registerAnswerer(ctx, { ask: () => pending.promise })
     const controller = new AbortController()
     const abortReason = new DOMException('This operation was aborted', 'AbortError')
 
@@ -109,7 +128,7 @@ describe('UserQuestionService', () => {
     await ctx.plugin(UserQuestionService)
     const controller = new AbortController()
     const cancelled = new UserQuestionError('the user cancelled ask_user_question', 'ASK_CANCELLED')
-    ctx.userQuestions.registerProvider({
+    registerAnswerer(ctx, {
       ask: () => {
         controller.abort()
         return Promise.reject(cancelled)
@@ -166,7 +185,7 @@ describe('UserQuestionService', () => {
     const ctx = new Context()
     await ctx.plugin(UserQuestionService)
     const p = { ask: vi.fn(async () => ({ answers: [] })) }
-    ctx.userQuestions.registerProvider(p)
+    registerAnswerer(ctx, p)
 
     await expect(ctx.userQuestions.ask({ questions: [] }))
       .rejects.toMatchObject({ name: 'UserQuestionError', code: 'EMPTY_QUESTIONS' })
@@ -178,7 +197,7 @@ describe('UserQuestionService', () => {
     await ctx.plugin(AgentRegistry)
     await ctx.plugin(UserQuestionService)
     const p = { ask: vi.fn(async () => ({ answers: [] })) }
-    ctx.userQuestions.registerProvider(p)
+    registerAnswerer(ctx, p)
     const root = stubAgent('root', 0)
     const child = stubAgent('child', 0)
     ctx.agents.enter(root, undefined)
@@ -200,42 +219,23 @@ describe('UserQuestionService', () => {
     await ctx.plugin(AgentRegistry)
     await ctx.plugin(UserQuestionService)
     const p = provider('yes')
-    ctx.userQuestions.registerProvider(p)
+    registerAnswerer(ctx, p)
     const agent = stubAgent('resumed-root', 1)
     ctx.agents.enter(agent, undefined)
 
     const result = await ctx.userQuestions.ask({
-      questions: [{ id: 'confirm', question: 'Proceed?' }],
+      questions: [{ id: 'confirm', question: 'Proceed?', options: [{ label: 'yes' }] }],
       agent,
     })
 
     expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] })
   })
 
-  it('offers an Agent-scoped waterfall before the provider fallback', async () => {
-    const ctx = new Context()
-    await ctx.plugin(AgentRegistry)
-    await ctx.plugin(UserQuestionService)
-    const p = provider('fallback')
-    ctx.userQuestions.registerProvider(p)
-    const agent = stubAgent('root')
-    ctx.agents.enter(agent, undefined)
-    ctx.on('user-questions/request', request => Promise.resolve({
-      answers: request.questions.map(question => ({ id: question.id, selected: ['remote'] })),
-    }))
-
-    await expect(ctx.userQuestions.ask({
-      questions: [{ id: 'confirm', question: 'Proceed?' }],
-      agent,
-    })).resolves.toEqual({ answers: [{ id: 'confirm', selected: ['remote'] }] })
-    expect(p.seen).toEqual([])
-  })
-
   it('rejects a supplied agent when no live registry can attest it', async () => {
     const ctx = new Context()
     await ctx.plugin(UserQuestionService)
     const p = { ask: vi.fn(async () => ({ answers: [] })) }
-    ctx.userQuestions.registerProvider(p)
+    registerAnswerer(ctx, p)
 
     await expect(ctx.userQuestions.ask({
       questions: [{ id: 'confirm', question: 'Proceed?' }],
@@ -249,7 +249,7 @@ describe('UserQuestionService', () => {
     await ctx.plugin(AgentRegistry)
     await ctx.plugin(UserQuestionService)
     const p = { ask: vi.fn(async () => ({ answers: [] })) }
-    ctx.userQuestions.registerProvider(p)
+    registerAnswerer(ctx, p)
     const live = stubAgent('same-id')
     ctx.agents.enter(live, undefined)
 
@@ -260,11 +260,41 @@ describe('UserQuestionService', () => {
     expect(p.ask).not.toHaveBeenCalled()
   })
 
+  it('restores a transported UserQuestionError to the public error class', async () => {
+    const ctx = new Context()
+    await ctx.plugin(UserQuestionService)
+    const transported = Object.assign(new Error('the user cancelled ask_user_question'), {
+      name: 'UserQuestionError',
+      code: 'ASK_CANCELLED',
+    })
+    registerAnswerer(ctx, { ask: () => Promise.reject(transported) })
+
+    const failure = await ctx.userQuestions.ask({
+      questions: [{ id: 'confirm', question: 'Proceed?' }],
+    }).then(() => undefined, (error: unknown) => error)
+
+    expect(failure).toBeInstanceOf(UserQuestionError)
+    expect(failure).toMatchObject({
+      name: 'UserQuestionError', code: 'ASK_CANCELLED', cause: transported,
+    })
+  })
+
+  it('preserves a provider rejection outside the UserQuestionError taxonomy', async () => {
+    const ctx = new Context()
+    await ctx.plugin(UserQuestionService)
+    const failure = new Error('provider failed')
+    registerAnswerer(ctx, { ask: () => Promise.reject(failure) })
+
+    await expect(ctx.userQuestions.ask({
+      questions: [{ id: 'confirm', question: 'Proceed?' }],
+    })).rejects.toBe(failure)
+  })
+
   it('rejects an intent whose approve label names none of its own options', async () => {
     const ctx = new Context()
     await ctx.plugin(UserQuestionService)
     const p = { ask: vi.fn(async () => ({ answers: [] })) }
-    ctx.userQuestions.registerProvider(p)
+    registerAnswerer(ctx, p)
     const question = { id: 'plan-review', question: 'Approve?', detail: '# Plan' }
 
     // A wrong label among offered options, and no options offered at all.
@@ -284,7 +314,7 @@ describe('UserQuestionService', () => {
     const ctx = new Context()
     await ctx.plugin(UserQuestionService)
     const p = { ask: vi.fn(async () => ({ answers: [] })) }
-    ctx.userQuestions.registerProvider(p)
+    registerAnswerer(ctx, p)
 
     // Detail IS the plan for this intent, so a UI honouring it would ask the
     // user to approve something they cannot see.
@@ -302,12 +332,12 @@ describe('UserQuestionService', () => {
     const ctx = new Context()
     await ctx.plugin(UserQuestionService)
     const p = provider('Approve')
-    ctx.userQuestions.registerProvider(p)
+    registerAnswerer(ctx, p)
     const intent = { kind: 'plan-review', approve: 'Approve' } as const
 
     const result = await ctx.userQuestions.ask({
       questions: [
-        { id: 'plain', question: 'Proceed?' },
+        { id: 'plain', question: 'Proceed?', options: [{ label: 'Approve' }] },
         {
           id: 'plan-review', question: 'Approve?', detail: '# Plan',
           options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent,
@@ -315,7 +345,10 @@ describe('UserQuestionService', () => {
       ],
     })
 
-    expect(result.answers).toEqual([{ id: 'plain', selected: ['Approve'] }])
+    expect(result.answers).toEqual([
+      { id: 'plain', selected: ['Approve'] },
+      { id: 'plan-review', selected: ['Approve'] },
+    ])
     expect(p.seen[0]?.questions[1]?.intent).toEqual(intent)
   })
 })

+ 19 - 11
packages/plan/plan-mode/tests/plan-mode.spec.ts

@@ -7,7 +7,7 @@ import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
 import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
 import { createScope } from '@deepseek-ai/dsh-scope'
 import UserQuestionService, {
-  UserQuestionError, type AskUserQuestionRequest,
+  UserQuestionError, type AskUserQuestionAnswer, type AskUserQuestionRequest,
 } from '@deepseek-ai/dsh-user-questions'
 import CommandRuntime from '@deepseek-ai/dsh-commands'
 import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
@@ -17,6 +17,14 @@ import type { PlanModeConfig } from '../src/index.ts'
 const TEST_PLAN_SECTION = 'Test plan mode instructions.'
 const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
 
+interface QuestionAnswerer {
+  ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
+}
+
+function registerQuestionAnswerer(ctx: Context, answerer: QuestionAnswerer): () => void {
+  return ctx.on('user-questions/request', request => answerer.ask(request))
+}
+
 /**
  * Drives the REAL plugin: mounts `dsh-plan-mode` beside real `SystemPrompt` and
  * `ToolRuntime` services, with fake Agents carrying real `Session`s and a
@@ -733,7 +741,7 @@ describe('exit_plan_mode', () => {
     await ctx.plugin(UserQuestionService)
     const asked: AskUserQuestionRequest[] = []
     if (answer !== undefined) {
-      ctx.userQuestions.registerProvider({
+      registerQuestionAnswerer(ctx, {
         ask: (request) => {
           asked.push(request)
           return Promise.resolve({ answers: [{ id: 'plan-review', ...answer }] })
@@ -803,7 +811,7 @@ describe('exit_plan_mode', () => {
     const { ctx, agent } = await setupWithReview()
     const result = await callExit(ctx, agent)
     expect(result.isError).toBe(true)
-    expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-questions provider is registered' }])
+    expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-questions answerer accepted the request' }])
     expect(foldPlanMode(agent.session.events)).toBe(true)
   })
 
@@ -812,7 +820,7 @@ describe('exit_plan_mode', () => {
     await ctx.plugin(AgentRegistry)
     await ctx.plugin(UserQuestionService)
     const ask = vi.fn(async () => ({ answers: [{ id: 'plan-review', selected: ['Approve'] }] }))
-    ctx.userQuestions.registerProvider({ ask })
+    registerQuestionAnswerer(ctx, { ask })
     const root = await agentWithSession(ctx, 'review-root')
     const child = await agentWithSession(ctx, 'review-child', { active: true, owner: root })
 
@@ -865,7 +873,7 @@ describe('exit_plan_mode', () => {
     await ctx.plugin(AgentRegistry)
     await ctx.plugin(UserQuestionService)
     const asked: AskUserQuestionRequest[] = []
-    ctx.userQuestions.registerProvider({
+    registerQuestionAnswerer(ctx, {
       ask: (request) => {
         asked.push(request)
         return Promise.resolve({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })
@@ -964,7 +972,7 @@ describe('exit_plan_mode', () => {
 
   it('treats duplicate review answer items as non-consent', async () => {
     const { ctx, agent } = await setupWithReview()
-    ctx.userQuestions.registerProvider({
+    registerQuestionAnswerer(ctx, {
       ask: () => Promise.resolve({ answers: [
         { id: 'plan-review', selected: ['Approve'] },
         { id: 'plan-review', selected: ['Keep planning'] },
@@ -978,7 +986,7 @@ describe('exit_plan_mode', () => {
 
   it('a missing answer item reads as keep-planning', async () => {
     const { ctx, agent } = await setupWithReview()
-    ctx.userQuestions.registerProvider({ ask: () => Promise.resolve({ answers: [] }) })
+    registerQuestionAnswerer(ctx, { ask: () => Promise.resolve({ answers: [] }) })
     const result = await callExit(ctx, agent)
     expect(result.isError).toBe(true)
     expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
@@ -996,7 +1004,7 @@ describe('exit_plan_mode', () => {
 
   it('reads a dismissed review as the user taking the turn back, not as a failure', async () => {
     const { ctx, agent } = await setupWithReview()
-    ctx.userQuestions.registerProvider({
+    registerQuestionAnswerer(ctx, {
       ask: () => Promise.reject(Object.assign(
         new Error('the user cancelled ask_user_question'),
         { name: 'UserQuestionError', code: 'ASK_CANCELLED' },
@@ -1010,7 +1018,7 @@ describe('exit_plan_mode', () => {
 
   it('leaves every other review failure its own message', async () => {
     const { ctx, agent } = await setupWithReview()
-    ctx.userQuestions.registerProvider({
+    registerQuestionAnswerer(ctx, {
       ask: () => Promise.reject(new UserQuestionError(
         'ask_user_question was aborted before the user answered', 'ASK_ABORTED')),
     })
@@ -1042,7 +1050,7 @@ describe('exit_plan_mode', () => {
     await ctx.plugin(AgentRegistry)
     await ctx.plugin(UserQuestionService)
     let answer!: (value: { answers: { id: string; selected: string[] }[] }) => void
-    ctx.userQuestions.registerProvider({
+    registerQuestionAnswerer(ctx, {
       ask: () => new Promise((resolve) => { answer = resolve }),
     })
     const agent = await agentWithSession(ctx, 'agent-1', { active: true })
@@ -1061,7 +1069,7 @@ describe('exit_plan_mode', () => {
 
   it('a throwing provider surfaces as the corrective isError and the mode stays plan', async () => {
     const { ctx, agent } = await setupWithReview()
-    ctx.userQuestions.registerProvider({ ask: () => { throw new Error('review aborted') } })
+    registerQuestionAnswerer(ctx, { ask: () => { throw new Error('review aborted') } })
     const result = await callExit(ctx, agent)
     expect(result.isError).toBe(true)
     expect(result.content).toEqual([{ type: 'text', text: 'Error: review aborted' }])