diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index c90c5011cc..f8e348cf75 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -79,11 +79,11 @@ export function apply(ctx: Context): void { slots.register({ name: 'conversation', - // Declaring the keyed composer slot here both creates it and authorizes - // ConversationRoot (the takeover dispatch site) to render it; feature - // plugins (ui-question) register their replacement composers into it. + // Declaring the chain composer slot here both creates it and authorizes + // ConversationRoot (the takeover dispatch site) to render it; takeover + // plugins (ui-question) register selector-routed composer replacements. children: { - 'conversation.composer': { kind: 'keyed', scope: 'session' }, + 'conversation.composer': { kind: 'chain', scope: 'session' }, }, store: chat, inject: (sessionId: SessionId, actions: BoundActions): ConversationInjected => { diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 4f6c2de4eb..45a5541ed2 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -271,7 +271,7 @@ export function createChatView(deps: ChatViewDeps): FC { )} {pending.map((item) => item.kind === 'approval' - ? + ? : null)} diff --git a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx index 408faa7ddf..ea2dbf9568 100644 --- a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx @@ -1,18 +1,18 @@ // PendingCard: approval placeholder card. Questions take over the composer. import { memo } from 'react' -import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client' +import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' import css from './PendingCard.module.css' export interface PendingCardProps { - item: Extract + item: PendingWait<'approval'> } export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) { return (
-
等待审批:{item.toolName}
- {item.reason !== undefined &&
{item.reason}
} +
等待审批:{item.payload.toolName}
+ {item.payload.reason !== undefined &&
{item.payload.reason}
}
请在原客户端处理(web 端作答后续里程碑提供)
) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index cb643ffe5d..a3acea8320 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -4,7 +4,7 @@ * conversation.empty). Terminal slot design (§3): full component props are the * automatic shares — PropsRuntime (framework standard kit) & PropsStore * (declared store's read/write faces) & the injected business face declared - * here. The conversation entry alone declares a child slot (the keyed + * here. The conversation entry alone declares a child slot (the chain-kind * conversation.composer takeover), so only ConversationSlotProps carries the * renderSlot share. */ @@ -42,9 +42,16 @@ export interface ConversationInjected { open(id: SessionId): void } -/** Question-composer owner share supplied by ConversationRoot at its renderSlot site. */ -export interface QuestionComposerOwnerProps { - interaction: Extract +/** + * Composer chain currency: what ConversationRoot dispatches at its + * renderSlotChain site. The owner declares the currency only — never a + * per-entry contract; takeover packages narrow it in their own selectors + * (`interactions.find(i => i.kind === ...)`), so new takeover kinds register + * with zero owner changes. + */ +export interface ComposerChainProps { + /** The session's live pending waits, in arrival order (snapshot reference). */ + interactions: readonly PendingInteraction[] } /** Full conversation-slot component props: runtime share & child-render share & store share & injected share. */ diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 4f27e6a63a..29e709576c 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -8,7 +8,7 @@ */ import type { ConversationService } from './service.ts' import type { ToolViewRegistry } from './toolviews/registry.ts' -import type { QuestionComposerOwnerProps } from './contract/slots.ts' +import type { ComposerChainProps } from './contract/slots.ts' export { apply, inject } from './apply.ts' export { ConversationService } from './service.ts' @@ -22,8 +22,8 @@ export type { ResolvedToolView, ToolCallBlock, ToolViewOptions, ToolViewProps, ToolViewResolver, } from './contract/toolview.ts' export type { - ChatStore, ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps, - EmptyStateInjected, EmptyStateSlotProps, QuestionComposerOwnerProps, + ChatStore, ComposerChainProps, ConversationInjected, ConversationSlotProps, DetailsInjected, + DetailsSlotProps, EmptyStateInjected, EmptyStateSlotProps, } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. @@ -37,9 +37,9 @@ declare module 'cordis' { declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { 'conversation.composer': { - kind: 'keyed' + kind: 'chain' scope: 'session' - owner: QuestionComposerOwnerProps + owner: ComposerChainProps } } } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 763b7fa343..4fb398ae36 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -36,7 +36,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session export function ConversationRoot({ sessionId, useSession, useSessions, useStore, actions, - views, send, stop, openDetails, loadOlder, open, renderSlot, + views, send, stop, openDetails, loadOlder, open, renderSlotChain, }: ConversationRootProps) { useSyncExternalStore(views.subscribe, views.version) const list = views.list() @@ -51,7 +51,7 @@ export function ConversationRoot({ const removed = useSession(s => s.removed) const promptError = useSession(s => s.promptError) const turns = useSession(s => countTurns(s)) - const question = useSession(s => s.pending.find(item => item.kind === 'question')) + const pending = useSession(s => s.pending) const error: InputBarError | null = promptError === null ? null @@ -78,8 +78,8 @@ export function ConversationRoot({ ) } - // The default composer doubles as the keyed slot's fallback: a pending - // question with no registered takeover must still leave the input usable. + // The default composer doubles as the chain's all-decline fallback: a + // pending wait with no registered takeover must still leave the input usable. const composerBar = ( - {question !== undefined && question.kind === 'question' - ? renderSlot('conversation.composer', { interaction: question }, { - entryKey: 'question', - fallback: composerBar, - }) - : composerBar} + {renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })} ) } diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index aad467f9f5..cd4fb51283 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -8,7 +8,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { act } from '@testing-library/react' import type { SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' -import type { RpcId } from '@deepseek-ai/dsh-client-connection/client' +import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' +import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import { hookOf } from './hook.ts' import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots' import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -65,7 +66,7 @@ describe('MessageItem arms', () => { describe('small branch tails', () => { it('PendingCard approval reason renders when present', () => { const view = render( - , + ['payload'], vi.fn())} />, ) expect(view.getByText('careful')).toBeTruthy() }) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index a93366ff93..f48580b3e7 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -9,6 +9,8 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react' import type { AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' +import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' +import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import { hookOf } from './hook.ts' import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots' import type { ConvViewProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -288,11 +290,10 @@ describe('ChatView', () => { it('renders approval cards while questions stay in the composer', () => { const h = makeHarness({ pending: [ - { kind: 'approval', rpcId: 'r1' as never, approvalId: 'ap1', toolName: 'bash' }, - { - kind: 'question', rpcId: 'r2' as never, - questions: [{ id: 'mode', question: 'Composer only?', options: [{ label: 'Yes' }] }], - }, + new PendingWait('approval', RpcId('r1'), SID, + { approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn()), + new PendingWait('question', RpcId('r2'), SID, + { questions: [{ id: 'mode', question: 'Composer only?', options: [{ label: 'Yes' }] }] } as PendingWait<'question'>['payload'], vi.fn()), ], }) const view = render() diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index c2c219f2b7..93f193a50a 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -21,9 +21,12 @@ import { EmptyState } from '../src/client/skeleton/EmptyState.tsx' afterEach(cleanup) const SID = 's1' as SessionId -/** Fallback-only renderSlot stub (no takeover registered in these benches). */ -const fallbackRenderSlot: ConversationSlotProps['renderSlot'] = +/** Fallback-only chain stub (no takeover registered in these benches). */ +const fallbackRenderSlotChain: ConversationSlotProps['renderSlotChain'] = (_key, _owner, opts) => opts?.fallback ?? null +/** Non-chain renderSlot stub: ConversationRoot renders no non-chain child keys. */ +const unusedRenderSlot: ConversationSlotProps['renderSlot'] = + (() => { throw new Error('no non-chain child keys') }) as unknown as ConversationSlotProps['renderSlot'] /** Standard-seat stub: ConversationRoot never renders it, delivery is mandatory in the props type. */ const StubSessionProvider: ConversationSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)} @@ -81,7 +84,8 @@ describe('ConversationRoot branches', () => { openDetails={vi.fn()} loadOlder={vi.fn()} open={open} - renderSlot={fallbackRenderSlot} + renderSlot={unusedRenderSlot} + renderSlotChain={fallbackRenderSlotChain} SessionProvider={StubSessionProvider} />, ) @@ -141,7 +145,8 @@ describe('ConversationRoot branches', () => { openDetails={vi.fn()} loadOlder={vi.fn()} open={vi.fn()} - renderSlot={fallbackRenderSlot} + renderSlot={unusedRenderSlot} + renderSlotChain={fallbackRenderSlotChain} SessionProvider={StubSessionProvider} />, ) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 6a97b8b7b8..7869842c70 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -12,8 +12,9 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FC } from 'react' import { hookOf } from './hook.ts' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client' import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots' +import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { ConversationSnapshot, PendingInteraction, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSlotProps, SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client' // Export discipline: packages/client/AGENTS.md. @@ -102,7 +103,7 @@ describe('EmptyState', () => { describe('ConversationRoot', () => { function bench( views: ViewEntry[], activeView?: string, init: Partial = {}, - renderSlot?: ConversationSlotProps['renderSlot'], + renderSlotChain?: ConversationSlotProps['renderSlotChain'], ) { const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init }) const { useSessions } = fakeSessions([ @@ -133,7 +134,8 @@ describe('ConversationRoot', () => { openDetails={openDetails} loadOlder={loadOlder} open={open} - renderSlot={renderSlot ?? ((_key, _owner, opts) => opts?.fallback ?? null)} + renderSlot={(() => { throw new Error('no non-chain child keys') }) as unknown as ConversationSlotProps['renderSlot']} + renderSlotChain={renderSlotChain ?? ((_key, _owner, opts) => opts?.fallback ?? null)} SessionProvider={StubSessionProvider} />) return { ui, chat, send, stop, open } @@ -195,20 +197,22 @@ describe('ConversationRoot', () => { expect(send).toHaveBeenCalledWith('hi', 'queue') }) - it('dispatches a pending question to the composer slot instead of rendering InputBar', () => { - const renderSlot = vi.fn(() =>
question takeover
) as unknown as ConversationSlotProps['renderSlot'] + it('dispatches the pending list to the composer chain instead of rendering InputBar', () => { + const renderSlotChain = vi.fn(() =>
question takeover
) as unknown as ConversationSlotProps['renderSlotChain'] bench([view('chat', 'Chat')], undefined, { - pending: [{ - kind: 'question', rpcId: 'rq' as never, - questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }], - }], - }, renderSlot) + pending: [new PendingWait('question', RpcId('rq'), sid('s1'), + { questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn())], + }, renderSlotChain) expect(screen.getByText('question takeover')).toBeTruthy() expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull() - expect(renderSlot).toHaveBeenCalledWith( + // The owner dispatches the raw pending list (chain currency); routing + // lives in entry selectors, not here. + expect(renderSlotChain).toHaveBeenCalledWith( 'conversation.composer', - expect.objectContaining({ interaction: expect.objectContaining({ rpcId: 'rq' }) }), - expect.objectContaining({ entryKey: 'question' }), + expect.objectContaining({ + interactions: expect.arrayContaining([expect.objectContaining({ key: 'q:rq' })]), + }), + expect.objectContaining({ fallback: expect.anything() }), ) }) }) diff --git a/packages/client/ui-question/src/client/QuestionComposer.tsx b/packages/client/ui-question/src/client/QuestionComposer.tsx index ec08d2c9c9..c85d731287 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.tsx +++ b/packages/client/ui-question/src/client/QuestionComposer.tsx @@ -1,10 +1,10 @@ -import { useState, type KeyboardEvent } from 'react' +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 type { QuestionAnswer, QuestionComposerProps } from './contract/slots.ts' +import { PendingQuestion, type QuestionAnswer, type QuestionComposerProps } from './contract/slots.ts' import css from './QuestionComposer.module.css' interface DraftAnswer { @@ -41,16 +41,20 @@ function isComposing(event: KeyboardEvent): boolean { } /** - * Composer takeover boundary; rpcId keys local drafts while same-id replay preserves them. - * @param props - Pending interaction and scoped answer/cancel actions. + * 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) { - return + // 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({ interaction, answer: submitAnswer, cancel }: QuestionComposerProps) { - const questions = interaction.questions +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, @@ -64,7 +68,7 @@ function QuestionFlow({ interaction, answer: submitAnswer, cancel }: QuestionCom const cancelFlow = (): void => { setBusy('cancel') setError(null) - void cancel(interaction).catch((cause: unknown) => { + void pending.cancel().catch((cause: unknown) => { setBusy(null) setError(cause instanceof Error ? cause.message : String(cause)) }) @@ -119,7 +123,7 @@ function QuestionFlow({ interaction, answer: submitAnswer, cancel }: QuestionCom } setBusy('answer') setError(null) - void submitAnswer(interaction, answer).catch((cause: unknown) => { + void pending.answer(answer).catch((cause: unknown) => { setBusy(null) setError(cause instanceof Error ? cause.message : String(cause)) }) @@ -156,12 +160,12 @@ function QuestionFlow({ interaction, answer: submitAnswer, cancel }: QuestionCom } return ( -
-
+
+
{question.header !== undefined &&
{question.header}
} -

+

{question.multiSelect === true ? parseQuestionTitle(question.question) : question.question} diff --git a/packages/client/ui-question/src/client/contract/slots.ts b/packages/client/ui-question/src/client/contract/slots.ts index 5a6139f118..e3c3e815bf 100644 --- a/packages/client/ui-question/src/client/contract/slots.ts +++ b/packages/client/ui-question/src/client/contract/slots.ts @@ -1,42 +1,77 @@ /** * Question-composer slot contract: the registrant-side props composition for - * the conversation-owned `conversation.composer` keyed slot. The own injected - * share is declared here (a share's type lives with whoever wires it); the - * runtime share — the owner-dispatched `interaction` plus the framework - * session/global standard kit — is PropsRuntime<'conversation.composer'>, - * resolved off ui-conversation's SlotMap declaration and never re-stated. - * Single domain — this is the package's whole contract surface. + * 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 { QuestionComposerOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +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 interaction the owner dispatches into the keyed slot. */ -export type QuestionInteraction = QuestionComposerOwnerProps['interaction'] +/** 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'] /** - * Registrant-private injected share (arrives via the register inject - * factory): plain session-scoped callbacks only — the question data rides the - * owner share and drafts are component-local. A type alias, not an interface: - * the alias carries an implicit index signature, so the factory's return - * crosses the registry's `Record` boundary uncast. + * 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 type QuestionComposerInjected = { - /** Deliver the whole answer batch; a rejected receipt surfaces as a thrown error. */ - answer: (interaction: QuestionInteraction, answer: QuestionAnswer) => Promise - /** Reject the whole wait (the host resolves the tool call as cancelled). */ - cancel: (interaction: QuestionInteraction) => Promise +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 { + 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 { + 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 (owner `interaction` + - * session/global standard kit) plus the own injected share. No children are - * declared and no store is registered, so no PropsRenderSlots/PropsStore - * term appears. + * 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'> & QuestionComposerInjected +export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & { matched: QuestionWait } diff --git a/packages/client/ui-question/src/client/index.ts b/packages/client/ui-question/src/client/index.ts index 56104d76ca..ef0a253b61 100644 --- a/packages/client/ui-question/src/client/index.ts +++ b/packages/client/ui-question/src/client/index.ts @@ -1,66 +1,37 @@ /** - * Web question plugin, browser half: QuestionComposer registered as the - * `question` entry of the conversation-declared keyed `conversation.composer` - * slot. Pure consumer — the pending interaction arrives through the owner - * share at the dispatch site, drafts are component-local, and the inject - * surface is plain session-scoped callbacks closed over the plugin's own ctx - * (slot design sections 5 and 6); props composition in contract/slots.ts. - * Export discipline: packages/client/AGENTS.md. + * 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, SessionId, SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { QuestionComposerInjected } from './contract/slots.ts' +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 type { - QuestionAnswer, QuestionComposerInjected, QuestionComposerProps, QuestionInteraction, -} from './contract/slots.ts' +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', 'sessions'] +export const inject = ['slots'] -/** Resolve a service via ctx.get, failing loud. This package's program holds - * the node half's host-side Context merges too (tool-ask-user), so property - * access would resolve the colliding host `sessions` seat — same budgeted - * cast as ui-conversation's need(). */ -// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- caller-named cast target -function need(ctx: ClientContext, name: string): T { - const value = ctx.get(name) as T | undefined - if (value === undefined) throw new Error(`ui-question: ${name} service unavailable`) - return value +/** 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 keyed composer - * slot. The inject factory returns receipt-checked answer/cancel callbacks - * only (no hooks, no store lines) — the framework resolves the sessionId, and - * the question payload rides the owner share. + * 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 = need(ctx, 'slots') - const sessions = need(ctx, 'sessions') - const injectProps = (sessionId: SessionId): QuestionComposerInjected => { - const session = sessions.manager.get(sessionId) - return { - answer: async (interaction, answer) => { - const receipt = await session.answerQuestion(interaction.rpcId, answer) - if (!receipt.accepted) { - throw new Error(`question response rejected: ${receipt.reason}`) - } - }, - cancel: async (interaction) => { - const receipt = await session.cancelQuestion(interaction.rpcId) - if (!receipt.accepted) { - throw new Error(`question cancellation rejected: ${receipt.reason}`) - } - }, - } - } + const slots = ctx.slots + if (slots === undefined) throw new Error('ui-question: slots service unavailable') ctx.effect( - () => slots.register( - { name: 'conversation.composer', key: 'question', inject: injectProps }, - QuestionComposer, - ), - 'ui-question: composer slot registration', + () => slots.register({ name: 'conversation.composer', select: selectQuestion }, QuestionComposer), + 'ui-question: composer chain registration', ) } diff --git a/packages/client/ui-question/tests/browser-plugin.spec.ts b/packages/client/ui-question/tests/browser-plugin.spec.ts index 3c6308433a..6fb90c3e47 100644 --- a/packages/client/ui-question/tests/browser-plugin.spec.ts +++ b/packages/client/ui-question/tests/browser-plugin.spec.ts @@ -1,101 +1,60 @@ /** - * apply wiring on a real cordis Context + SlotsService (terminal register - * form): QuestionComposer registered as the `question` entry of the - * conversation-declared keyed composer slot, the thin inject surface (two - * receipt-checked session callbacks closed over the plugin ctx — no hooks, no - * store lines), load-order fail-loud, and fiber-teardown unregistration. - * Component behavior is covered props-direct in question-composer.spec.tsx; + * 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, vi } from 'vitest' -import { RpcId } from '@deepseek-ai/dsh-client-connection/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { describe, expect, it } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { QuestionComposerInjected, QuestionInteraction } from '../src/client/contract/slots.ts' +import { QuestionComposer } from '../src/client/QuestionComposer.tsx' import { apply, inject } from '../src/client/index.ts' -function interaction(): QuestionInteraction { - return { - kind: 'question', rpcId: RpcId('question-1'), - questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }], - } -} - async function bench() { const ctx = new Context() await ctx.plugin(SlotsService).await() - const answerQuestion = vi.fn() - .mockResolvedValueOnce({ accepted: true }) - .mockResolvedValueOnce({ accepted: false, reason: 'not-pending' }) - const cancelQuestion = vi.fn() - .mockResolvedValueOnce({ accepted: true }) - .mockResolvedValueOnce({ accepted: false, reason: 'bad-response' }) - const get = vi.fn(() => ({ answerQuestion, cancelQuestion })) - ctx.provide('sessions', { manager: { get } }) 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: 'keyed', scope: 'session' } } } as never, + { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never, () => null, ) - return { ctx, slots, get, answerQuestion, cancelQuestion } -} - -/** The question entry's injected share, resolved for one session id. */ -function injectedOf(slots: SlotsService, sessionId: SessionId): QuestionComposerInjected { - const entries = slots.entries('conversation.composer') - expect(entries).toHaveLength(1) - // The typed StoredEntry.inject is declaration-derived ((...args: never[]) - // shape); the question factory takes the framework-resolved sessionId. - const inject = entries[0]!.inject as ((id: SessionId) => QuestionComposerInjected) | undefined - return inject!(sessionId) + return { ctx, slots } } describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slots', 'sessions']) + expect(inject).toEqual(['slots']) }) - it('fails loud when its services are missing', () => { - // apply resolves both services through the strict need() reader (the - // program's host-side Context merge shadows typed property access). + it('fails loud when the slots service is missing', () => { expect(() => { apply(new Context()) }).toThrow(/slots service unavailable/) }) it('fails loud when no live entry has declared the composer slot', async () => { const ctx = new Context() await ctx.plugin(SlotsService).await() - ctx.provide('sessions', {}) await expect(ctx.plugin({ inject: [...inject], apply })) .rejects.toThrow(/slot "conversation.composer" is not declared/) }) - it('registers the question entry with the thin two-callback inject surface', async () => { - const { ctx, slots, get } = await bench() + it('registers the question entry: routing selector, no inject face', async () => { + const { ctx, slots } = await bench() await ctx.plugin({ inject: [...inject], apply }).await() - expect(slots.entries('conversation.composer')[0]!.options.key).toBe('question') - const injected = injectedOf(slots, 'session-1' as SessionId) - // The whole business face: two plain callbacks, no hooks, no store lines. - expect(Object.keys(injected).sort()).toEqual(['answer', 'cancel']) - expect(get).toHaveBeenCalledWith('session-1') - }) - - it('routes answer/cancel through the session and surfaces rejected receipts', async () => { - const { ctx, slots, answerQuestion, cancelQuestion } = await bench() - await ctx.plugin({ inject: [...inject], apply }).await() - const { answer, cancel } = injectedOf(slots, 'session-1' as SessionId) - const item = interaction() - const batch = { answers: [{ id: 'mode', selected: ['Fast'] }] } - - await expect(answer(item, batch)).resolves.toBeUndefined() - await expect(answer(item, batch)).rejects.toThrow(/not-pending/) - await expect(cancel(item)).resolves.toBeUndefined() - await expect(cancel(item)).rejects.toThrow(/bad-response/) - expect(answerQuestion).toHaveBeenCalledWith(item.rpcId, batch) - expect(cancelQuestion).toHaveBeenCalledWith(item.rpcId) + 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 () => { diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index 0a71c0d4d1..87e6ad36b4 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -1,60 +1,71 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import type { PendingInteraction, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +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 { QuestionComposerProps } from '../src/client/contract/slots.ts' +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) -type Interaction = Extract +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: Pick = { - sessionId: 's1' as SessionId, - useSession: (() => { throw new Error('unused') }) as unknown as QuestionComposerProps['useSession'], - useSessions: (() => { throw new Error('unused') }) as unknown as QuestionComposerProps['useSessions'], +const kit = { + sessionId: SID, + useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, + useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, } -function interaction(rpcId = 'question-1'): Interaction { - return { - kind: 'question', - rpcId: RpcId(rpcId), - questions: [ - { - id: 'profile', header: '偏好', question: '选择候选人类型', - options: [ - { label: '工程落地型 (Recommended)', description: '优先工程交付。' }, - { label: '研究潜力型', description: '优先研究能力。' }, - ], - }, - { - id: 'detail', question: '补充你的要求', - }, - { - id: 'signals', question: '选择重要信号(可多选)', multiSelect: true, - options: [{ label: '系统设计' }, { label: '代码质量' }, { label: '产品判断' }], - }, +const QUESTIONS = [ + { + id: 'profile', header: '偏好', question: '选择候选人类型', + 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({ 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 answer = vi.fn(() => Promise.resolve()) - const cancel = vi.fn(() => Promise.resolve()) - render() + const { carrier, respond } = wait() + render() expect(screen.getByText('1 / 3')).toBeTruthy() expect(screen.getByText('推荐')).toBeTruthy() expect(screen.getByText('工程落地型')).toBeTruthy() fireEvent.keyDown(screen.getByRole('radio', { name: /工程落地型/ }), { key: 'Enter' }) - expect(answer).not.toHaveBeenCalled() + expect(respond).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ })) expect(screen.getByText('2 / 3')).toBeTruthy() @@ -73,20 +84,18 @@ describe('QuestionComposer', () => { fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' })) fireEvent.keyDown(screen.getByRole('checkbox', { name: '代码质量' }), { key: 'Enter' }) - expect(answer).toHaveBeenCalledWith(interaction(), { - answers: [ - { id: 'profile', selected: ['工程落地型 (Recommended)'] }, - { id: 'detail', selected: [], custom: '要能独立排查线上问题' }, - { id: 'signals', selected: ['系统设计', '代码质量'] }, - ], - }) + // 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 answer = vi.fn(() => Promise.resolve()) - const cancel = vi.fn(() => Promise.resolve()) - render() + const { carrier, respond } = wait() + render() expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true) fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' })) @@ -95,20 +104,16 @@ describe('QuestionComposer', () => { expect(screen.getByText('3 / 3')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: '跳过本题' })) - expect(cancel).not.toHaveBeenCalled() - expect(answer).toHaveBeenCalledWith(interaction(), { - answers: [ - { id: 'profile', selected: ['研究潜力型'] }, - { id: 'detail', selected: [] }, - { id: 'signals', selected: [] }, - ], - }) + 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 answer = vi.fn(() => Promise.resolve()) - const cancel = vi.fn(() => Promise.resolve()) - render() + const { carrier, respond } = wait() + render() fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' })) const custom = screen.getByPlaceholderText('输入你的答案') @@ -116,20 +121,19 @@ describe('QuestionComposer', () => { fireEvent.keyDown(custom, { key: 'Enter', isComposing: true }) expect(screen.getByText('2 / 3')).toBeTruthy() - expect(answer).not.toHaveBeenCalled() + expect(respond).not.toHaveBeenCalled() fireEvent.keyDown(custom, { key: 'Enter', keyCode: 229 }) expect(screen.getByText('2 / 3')).toBeTruthy() - expect(answer).not.toHaveBeenCalled() + 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 answer = vi.fn(() => Promise.resolve()) - const cancel = vi.fn(() => Promise.resolve()) - render() + const { carrier, respond } = wait() + render() fireEvent.click(screen.getByRole('button', { name: '其他,请填写自定义答案' })) expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy() @@ -147,32 +151,36 @@ describe('QuestionComposer', () => { expect(screen.getByText('2 / 3')).toBeTruthy() fireEvent.click(screen.getByLabelText('上一题')) expect(screen.getByText('1 / 3')).toBeTruthy() - expect(answer).not.toHaveBeenCalled() + expect(respond).not.toHaveBeenCalled() }) - it('surfaces explicit cancellation rejection', async () => { - const answer = vi.fn(() => Promise.resolve()) - const cancel = vi.fn(() => Promise.reject('取消请求失败')) - render() + 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() + // Receipt rejection surfaces through the domain face's thrown message. fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' })) - expect(await screen.findByText('取消请求失败')).toBeTruthy() + expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy() expect((screen.getByRole('button', { name: '跳过本题' }) as HTMLButtonElement).disabled).toBe(false) - cancel.mockRejectedValueOnce(new Error('第二次取消失败')) fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' })) expect(await screen.findByText('第二次取消失败')).toBeTruthy() }) - it('surfaces transport rejection and resets local drafts for a different rpcId', async () => { - const answer = vi.fn(() => Promise.reject(new Error('网络中断'))) - const cancel = vi.fn(() => Promise.resolve()) - const first = interaction('first') - const view = render() + 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() fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ })) expect(screen.getByText('2 / 3')).toBeTruthy() - view.rerender() + const second = wait('second', respond) + view.rerender() expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('false') fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ })) @@ -184,10 +192,55 @@ describe('QuestionComposer', () => { expect(await screen.findByText('网络中断')).toBeTruthy() expect((screen.getByRole('button', { name: '提交' }) as HTMLButtonElement).disabled).toBe(false) - answer.mockRejectedValueOnce('字符串错误') fireEvent.click(screen.getByRole('button', { name: '提交' })) expect(await screen.findByText('字符串错误')).toBeTruthy() }) + + it('same-key carrier replacement (baseline replay) keeps drafts', () => { + const first = wait('same-id') + const view = render() + 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() + 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', () => { diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 6458376d96..9806039b18 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -28,9 +28,12 @@ import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/ import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' const SID = 's1' as SessionId -/** Fallback-only renderSlot stub (no composer takeover in these benches). */ -const fallbackRenderSlot: ConversationSlotProps['renderSlot'] = +/** Fallback-only chain stub (no composer takeover in these benches). */ +const fallbackRenderSlotChain: ConversationSlotProps['renderSlotChain'] = (_key, _owner, opts) => opts?.fallback ?? null +/** Non-chain renderSlot stub: ConversationRoot renders no non-chain child keys. */ +const unusedRenderSlot: ConversationSlotProps['renderSlot'] = + (() => { throw new Error('no non-chain child keys') }) as unknown as ConversationSlotProps['renderSlot'] /** Standard-seat stub: ConversationRoot never renders it, delivery is mandatory in the props type. */ const StubSessionProvider: ConversationSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)} @@ -112,7 +115,8 @@ function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] = openDetails={vi.fn()} loadOlder={vi.fn()} open={vi.fn()} - renderSlot={fallbackRenderSlot} + renderSlot={unusedRenderSlot} + renderSlotChain={fallbackRenderSlotChain} SessionProvider={StubSessionProvider} />, )