Merge branch 'master' into worktree/web-session-titles

# Conflicts:
#	apps/web/tests/smoke-real.e2e.ts
#	packages/client/connection/tests/fixture.spec.ts
#	packages/host/runtime/README.md
#	packages/host/runtime/src/api-proxy.ts
This commit is contained in:
Tianyi Cui
2026-07-23 22:00:17 +08:00
111 changed files with 3906 additions and 336 deletions

View File

@@ -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'
@@ -301,6 +301,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>>()
@@ -497,7 +532,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 } })
@@ -512,6 +547,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 {
@@ -541,9 +584,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 })
},
}
}

View File

@@ -149,14 +149,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 >= 3) abort.abort()
if (envelopes.length >= 4) abort.abort()
}
return envelopes
}
@@ -167,6 +167,8 @@ describe('createFixtureApi', () => {
expect(first[1]?.payload).toMatchObject({ type: 'session/title', sessionId: 'fx-alpha', title: 'Fixture 历史会话' })
expect(first[2]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[2]?.rpcId).toBe(first[2]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[3]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[3]?.rpcId).toBe(first[3]?.rpcId)
})
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
@@ -219,9 +221,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 () => {

View File

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

View File

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

View File

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

View File

@@ -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"
]
}

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -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 }

View File

@@ -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',
)
}

View File

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

View File

@@ -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)
}

View File

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

View File

@@ -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)
})
})

View File

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

View File

@@ -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('选择信号')
})
})

View File

@@ -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"
}
]
}

View File

@@ -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'])