diff --git a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.md b/.agents/notes/implemented/feature/2026-06-25-ask-user-question.md index e1c5d03c08..cd1a423499 100644 --- a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.md +++ b/.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. diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 4e489b264f..aae92861fe 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/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> 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([]) }) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 9ccdcad606..2ff6189a17 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -95,10 +95,10 @@ async function detailsTrack(page: Page): Promise { 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') diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d302361d11..082528d828 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1862,6 +1862,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)) diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index 1ec83b7827..e010b48987 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/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 diff --git a/docs/module-graph.md b/docs/module-graph.md index 289030e426..39c9f88b91 100644 --- a/docs/module-graph.md +++ b/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 @@ -765,6 +767,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) | diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 20312b41a3..d609009bc1 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2,8 +2,8 @@ // RpcRequest

and returns RpcResponse (echoing the rpcId); streams yield RpcRequest // (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 => 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['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>() const hostConns = new Set>() @@ -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 { - // 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 }) }, } } diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index c9c90f1ae0..0ced5f405e 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/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[]> => { const abort = new AbortController() const envelopes: RpcRequest[] = [] 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 | 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 | 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 () => { diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 6d3cec90ea..ebd7474298 100644 --- a/packages/client/ui-conversation/README.md +++ b/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. diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 23215f17d8..8cfebac81c 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/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. diff --git a/packages/client/ui-question/README.md b/packages/client/ui-question/README.md new file mode 100644 index 0000000000..a02c85d54c --- /dev/null +++ b/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. diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json new file mode 100644 index 0000000000..e40deeb248 --- /dev/null +++ b/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" + ] +} diff --git a/packages/client/ui-question/src/client/QuestionComposer.module.css b/packages/client/ui-question/src/client/QuestionComposer.module.css new file mode 100644 index 0000000000..6c0c1b854c --- /dev/null +++ b/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; + } +} diff --git a/packages/client/ui-question/src/client/QuestionComposer.tsx b/packages/client/ui-question/src/client/QuestionComposer.tsx new file mode 100644 index 0000000000..3571263f61 --- /dev/null +++ b/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): 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 +} + +function QuestionFlow({ pending }: { pending: PendingQuestion }) { + const questions = pending.questions + const [index, setIndex] = useState(0) + const [drafts, setDrafts] = useState(() => 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(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 ( +

+
+
+
+ {question.header !== undefined &&
{question.header}
} +

+ {question.multiSelect === true + ? parseQuestionTitle(question.question) + : question.question} + {question.multiSelect === true && 可多选} +

+ {question.detail !== undefined &&

{question.detail}

} +
+
+ {index + 1} / {questions.length} + + + +
+
+ +
+ {(question.options ?? []).map((option, optionIndex) => { + const selected = draft.selected.includes(option.label) + const display = parseRecommendedLabel(option.label) + return ( + + ) + })} + +
+ {hasOptions && ( + + )} + {draft.customOpen && ( +