Просмотр исходного кода

Merge master into worktree/pty-review-fixes

Tianyi Cui 1 месяц назад
Родитель
Сommit
bd04f759a0
46 измененных файлов с 1871 добавлено и 57 удалено
  1. 7 3
      .agents/notes/implemented/feature/2026-06-25-ask-user-question.md
  2. 45 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. 56 6
      packages/client/connection/src/client/fixture.ts
  8. 34 3
      packages/client/connection/tests/fixture.spec.ts
  9. 1 1
      packages/client/ui-conversation/README.md
  10. 2 2
      packages/client/ui-conversation/src/client/index.ts
  11. 20 0
      packages/client/ui-question/README.md
  12. 67 0
      packages/client/ui-question/package.json
  13. 348 0
      packages/client/ui-question/src/client/QuestionComposer.module.css
  14. 297 0
      packages/client/ui-question/src/client/QuestionComposer.tsx
  15. 77 0
      packages/client/ui-question/src/client/contract/slots.ts
  16. 36 0
      packages/client/ui-question/src/client/index.ts
  17. 4 0
      packages/client/ui-question/src/css-modules.d.ts
  18. 17 0
      packages/client/ui-question/src/index.ts
  19. 31 0
      packages/client/ui-question/src/invariant.ts
  20. 64 0
      packages/client/ui-question/tests/browser-plugin.spec.ts
  21. 28 0
      packages/client/ui-question/tests/node-plugin.spec.ts
  22. 265 0
      packages/client/ui-question/tests/question-composer.spec.tsx
  23. 36 0
      packages/client/ui-question/tsconfig.json
  24. 3 0
      packages/client/ui-question/tsdown.config.ts
  25. 5 1
      packages/host/apiproxy/src/api/events.schema.ts
  26. 1 0
      packages/host/apiproxy/src/api/rpc.schema.ts
  27. 1 0
      packages/host/apiproxy/src/api/rpc.ts
  28. 5 0
      packages/host/apiproxy/tests/rpc-schemas.spec.ts
  29. 3 3
      packages/host/runtime/README.md
  30. 2 0
      packages/host/runtime/package.json
  31. 144 10
      packages/host/runtime/src/api-proxy.ts
  32. 2 0
      packages/host/runtime/src/boot.ts
  33. 6 5
      packages/host/runtime/src/web-plugins.ts
  34. 3 0
      packages/host/runtime/tests/api-proxy-cold.spec.ts
  35. 2 0
      packages/host/runtime/tests/api-proxy-view.spec.ts
  36. 170 5
      packages/host/runtime/tests/host-runtime.spec.ts
  37. 14 3
      packages/host/runtime/tests/web-plugins.e2e.ts
  38. 3 3
      packages/host/runtime/tests/web-plugins.spec.ts
  39. 3 0
      packages/host/runtime/tsconfig.json
  40. 1 1
      packages/ui/user-interaction/README.md
  41. 1 1
      packages/ui/user-interaction/src/types.ts
  42. 55 0
      pnpm-lock.yaml
  43. 1 0
      scripts/verify-package-readme-model-experience.ts
  44. 1 0
      tsconfig.base.json
  45. 1 0
      tsconfig.client.json
  46. 1 0
      vitest.config.ts

+ 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.

+ 45 - 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 + static serving. Second
-// describe: the settled success pass — seven REAL tsdown bundles (the
-// infrastructure four + layout/sidebar/conversation) 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,7 +17,7 @@ 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/sidebar). */
+/** 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 },
@@ -26,6 +26,8 @@ const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: b
   { 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. */
@@ -84,7 +86,7 @@ describe('web boot chain (keyless, real carrier)', () => {
   })
 })
 
-describe('web boot chain success pass (keyless, seven 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
@@ -216,6 +218,43 @@ describe('web boot chain success pass (keyless, seven real bundles, ?fixture)',
     expect(await external.getAttribute('rel')).toBe('noopener noreferrer')
   })
 
+  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

@@ -95,10 +95,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

@@ -1878,6 +1878,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

@@ -58,14 +58,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

@@ -137,6 +137,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"]
@@ -216,6 +217,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
@@ -769,6 +771,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) |

+ 56 - 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'
@@ -281,6 +281,41 @@ 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: '哪些面试信号最重要?',
+      detail: '按当前招聘目标选择;跳过则视为不设偏好。',
+      multiSelect: true,
+      options: [
+        { label: '系统设计' },
+        { label: '代码质量' },
+        { label: 'Agent 产品判断' },
+      ],
+    },
+  ]
 
   const muxConns = new Set<StreamConn<MuxFrame>>()
   const hostConns = new Set<StreamConn<HostFrame>>()
@@ -467,7 +502,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 } })
@@ -480,6 +515,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 {
@@ -509,9 +552,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 () => {

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

@@ -26,4 +26,4 @@ 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 answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project.

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

@@ -18,8 +18,8 @@ export type {
 } from './contract/views.ts'
 export type { ToolCallBlock } from './contract/tool-call-model.ts'
 export type {
-  ChatStore, ChatViewInjected, ChatViewSlotProps, ConversationInjected, ConversationSlotProps,
-  ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
+  ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ConversationInjected,
+  ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
   EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
 } from './contract/slots.ts'
 // Export discipline: packages/client/AGENTS.md.

+ 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.

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

@@ -0,0 +1,67 @@
+{
+  "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-client-ui-slots": "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"
+  ]
+}

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

@@ -0,0 +1,348 @@
+.frame {
+  display: flex;
+  justify-content: center;
+  padding: 6px 24px 10px;
+}
+
+.card {
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  max-width: 720px;
+  /* Composer seat sits in a fixed-height conversation column (overflow
+     hidden): cap the card against the viewport and scroll the option list
+     so header and footer actions stay reachable on long batches. */
+  max-height: min(60vh, 520px);
+  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;
+  flex-shrink: 0;
+  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;
+}
+
+.detail {
+  margin: 2px 0 0;
+  color: var(--dsw-alias-label-tertiary);
+  font-size: 13px;
+  line-height: 20px;
+  font-weight: 400;
+}
+
+.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;
+  /* The scrollable region of the capped card (ChatView list pattern). */
+  min-height: 0;
+  overflow-y: auto;
+}
+
+.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;
+  flex-shrink: 0;
+  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;
+  }
+}

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

@@ -0,0 +1,297 @@
+import { useMemo, useState, type KeyboardEvent } from 'react'
+import clsx from 'clsx'
+import {
+  Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
+  IconCloseOutline16, IconEditOutline16,
+} from '@deepseek-ai/dsh-client-ui-primitives'
+import { PendingQuestion, type QuestionAnswer, type QuestionComposerProps } from './contract/slots.ts'
+import css from './QuestionComposer.module.css'
+
+interface DraftAnswer {
+  selected: string[]
+  custom: string
+  customOpen: boolean
+  skipped: boolean
+}
+
+/**
+ * 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; the carrier key keys local drafts, so a
+ * same-request replay (same key, new carrier object) preserves them.
+ * @param props - the selector-matched pending question carrier plus the framework standard kit.
+ * @returns The question flow for this request.
+ */
+export function QuestionComposer(props: QuestionComposerProps) {
+  // Domain-face mint rides the carrier's stable identity (never minted in a
+  // select/render dispatch — per-dispatch minting would churn memo identity).
+  const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
+  return <QuestionFlow key={question.key} pending={question} />
+}
+
+function QuestionFlow({ pending }: { pending: PendingQuestion }) {
+  const questions = pending.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 pending.cancel().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: QuestionAnswer = {
+      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 pending.answer(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-key={pending.key}>
+      <section className={css.card} aria-labelledby={`question-${pending.key}-${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-${pending.key}-${String(index)}`}>
+              <span>{question.multiSelect === true
+                ? parseQuestionTitle(question.question)
+                : question.question}</span>
+              {question.multiSelect === true && <span className={css.multiSelectHint}>可多选</span>}
+            </h2>
+            {question.detail !== undefined && <p className={css.detail}>{question.detail}</p>}
+          </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>
+  )
+}

+ 77 - 0
packages/client/ui-question/src/client/contract/slots.ts

@@ -0,0 +1,77 @@
+/**
+ * Question-composer slot contract: the registrant-side props composition for
+ * the conversation-owned `conversation.composer` slot, plus the question
+ * domain face over the runtime's carrier object. The carrier (PendingWait)
+ * owns envelope transport only; the question protocol — answer value shape,
+ * cancelled error encoding, receipt checks — lives HERE, with the package
+ * that consumes it.
+ */
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+// Also pulls ui-conversation's SlotMap merge (the 'conversation.composer'
+// entry) into every program that sees this contract, so PropsRuntime resolves.
+import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
+import type { QuestionResponsePayload } from '@deepseek-ai/dsh-client-connection/client'
+
+/** The pending question carrier the owner dispatches into the composer slot. */
+export type QuestionWait = PendingWait<'question'>
+
+/** One structured answer batch covering every question of the request. */
+export type QuestionAnswer = QuestionResponsePayload['answer']
+
+/**
+ * Question domain face over the carrier: render identity and questions
+ * transparently forwarded; answer/cancel own the wire encoding (the ok value
+ * shape and the cancelled error) and turn a rejected carrier receipt into a
+ * thrown error. Components mint one per carrier via useMemo (never inside a
+ * select — a per-dispatch mint would churn identity and break memoization).
+ */
+export class PendingQuestion {
+  /**
+   * @param wait - the runtime carrier for one pending question request.
+   */
+  constructor(private readonly wait: QuestionWait) {}
+
+  /** Opaque render identity (React key / draft remount axis), forwarded from the carrier. */
+  get key(): string {
+    return this.wait.key
+  }
+
+  /** The request's question list, forwarded from the carrier payload. */
+  get questions(): QuestionWait['payload']['questions'] {
+    return this.wait.payload.questions
+  }
+
+  /**
+   * Deliver the whole answer batch; a rejected carrier receipt throws.
+   * @param answer - complete structured answer batch.
+   */
+  async answer(answer: QuestionAnswer): Promise<void> {
+    const receipt = await this.wait.respond({
+      ok: true, value: { sessionId: this.wait.sessionId, answer },
+    })
+    if (!receipt.accepted) {
+      throw new Error(`question response rejected: ${receipt.reason}`)
+    }
+  }
+
+  /** Reject the whole wait (the host resolves the tool call as cancelled); a rejected receipt throws. */
+  async cancel(): Promise<void> {
+    const receipt = await this.wait.respond({
+      ok: false,
+      error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
+    })
+    if (!receipt.accepted) {
+      throw new Error(`question cancellation rejected: ${receipt.reason}`)
+    }
+  }
+}
+
+/**
+ * Full component props: the framework runtime share (chain currency +
+ * session/global standard kit) plus the chain `matched` share — the entry's
+ * selector result, already narrowed to the question carrier. No injected
+ * share: the carrier plus the domain face above carry the whole behavior
+ * surface.
+ */
+export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & { matched: QuestionWait }

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

@@ -0,0 +1,36 @@
+/**
+ * Web question plugin, browser half: QuestionComposer registered as a
+ * selector-routed entry of the conversation-declared composer chain. Pure
+ * consumer — the selector narrows the owner's currency to the question
+ * carrier (matched prop), and the whole behavior surface rides the carrier
+ * (domain encoding in contract/slots.ts PendingQuestion); no inject face, no
+ * service dependency beyond slots. Export discipline: packages/client/AGENTS.md.
+ */
+import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { QuestionWait } from './contract/slots.ts'
+import { QuestionComposer } from './QuestionComposer.tsx'
+
+export { PendingQuestion } from './contract/slots.ts'
+export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
+
+/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
+export const inject = ['slots']
+
+/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
+function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
+  return interactions.find((i): i is QuestionWait => i.kind === 'question') ?? null
+}
+
+/**
+ * Client plugin body: register the question composer into the composer chain.
+ * Zero business face — data and verbs both live on the matched carrier.
+ * @param ctx - client root context.
+ */
+export function apply(ctx: ClientContext): void {
+  const slots = ctx.slots
+  ctx.effect(
+    () => slots.register({ name: 'conversation.composer', select: selectQuestion }, QuestionComposer),
+    'ui-question: composer chain registration',
+  )
+}

+ 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 */

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

@@ -0,0 +1,64 @@
+/**
+ * apply wiring on a real cordis Context + SlotsService: QuestionComposer
+ * registered as the `question` entry of the conversation-declared composer
+ * slot with ZERO business face (data and verbs ride the dispatched carrier),
+ * load-order fail-loud, and fiber-teardown unregistration. Component and
+ * domain-face behavior is covered props-direct in question-composer.spec.tsx;
+ * no renderer machinery here.
+ */
+import { Context } from 'cordis'
+import { describe, expect, it } from 'vitest'
+import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
+import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
+import { apply, inject } from '../src/client/index.ts'
+
+async function bench() {
+  const ctx = new Context()
+  await ctx.plugin(SlotsService).await()
+  const slots = ctx.get('slots') as SlotsService
+  // Stand-in for ui-conversation's conversation entry: the composer slot only
+  // exists while a live entry declares it in children (declaration account:
+  // design §2.2).
+  slots.register(
+    { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
+    () => null,
+  )
+  return { ctx, slots }
+}
+
+describe('apply', () => {
+  it('declares the services it binds', () => {
+    expect(inject).toEqual(['slots'])
+  })
+
+  it('fails loud when no live entry has declared the composer slot', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SlotsService).await()
+    await expect(ctx.plugin({ inject: [...inject], apply }))
+      .rejects.toThrow(/slot "conversation.composer" is not declared/)
+  })
+
+  it('registers the question entry: routing selector, no inject face', async () => {
+    const { ctx, slots } = await bench()
+    await ctx.plugin({ inject: [...inject], apply }).await()
+    const entry = slots.entries('conversation.composer')[0]!
+    expect(entry.component).toBe(QuestionComposer)
+    // The whole behavior surface rides the matched carrier: no business face.
+    expect(entry.inject).toBeUndefined()
+    // The selector narrows the chain currency: question wait in → that wait; none → null.
+    const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown
+    const question = { kind: 'question' }
+    expect(select({ interactions: [{ kind: 'approval' }, question] })).toBe(question)
+    expect(select({ interactions: [{ kind: 'approval' }] })).toBeNull()
+    expect(select({ interactions: [] })).toBeNull()
+  })
+
+  it('teardown unregisters the slot entry', async () => {
+    const { ctx, slots } = await bench()
+    const fiber = ctx.plugin({ inject: [...inject], apply })
+    await fiber.await()
+    expect(slots.entries('conversation.composer')).toHaveLength(1)
+    await fiber.dispose()
+    expect(slots.entries('conversation.composer')).toHaveLength(0)
+  })
+})

+ 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()
+  })
+})

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

@@ -0,0 +1,265 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
+import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
+import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
+import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
+import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
+import { PendingQuestion } from '../src/client/contract/slots.ts'
+import {
+  QuestionComposer, parseQuestionTitle, parseRecommendedLabel,
+} from '../src/client/QuestionComposer.tsx'
+
+afterEach(cleanup)
+
+const SID = 's1' as SessionId
+
+/** Framework standard-kit stubs: the composer consumes none of them, the
+ *  composed props type mandates their delivery (framework hooks are plain
+ *  stubs per the client testing discipline). */
+const kit = {
+  sessionId: SID,
+  useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
+  useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
+}
+
+const QUESTIONS = [
+  {
+    id: 'profile', header: '偏好', question: '选择候选人类型',
+    detail: '按当前空缺岗位的优先级选择。',
+    options: [
+      { label: '工程落地型 (Recommended)', description: '优先工程交付。' },
+      { label: '研究潜力型', description: '优先研究能力。' },
+    ],
+  },
+  {
+    id: 'detail', question: '补充你的要求',
+  },
+  {
+    id: 'signals', question: '选择重要信号(可多选)', multiSelect: true,
+    options: [{ label: '系统设计' }, { label: '代码质量' }, { label: '产品判断' }],
+  },
+]
+
+/** Carrier fixture: a real PendingWait over a scripted respond carrier. */
+function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))) {
+  const carrier = new PendingWait(
+    'question', RpcId(rpcId), SID, { questions: QUESTIONS } as PendingWait<'question'>['payload'], respond)
+  return { carrier, respond }
+}
+
+/** The client-response envelope respond must have received for an answer batch. */
+function answeredEnvelope(rpcId: string, answers: object[]) {
+  return {
+    type: 'client-response', rpcId: RpcId(rpcId),
+    result: { ok: true, value: { sessionId: SID, answer: { answers } } },
+  }
+}
+
+describe('QuestionComposer', () => {
+  it('collects single, custom, and multi-select answers before one batch submit', () => {
+    const { carrier, respond } = wait()
+    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+
+    expect(screen.getByText('1 / 3')).toBeTruthy()
+    expect(screen.getByText('推荐')).toBeTruthy()
+    expect(screen.getByText('工程落地型')).toBeTruthy()
+    expect(screen.getByText('按当前空缺岗位的优先级选择。')).toBeTruthy()
+    fireEvent.keyDown(screen.getByRole('radio', { name: /工程落地型/ }), { key: 'Enter' })
+    expect(respond).not.toHaveBeenCalled()
+    fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
+
+    expect(screen.getByText('2 / 3')).toBeTruthy()
+    // detail is per-question: the second question carries none.
+    expect(screen.queryByText('按当前空缺岗位的优先级选择。')).toBeNull()
+    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' })
+
+    // The domain face encoded the whole batch into one carrier envelope.
+    expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
+      { 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 { carrier, respond } = wait()
+    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+
+    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(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
+      { id: 'profile', selected: ['研究潜力型'] },
+      { id: 'detail', selected: [] },
+      { id: 'signals', selected: [] },
+    ]))
+  })
+
+  it('keeps IME Enter inside the custom input until composition finishes', () => {
+    const { carrier, respond } = wait()
+    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+
+    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(respond).not.toHaveBeenCalled()
+
+    fireEvent.keyDown(custom, { key: 'Enter', keyCode: 229 })
+    expect(screen.getByText('2 / 3')).toBeTruthy()
+    expect(respond).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 { carrier, respond } = wait()
+    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+
+    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(respond).not.toHaveBeenCalled()
+  })
+
+  it('surfaces cancellation failures: rejected receipt text and raw transport reasons', async () => {
+    const respond = vi.fn()
+      .mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
+      .mockRejectedValueOnce(new Error('第二次取消失败'))
+    const { carrier } = wait('question-1', respond)
+    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+
+    // Receipt rejection surfaces through the domain face's thrown message.
+    fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
+    expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy()
+    expect((screen.getByRole('button', { name: '跳过本题' }) as HTMLButtonElement).disabled).toBe(false)
+
+    fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
+    expect(await screen.findByText('第二次取消失败')).toBeTruthy()
+  })
+
+  it('surfaces transport rejection and resets local drafts for a different request', async () => {
+    const respond = vi.fn()
+      .mockRejectedValueOnce(new Error('网络中断'))
+      .mockRejectedValueOnce('字符串错误')
+    const first = wait('first', respond)
+    const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
+
+    fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
+    expect(screen.getByText('2 / 3')).toBeTruthy()
+    const second = wait('second', respond)
+    view.rerender(<QuestionComposer matched={second.carrier} interactions={[second.carrier]} {...kit} />)
+    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)
+
+    fireEvent.click(screen.getByRole('button', { name: '提交' }))
+    expect(await screen.findByText('字符串错误')).toBeTruthy()
+  })
+
+  it('same-key carrier replacement (baseline replay) keeps drafts', () => {
+    const first = wait('same-id')
+    const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
+    fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
+    expect(screen.getByText('2 / 3')).toBeTruthy()
+    // Replay mints a NEW carrier for the same request; same key = no remount.
+    const replayed = wait('same-id')
+    view.rerender(<QuestionComposer matched={replayed.carrier} interactions={[replayed.carrier]} {...kit} />)
+    expect(screen.getByText('2 / 3')).toBeTruthy()
+  })
+})
+
+describe('PendingQuestion domain face', () => {
+  it('encodes the answer batch into the ok envelope and throws on a rejected receipt', async () => {
+    const respond = vi.fn()
+      .mockResolvedValueOnce({ accepted: true })
+      .mockResolvedValueOnce({ accepted: false, reason: 'not-pending' })
+    const question = new PendingQuestion(wait('rq', respond).carrier)
+    const batch = { answers: [{ id: 'mode', selected: ['Fast'] }] }
+    await expect(question.answer(batch)).resolves.toBeUndefined()
+    expect(respond).toHaveBeenCalledWith(answeredEnvelope('rq', batch.answers))
+    await expect(question.answer(batch)).rejects.toThrow(/question response rejected: not-pending/)
+  })
+
+  it('encodes cancellation as the cancelled error envelope and throws on a rejected receipt', async () => {
+    const respond = vi.fn()
+      .mockResolvedValueOnce({ accepted: true })
+      .mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
+    const question = new PendingQuestion(wait('rc', respond).carrier)
+    await expect(question.cancel()).resolves.toBeUndefined()
+    expect(respond).toHaveBeenCalledWith({
+      type: 'client-response', rpcId: RpcId('rc'),
+      result: {
+        ok: false,
+        error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
+      },
+    })
+    await expect(question.cancel()).rejects.toThrow(/question cancellation rejected: bad-response/)
+  })
+
+  it('forwards key and questions from the carrier', () => {
+    const question = new PendingQuestion(wait('rk').carrier)
+    expect(question.key).toBe('q:rk')
+    expect(question.questions).toBe(wait('rk').carrier.payload.questions)
+  })
+})
+
+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('选择信号')
+  })
+})

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

@@ -0,0 +1,36 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../connection"
+    },
+    {
+      "path": "../runtime"
+    },
+    {
+      "path": "../ui-conversation"
+    },
+    {
+      "path": "../ui-primitives"
+    },
+    {
+      "path": "../ui-slots"
+    },
+    {
+      "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'])

+ 5 - 1
packages/host/apiproxy/src/api/events.schema.ts

@@ -17,6 +17,7 @@ export const askUserQuestionItemSchema = z.object({
   id: z.string(),
   question: z.string(),
   header: z.string().optional(),
+  detail: z.string().optional(),
   options: z.array(z.object({ label: z.string(), description: z.string().optional() })).optional(),
   multiSelect: z.boolean().optional(),
 }) satisfies z.ZodType<Wire<AskUserQuestionItem>>
@@ -27,7 +28,10 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
   z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
   z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
   z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }),
-  z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema) }),
+  // Non-empty by wire contract: the user-interaction service rejects empty
+  // batches at ask() (EMPTY_QUESTIONS), so an empty frame is host breakage
+  // and must fail loud here, not reach the composer.
+  z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema).min(1) }),
   z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }),
   z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
 ]) as unknown as z.ZodType<MuxFrame>

+ 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': {}

+ 5 - 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')
@@ -134,6 +135,10 @@ describe('events frame schemas', () => {
     expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q')
   })
 
+  it('rejects an empty question batch (ask() guarantees at least one, so an empty frame is host breakage)', () => {
+    expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow()
+  })
+
   it('accepts every host frame branch', () => {
     const frames = [
       { type: 'host/session-added', sessionId: 's', parentSessionId: 'p' },

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

@@ -1,6 +1,6 @@
 # @deepseek-ai/dsh-host-runtime
 
-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, workspace instructions, 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, workspace instructions, 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.
 
@@ -15,7 +15,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
 
@@ -27,6 +27,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.
+- **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 report 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-workspace-context": "workspace:^",
     "@deepseek-ai/dsh-workflow-workerthread": "workspace:^"
   },

+ 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 { JsonValue, 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?: JsonValue }
 
+/** 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

@@ -38,6 +38,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 {
@@ -94,6 +95,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

@@ -18,6 +18,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'
@@ -39,6 +40,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

@@ -418,10 +418,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

@@ -140,6 +140,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

@@ -33,7 +33,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

+ 55 - 0
pnpm-lock.yaml

@@ -631,6 +631,55 @@ 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-client-ui-slots':
+        specifier: workspace:^
+        version: link:../ui-slots
+      '@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':
@@ -1984,6 +2033,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
@@ -2080,6 +2132,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

@@ -108,6 +108,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.client.json

@@ -32,6 +32,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
vitest.config.ts

@@ -96,6 +96,7 @@ export default defineConfig({
         // branches need a browser-grade harness the jsdom lane doesn't cover
         // yet. TODO(gui): cover and remove as the client test lane matures.
         'packages/client/ui-trajectory/src/*',
+        'packages/client/ui-question/src/client/QuestionComposer.tsx',
         'packages/client/web-react/src/*',
         'packages/client/runtime/src/*',
         'packages/client/ui-conversation/src/*',