Procházet zdrojové kódy

feat(gui): add ask-user question composer

Yichen Jiang před 3 měsíci
rodič
revize
03889cee1a
58 změnil soubory, kde provedl 1905 přidání a 115 odebrání
  1. 7 3
      .agents/notes/implemented/feature/2026-06-25-ask-user-question.md
  2. 47 6
      apps/web/tests/smoke-fixture.e2e.ts
  3. 2 2
      apps/web/tests/smoke-real.e2e.ts
  4. 1 0
      docs/config-catalog.md
  5. 2 2
      docs/core-data-structures/user-interaction.md
  6. 3 0
      docs/module-graph.md
  7. 55 6
      packages/client/connection/src/client/fixture.ts
  8. 34 3
      packages/client/connection/tests/fixture.spec.ts
  9. 7 4
      packages/client/runtime/src/client/sessions/conversation.ts
  10. 32 1
      packages/client/runtime/src/client/sessions/session.ts
  11. 4 3
      packages/client/runtime/tests/fake-api.ts
  12. 24 0
      packages/client/runtime/tests/session.spec.ts
  13. 2 2
      packages/client/ui-conversation/README.md
  14. 3 0
      packages/client/ui-conversation/src/client/apply.ts
  15. 3 1
      packages/client/ui-conversation/src/client/chat/ChatView.tsx
  16. 4 16
      packages/client/ui-conversation/src/client/chat/PendingCard.tsx
  17. 9 1
      packages/client/ui-conversation/src/client/contract/slots.ts
  18. 12 1
      packages/client/ui-conversation/src/client/index.ts
  19. 32 11
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
  20. 9 2
      packages/client/ui-conversation/tests/chat-view.spec.tsx
  21. 1 10
      packages/client/ui-conversation/tests/coverage-tails.spec.tsx
  22. 6 1
      packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
  23. 27 5
      packages/client/ui-conversation/tests/skeleton.spec.tsx
  24. 20 0
      packages/client/ui-question/README.md
  25. 66 0
      packages/client/ui-question/package.json
  26. 329 0
      packages/client/ui-question/src/client/QuestionComposer.module.css
  27. 308 0
      packages/client/ui-question/src/client/QuestionComposer.tsx
  28. 47 0
      packages/client/ui-question/src/client/index.ts
  29. 4 0
      packages/client/ui-question/src/css-modules.d.ts
  30. 17 0
      packages/client/ui-question/src/index.ts
  31. 31 0
      packages/client/ui-question/src/invariant.ts
  32. 58 0
      packages/client/ui-question/tests/browser-plugin.spec.ts
  33. 28 0
      packages/client/ui-question/tests/node-plugin.spec.ts
  34. 198 0
      packages/client/ui-question/tests/question-composer.spec.tsx
  35. 43 0
      packages/client/ui-question/tsconfig.json
  36. 3 0
      packages/client/ui-question/tsdown.config.ts
  37. 13 3
      packages/client/ui-trajectory/tests/views.spec.tsx
  38. 1 0
      packages/host/apiproxy/src/api/rpc.schema.ts
  39. 1 0
      packages/host/apiproxy/src/api/rpc.ts
  40. 1 0
      packages/host/apiproxy/tests/rpc-schemas.spec.ts
  41. 4 4
      packages/host/runtime/README.md
  42. 2 0
      packages/host/runtime/package.json
  43. 144 10
      packages/host/runtime/src/api-proxy.ts
  44. 2 0
      packages/host/runtime/src/boot.ts
  45. 6 5
      packages/host/runtime/src/web-plugins.ts
  46. 3 0
      packages/host/runtime/tests/api-proxy-cold.spec.ts
  47. 2 0
      packages/host/runtime/tests/api-proxy-view.spec.ts
  48. 170 5
      packages/host/runtime/tests/host-runtime.spec.ts
  49. 14 3
      packages/host/runtime/tests/web-plugins.e2e.ts
  50. 3 3
      packages/host/runtime/tests/web-plugins.spec.ts
  51. 3 0
      packages/host/runtime/tsconfig.json
  52. 1 1
      packages/ui/user-interaction/README.md
  53. 1 1
      packages/ui/user-interaction/src/types.ts
  54. 52 0
      pnpm-lock.yaml
  55. 1 0
      scripts/verify-package-readme-model-experience.ts
  56. 1 0
      tsconfig.base.json
  57. 1 0
      tsconfig.build.json
  58. 1 0
      tsconfig.client.json

+ 7 - 3
.agents/notes/implemented/feature/2026-06-25-ask-user-question.md

@@ -14,12 +14,16 @@ Introduce `dsh-user-interaction` as the provider-neutral interface package for `
 
 The model-facing request vocabulary is deliberately aligned with the product-research schema: `ask_user_question({ questions: [{ id, question, header?, options?: [{ label, description? }], multi_select? }] })`. `id` is supplied per question and echoed in the result so a batch can be routed without relying on question text. `label` is both user-facing display text and the selected value returned to the model; there is no separate `value`, no `recommended`, no `allow_custom`, and no `desc` alias.
 
-Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is always an array of selected option labels, so single-select and `multi_select` answers share one result shape. `custom` carries a free-text "Other" answer; optionless questions collect `custom` directly. When `custom` is present, it overrides any selected choices and `selected` is empty.
+Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is always an array of selected option labels, so single-select and `multi_select` answers share one result shape. `custom` carries a free-text "Other" answer; optionless questions collect `custom` directly. When `custom` is present, it overrides any selected choices and `selected` is empty. A provider that supports partial completion represents a deliberately skipped item with the existing `{ id, selected: [] }` shape, preserving the other answers without extending the tool result vocabulary.
 
 `UserInteractionError` extends `HarnessError`, so failures such as `NO_PROVIDER`, `ASK_ABORTED`, ACP cancellation, or missing session routing survive `ctx.tools.execute()` as machine-routable `{ name, code }` tool errors. This matches the structured-error taxonomy and lets the model or a wrapping plugin distinguish "user cancelled" from a generic thrown exception.
 
 ## UI mappings
 
+`dsh web` mounts `dsh-client-ui-question`, whose host half opts the Web product into the model-facing tool and whose browser half registers a `question` entry in the conversation-owned keyed composer slot. `createApiProxy` implements the Web provider with a process-memory pending table keyed by a host-minted rpcId. It registers the wait before broadcasting `question/requested`, replays the same id on every mux reopen, validates the session and complete answer batch before claiming it, and broadcasts `question/resolved` after answer, cancellation, abort, or disposal. Claiming deletes the entry synchronously, so the first valid response wins and duplicate or late responses return `not-pending`.
+
+The Web composer shows one question at a time while retaining every request in the session object layer. It supports single-select, multi-select, optionless or explicit custom answers, description text, and a visual recommendation badge without selecting the recommendation automatically. Single-select choices advance to the next item immediately, and Enter submits when every item is answered or explicitly skipped; Enter during IME composition only confirms the input candidate. The footer skips only the current item and preserves earlier drafts; the close control rejects the whole tool call with `ASK_CANCELLED`. The normal composer returns only after the host's resolved frame removes the pending item.
+
 `dsh-tui` renders each question as a keyboard overlay, shows option descriptions, supports single- and multi-select choices plus free-form custom answers, and rejects pending questions on abort, provider disposal, or terminal shutdown. Batched and simultaneous requests are queued so one overlay owns keyboard focus at a time.
 
 `dsh-acp` provides the same seam for ACP sessions. It resolves the calling `Agent` through `ownedRecord`, requiring the forward session-map record at `agent.session.id` to own that exact agent object, and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s.
@@ -42,8 +46,8 @@ ACP elicitation is currently marked unstable in the SDK. The fallback is still s
 
 The feature gives the model a powerful pause primitive, so prompt guidance matters. The tool description tells the model to ask concise questions and use options when possible. Product policy can later wrap `tools/execute` to restrict when the tool is allowed, but the loop should not special-case it.
 
-`dsh-user-interaction` and `dsh-tool-ask-user` both live in `packages/ui` because they form one product-facing human-interaction capability. `agent-core` does not load either the tool or a provider. `dsh-tui-demo` opts into the seam, TUI provider, and model-facing tool. `acp-agent` keeps only the `userInteraction` seam/provider by default: ACP elicitation support is still client-dependent, so an ACP leaf must opt into the model-facing tool deliberately once its client can complete elicitation requests.
+`dsh-user-interaction` and `dsh-tool-ask-user` both live in `packages/ui` because they form one product-facing human-interaction capability. `agent-core` does not load either the tool or a provider. `dsh-tui-demo` opts into the seam, TUI provider, and model-facing tool. `dsh web` boots the seam/provider in the host runtime and exposes the tool through the selected Web question plugin. `acp-agent` keeps only the `userInteraction` seam/provider by default: ACP elicitation support is still client-dependent, so an ACP leaf must opt into the model-facing tool deliberately once its client can complete elicitation requests.
 
 ## Testing
 
-Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. TUI tests cover option descriptions, queued requests, shutdown/abort cleanup, optionless free-form input, invalid choices, duplicate multi-select selections, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop.
+Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, explicit per-item skips, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. TUI tests cover option descriptions, queued requests, shutdown/abort cleanup, optionless free-form input, invalid choices, duplicate multi-select selections, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop. Web tests pin stable-id replay, response validation, first-wins settlement, duplicate and late responses, whole-request cancellation versus owner abort, single-select advance, IME-safe Enter submission, per-item skip preservation, composer takeover, structured batch submission, and restoration of the normal composer.

+ 47 - 6
apps/web/tests/smoke-fixture.e2e.ts

@@ -1,10 +1,10 @@
 // Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins
 // registry surface + __DSH_BOOT__ injection + built shell dist in a real
 // chromium. First describe: manifest injection + fail-loud half. Second
-// describe: the settled success pass — five REAL tsdown bundles (the
-// infrastructure four + layout) load through the DI chain in ?fixture mode
-// and the three-column frame appears in one flip. The full conversation
-// round lands in smoke-real under the W5 real-host standard.
+// describe: the settled success pass — all nine REAL tsdown bundles load
+// through the DI chain in ?fixture mode, the three-column frame appears in
+// one flip, and the resident question completes through the real UI stack.
+// The full model round lands in smoke-real under the W5 real-host standard.
 import { existsSync } from 'node:fs'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
@@ -17,13 +17,17 @@ import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './suppo
 const bundlePath = (dir: string): string =>
   fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url))
 
-/** id ↔ bundle table for the success pass (immediately four + layout). */
+/** id ↔ bundle table for the success pass (the complete Web UI assembly). */
 const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [
   { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true },
   { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
   { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true },
   { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true },
   { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
+  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+  { id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
+  { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
 ]
 
 /** Manifest served by the fake registry: one live bundle row, one missing row. */
@@ -90,7 +94,7 @@ describe('web boot chain (keyless, real carrier)', () => {
   })
 })
 
-describe('web boot chain success pass (keyless, five real bundles, ?fixture)', () => {
+describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', () => {
   const missing = REAL_PLUGINS.filter((p) => !existsSync(bundlePath(p.dir)))
   let server: Awaited<ReturnType<typeof startWebServer>>
   let browser: Browser
@@ -141,6 +145,43 @@ describe('web boot chain success pass (keyless, five real bundles, ?fixture)', (
     expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout')
   })
 
+  it('renders and completes the resident question through the composer slot', async () => {
+    onTestFailed(() => saveFailureShot(page, 'smoke-question-composer'))
+    await page.getByText('fixture', { exact: true }).click()
+    await page.locator('[role="treeitem"]').nth(1).click()
+    const composer = page.locator('[data-question-rpc-id]')
+    await composer.waitFor({ timeout: 15_000 })
+    expect({
+      question: await composer.getByRole('heading').innerText(),
+      progress: await composer.getByText('1 / 3', { exact: true }).innerText(),
+      options: await composer.getByRole('radio').allTextContents(),
+      custom: await composer.getByRole('button', { name: '其他,请填写自定义答案' }).innerText(),
+    }).toMatchInlineSnapshot(`
+      {
+        "custom": "其他,请填写自定义答案",
+        "options": [
+          "1工程落地型推荐更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。",
+          "2研究潜力型更看重 Agent 理解、训练评测思路和长期成长空间。",
+          "3均衡型同时要求工程能力和 Agent 认知,但可能筛选门槛更高。",
+        ],
+        "progress": "1 / 3",
+        "question": "你现在更想招哪类 Agent/Harness 候选人?",
+      }
+    `)
+
+    await composer.getByRole('radio', { name: '工程落地型' }).click()
+    await composer.getByText('2 / 3', { exact: true }).waitFor()
+    await composer.getByRole('button', { name: '跳过本题', exact: true }).click()
+    await composer.getByRole('checkbox', { name: '系统设计' }).click()
+    await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).click()
+    await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).press('Enter')
+
+    await composer.waitFor({ state: 'detached' })
+    const restoredInput = page.locator('textarea[placeholder]')
+    await restoredInput.waitFor()
+    expect(await restoredInput.getAttribute('placeholder')).toBe('回复生成中,可停止后再输入')
+  })
+
   it('stayed clean: no page errors across the whole load chain', () => {
     expect(pageErrors).toEqual([])
   })

+ 2 - 2
apps/web/tests/smoke-real.e2e.ts

@@ -75,10 +75,10 @@ async function detailsTrack(page: Page): Promise<number> {
   return Number(cols.split(' ').pop()!.replace('px', ''))
 }
 
-// Readiness gate: `dsh web` serves ALL eight manifest plugins; until every UI
+// Readiness gate: `dsh web` serves ALL nine manifest plugins; until every UI
 // plugin's client bundle exists and exports apply, the loader fail-louds and
 // the frame never appears.
-const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-trajectory']
+const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory']
 const notReady = UI_PLUGIN_DIRS.filter((dir) => {
   const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js')
   return !existsSync(bundle) || !readFileSync(bundle, 'utf8').includes('exports.apply')

+ 1 - 0
docs/config-catalog.md

@@ -1743,6 +1743,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts))

+ 2 - 2
docs/core-data-structures/user-interaction.md

@@ -56,14 +56,14 @@ interface AskUserQuestionRequest {
 
 ## Answer
 
-Providers return one answer per answered question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices.
+Providers return one answer item per question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices. A UI may also use an item with empty `selected` and no `custom` to preserve a skipped question in an otherwise completed batch.
 
 ```ts type-equiv
 /** Answer to one question. */
 interface AskUserQuestionAnswerItem {
   /** The answered question id. */
   id: string
-  /** Selected option labels. Empty when the answer is purely custom text. */
+  /** Selected option labels. Empty for custom or unanswered choices. */
   selected: string[]
   /** Optional free-text "Other" answer. */
   custom?: string

+ 3 - 0
docs/module-graph.md

@@ -133,6 +133,7 @@ flowchart TD
     pkg_client_ui_conversation["client-ui-conversation"]
     pkg_client_ui_layout["client-ui-layout"]
     pkg_client_ui_primitives["client-ui-primitives"]
+    pkg_client_ui_question["client-ui-question"]
     pkg_client_ui_sidebar["client-ui-sidebar"]
     pkg_client_ui_slots["client-ui-slots"]
     pkg_client_ui_theme["client-ui-theme"]
@@ -207,6 +208,7 @@ flowchart TD
   pkg_client_ui_conversation --> pkg_invariants
   pkg_client_ui_layout --> pkg_invariants
   pkg_client_ui_primitives --> pkg_invariants
+  pkg_client_ui_question --> pkg_invariants
   pkg_client_ui_sidebar --> pkg_invariants
   pkg_client_ui_slots --> pkg_invariants
   pkg_client_ui_theme --> pkg_invariants
@@ -706,6 +708,7 @@ flowchart TD
 | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) |
+| [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`invariants`](../packages/support/invariants) |

+ 55 - 6
packages/client/connection/src/client/fixture.ts

@@ -2,8 +2,8 @@
 // RpcRequest<P> and returns RpcResponse<T> (echoing the rpcId); streams yield RpcRequest<frame>
 // (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse
 // and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable);
-// prompt triggers a chunked streaming replay; cancel stops the replay; one resident pending
-// approval (placeholder-card material, subscribed-baseline-replay semantics: stable rpcId reuse).
+// prompt triggers a chunked streaming replay; cancel stops the replay; resident pending
+// approval/question requests exercise replay and composer takeover with stable rpcIds.
 
 import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
@@ -249,6 +249,40 @@ export function createFixtureApi(): ApiProxy {
   const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
   /** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
   const pendingApprovalRpcId = mint()
+  const pendingQuestionRpcId = mint()
+  let questionPending = true
+  const fixtureQuestions: Extract<MuxFrame, { type: 'question/requested' }>['questions'] = [
+    {
+      id: 'harness-profile',
+      header: '偏好',
+      question: '你现在更想招哪类 Agent/Harness 候选人?',
+      options: [
+        { label: '工程落地型 (Recommended)', description: '更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。' },
+        { label: '研究潜力型', description: '更看重 Agent 理解、训练评测思路和长期成长空间。' },
+        { label: '均衡型', description: '同时要求工程能力和 Agent 认知,但可能筛选门槛更高。' },
+      ],
+    },
+    {
+      id: 'work-mode',
+      header: '方式',
+      question: '你希望候选人优先展示哪种工作方式?',
+      options: [
+        { label: '先做小型原型 (Recommended)', description: '用可运行结果尽快验证关键假设。' },
+        { label: '先写完整设计', description: '先收敛边界、协议和风险,再开始实现。' },
+      ],
+    },
+    {
+      id: 'signals',
+      header: '信号',
+      question: '哪些面试信号最重要?',
+      multiSelect: true,
+      options: [
+        { label: '系统设计' },
+        { label: '代码质量' },
+        { label: 'Agent 产品判断' },
+      ],
+    },
+  ]
 
   const muxConns = new Set<StreamConn<MuxFrame>>()
   const hostConns = new Set<StreamConn<HostFrame>>()
@@ -429,7 +463,7 @@ export function createFixtureApi(): ApiProxy {
         muxConns.add(conn)
         const breakNow = (): void => { conn.breakNow() }
         streamBreakers.add(breakNow)
-        // Open baseline: subscribed for attached (running) sessions + pending approval replay (stable rpcId).
+        // Open baseline: subscribed sessions + pending interactions replayed with stable rpcIds.
         for (const s of sessions) {
           if (!s.running) continue
           conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: (logs.get(s.sessionId)?.length ?? 0) - 1 } })
@@ -442,6 +476,14 @@ export function createFixtureApi(): ApiProxy {
             toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)',
           },
         })
+        if (questionPending) {
+          conn.push({
+            rpcId: pendingQuestionRpcId,
+            payload: {
+              type: 'question/requested', sessionId: sid('fx-alpha'), questions: fixtureQuestions,
+            },
+          })
+        }
         try {
           yield* conn.drain(signal)
         } finally {
@@ -471,9 +513,16 @@ export function createFixtureApi(): ApiProxy {
       },
     },
     respond(message: ClientResponse): Promise<RpcReceipt> {
-      // The v1 UI never answers (PendingCard is visible but not answerable); implemented for type completeness, always not-pending.
-      void message
-      return Promise.resolve({ accepted: false, reason: 'not-pending' })
+      if (!questionPending || message.rpcId !== pendingQuestionRpcId) {
+        return Promise.resolve({ accepted: false, reason: 'not-pending' })
+      }
+      questionPending = false
+      emitMux({
+        type: 'question/resolved', sessionId: sid('fx-alpha'),
+        questionRpcId: pendingQuestionRpcId,
+        outcome: message.result.ok ? 'answered' : 'cancelled',
+      })
+      return Promise.resolve({ accepted: true })
     },
   }
 }

+ 34 - 3
packages/client/connection/tests/fixture.spec.ts

@@ -148,14 +148,14 @@ describe('createFixtureApi', () => {
     expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn
   })
 
-  it('mux open replays the baseline: subscribed for running sessions + the resident approval with a stable rpcId', async () => {
+  it('mux open replays subscribed sessions and resident interactions with stable rpcIds', async () => {
     const api = createFixtureApi()
     const openOnce = async (): Promise<RpcRequest<MuxFrame>[]> => {
       const abort = new AbortController()
       const envelopes: RpcRequest<MuxFrame>[] = []
       for await (const envelope of api.events.mux(req({}), abort.signal)) {
         envelopes.push(envelope)
-        if (envelopes.length >= 2) abort.abort()
+        if (envelopes.length >= 3) abort.abort()
       }
       return envelopes
     }
@@ -165,6 +165,8 @@ describe('createFixtureApi', () => {
     expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
     expect(first[1]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
     expect(second[1]?.rpcId).toBe(first[1]?.rpcId) // stable rpcId across replays (host replay semantics)
+    expect(first[2]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
+    expect(second[2]?.rpcId).toBe(first[2]?.rpcId)
   })
 
   it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
@@ -217,9 +219,38 @@ describe('createFixtureApi', () => {
     }
   })
 
-  it('respond is a typed stub: always not-pending', async () => {
+  it('respond resolves the resident question once and rejects duplicate or unrelated ids', async () => {
     const api = createFixtureApi()
     expect(await api.respond({ type: 'client-response', rpcId: RpcId('x'), result: { ok: true, value: {} } })).toEqual({ accepted: false, reason: 'not-pending' })
+    const abort = new AbortController()
+    let question: RpcRequest<MuxFrame> | undefined
+    for await (const envelope of api.events.mux(req({}), abort.signal)) {
+      if (envelope.payload.type !== 'question/requested') continue
+      question = envelope
+      abort.abort()
+    }
+    if (question === undefined) throw new Error('fixture question missing')
+    const response = { type: 'client-response' as const, rpcId: question.rpcId, result: { ok: true as const, value: {} } }
+    expect(await api.respond(response)).toEqual({ accepted: true })
+    expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' })
+
+    const replayAbort = new AbortController()
+    const replayed = await collect(api.events.mux(req({}), replayAbort.signal), replayAbort, frames => frames.length === 2)
+    expect(replayed.every(frame => frame.type !== 'question/requested')).toBe(true)
+
+    const cancelledApi = createFixtureApi()
+    const cancelAbort = new AbortController()
+    let cancelQuestion: RpcRequest<MuxFrame> | undefined
+    for await (const envelope of cancelledApi.events.mux(req({}), cancelAbort.signal)) {
+      if (envelope.payload.type !== 'question/requested') continue
+      cancelQuestion = envelope
+      cancelAbort.abort()
+    }
+    if (cancelQuestion === undefined) throw new Error('fixture cancellation question missing')
+    expect(await cancelledApi.respond({
+      type: 'client-response', rpcId: cancelQuestion.rpcId,
+      result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } },
+    })).toEqual({ accepted: true })
   })
 
   it('describe answers the fixture identity', async () => {

+ 7 - 4
packages/client/runtime/src/client/sessions/conversation.ts

@@ -4,7 +4,7 @@
 // string here (narrow to real brands when convenient).
 
 import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
-import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
+import type { MuxFrame, RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
 
 /** Assistant content blocks sorted by what the UI cares about
  *  (text body / collapsible reasoning / tool-call card head / other fallback). */
@@ -121,11 +121,14 @@ export interface RunningToolCall {
   callView: ToolCallView | null
 }
 
-/** Approval/question placeholder cards (visible, not answerable;
- *  rpcId = the requested frame's envelope id, the future respond backfill key). */
+/** Approval/question pending state; rpcId is the requested frame's response-backfill key. */
 export type PendingInteraction =
   | { kind: 'approval'; rpcId: RpcId; approvalId: string; toolName: string; callId?: string; reason?: string }
-  | { kind: 'question'; rpcId: RpcId; questions: readonly unknown[] }
+  | {
+    kind: 'question'
+    rpcId: RpcId
+    questions: readonly Extract<MuxFrame, { type: 'question/requested' }>['questions'][number][]
+  }
 
 /** In-progress assistant output (chunk accumulator product). */
 export interface PartialAssistant {

+ 32 - 1
packages/client/runtime/src/client/sessions/session.ts

@@ -5,7 +5,10 @@
 
 import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
-import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
+import type {
+  HistoryEntry, IApiClient, MuxFrame, QuestionResponsePayload, RpcError, RpcId, RpcReceipt, RpcResult,
+  SessionId, ToolEventView,
+} from '@deepseek-ai/dsh-client-connection/client'
 import { transportError } from '@deepseek-ai/dsh-client-connection/client'
 import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
@@ -120,6 +123,34 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
     return result
   }
 
+  /**
+   * Answer one host-owned question wait; pending clears only on the authoritative resolved frame.
+   * @param rpcId - Stable id from the requested frame.
+   * @param answer - Complete structured answer batch.
+   * @returns Carrier receipt; rejection leaves pending state unchanged.
+   */
+  answerQuestion(rpcId: RpcId, answer: QuestionResponsePayload['answer']): Promise<RpcReceipt> {
+    return this.api.respond({
+      type: 'client-response', rpcId,
+      result: { ok: true, value: { sessionId: this.sessionId, answer } },
+    })
+  }
+
+  /**
+   * Cancel one host-owned question wait without encoding closure as skipped answers.
+   * @param rpcId - Stable id from the requested frame.
+   * @returns Carrier receipt; rejection leaves pending state unchanged.
+   */
+  cancelQuestion(rpcId: RpcId): Promise<RpcReceipt> {
+    return this.api.respond({
+      type: 'client-response', rpcId,
+      result: {
+        ok: false,
+        error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
+      },
+    })
+  }
+
   /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
   open(): Promise<void> {
     if (this.openState === 'open') return Promise.resolve()

+ 4 - 3
packages/client/runtime/tests/fake-api.ts

@@ -2,7 +2,7 @@
 // data source on a real clock; behavior tests need per-case responses and
 // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
 import type {
-  HostFrame, IApiClient, MuxFrame, RpcError, RpcRequest, RpcResponse, SessionId,
+  ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId,
 } from '@deepseek-ai/dsh-client-connection/client'
 import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
 
@@ -54,6 +54,7 @@ export class FakeApiClient implements IApiClient {
   onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
   onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
     () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
+  onRespond: (message: ClientResponse) => Promise<RpcReceipt> = () => Promise.resolve({ accepted: true })
 
   private readonly muxConns: StreamConn<MuxFrame>[] = []
   private readonly hostConns: StreamConn<HostFrame>[] = []
@@ -93,8 +94,8 @@ export class FakeApiClient implements IApiClient {
     host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.hostConns, signal, onOpen),
   }
 
-  respond(): Promise<{ accepted: false; reason: 'not-pending' }> {
-    return Promise.resolve({ accepted: false, reason: 'not-pending' })
+  respond(message: ClientResponse): Promise<RpcReceipt> {
+    return this.record('respond', message, this.onRespond(message))
   }
 
   /** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */

+ 24 - 0
packages/client/runtime/tests/session.spec.ts

@@ -251,6 +251,30 @@ describe('pending interactions', () => {
     session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq' as never, outcome: 'answered' })
     expect(session.getSnapshot().pending).toEqual([])
   })
+
+  it('backfills the requested rpcId for structured answers and explicit cancellation', async () => {
+    const { api, session } = makeSession()
+    await session.answerQuestion('rq-answer' as never, {
+      answers: [{ id: 'mode', selected: ['Fast'] }],
+    })
+    await session.cancelQuestion('rq-cancel' as never)
+    expect(api.callsOf('respond')).toEqual([
+      {
+        type: 'client-response', rpcId: 'rq-answer',
+        result: {
+          ok: true,
+          value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } },
+        },
+      },
+      {
+        type: 'client-response', rpcId: 'rq-cancel',
+        result: {
+          ok: false,
+          error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
+        },
+      },
+    ])
+  })
 })
 
 describe('remaining branches', () => {

+ 2 - 2
packages/client/ui-conversation/README.md

@@ -1,6 +1,6 @@
 # @deepseek-ai/dsh-client-ui-conversation
 
-Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7.
+Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. The keyed `conversation.composer` slot lets pending interaction features replace InputBar without moving interaction state into the skeleton. Contract: api-contracts v3 §7.
 
 `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains.
 
@@ -18,5 +18,5 @@ None; this package neither assembles nor sends a provider request.
 - **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
 - **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
 - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
-- **Approval/question cards are display-only placeholders** — web-side answering (composer takeover panel) is the P-II approvals project.
+- **Approval cards are display-only placeholders** — question requests use the composer slot, while Web approval answering remains deferred.
 - **Module-level toolview caches are single-bundle state** — the inject cache and registry maps must reach cross-bundle consumers through the package export surface and loader module table, never by a second bundle copy.

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

@@ -62,6 +62,8 @@ export function apply(ctx: Context): void {
   const layout = need<LayoutService>(ctx, 'layout')
   const i18n = need<I18nService>(ctx, 'i18n')
   const slots = need<SlotsService>(ctx, 'slots')
+  slots.define('conversation.composer', { kind: 'keyed', scope: 'session' })
+  const composerSlots = scopedSlots(slots.core, 'conversation.composer')
 
   const conversation = new ConversationService(ctx)
   const toolviews = new ToolViewRegistry()
@@ -153,6 +155,7 @@ export function apply(ctx: Context): void {
         }
         return createElement(Fragment, null, ...children)
       },
+      slots: composerSlots,
     }
     return injected
   }

+ 3 - 1
packages/client/ui-conversation/src/client/chat/ChatView.tsx

@@ -270,7 +270,9 @@ export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
               ))}
             </div>
           )}
-          {pending.map((item) => <PendingCard key={item.rpcId} item={item} />)}
+          {pending.map((item) => item.kind === 'approval'
+            ? <PendingCard key={item.rpcId} item={item} />
+            : null)}
           </div>
         </div>
         {!atBottom && (

+ 4 - 16
packages/client/ui-conversation/src/client/chat/PendingCard.tsx

@@ -1,30 +1,18 @@
-// PendingCard: approval/question placeholder card (visible, not answerable —
-// the composer-takeover approval panel is a P-II item; wire pending semantics
-// already exist so the flow must show them).
+// PendingCard: approval placeholder card. Questions take over the composer.
 
 import { memo } from 'react'
 import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
-import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
 import css from './PendingCard.module.css'
 
 export interface PendingCardProps {
-  item: PendingInteraction
+  item: Extract<PendingInteraction, { kind: 'approval' }>
 }
 
 export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) {
   return (
     <div className={css.card}>
-      {item.kind === 'approval' ? (
-        <>
-          <div className={css.title}>等待审批:<span className={css.mono}>{item.toolName}</span></div>
-          {item.reason !== undefined && <div className={css.reason}>{item.reason}</div>}
-        </>
-      ) : (
-        <>
-          <div className={css.title}>等待回答({item.questions.length} 题)</div>
-          <JsonBlock label="问题内容" payload={item.questions} />
-        </>
-      )}
+      <div className={css.title}>等待审批:<span className={css.mono}>{item.toolName}</span></div>
+      {item.reason !== undefined && <div className={css.reason}>{item.reason}</div>}
       <div className={css.hint}>请在原客户端处理(web 端作答后续里程碑提供)</div>
     </div>
   )

+ 9 - 1
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -8,7 +8,8 @@
  * standard share & own injected share.
  */
 import type { ReactNode } from 'react'
-import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
+import type { PendingInteraction, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ScopedSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
 import type { ConvOwnerProps, DetailsOwnerProps, EmptyOwnerProps } from '@deepseek-ai/dsh-client-ui-layout/client'
 import type { SelectionTarget, ViewEntry, ViewId } from './views.ts'
@@ -38,6 +39,13 @@ export interface ConversationInjected {
   }
   /** Renders the active view's body (the owner closes over ConvViewProps assembly). */
   renderView: (entry: ViewEntry) => ReactNode
+  /** Feature-owned composer replacements, dispatched by pending interaction kind. */
+  slots: ScopedSlots<'conversation.composer'>
+}
+
+/** Question-composer owner share supplied by ConversationRoot. */
+export interface QuestionComposerOwnerProps {
+  interaction: Extract<PendingInteraction, { kind: 'question' }>
 }
 
 /** Full conversation-slot component props: owner share & standard share & injected share. */

+ 12 - 1
packages/client/ui-conversation/src/client/index.ts

@@ -8,6 +8,7 @@
  */
 import type { ConversationService } from './service.ts'
 import type { ToolViewRegistry } from './toolviews/registry.ts'
+import type { QuestionComposerOwnerProps } from './contract/slots.ts'
 
 export { apply, inject } from './apply.ts'
 export { ConversationService } from './service.ts'
@@ -22,7 +23,7 @@ export type {
 } from './contract/toolview.ts'
 export type {
   ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps,
-  EmptyStateInjected, EmptyStateSlotProps,
+  EmptyStateInjected, EmptyStateSlotProps, QuestionComposerOwnerProps,
 } from './contract/slots.ts'
 
 export { ConversationRoot } from './skeleton/ConversationRoot.tsx'
@@ -40,3 +41,13 @@ declare module 'cordis' {
     toolviews: ToolViewRegistry
   }
 }
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface SlotMap {
+    'conversation.composer': {
+      kind: 'keyed'
+      scope: 'session'
+      owner: QuestionComposerOwnerProps
+    }
+  }
+}

+ 32 - 11
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx

@@ -7,6 +7,7 @@
 
 import { useSyncExternalStore } from 'react'
 import clsx from 'clsx'
+import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ConversationSlotProps } from '../contract/slots.ts'
 import { InputBar } from './InputBar.tsx'
 import type { InputBarError } from './InputBar.tsx'
@@ -20,7 +21,7 @@ import css from './ConversationRoot.module.css'
 export type ConversationRootProps = ConversationSlotProps
 
 export function ConversationRoot({
-  sessionId, useSession, useAncestry, views, useActiveView, composer, actions, renderView,
+  sessionId, useSession, useAncestry, views, useActiveView, composer, actions, renderView, slots,
 }: ConversationRootProps) {
   useSyncExternalStore(views.subscribe, views.version)
   const list = views.list()
@@ -33,6 +34,9 @@ export function ConversationRoot({
   const removed = useSession(s => (s as { removed: boolean }).removed)
   const promptError = useSession(s => (s as { promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null }).promptError)
   const turns = useSession(s => countTurns(s as { nodes: readonly { kind: string }[] }))
+  const question = useSession(s => (
+    s as { pending: readonly PendingInteraction[] }
+  ).pending.find(item => item.kind === 'question'))
 
   const error: InputBarError | null = promptError === null
     ? null
@@ -87,20 +91,37 @@ export function ConversationRoot({
         {active !== undefined && renderView(active)}
       </div>
 
-      <InputBar
-        draft={draft}
-        running={running}
-        disabled={removed}
-        error={error}
-        variant="composer"
-        onDraftChange={composer.setDraft}
-        onSend={composer.send}
-        onStop={composer.stop}
-      />
+      {question?.kind === 'question'
+        ? slots.renderSlot('conversation.composer', { interaction: question }, {
+            entryKey: 'question',
+            fallback: <ComposerInput {...{ draft, running, removed, error, composer }} />,
+          })
+        : <ComposerInput {...{ draft, running, removed, error, composer }} />}
     </div>
   )
 }
 
+function ComposerInput({ draft, running, removed, error, composer }: {
+  draft: string
+  running: boolean
+  removed: boolean
+  error: InputBarError | null
+  composer: ConversationSlotProps['composer']
+}) {
+  return (
+    <InputBar
+      draft={draft}
+      running={running}
+      disabled={removed}
+      error={error}
+      variant="composer"
+      onDraftChange={composer.setDraft}
+      onSend={composer.send}
+      onStop={composer.stop}
+    />
+  )
+}
+
 /** Turn count = user message nodes in the window (display meta; exact host count deferred). */
 function countTurns(s: { nodes: readonly { kind: string }[] }): number {
   let n = 0

+ 9 - 2
packages/client/ui-conversation/tests/chat-view.spec.tsx

@@ -295,11 +295,18 @@ describe('ChatView', () => {
     expect(lv.getByText('载入历史…')).toBeTruthy()
   })
 
-  it('pending interactions render placeholder cards', () => {
+  it('renders approval cards while questions stay in the composer', () => {
     const h = makeHarness({
-      pending: [{ kind: 'approval', rpcId: 'r1' as never, approvalId: 'ap1', toolName: 'bash' }],
+      pending: [
+        { kind: 'approval', rpcId: 'r1' as never, approvalId: 'ap1', toolName: 'bash' },
+        {
+          kind: 'question', rpcId: 'r2' as never,
+          questions: [{ id: 'mode', question: 'Composer only?', options: [{ label: 'Yes' }] }],
+        },
+      ],
     })
     const view = render(<h.ChatView {...h.props} />)
     expect(view.getByText(/等待审批/)).toBeTruthy()
+    expect(view.queryByText('Composer only?')).toBeNull()
   })
 })

+ 1 - 10
packages/client/ui-conversation/tests/coverage-tails.spec.tsx

@@ -1,20 +1,18 @@
 // @vitest-environment jsdom
 // Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
-// PendingCard question arm, bash sample error pill, registry disposer
+// Bash sample error pill and registry disposer
 // idempotence re-entry, register.ts explicit bashSampleScope override, the
 // node-half empty apply, and AssistantMarkdown reasoning/unknown block arms.
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, render } from '@testing-library/react'
 import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
-import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
 import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
 import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { ConversationService, Translate, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { apply as nodeApply } from '../src/index.ts'
 import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
 import { ToolRow } from '../src/client/chat/ToolRow.tsx'
-import { PendingCard } from '../src/client/chat/PendingCard.tsx'
 import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
 import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
 import { registerChat } from '../src/client/chat/register.ts'
@@ -34,13 +32,6 @@ describe('tails', () => {
     expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
   })
 
-  it('PendingCard renders the question arm with its count', () => {
-    const view = render(
-      <PendingCard item={{ kind: 'question', rpcId: 'r1' as RpcId, questions: [{}, {}] }} />,
-    )
-    expect(view.getByText(/等待回答(2 题)/)).toBeTruthy()
-  })
-
   it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
     const view = render(
       <AssistantMarkdown

+ 6 - 1
packages/client/ui-conversation/tests/skeleton-branches.spec.tsx

@@ -10,11 +10,14 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
 import type { ConversationSnapshot, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
 import { ConversationRoot, DetailsPanel, EmptyState } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import type { SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { ConversationInjected, SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
 
 afterEach(cleanup)
 
 const SID = 's1' as SessionId
+const fallbackSlots: ConversationInjected['slots'] = {
+  renderSlot: (_key, _props, opts) => opts?.fallback ?? null,
+}
 
 function snapshotBase(): ConversationSnapshot {
   return {
@@ -55,6 +58,7 @@ describe('ConversationRoot branches', () => {
         composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
         actions={{ openView: vi.fn(), open }}
         renderView={() => <div data-testid="view-body" />}
+        slots={fallbackSlots}
       />,
     )
     return { view, open }
@@ -99,6 +103,7 @@ describe('ConversationRoot branches', () => {
         composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
         actions={{ openView: vi.fn(), open: vi.fn() }}
         renderView={(entry) => <div data-testid={`body-${entry.id}`} />}
+        slots={fallbackSlots}
       />,
     )
     expect(view.getByTestId('body-chat')).toBeTruthy()

+ 27 - 5
packages/client/ui-conversation/tests/skeleton.spec.tsx

@@ -11,11 +11,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
 import type { FC } from 'react'
 import { bindSnapshotSelector, createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
 import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
-import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
+import type { PendingInteraction, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
 import {
   ConversationRoot, DetailsPanel, EmptyState,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import type { SelectionTarget, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { ConversationInjected, SelectionTarget, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
 
 const sid = (s: string): SessionId => s as SessionId
 
@@ -28,11 +28,12 @@ interface FakeSnapshot {
   running: boolean
   removed: boolean
   promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
+  pending: readonly PendingInteraction[]
 }
 
 function fakeSession(init: Partial<FakeSnapshot> = {}) {
   const store = createSnapshotStore<FakeSnapshot>({
-    nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init,
+    nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init,
   })
   return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession }
 }
@@ -67,8 +68,11 @@ describe('EmptyState', () => {
 })
 
 describe('ConversationRoot', () => {
-  function bench(views: ViewEntry[], active?: string) {
-    const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }] })
+  function bench(
+    views: ViewEntry[], active?: string, init: Partial<FakeSnapshot> = {},
+    renderSlot?: ConversationInjected['slots']['renderSlot'],
+  ) {
+    const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init })
     const activeStore = createSnapshotStore<string | undefined>(active)
     const openView = vi.fn((v: string) => { activeStore.set(v) })
     const open = vi.fn()
@@ -98,6 +102,7 @@ describe('ConversationRoot', () => {
         }}
         actions={{ openView: openView as (v: never) => void, open }}
         renderView={(entry) => { rendered.push(entry.id); return <div data-testid={`view-${entry.id}`} /> }}
+        slots={{ renderSlot: renderSlot ?? ((_key, _props, opts) => opts?.fallback ?? null) } as ConversationInjected['slots']}
       />)
     return { ui, openView, open, rendered, send, drafts }
   }
@@ -133,6 +138,23 @@ describe('ConversationRoot', () => {
     fireEvent.keyDown(box, { key: 'Enter' })
     expect(send).toHaveBeenCalledWith('queue')
   })
+
+  it('dispatches a pending question to the composer slot instead of rendering InputBar', () => {
+    const renderSlot = vi.fn(() => <div>question takeover</div>) as unknown as ConversationInjected['slots']['renderSlot']
+    bench([view('chat', 'Chat')], undefined, {
+      pending: [{
+        kind: 'question', rpcId: 'rq' as never,
+        questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }],
+      }],
+    }, renderSlot)
+    expect(screen.getByText('question takeover')).toBeTruthy()
+    expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull()
+    expect(renderSlot).toHaveBeenCalledWith(
+      'conversation.composer',
+      expect.objectContaining({ interaction: expect.objectContaining({ rpcId: 'rq' }) }),
+      expect.objectContaining({ entryKey: 'question' }),
+    )
+  })
 })
 
 describe('DetailsPanel', () => {

+ 20 - 0
packages/client/ui-question/README.md

@@ -0,0 +1,20 @@
+# @deepseek-ai/dsh-client-ui-question
+
+Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot.
+
+The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
+
+Selection state is local to a component keyed by the request rpcId. A replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally.
+
+## Model Experience
+
+Indirectly, through `dsh-tool-ask-user`; that package owns the model-visible tool schema and structured result.
+
+#### KV Cache effect
+
+No direct invalidation; `dsh-tool-ask-user` owns the model-visible tool call and result.
+
+## Known Limitations and Deferred Work
+
+- **Unsubmitted drafts are not durable** — reconnect resync or a full page reload restores the host-owned pending request with the same rpcId, but a composer unmount resets local option and custom-text drafts.
+- **One request owns the composer at a time** — later pending requests remain in the session snapshot and become visible after the earlier request resolves.

+ 66 - 0
packages/client/ui-question/package.json

@@ -0,0 +1,66 @@
+{
+  "name": "@deepseek-ai/dsh-client-ui-question",
+  "description": "Web ask_user_question feature: host tool mount plus composer-takeover question UI",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dshClient": {
+    "inject": [
+      "@deepseek-ai/dsh-client-ui-conversation"
+    ],
+    "platform": "web"
+  },
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
+  "license": "BSD-3-Clause",
+  "dependencies": {
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-tool-ask-user": "workspace:^",
+    "clsx": "^2.0.0",
+    "react": "^18.2.0"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-user-interaction": "workspace:^",
+    "@types/react": "~18.3.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ]
+}

+ 329 - 0
packages/client/ui-question/src/client/QuestionComposer.module.css

@@ -0,0 +1,329 @@
+.frame {
+  display: flex;
+  justify-content: center;
+  padding: 6px 24px 10px;
+}
+
+.card {
+  width: 100%;
+  max-width: 720px;
+  padding: 14px 16px 12px;
+  border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
+  border-radius: 18px;
+  background: var(--dsw-specific-input-major);
+  box-shadow: var(--dsw-shadow-lv1-blur);
+  color: var(--dsw-alias-label-primary);
+}
+
+.card,
+.card * {
+  box-sizing: border-box;
+}
+
+.header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 8px;
+}
+
+.headingBlock {
+  min-width: 0;
+  padding: 1px 2px;
+}
+
+.eyebrow {
+  margin-bottom: 2px;
+  color: var(--dsw-alias-label-tertiary);
+  font-size: 11px;
+  line-height: 16px;
+}
+
+.title {
+  display: flex;
+  align-items: baseline;
+  flex-wrap: wrap;
+  gap: 6px;
+  margin: 0;
+  font-size: 16px;
+  line-height: 22px;
+  font-weight: 600;
+}
+
+.multiSelectHint {
+  color: var(--dsw-alias-label-tertiary);
+  font-size: 14px;
+  line-height: 20px;
+  font-weight: 400;
+  white-space: nowrap;
+}
+
+.headerActions,
+.footerActions {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  flex-shrink: 0;
+}
+
+.progress {
+  padding: 0 6px;
+  color: var(--dsw-alias-label-tertiary);
+  font-size: 12px;
+  line-height: 24px;
+  white-space: nowrap;
+}
+
+.iconButton {
+  display: grid;
+  place-items: center;
+  width: 24px;
+  height: 24px;
+  padding: 0;
+  border: none;
+  border-radius: 999px;
+  background: transparent;
+  color: var(--dsw-alias-label-tertiary);
+  cursor: pointer;
+}
+
+.iconButton:hover:not(:disabled) {
+  background: var(--dsw-alias-interactive-bg-hover);
+  color: var(--dsw-alias-label-primary);
+}
+
+.iconButton:disabled {
+  color: var(--dsw-alias-label-dimmed);
+  cursor: default;
+}
+
+.options {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.option {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  width: 100%;
+  min-height: 42px;
+  padding: 5px 8px;
+  border: 1px solid transparent;
+  border-radius: 12px;
+  background: transparent;
+  color: inherit;
+  text-align: left;
+  cursor: pointer;
+  transition: background-color 120ms ease, border-color 120ms ease;
+}
+
+.option:hover:not(:disabled),
+.optionSelected {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.optionSelected {
+  border-color: var(--dsw-alias-border-l2);
+}
+
+.option:disabled,
+.customTrigger:disabled {
+  cursor: default;
+}
+
+.number {
+  display: grid;
+  place-items: center;
+  flex: 0 0 28px;
+  width: 28px;
+  height: 28px;
+  border: 1px solid var(--dsw-alias-border-l2);
+  border-radius: 999px;
+  background: var(--dsw-alias-bg-module-platform);
+  color: var(--dsw-alias-label-tertiary);
+  font-size: 12px;
+  line-height: 18px;
+}
+
+.optionCopy {
+  min-width: 0;
+  flex: 1;
+}
+
+.optionLine {
+  display: flex;
+  align-items: baseline;
+  flex-wrap: wrap;
+  gap: 2px 6px;
+}
+
+.optionLabel {
+  font-size: 14px;
+  line-height: 20px;
+  font-weight: 600;
+}
+
+.badge {
+  padding: 0 6px;
+  border-radius: 999px;
+  background: var(--dsw-alias-bg-module-platform);
+  color: var(--dsw-alias-label-secondary);
+  font-size: 11px;
+  line-height: 18px;
+}
+
+.description {
+  color: var(--dsw-alias-label-tertiary);
+  font-size: 13px;
+  line-height: 20px;
+  font-weight: 400;
+}
+
+.choiceIcon {
+  display: grid;
+  place-items: center;
+  width: 20px;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.custom {
+  border: 1px solid transparent;
+  border-radius: 12px;
+}
+
+.customOpen {
+  border-color: var(--dsw-alias-border-l2);
+  background: var(--dsw-alias-bg-module-platform);
+}
+
+.customOptionless {
+  border: none;
+  background: transparent;
+}
+
+.customTrigger {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  width: 100%;
+  min-height: 42px;
+  padding: 5px 8px;
+  border: none;
+  background: transparent;
+  color: var(--dsw-alias-label-tertiary);
+  font-size: 14px;
+  line-height: 20px;
+  text-align: left;
+  cursor: pointer;
+}
+
+.customTrigger:hover:not(:disabled) {
+  color: var(--dsw-alias-label-primary);
+}
+
+.customInput {
+  display: block;
+  width: calc(100% - 20px);
+  min-height: 54px;
+  max-height: 140px;
+  margin: 0 10px 10px;
+  padding: 7px 10px;
+  resize: vertical;
+  border: 1px solid var(--dsw-alias-border-l2);
+  border-radius: 10px;
+  outline: none;
+  background: var(--dsw-specific-input-major);
+  color: var(--dsw-alias-label-primary);
+  caret-color: var(--dsw-alias-state-business-primary);
+  font: inherit;
+  font-size: 13px;
+  line-height: 20px;
+}
+
+.customInput:focus {
+  border-color: var(--dsw-alias-state-business-primary);
+}
+
+.customInput::placeholder {
+  color: var(--dsw-alias-label-caption);
+}
+
+.customOptionless .customInput {
+  width: 100%;
+  min-height: 58px;
+  margin: 0;
+  background: var(--dsw-alias-bg-module-platform);
+}
+
+.footer {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-top: 8px;
+  padding: 0 2px;
+}
+
+.feedback {
+  min-height: 16px;
+  color: var(--dsw-alias-state-error-primary);
+  font-size: 11px;
+  line-height: 16px;
+}
+
+@media (max-width: 720px) {
+  .frame {
+    padding: 6px 10px 10px;
+  }
+
+  .card {
+    padding: 12px 10px 10px;
+    border-radius: 16px;
+  }
+
+  .header {
+    display: block;
+  }
+
+  .headerActions {
+    justify-content: flex-end;
+    margin-top: 8px;
+  }
+
+  .headingBlock {
+    padding: 0 2px;
+  }
+
+  .title {
+    font-size: 15px;
+    line-height: 21px;
+  }
+
+  .option,
+  .customTrigger {
+    align-items: flex-start;
+    gap: 8px;
+    padding: 6px;
+  }
+
+  .choiceIcon {
+    margin-top: 3px;
+  }
+
+  .footer {
+    align-items: flex-end;
+  }
+
+  .footerActions {
+    flex-shrink: 0;
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .option {
+    transition: none;
+  }
+}

+ 308 - 0
packages/client/ui-question/src/client/QuestionComposer.tsx

@@ -0,0 +1,308 @@
+import { useState, type KeyboardEvent } from 'react'
+import clsx from 'clsx'
+import type { QuestionResponsePayload } from '@deepseek-ai/dsh-client-connection/client'
+import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
+import {
+  Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
+  IconCloseOutline16, IconEditOutline16,
+} from '@deepseek-ai/dsh-client-ui-primitives'
+import type { QuestionComposerOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import css from './QuestionComposer.module.css'
+
+type QuestionInteraction = Extract<PendingInteraction, { kind: 'question' }>
+type Answer = QuestionResponsePayload['answer']
+
+interface DraftAnswer {
+  selected: string[]
+  custom: string
+  customOpen: boolean
+  skipped: boolean
+}
+
+/** Actions assembled from the session object layer. */
+export interface QuestionComposerInjected {
+  actions: {
+    answer(interaction: QuestionInteraction, answer: Answer): Promise<void>
+    cancel(interaction: QuestionInteraction): Promise<void>
+  }
+}
+
+/** Full question-composer props. */
+export type QuestionComposerProps = QuestionComposerOwnerProps & QuestionComposerInjected
+
+/**
+ * Split the conventional recommendation suffix without changing the answer value.
+ * @param label - Original option label returned if selected.
+ * @returns Display label plus recommendation state.
+ */
+export function parseRecommendedLabel(label: string): { label: string; recommended: boolean } {
+  const suffix = /\s*(?:\((?:recommended|推荐)\)|((?:recommended|推荐)))\s*$/i
+  return suffix.test(label)
+    ? { label: label.replace(suffix, ''), recommended: true }
+    : { label, recommended: false }
+}
+
+/**
+ * Remove a conventional multi-select suffix so the hint can be styled separately.
+ * @param title - Question title supplied by the interaction request.
+ * @returns Question title without a trailing multi-select marker.
+ */
+export function parseQuestionTitle(title: string): string {
+  return title.replace(/\s*[((]可多选[))]\s*$/, '')
+}
+
+/** Return whether a textarea key event belongs to an active IME composition. */
+function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
+  return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
+}
+
+/**
+ * Composer takeover boundary; rpcId keys local drafts while same-id replay preserves them.
+ * @param props - Pending interaction and scoped answer/cancel actions.
+ * @returns The question flow for this request.
+ */
+export function QuestionComposer(props: QuestionComposerProps) {
+  return <QuestionFlow key={props.interaction.rpcId} {...props} />
+}
+
+function QuestionFlow({ interaction, actions }: QuestionComposerProps) {
+  const questions = interaction.questions
+  const [index, setIndex] = useState(0)
+  const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(question => ({
+    selected: [], custom: '', customOpen: (question.options?.length ?? 0) === 0, skipped: false,
+  })))
+  const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
+  const [error, setError] = useState<string | null>(null)
+  const question = questions[index]!
+  const draft = drafts[index]!
+  const hasOptions = (question.options?.length ?? 0) > 0
+
+  const cancelFlow = (): void => {
+    setBusy('cancel')
+    setError(null)
+    void actions.cancel(interaction).catch((cause: unknown) => {
+      setBusy(null)
+      setError(cause instanceof Error ? cause.message : String(cause))
+    })
+  }
+
+  const updateDraft = (update: (current: DraftAnswer) => DraftAnswer): void => {
+    setDrafts(current => current.map((item, itemIndex) => itemIndex === index ? update(item) : item))
+    setError(null)
+  }
+
+  const choose = (label: string): void => {
+    updateDraft((current) => {
+      const selected = question.multiSelect === true
+        ? current.selected.includes(label)
+          ? current.selected.filter(item => item !== label)
+          : [...current.selected, label]
+        : [label]
+      return { selected, custom: '', customOpen: false, skipped: false }
+    })
+    if (question.multiSelect !== true && index < questions.length - 1) {
+      setIndex(current => current + 1)
+    }
+  }
+
+  const openCustom = (): void => {
+    updateDraft(current => ({ ...current, selected: [], customOpen: true, skipped: false }))
+  }
+
+  const answered = (item: DraftAnswer): boolean =>
+    item.selected.length > 0 || item.custom.trim() !== ''
+
+  const completed = (item: DraftAnswer): boolean => answered(item) || item.skipped
+
+  const submitDrafts = (values: DraftAnswer[]): void => {
+    const missing = values.findIndex(item => !completed(item))
+    if (missing >= 0) {
+      setIndex(missing)
+      setError('请先完成这道问题。')
+      return
+    }
+    const answer: Answer = {
+      answers: questions.map((item, itemIndex) => {
+        const value = values[itemIndex] as DraftAnswer
+        if (value.skipped) return { id: item.id, selected: [] }
+        const custom = value.custom.trim()
+        return {
+          id: item.id,
+          selected: custom === '' ? value.selected : [],
+          ...(custom === '' ? {} : { custom }),
+        }
+      }),
+    }
+    setBusy('answer')
+    setError(null)
+    void actions.answer(interaction, answer).catch((cause: unknown) => {
+      setBusy(null)
+      setError(cause instanceof Error ? cause.message : String(cause))
+    })
+  }
+
+  const continueFlow = (): void => {
+    if (!answered(draft)) {
+      setError('请选择一个选项或填写自定义答案。')
+      return
+    }
+    if (index < questions.length - 1) {
+      setIndex(current => current + 1)
+      setError(null)
+      return
+    }
+    submitDrafts(drafts)
+  }
+
+  const skipQuestion = (): void => {
+    const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
+      ? {
+          selected: [], custom: '',
+          customOpen: (question.options?.length ?? 0) === 0,
+          skipped: true,
+        }
+      : item)
+    setDrafts(nextDrafts)
+    setError(null)
+    if (index < questions.length - 1) {
+      setIndex(current => current + 1)
+      return
+    }
+    submitDrafts(nextDrafts)
+  }
+
+  return (
+    <div className={css.frame} data-question-rpc-id={interaction.rpcId}>
+      <section className={css.card} aria-labelledby={`question-${interaction.rpcId}-${String(index)}`}>
+        <header className={css.header}>
+          <div className={css.headingBlock}>
+            {question.header !== undefined && <div className={css.eyebrow}>{question.header}</div>}
+            <h2 className={css.title} id={`question-${interaction.rpcId}-${String(index)}`}>
+              <span>{question.multiSelect === true
+                ? parseQuestionTitle(question.question)
+                : question.question}</span>
+              {question.multiSelect === true && <span className={css.multiSelectHint}>可多选</span>}
+            </h2>
+          </div>
+          <div className={css.headerActions}>
+            <span className={css.progress}>{index + 1} / {questions.length}</span>
+            <button
+              type="button" className={css.iconButton} aria-label="上一题"
+              disabled={index === 0 || busy !== null}
+              onClick={() => { setIndex(index - 1); setError(null) }}
+            >
+              <IconChevronLeftOutline14 />
+            </button>
+            <button
+              type="button" className={css.iconButton} aria-label="下一题"
+              disabled={index === questions.length - 1 || busy !== null}
+              onClick={() => { setIndex(index + 1); setError(null) }}
+            >
+              <IconChevronRightOutline14 />
+            </button>
+            <button
+              type="button" className={css.iconButton} aria-label="放弃整组问题"
+              title="放弃整组问题"
+              disabled={busy !== null} onClick={cancelFlow}
+            >
+              <IconCloseOutline16 />
+            </button>
+          </div>
+        </header>
+
+        <div className={css.options} role={question.multiSelect === true ? 'group' : 'radiogroup'}>
+          {(question.options ?? []).map((option, optionIndex) => {
+            const selected = draft.selected.includes(option.label)
+            const display = parseRecommendedLabel(option.label)
+            return (
+              <button
+                type="button" key={`${option.label}-${String(optionIndex)}`}
+                className={clsx(css.option, selected && css.optionSelected)}
+                role={question.multiSelect === true ? 'checkbox' : 'radio'}
+                aria-checked={selected}
+                aria-label={display.label}
+                disabled={busy !== null}
+                onClick={() => { choose(option.label) }}
+                onKeyDown={(event) => {
+                  if (event.key !== 'Enter' || !drafts.every(completed)) return
+                  event.preventDefault()
+                  submitDrafts(drafts)
+                }}
+              >
+                <span className={css.number}>{optionIndex + 1}</span>
+                <span className={css.optionCopy}>
+                  <span className={css.optionLine}>
+                    <span className={css.optionLabel}>{display.label}</span>
+                    {display.recommended && <span className={css.badge}>推荐</span>}
+                    {option.description !== undefined && (
+                      <span className={css.description}>{option.description}</span>
+                    )}
+                  </span>
+                </span>
+                <span className={css.choiceIcon}>
+                  {selected ? <IconCheckOutline16 /> : <IconChevronRightOutline14 />}
+                </span>
+              </button>
+            )
+          })}
+
+          <div className={clsx(
+            css.custom,
+            draft.customOpen && css.customOpen,
+            !hasOptions && css.customOptionless,
+          )}>
+            {hasOptions && (
+              <button
+                type="button" className={css.customTrigger}
+                disabled={busy !== null} onClick={openCustom}
+                aria-expanded={draft.customOpen}
+              >
+                <span className={css.number}><IconEditOutline16 /></span>
+                <span>其他,请填写自定义答案</span>
+              </button>
+            )}
+            {draft.customOpen && (
+              <textarea
+                autoFocus
+                className={css.customInput}
+                value={draft.custom}
+                disabled={busy !== null}
+                rows={2}
+                placeholder="输入你的答案"
+                onChange={(event) => {
+                  const value = event.target.value
+                  updateDraft(current => ({
+                    ...current, selected: [], custom: value, customOpen: true, skipped: false,
+                  }))
+                }}
+                onKeyDown={(event) => {
+                  if (event.key === 'Enter' && !event.shiftKey && !isComposing(event)) {
+                    event.preventDefault()
+                    continueFlow()
+                  }
+                }}
+              />
+            )}
+          </div>
+        </div>
+
+        <footer className={css.footer}>
+          <div className={css.feedback} role="status">{error}</div>
+          <div className={css.footerActions}>
+            <Button variant="ghost" size="sm" disabled={busy !== null} onClick={skipQuestion}>
+              跳过本题
+            </Button>
+            <Button
+              variant="primary" size="sm"
+              disabled={busy !== null || !answered(draft)} onClick={continueFlow}
+            >
+              {busy === 'answer'
+                ? '正在提交…'
+                : index === questions.length - 1 ? '提交' : '下一题'}
+            </Button>
+          </div>
+        </footer>
+      </section>
+    </div>
+  )
+}

+ 47 - 0
packages/client/ui-question/src/client/index.ts

@@ -0,0 +1,47 @@
+/**
+ * Web question plugin, browser half: registers a composer replacement for
+ * pending ask_user_question requests.
+ */
+import type { Context } from 'cordis'
+import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
+import { QuestionComposer, type QuestionComposerInjected } from './QuestionComposer.tsx'
+
+export { QuestionComposer, parseRecommendedLabel } from './QuestionComposer.tsx'
+export type { QuestionComposerInjected, QuestionComposerProps } from './QuestionComposer.tsx'
+
+/** Required browser services. */
+export const inject = ['slots', 'sessions']
+
+/**
+ * Register the question composer into the conversation-owned keyed slot.
+ * @param ctx - Browser plugin context carrying slots and sessions.
+ */
+export function apply(ctx: Context): void {
+  const slots = ctx.get('slots')
+  const sessions = ctx.get('sessions') as SessionsService | undefined
+  if (slots === undefined || sessions === undefined) {
+    throw new Error('ui-question: slots and sessions services are required')
+  }
+  slots.register<'conversation.composer', QuestionComposerInjected>('conversation.composer', QuestionComposer, {
+    key: 'question',
+    inject(binding): QuestionComposerInjected {
+      const session = sessions.manager.get(binding.sessionId as SessionId)
+      return {
+        actions: {
+          async answer(interaction, answer) {
+            const receipt = await session.answerQuestion(interaction.rpcId, answer)
+            if (!receipt.accepted) {
+              throw new Error(`question response rejected: ${receipt.reason}`)
+            }
+          },
+          async cancel(interaction) {
+            const receipt = await session.cancelQuestion(interaction.rpcId)
+            if (!receipt.accepted) {
+              throw new Error(`question cancellation rejected: ${receipt.reason}`)
+            }
+          },
+        },
+      }
+    },
+  })
+}

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

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

+ 17 - 0
packages/client/ui-question/src/index.ts

@@ -0,0 +1,17 @@
+/**
+ * Web question plugin, node half: enabling this UI feature also exposes the
+ * model-facing ask_user_question tool on the host composition.
+ */
+import type { Context } from 'cordis'
+import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
+
+/** Host services required by the model-facing tool. */
+export const inject = ['tools', 'userInteraction']
+
+/**
+ * Mount ask_user_question for hosts that selected the Web question plugin.
+ * @param ctx - Host plugin context carrying tools and userInteraction.
+ */
+export function apply(ctx: Context): void {
+  toolAskUser.apply(ctx)
+}

+ 31 - 0
packages/client/ui-question/src/invariant.ts

@@ -0,0 +1,31 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-question`.
+ * @module @deepseek-ai/dsh-client-ui-question/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-question'
+
+/** Cordis companion plugin name. */
+export const name = 'client-ui-question-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: tool and slot registrations are effects
+ * owned and observed by their respective registries; the host pending table is
+ * exercised through the public wire protocol.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns The installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 58 - 0
packages/client/ui-question/tests/browser-plugin.spec.ts

@@ -0,0 +1,58 @@
+import { Context } from 'cordis'
+import { describe, expect, it, vi } from 'vitest'
+import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
+import type { PendingInteraction, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
+import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
+import { apply, inject } from '../src/client/index.ts'
+
+type QuestionInteraction = Extract<PendingInteraction, { kind: 'question' }>
+
+function interaction(): QuestionInteraction {
+  return {
+    kind: 'question', rpcId: RpcId('question-1'),
+    questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }],
+  }
+}
+
+describe('ui-question browser plugin', () => {
+  it('declares its services and fails loud without them', () => {
+    expect(inject).toEqual(['slots', 'sessions'])
+    expect(() => { apply(new Context()) }).toThrow(/slots and sessions services are required/)
+  })
+
+  it('registers scoped answer and cancel actions, including rejected receipts', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SlotsService).await()
+    const answerQuestion = vi.fn()
+      .mockResolvedValueOnce({ accepted: true })
+      .mockResolvedValueOnce({ accepted: false, reason: 'not-pending' })
+    const cancelQuestion = vi.fn()
+      .mockResolvedValueOnce({ accepted: true })
+      .mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
+    ctx.provide('sessions', {
+      manager: { get: vi.fn(() => ({ answerQuestion, cancelQuestion })) },
+    })
+    const slots = ctx.get('slots') as SlotsService
+    slots.define('conversation.composer', { kind: 'keyed', scope: 'session' })
+    const fiber = ctx.plugin({ inject: [...inject], apply })
+    await fiber.await()
+
+    const entry = slots.entries('conversation.composer')[0] as unknown as {
+      options: { inject(binding: { sessionId: SessionId }): { actions: {
+        answer: (item: QuestionInteraction, answer: { answers: { id: string; selected: string[] }[] }) => Promise<void>
+        cancel: (item: QuestionInteraction) => Promise<void>
+      } } }
+    }
+    const actions = entry.options.inject({ sessionId: 'session-1' as SessionId }).actions
+    const item = interaction()
+    const answer = { answers: [{ id: 'mode', selected: ['Fast'] }] }
+
+    await expect(actions.answer(item, answer)).resolves.toBeUndefined()
+    await expect(actions.answer(item, answer)).rejects.toThrow(/not-pending/)
+    await expect(actions.cancel(item)).resolves.toBeUndefined()
+    await expect(actions.cancel(item)).rejects.toThrow(/bad-response/)
+    expect(answerQuestion).toHaveBeenCalledWith(item.rpcId, answer)
+    expect(cancelQuestion).toHaveBeenCalledWith(item.rpcId)
+    await ctx.fiber.dispose()
+  })
+})

+ 28 - 0
packages/client/ui-question/tests/node-plugin.spec.ts

@@ -0,0 +1,28 @@
+import { Context } from 'cordis'
+import { afterEach, describe, expect, it } from 'vitest'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
+import { apply, inject } from '../src/index.ts'
+
+let ctx: Context | undefined
+
+afterEach(async () => {
+  await ctx?.fiber.dispose()
+  ctx = undefined
+})
+
+describe('ui-question node plugin', () => {
+  it('exposes ask_user_question only for the selected Web feature lifecycle', async () => {
+    ctx = new Context()
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    await ctx.plugin(UserInteractionService)
+    const feature = ctx.plugin({ inject: [...inject], apply })
+    await feature.await()
+    expect(ctx.tools.get('ask_user_question')).toBeDefined()
+
+    await feature.dispose()
+    expect(ctx.tools.get('ask_user_question')).toBeUndefined()
+  })
+})

+ 198 - 0
packages/client/ui-question/tests/question-composer.spec.tsx

@@ -0,0 +1,198 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
+import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
+import {
+  QuestionComposer, parseQuestionTitle, parseRecommendedLabel,
+} from '../src/client/QuestionComposer.tsx'
+
+afterEach(cleanup)
+
+type Interaction = Extract<PendingInteraction, { kind: 'question' }>
+
+function interaction(rpcId = 'question-1'): Interaction {
+  return {
+    kind: 'question',
+    rpcId: RpcId(rpcId),
+    questions: [
+      {
+        id: 'profile', header: '偏好', question: '选择候选人类型',
+        options: [
+          { label: '工程落地型 (Recommended)', description: '优先工程交付。' },
+          { label: '研究潜力型', description: '优先研究能力。' },
+        ],
+      },
+      {
+        id: 'detail', question: '补充你的要求',
+      },
+      {
+        id: 'signals', question: '选择重要信号(可多选)', multiSelect: true,
+        options: [{ label: '系统设计' }, { label: '代码质量' }, { label: '产品判断' }],
+      },
+    ],
+  }
+}
+
+describe('QuestionComposer', () => {
+  it('collects single, custom, and multi-select answers before one batch submit', () => {
+    const answer = vi.fn(() => Promise.resolve())
+    const cancel = vi.fn(() => Promise.resolve())
+    render(<QuestionComposer interaction={interaction()} actions={{ answer, cancel }} />)
+
+    expect(screen.getByText('1 / 3')).toBeTruthy()
+    expect(screen.getByText('推荐')).toBeTruthy()
+    expect(screen.getByText('工程落地型')).toBeTruthy()
+    fireEvent.keyDown(screen.getByRole('radio', { name: /工程落地型/ }), { key: 'Enter' })
+    expect(answer).not.toHaveBeenCalled()
+    fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
+
+    expect(screen.getByText('2 / 3')).toBeTruthy()
+    expect(screen.queryByRole('button', { name: '填写答案' })).toBeNull()
+    const custom = screen.getByPlaceholderText('输入你的答案')
+    fireEvent.change(custom, { target: { value: '要能独立排查线上问题' } })
+    fireEvent.keyDown(custom, { key: 'Enter' })
+
+    expect(screen.getByText('3 / 3')).toBeTruthy()
+    expect(screen.getByText('选择重要信号')).toBeTruthy()
+    expect(screen.getByText('可多选')).toBeTruthy()
+    expect(screen.queryByText('(可多选)')).toBeNull()
+    fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
+    fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
+    fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
+    fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' }))
+    fireEvent.keyDown(screen.getByRole('checkbox', { name: '代码质量' }), { key: 'Enter' })
+
+    expect(answer).toHaveBeenCalledWith(interaction(), {
+      answers: [
+        { id: 'profile', selected: ['工程落地型 (Recommended)'] },
+        { id: 'detail', selected: [], custom: '要能独立排查线上问题' },
+        { id: 'signals', selected: ['系统设计', '代码质量'] },
+      ],
+    })
+    expect((screen.getByRole('button', { name: '正在提交…' }) as HTMLButtonElement).disabled).toBe(true)
+  })
+
+  it('skips individual questions without discarding earlier answers', () => {
+    const answer = vi.fn(() => Promise.resolve())
+    const cancel = vi.fn(() => Promise.resolve())
+    render(<QuestionComposer interaction={interaction()} actions={{ answer, cancel }} />)
+
+    expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true)
+    fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
+    expect(screen.getByText('2 / 3')).toBeTruthy()
+    fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
+    expect(screen.getByText('3 / 3')).toBeTruthy()
+    fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
+
+    expect(cancel).not.toHaveBeenCalled()
+    expect(answer).toHaveBeenCalledWith(interaction(), {
+      answers: [
+        { id: 'profile', selected: ['研究潜力型'] },
+        { id: 'detail', selected: [] },
+        { id: 'signals', selected: [] },
+      ],
+    })
+  })
+
+  it('keeps IME Enter inside the custom input until composition finishes', () => {
+    const answer = vi.fn(() => Promise.resolve())
+    const cancel = vi.fn(() => Promise.resolve())
+    render(<QuestionComposer interaction={interaction()} actions={{ answer, cancel }} />)
+
+    fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
+    const custom = screen.getByPlaceholderText('输入你的答案')
+    fireEvent.change(custom, { target: { value: '中文输入' } })
+
+    fireEvent.keyDown(custom, { key: 'Enter', isComposing: true })
+    expect(screen.getByText('2 / 3')).toBeTruthy()
+    expect(answer).not.toHaveBeenCalled()
+
+    fireEvent.keyDown(custom, { key: 'Enter', keyCode: 229 })
+    expect(screen.getByText('2 / 3')).toBeTruthy()
+    expect(answer).not.toHaveBeenCalled()
+
+    fireEvent.keyDown(custom, { key: 'Enter' })
+    expect(screen.getByText('3 / 3')).toBeTruthy()
+  })
+
+  it('opens custom input, reports missing skipped answers, and supports header navigation', () => {
+    const answer = vi.fn(() => Promise.resolve())
+    const cancel = vi.fn(() => Promise.resolve())
+    render(<QuestionComposer interaction={interaction()} actions={{ answer, cancel }} />)
+
+    fireEvent.click(screen.getByRole('button', { name: '其他,请填写自定义答案' }))
+    expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy()
+    fireEvent.click(screen.getByRole('radio', { name: '工程落地型' }))
+    const emptyCustom = screen.getByPlaceholderText('输入你的答案')
+    fireEvent.keyDown(emptyCustom, { key: 'Enter', shiftKey: true })
+    expect(screen.getByText('2 / 3')).toBeTruthy()
+    fireEvent.keyDown(emptyCustom, { key: 'Enter' })
+    expect(screen.getByText('请选择一个选项或填写自定义答案。')).toBeTruthy()
+
+    fireEvent.click(screen.getByLabelText('下一题'))
+    fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' }))
+    fireEvent.click(screen.getByRole('button', { name: '提交' }))
+    expect(screen.getByText('请先完成这道问题。')).toBeTruthy()
+    expect(screen.getByText('2 / 3')).toBeTruthy()
+    fireEvent.click(screen.getByLabelText('上一题'))
+    expect(screen.getByText('1 / 3')).toBeTruthy()
+    expect(answer).not.toHaveBeenCalled()
+  })
+
+  it('surfaces explicit cancellation rejection', async () => {
+    const answer = vi.fn(() => Promise.resolve())
+    const cancel = vi.fn(() => Promise.reject('取消请求失败'))
+    render(<QuestionComposer interaction={interaction()} actions={{ answer, cancel }} />)
+
+    fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
+    expect(await screen.findByText('取消请求失败')).toBeTruthy()
+    expect((screen.getByRole('button', { name: '跳过本题' }) as HTMLButtonElement).disabled).toBe(false)
+
+    cancel.mockRejectedValueOnce(new Error('第二次取消失败'))
+    fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
+    expect(await screen.findByText('第二次取消失败')).toBeTruthy()
+  })
+
+  it('surfaces transport rejection and resets local drafts for a different rpcId', async () => {
+    const answer = vi.fn(() => Promise.reject(new Error('网络中断')))
+    const cancel = vi.fn(() => Promise.resolve())
+    const first = interaction('first')
+    const view = render(<QuestionComposer interaction={first} actions={{ answer, cancel }} />)
+
+    fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
+    expect(screen.getByText('2 / 3')).toBeTruthy()
+    view.rerender(<QuestionComposer interaction={interaction('second')} actions={{ answer, cancel }} />)
+    expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('false')
+
+    fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
+    const custom = screen.getByPlaceholderText('输入你的答案')
+    fireEvent.change(custom, { target: { value: 'x' } })
+    fireEvent.keyDown(custom, { key: 'Enter' })
+    fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
+    fireEvent.click(screen.getByRole('button', { name: '提交' }))
+    expect(await screen.findByText('网络中断')).toBeTruthy()
+    expect((screen.getByRole('button', { name: '提交' }) as HTMLButtonElement).disabled).toBe(false)
+
+    answer.mockRejectedValueOnce('字符串错误')
+    fireEvent.click(screen.getByRole('button', { name: '提交' }))
+    expect(await screen.findByText('字符串错误')).toBeTruthy()
+  })
+})
+
+describe('parseRecommendedLabel', () => {
+  it('recognizes English and Chinese suffixes without changing ordinary labels', () => {
+    expect(parseRecommendedLabel('Fast (Recommended)')).toEqual({ label: 'Fast', recommended: true })
+    expect(parseRecommendedLabel('稳妥(推荐)')).toEqual({ label: '稳妥', recommended: true })
+    expect(parseRecommendedLabel('稳妥 (推荐)')).toEqual({ label: '稳妥', recommended: true })
+    expect(parseRecommendedLabel('Plain')).toEqual({ label: 'Plain', recommended: false })
+  })
+})
+
+describe('parseQuestionTitle', () => {
+  it('removes Chinese and ASCII multi-select suffixes', () => {
+    expect(parseQuestionTitle('选择信号(可多选)')).toBe('选择信号')
+    expect(parseQuestionTitle('选择信号 (可多选)')).toBe('选择信号')
+    expect(parseQuestionTitle('选择信号')).toBe('选择信号')
+  })
+})

+ 43 - 0
packages/client/ui-question/tsconfig.json

@@ -0,0 +1,43 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types",
+    "jsx": "react-jsx",
+    "lib": [
+      "ES2024",
+      "DOM",
+      "DOM.Iterable"
+    ],
+    "types": []
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../runtime"
+    },
+    {
+      "path": "../ui-conversation"
+    },
+    {
+      "path": "../ui-primitives"
+    },
+    {
+      "path": "../ui-slots"
+    },
+    {
+      "path": "../web-react"
+    },
+    {
+      "path": "../../ui/tool-ask-user"
+    },
+    {
+      "path": "../../support/invariants"
+    }
+  ]
+}

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

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

+ 13 - 3
packages/client/ui-trajectory/tests/views.spec.tsx

@@ -14,13 +14,16 @@ import { bindSnapshotSelector, createSnapshotStore } from '@deepseek-ai/dsh-clie
 import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
 import type { ConversationSnapshot, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
 import { ConversationRoot, ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import type { ConvViewProps, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { ConversationInjected, ConvViewProps, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import {
   apply, deriveSpans, deriveSpanStats, inject, TrajectoryStatsHeader, TrajectoryView, WaterfallView,
 } from '@deepseek-ai/dsh-client-ui-trajectory/client'
 import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
 
 const SID = 's1' as SessionId
+const fallbackSlots: ConversationInjected['slots'] = {
+  renderSlot: (_key, _props, opts) => opts?.fallback ?? null,
+}
 
 afterEach(cleanup)
 
@@ -70,8 +73,14 @@ function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] =
     }
     return createElement(Fragment, null, children)
   }
-  const sessionSnapshot = createSnapshotStore<{ running: boolean; removed: boolean; promptError: null; nodes: ConversationSnapshot['nodes'] }>({
-    running: false, removed: false, promptError: null, nodes,
+  const sessionSnapshot = createSnapshotStore<{
+    running: boolean
+    removed: boolean
+    promptError: null
+    nodes: ConversationSnapshot['nodes']
+    pending: ConversationSnapshot['pending']
+  }>({
+    running: false, removed: false, promptError: null, nodes, pending: [],
   })
   return render(
     <ConversationRoot
@@ -87,6 +96,7 @@ function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] =
       composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
       actions={{ openView: ((v: string) => { activeStore.set(v) }) as (v: never) => void, open: vi.fn() }}
       renderView={renderView}
+      slots={fallbackSlots}
     />,
   )
 }

+ 1 - 0
packages/host/apiproxy/src/api/rpc.schema.ts

@@ -33,6 +33,7 @@ export const rpcIdSchema = z.string() as unknown as z.ZodType<RpcId>
 /** Error body: discriminated by code, per-branch details aligned to RpcErrorDetailsMap; details is required. */
 export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code', [
   z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
+  z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
   z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
   z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
   z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),

+ 1 - 0
packages/host/apiproxy/src/api/rpc.ts

@@ -30,6 +30,7 @@ export function RpcId(id: string): RpcId {
 /** Error code → details type map (a second table isomorphic to RpcMethodMap). New code = one row here + one branch in the error schema. */
 export interface RpcErrorDetailsMap {
   'bad-request': { issues: ZodIssue[] }
+  'cancelled': {}
   'session-not-found': { sessionId: SessionId }
   'agent-busy': { reason: string }
   'internal': {}

+ 1 - 0
packages/host/apiproxy/tests/rpc-schemas.spec.ts

@@ -29,6 +29,7 @@ describe('RpcId', () => {
 describe('rpcErrorSchema', () => {
   it('accepts every code branch with its required details', () => {
     expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
+    expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled')
     expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
     expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
     expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')

+ 4 - 4
packages/host/runtime/README.md

@@ -1,6 +1,6 @@
 # @deepseek-ai/dsh-host-runtime
 
-Host runtime assembly for `dsc`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, system prompt, tools, agents, agent loop, local bash), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
+Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, system prompt, tools, agents, agent loop, local bash, and the provider-neutral user-interaction service), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
 
 Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it.
 
@@ -14,7 +14,7 @@ Which plugins mount and with what defaults is decided only here — shells must
 
 ## ApiProxy implementation notes
 
-Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session on open; the host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
+Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session and every still-pending question with its original rpcId. Question responses, including blank per-item answers, are validated against the owning session and exact request before an atomic first-wins claim; answer, whole-request cancellation, owner abort, and provider disposal broadcast `question/resolved`. The host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
 
 ## Model Experience
 
@@ -26,6 +26,6 @@ No direct invalidation; the mounted model-facing plugins own their request-prefi
 
 ## Known Limitations and Deferred Work
 
-- **`respond` is a stub** — it always returns `not-pending`; the approval/question pending registry (stable-rpcId mint on accept, baseline replay on stream reopen, wire answerer) is the next host-side step.
-- **`session.list` covers live sessions only** — cold sessions in the persistence directory are not yet merged into the listing; `host.describe.version` is a placeholder rather than the `apps/cli` package version.
+- **Question waits are process-memory state** — browser reconnects recover them, but a host process restart aborts the owning tool call instead of restoring the wait from persistence.
+- **`host.describe.version` is a placeholder** — it does not yet read the `apps/cli` package version.
 - **The assembly is fixed** — per-deployment plugin selection (user profile, log sinks, alternative persistence) has a documented home here but no configuration surface yet.

+ 2 - 0
packages/host/runtime/package.json

@@ -36,6 +36,7 @@
     "@deepseek-ai/dsh-client-i18n": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-question": "workspace:^",
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-client-ui-theme": "workspace:^",
@@ -69,6 +70,7 @@
     "@deepseek-ai/dsh-tool-todo": "workspace:^",
     "@deepseek-ai/dsh-tool-workflow": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-user-interaction": "workspace:^",
     "@deepseek-ai/dsh-workflow-workerthread": "workspace:^"
   },
   "peerDependencies": {

+ 144 - 10
packages/host/runtime/src/api-proxy.ts

@@ -1,8 +1,6 @@
 /**
- * Host-side ApiProxy implementation (minimal-first —
- * describe/list/create/history/prompt/cancel and both streams are real,
- * respond is a stub). Signature discipline: unary takes the narrow
- * RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
+ * Host-side ApiProxy implementation. Signature discipline: unary takes the
+ * narrow RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
  */
 
 import { randomUUID } from 'node:crypto'
@@ -12,9 +10,16 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
 import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
 import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
-import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api'
+import type {
+  ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
+} from '@deepseek-ai/dsh-host-apiproxy/api'
+import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema'
 import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
 import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
+import type {
+  AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
+} from '@deepseek-ai/dsh-user-interaction'
+import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
 
 /** Page size when history is called without maxMessages. */
 const DEFAULT_MAX_MESSAGES = 50
@@ -155,6 +160,35 @@ interface ToolCallData { callId: string; name: string; arguments: string }
 /** The tool/result payload fields the presenter path reads. */
 interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: unknown }
 
+/** One host-owned question wait, addressed by the stable server-request id. */
+interface PendingQuestion {
+  rpcId: RpcId
+  sessionId: SessionId
+  questions: AskUserQuestionItem[]
+  resolve: (answer: AskUserQuestionAnswer) => void
+  reject: (error: UserInteractionError) => void
+  signal?: AbortSignal
+  onAbort?: () => void
+}
+
+/** Validate one answer batch against the exact question request it resolves. */
+function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQuestion): boolean {
+  if (payload.sessionId !== pending.sessionId) return false
+  const answers = payload.answer.answers
+  if (answers.length !== pending.questions.length) return false
+  return answers.every((answer, index) => {
+    const question = pending.questions[index] as AskUserQuestionItem
+    if (answer.id !== question.id) return false
+    if (new Set(answer.selected).size !== answer.selected.length) return false
+    const custom = answer.custom?.trim()
+    if (custom !== undefined && custom === '') return false
+    if (custom !== undefined && answer.selected.length > 0) return false
+    if (question.multiSelect !== true && answer.selected.length > 1) return false
+    const labels = new Set(question.options?.map(option => option.label) ?? [])
+    return answer.selected.every(label => labels.has(label))
+  })
+}
+
 /**
  * Compute the render intent for a tool/call or tool/result event through the
  * presenters registered at this moment; every other event type gets none. A
@@ -219,12 +253,70 @@ class SessionNotFound extends Error {}
  * @param ctx - the root context returned by bootHost (sessions/agents services mounted).
  * @param defaults - host-level default provider/model: injected as
  * agentOptions on create/resume, reported by describe from the same source.
- * @returns the ApiProxy implementation (minimal-first; stubs noted per method).
+ * @returns the ApiProxy implementation.
  */
 export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
   const agentOptions = { provider: defaults.provider, model: defaults.model }
   /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
   const resumes = new Map<SessionId, Promise<Agent>>()
+  const pendingQuestions = new Map<RpcId, PendingQuestion>()
+  const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
+
+  /** Send one transient frame to every connected mux consumer. */
+  function broadcast(payload: MuxFrame): void {
+    const envelope = frame(payload)
+    for (const queue of muxQueues) queue.push(envelope)
+  }
+
+  /** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
+  function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
+    pendingQuestions.delete(pending.rpcId)
+    if (pending.signal !== undefined && pending.onAbort !== undefined) {
+      pending.signal.removeEventListener('abort', pending.onAbort)
+    }
+    broadcast({
+      type: 'question/resolved', sessionId: pending.sessionId,
+      questionRpcId: pending.rpcId, outcome,
+    })
+  }
+
+  const disposeProvider = ctx.userInteraction.registerProvider({
+    ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
+      const sessionId = request.agent?.id
+      if (sessionId === undefined) {
+        return Promise.reject(new UserInteractionError(
+          'web user interaction requires an agent-owned session', 'ASK_MISSING_AGENT'))
+      }
+      return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
+        const rpcId = RpcId(randomUUID())
+        const pending: PendingQuestion = {
+          rpcId, sessionId, questions: request.questions, resolve, reject,
+          ...(request.signal === undefined ? {} : { signal: request.signal }),
+        }
+        const onAbort = (): void => {
+          claimQuestion(pending, 'cancelled')
+          reject(new UserInteractionError(
+            'ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
+        }
+        pending.onAbort = onAbort
+        pendingQuestions.set(rpcId, pending)
+        request.signal?.addEventListener('abort', onAbort, { once: true })
+        const envelope: RpcRequest<MuxFrame> = {
+          rpcId,
+          payload: { type: 'question/requested', sessionId, questions: request.questions },
+        }
+        for (const queue of muxQueues) queue.push(envelope)
+      })
+    },
+  })
+  ctx.effect(() => () => {
+    disposeProvider()
+    for (const pending of [...pendingQuestions.values()]) {
+      claimQuestion(pending, 'cancelled')
+      pending.reject(new UserInteractionError(
+        'web user-interaction provider was disposed', 'ASK_ABORTED'))
+    }
+  }, 'api-proxy: user-interaction provider')
 
   /**
    * Gate the cold path on the store: an id absent from it, or naming a legacy
@@ -361,9 +453,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
     events: {
       mux(_request, signal) {
         const queue = new FrameQueue<RpcRequest<MuxFrame>>()
+        muxQueues.add(queue)
         for (const session of ctx.sessions.list()) {
           queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
         }
+        for (const pending of pendingQuestions.values()) {
+          queue.push({
+            rpcId: pending.rpcId,
+            payload: {
+              type: 'question/requested', sessionId: pending.sessionId,
+              questions: pending.questions,
+            },
+          })
+        }
         // Per-session open-call table for result-view pairing. Bounded by the
         // per-turn call count: entries clear on turn/end; a table miss (stream
         // opened mid-turn) backscans the session's in-memory events instead.
@@ -393,7 +495,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
             openCalls.delete(session.id)
           }),
         ]
-        return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
+        return queue.iterate(signal, () => {
+          muxQueues.delete(queue)
+          for (const dispose of disposers) dispose()
+        })
       },
 
       host(_request, signal) {
@@ -421,9 +526,38 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
       },
     },
 
-    // TODO(step2): approval/question pending registry (wire answerer + proxy provider).
-    respond(_message: ClientResponse): Promise<RpcReceipt> {
-      return Promise.resolve({ accepted: false, reason: 'not-pending' })
+    respond(message: ClientResponse): Promise<RpcReceipt> {
+      const pending = pendingQuestions.get(message.rpcId)
+      if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
+      if (!message.result.ok) {
+        if (message.result.error.code !== 'cancelled') {
+          return Promise.resolve({ accepted: false, reason: 'bad-response' })
+        }
+        claimQuestion(pending, 'cancelled')
+        pending.reject(new UserInteractionError(
+          'the user cancelled ask_user_question', 'ASK_CANCELLED'))
+        return Promise.resolve({ accepted: true })
+      }
+      const parsed = questionResponsePayloadSchema.safeParse(message.result.value)
+      if (!parsed.success) {
+        return Promise.resolve({ accepted: false, reason: 'bad-response' })
+      }
+      const payload: QuestionResponsePayload = {
+        sessionId: parsed.data.sessionId,
+        answer: {
+          answers: parsed.data.answer.answers.map(answer => ({
+            id: answer.id,
+            selected: answer.selected,
+            ...(answer.custom === undefined ? {} : { custom: answer.custom }),
+          })),
+        },
+      }
+      if (!matchesQuestions(payload, pending)) {
+        return Promise.resolve({ accepted: false, reason: 'bad-response' })
+      }
+      claimQuestion(pending, 'answered')
+      pending.resolve(payload.answer)
+      return Promise.resolve({ accepted: true })
     },
   }
 }

+ 2 - 0
packages/host/runtime/src/boot.ts

@@ -37,6 +37,7 @@ import * as toolWorkflow from '@deepseek-ai/dsh-tool-workflow'
 import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
 import SpillLocal from '@deepseek-ai/dsh-spill-local'
 import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
+import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
 
 /** Options for bootHost — the assembly-layer composition knobs. */
 export interface BootHostOptions {
@@ -91,6 +92,7 @@ export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
   await ctx.plugin(SessionStore)
   await ctx.plugin(SystemPrompt, { persona: '' })
   await ctx.plugin(ToolRegistry)
+  await ctx.plugin(UserInteractionService)
   await ctx.plugin(AgentRegistry)
   await ctx.plugin(TaskService)
   await ctx.plugin(AgentLoop, { agents: [] })

+ 6 - 5
packages/host/runtime/src/web-plugins.ts

@@ -1,16 +1,16 @@
 /**
  * Web UI plugin assembly: mounts @cordisjs/plugin-loader with an in-memory
- * entry tree listing the eight UI plugin packages (the P-I config-source bar —
+ * entry tree listing the nine UI plugin packages (the P-I config-source bar —
  * a cordis.yml file form comes later; install/remove currently means editing
  * this list and restarting). The web plugin registry discovers the entries by
- * their package.json dshClient declarations; node halves are empty applies,
- * so mounting them here costs nothing beyond Loader governance.
+ * their package.json dshClient declarations; feature packages may also mount
+ * their interface-specific host half through the same lifecycle.
  */
 import { createRequire } from 'node:module'
 import type { Context } from 'cordis'
 import Loader from '@cordisjs/plugin-loader'
 
-/** The eight UI plugin packages served to the browser (order = manifest order). */
+/** The nine UI plugin packages served to the browser (order = manifest order). */
 export const WEB_UI_PLUGINS = [
   '@deepseek-ai/dsh-client-connection',
   '@deepseek-ai/dsh-client-runtime',
@@ -19,6 +19,7 @@ export const WEB_UI_PLUGINS = [
   '@deepseek-ai/dsh-client-ui-layout',
   '@deepseek-ai/dsh-client-ui-sidebar',
   '@deepseek-ai/dsh-client-ui-conversation',
+  '@deepseek-ai/dsh-client-ui-question',
   '@deepseek-ai/dsh-client-ui-trajectory',
 ] as const
 
@@ -41,7 +42,7 @@ export interface MountedWebPlugins {
 export async function mountWebPlugins(ctx: Context): Promise<MountedWebPlugins> {
   // The Loader resolves bare specifiers against ctx.baseUrl; without one the
   // import silently fails and every entry stays fiber-less. This package
-  // depends on all eight UI plugins, so its own URL is the right anchor.
+  // depends on all nine UI plugins, so its own URL is the right anchor.
   ctx.baseUrl ??= import.meta.url
   if (ctx.get('loader') === undefined) await ctx.plugin(Loader)
   const existing = new Set([...ctx.loader.entries()].map(entry => entry.options.name))

+ 3 - 0
packages/host/runtime/tests/api-proxy-cold.spec.ts

@@ -12,6 +12,7 @@ import { describe, expect, it } from 'vitest'
 import { Context } from 'cordis'
 import SessionStore from '@deepseek-ai/dsh-session'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
+import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
 import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
 import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -32,6 +33,7 @@ describe('sessions.list cold merge', () => {
   it('summarizes unattached sessions: log mtime, locate-less and vanished-log createdAt fallbacks, lineage', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
+    await ctx.plugin(UserInteractionService)
     const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
     const logPath = join(root, 'a.log')
     writeFileSync(logPath, 'log-bytes')
@@ -76,6 +78,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     await ctx.plugin(AgentRegistry)
+    await ctx.plugin(UserInteractionService)
     const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
 
     const listed = await api.sessions.list(request({}))

+ 2 - 0
packages/host/runtime/tests/api-proxy-view.spec.ts

@@ -17,6 +17,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
 import { CallId } from '@deepseek-ai/dsh-llm'
 import type { Session, SessionId } from '@deepseek-ai/dsh-session'
 import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
+import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
 import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
 import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
 import { createApiProxy } from '../src/api-proxy.ts'
@@ -38,6 +39,7 @@ async function harness(): Promise<{ ctx: Context }> {
   await ctx.plugin(SessionStore)
   await ctx.plugin(SystemPrompt, { persona: '' })
   await ctx.plugin(ToolRegistry)
+  await ctx.plugin(UserInteractionService)
   await ctx.plugin(AgentRegistry)
   ctx.tools.register(tool('gen', {
     presentCall: () => ({ card: 'generic', title: 'gen call' }),

+ 170 - 5
packages/host/runtime/tests/host-runtime.spec.ts

@@ -358,10 +358,175 @@ describe('events streams', () => {
   })
 })
 
-describe('respond stub', () => {
-  it('always reports not-pending (step2 registry pending)', async () => {
-    const { api } = await boot()
-    const receipt = await api.respond({ type: 'client-response', rpcId: RpcId('r'), result: { ok: true, value: null } })
-    expect(receipt).toEqual({ accepted: false, reason: 'not-pending' })
+describe('question request / response', () => {
+  const questions = [{
+    id: 'mode', question: 'Choose a mode',
+    options: [
+      { label: 'Fast (Recommended)', description: 'Move quickly.' },
+      { label: 'Careful', description: 'Review first.' },
+    ],
+  }]
+
+  it('waits, replays the same rpcId on reconnect, validates, and resolves first-wins', async () => {
+    const running = await boot()
+    const { api, ctx } = running
+    const { sessionId } = expectOk(await api.sessions.create(request({})))
+    const agent = ctx.agents.get(sessionId) as Agent
+    const ac = new AbortController()
+    const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
+    await stream.next() // subscribed baseline starts the generator and installs the queue
+
+    const answerPromise = ctx.userInteraction.ask({ questions, agent })
+    const requested = (await stream.next()).value as RpcRequest<MuxFrame>
+    expect(requested.payload).toMatchObject({ type: 'question/requested', sessionId, questions })
+
+    const wrongSession = await api.respond({
+      type: 'client-response', rpcId: requested.rpcId,
+      result: {
+        ok: true,
+        value: { sessionId: 'session-other', answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }] } },
+      },
+    })
+    expect(wrongSession).toEqual({ accepted: false, reason: 'bad-response' })
+    const badChoice = await api.respond({
+      type: 'client-response', rpcId: requested.rpcId,
+      result: {
+        ok: true,
+        value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Unknown'] }] } },
+      },
+    })
+    expect(badChoice).toEqual({ accepted: false, reason: 'bad-response' })
+    const invalidResults = [
+      { ok: true as const, value: null },
+      { ok: true as const, value: { sessionId, answer: { answers: [] } } },
+      { ok: true as const, value: { sessionId, answer: { answers: [{ id: 'wrong', selected: ['Fast (Recommended)'] }] } } },
+      { ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)', 'Fast (Recommended)'] }] } } },
+      { ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)', 'Careful'] }] } } },
+      { ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: [], custom: '   ' }] } } },
+      { ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Careful'], custom: 'Other' }] } } },
+      { ok: false as const, error: { code: 'internal' as const, message: 'wrong error', details: {} } },
+    ]
+    for (const result of invalidResults) {
+      expect(await api.respond({
+        type: 'client-response', rpcId: requested.rpcId, result,
+      })).toEqual({ accepted: false, reason: 'bad-response' })
+    }
+
+    const reconnectAbort = new AbortController()
+    const replay = api.events.mux(request({}), reconnectAbort.signal)[Symbol.asyncIterator]()
+    await replay.next()
+    const replayed = (await replay.next()).value as RpcRequest<MuxFrame>
+    expect(replayed.rpcId).toBe(requested.rpcId)
+    expect(replayed.payload).toEqual(requested.payload)
+
+    const response = {
+      type: 'client-response' as const,
+      rpcId: requested.rpcId,
+      result: {
+        ok: true as const,
+        value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }] } },
+      },
+    }
+    const [first, duplicate] = await Promise.all([api.respond(response), api.respond(response)])
+    expect([first, duplicate]).toContainEqual({ accepted: true })
+    expect([first, duplicate]).toContainEqual({ accepted: false, reason: 'not-pending' })
+    await expect(answerPromise).resolves.toEqual({
+      answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }],
+    })
+
+    const resolved = (await stream.next()).value as RpcRequest<MuxFrame>
+    expect(resolved.payload).toMatchObject({
+      type: 'question/resolved', sessionId, questionRpcId: requested.rpcId, outcome: 'answered',
+    })
+    expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' })
+
+    const customQuestions = [{ id: 'detail', question: 'What else?' }]
+    const customAnswer = ctx.userInteraction.ask({ questions: customQuestions, agent })
+    const customRequested = (await stream.next()).value as RpcRequest<MuxFrame>
+    expect(await api.respond({
+      type: 'client-response', rpcId: customRequested.rpcId,
+      result: {
+        ok: true,
+        value: { sessionId, answer: { answers: [{ id: 'detail', selected: [], custom: 'Keep traces' }] } },
+      },
+    })).toEqual({ accepted: true })
+    await expect(customAnswer).resolves.toEqual({
+      answers: [{ id: 'detail', selected: [], custom: 'Keep traces' }],
+    })
+    expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
+      type: 'question/resolved', questionRpcId: customRequested.rpcId, outcome: 'answered',
+    })
+
+    const blankAnswer = ctx.userInteraction.ask({ questions, agent })
+    const blankRequested = (await stream.next()).value as RpcRequest<MuxFrame>
+    expect(await api.respond({
+      type: 'client-response', rpcId: blankRequested.rpcId,
+      result: {
+        ok: true,
+        value: { sessionId, answer: { answers: [{ id: 'mode', selected: [] }] } },
+      },
+    })).toEqual({ accepted: true })
+    await expect(blankAnswer).resolves.toEqual({
+      answers: [{ id: 'mode', selected: [] }],
+    })
+    expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
+      type: 'question/resolved', questionRpcId: blankRequested.rpcId, outcome: 'answered',
+    })
+    ac.abort()
+    reconnectAbort.abort()
+  })
+
+  it('distinguishes user cancellation from owner abort and rejects late responses', async () => {
+    const running = await boot()
+    const { api, ctx } = running
+    const { sessionId } = expectOk(await api.sessions.create(request({})))
+    const agent = ctx.agents.get(sessionId) as Agent
+    const streamAbort = new AbortController()
+    const stream = api.events.mux(request({}), streamAbort.signal)[Symbol.asyncIterator]()
+    await stream.next()
+
+    const cancelled = ctx.userInteraction.ask({ questions, agent }).catch((error: unknown) => error)
+    const requested = (await stream.next()).value as RpcRequest<MuxFrame>
+    expect(await api.respond({
+      type: 'client-response', rpcId: requested.rpcId,
+      result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } },
+    })).toEqual({ accepted: true })
+    await expect(cancelled).resolves.toMatchObject({ code: 'ASK_CANCELLED' })
+    expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
+      type: 'question/resolved', outcome: 'cancelled',
+    })
+
+    const ownerAbort = new AbortController()
+    const aborted = ctx.userInteraction.ask({ questions, agent, signal: ownerAbort.signal })
+      .catch((error: unknown) => error)
+    const abortRequest = (await stream.next()).value as RpcRequest<MuxFrame>
+    ownerAbort.abort()
+    await expect(aborted).resolves.toMatchObject({ code: 'ASK_ABORTED' })
+    expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
+      type: 'question/resolved', questionRpcId: abortRequest.rpcId, outcome: 'cancelled',
+    })
+    expect(await api.respond({
+      type: 'client-response', rpcId: abortRequest.rpcId,
+      result: { ok: false, error: { code: 'cancelled', message: 'late', details: {} } },
+    })).toEqual({ accepted: false, reason: 'not-pending' })
+    streamAbort.abort()
+  })
+
+  it('rejects missing routing and pre-abort, then aborts outstanding waits on disposal', async () => {
+    const running = await boot()
+    const { ctx } = running
+    await expect(ctx.userInteraction.ask({ questions })).rejects.toMatchObject({ code: 'ASK_MISSING_AGENT' })
+    const { sessionId } = expectOk(await running.api.sessions.create(request({})))
+    const agent = ctx.agents.get(sessionId) as Agent
+    const alreadyAborted = new AbortController()
+    alreadyAborted.abort()
+    await expect(ctx.userInteraction.ask({ questions, agent, signal: alreadyAborted.signal }))
+      .rejects.toMatchObject({ code: 'ASK_ABORTED' })
+
+    const outstanding = ctx.userInteraction.ask({ questions, agent })
+    const disposed = running.dispose()
+    host = undefined
+    await expect(outstanding).rejects.toMatchObject({ code: 'ASK_ABORTED' })
+    await disposed
   })
 })

+ 14 - 3
packages/host/runtime/tests/web-plugins.e2e.ts

@@ -1,5 +1,5 @@
 /**
- * Web UI plugin assembly: the in-memory Loader tree mounts all eight UI
+ * Web UI plugin assembly: the in-memory Loader tree mounts all nine UI
  * packages (node halves), and the webserver registry built over it yields the
  * full __DSH_BOOT__ manifest — the P-I config-source bar end to end.
  *
@@ -10,6 +10,9 @@
 import { existsSync } from 'node:fs'
 import { createRequire } from 'node:module'
 import { Context } from 'cordis'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
 import { afterEach, describe, expect, it } from 'vitest'
 import { createHostWebPluginRegistry } from '@deepseek-ai/dsh-host-webserver'
 import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts'
@@ -31,8 +34,16 @@ afterEach(async () => {
 })
 
 describe.skipIf(!built)('mountWebPlugins + registry', () => {
-  it('mounts the eight-package in-memory Loader tree and projects the boot manifest', async () => {
+  async function rootWithHostServices(): Promise<Context> {
     root = new Context()
+    await root.plugin(SystemPrompt)
+    await root.plugin(ToolRegistry)
+    await root.plugin(UserInteractionService)
+    return root
+  }
+
+  it('mounts the nine-package in-memory Loader tree and projects the boot manifest', async () => {
+    root = await rootWithHostServices()
     const mounted = await mountWebPlugins(root)
     const registry = createHostWebPluginRegistry({
       ctx: root,
@@ -59,7 +70,7 @@ describe.skipIf(!built)('mountWebPlugins + registry', () => {
   })
 
   it('is idempotent: a second mount reuses the loader and creates no duplicate entries', async () => {
-    root = new Context()
+    root = await rootWithHostServices()
     await mountWebPlugins(root)
     const second = await mountWebPlugins(root)
     // ctx.loader hands out a fresh traced proxy per access, so loader identity

+ 3 - 3
packages/host/runtime/tests/web-plugins.spec.ts

@@ -1,5 +1,5 @@
 /**
- * mountWebPlugins unit coverage (keyless; the real eight-package walk is the
+ * mountWebPlugins unit coverage (keyless; the real nine-package walk is the
  * built-artifact e2e). The Loader-facing behavior — baseUrl anchoring, entry
  * creation with idempotent reuse, the fiber-less fail-loud sweep, and the
  * resolver seam — is exercised against a stubbed loader service so it runs
@@ -85,7 +85,7 @@ describe('mountWebPlugins (stubbed loader)', () => {
 
   it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => {
     root = new Context()
-    // Environment-dependent outcome: with built lib/ the eight imports load
+    // Environment-dependent outcome: with built lib/ the nine imports load
     // and the mount resolves; without them every entry stays fiber-less and
     // the sweep throws its loud list. Either way the branch under test is the
     // Loader auto-mount. Manual try/catch keeps cordis-traced proxies out of
@@ -100,7 +100,7 @@ describe('mountWebPlugins (stubbed loader)', () => {
     }
     expect(outcome === 'resolved' || /UI plugin\(s\) failed to load/.test(outcome)).toBe(true)
     expect(root.get('loader') !== undefined).toBe(true)
-  }, 30_000) // built-env run imports eight real plugin packages through the Loader
+  }, 30_000) // built-env run imports nine real plugin packages through the Loader
 
   it('keeps a caller-set baseUrl (anchors only when absent)', async () => {
     const entriesList: FakeEntry[] = []

+ 3 - 0
packages/host/runtime/tsconfig.json

@@ -137,6 +137,9 @@
     {
       "path": "../../client/ui-conversation"
     },
+    {
+      "path": "../../client/ui-question"
+    },
     {
       "path": "../../client/ui-trajectory"
     }

+ 1 - 1
packages/ui/user-interaction/README.md

@@ -17,7 +17,7 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod
 - `UserInteractionProvider` — UI implementation with `ask(request)`.
 - `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
 
-When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices.
+When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch.
 
 ## Role
 

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

@@ -31,7 +31,7 @@ export interface AskUserQuestionItem {
 export interface AskUserQuestionAnswerItem {
   /** The answered question id. */
   id: string
-  /** Selected option labels. Empty when the answer is purely custom text. */
+  /** Selected option labels. Empty for custom or unanswered choices. */
   selected: string[]
   /** Optional free-text "Other" answer. */
   custom?: string

+ 52 - 0
pnpm-lock.yaml

@@ -607,6 +607,52 @@ importers:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
 
+  packages/client/ui-question:
+    dependencies:
+      '@deepseek-ai/dsh-client-connection':
+        specifier: workspace:^
+        version: link:../connection
+      '@deepseek-ai/dsh-client-runtime':
+        specifier: workspace:^
+        version: link:../runtime
+      '@deepseek-ai/dsh-client-ui-conversation':
+        specifier: workspace:^
+        version: link:../ui-conversation
+      '@deepseek-ai/dsh-client-ui-primitives':
+        specifier: workspace:^
+        version: link:../ui-primitives
+      '@deepseek-ai/dsh-tool-ask-user':
+        specifier: workspace:^
+        version: link:../../ui/tool-ask-user
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
+    devDependencies:
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
+      '@deepseek-ai/dsh-user-interaction':
+        specifier: workspace:^
+        version: link:../../ui/user-interaction
+      '@types/react':
+        specifier: ~18.3.1
+        version: 18.3.31
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
   packages/client/ui-sidebar:
     dependencies:
       '@deepseek-ai/dsh-client-runtime':
@@ -1899,6 +1945,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-layout':
         specifier: workspace:^
         version: link:../../client/ui-layout
+      '@deepseek-ai/dsh-client-ui-question':
+        specifier: workspace:^
+        version: link:../../client/ui-question
       '@deepseek-ai/dsh-client-ui-sidebar':
         specifier: workspace:^
         version: link:../../client/ui-sidebar
@@ -1995,6 +2044,9 @@ importers:
       '@deepseek-ai/dsh-tools':
         specifier: workspace:^
         version: link:../../core/tools
+      '@deepseek-ai/dsh-user-interaction':
+        specifier: workspace:^
+        version: link:../../ui/user-interaction
       '@deepseek-ai/dsh-workflow-workerthread':
         specifier: workspace:^
         version: link:../../workflow/workflow-workerthread

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

@@ -53,6 +53,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
   'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
   'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
+  'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' },
   'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
   'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
   'packages/client/i18n': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },

+ 1 - 0
tsconfig.base.json

@@ -103,6 +103,7 @@
       "@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"],
       "@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"],
       "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"],
+      "@deepseek-ai/dsh-client-ui-question": ["./packages/client/ui-question/src"],
       "@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"],
       "@deepseek-ai/dsh-client-ui-theme": ["./packages/client/ui-theme/src"],
       "@deepseek-ai/dsh-client-i18n": ["./packages/client/i18n/src"],

+ 1 - 0
tsconfig.build.json

@@ -119,6 +119,7 @@
     { "path": "./packages/client/ui-layout" },
     { "path": "./packages/client/ui-sidebar" },
     { "path": "./packages/client/ui-conversation" },
+    { "path": "./packages/client/ui-question" },
     { "path": "./packages/client/ui-trajectory" },
     { "path": "./packages/client/ui-theme" },
     { "path": "./packages/client/i18n" },

+ 1 - 0
tsconfig.client.json

@@ -33,6 +33,7 @@
     { "path": "./packages/client/ui-layout" },
     { "path": "./packages/client/ui-sidebar" },
     { "path": "./packages/client/ui-conversation" },
+    { "path": "./packages/client/ui-question" },
     { "path": "./packages/client/ui-trajectory" },
     { "path": "./packages/client/ui-theme" },
     { "path": "./packages/client/i18n" },