From 1210735959dadc6002f024bad46dc57516b67763 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:23:10 +0800 Subject: [PATCH] refactor(gui): rework ui-question to the terminal slot standard Contract face moves to contract/slots.ts (PropsRuntime composition off the conversation SlotMap entry, flat answer/cancel injected share); apply takes the ui-sidebar terminal form (strict need() service reads, ctx.effect-wrapped single register, framework-resolved sessionId); tests upgrade to the terminal style (props-direct component specs with standard-kit stubs, real-registry apply spec with children-declared slot, fiber-teardown case). --- .../src/client/QuestionComposer.tsx | 27 +--- .../ui-question/src/client/contract/slots.ts | 42 ++++++ .../client/ui-question/src/client/index.ts | 102 ++++++++------ .../ui-question/tests/browser-plugin.spec.ts | 130 ++++++++++++------ .../tests/question-composer.spec.tsx | 26 ++-- 5 files changed, 209 insertions(+), 118 deletions(-) create mode 100644 packages/client/ui-question/src/client/contract/slots.ts diff --git a/packages/client/ui-question/src/client/QuestionComposer.tsx b/packages/client/ui-question/src/client/QuestionComposer.tsx index a85aa1a0de..ec08d2c9c9 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.tsx +++ b/packages/client/ui-question/src/client/QuestionComposer.tsx @@ -1,16 +1,12 @@ import { useState, type KeyboardEvent } from 'react' import clsx from 'clsx' -import type { QuestionResponsePayload } from '@deepseek-ai/dsh-client-connection/client' import { Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseOutline16, IconEditOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { QuestionComposerOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { QuestionAnswer, QuestionComposerProps } from './contract/slots.ts' import css from './QuestionComposer.module.css' -type QuestionInteraction = QuestionComposerOwnerProps['interaction'] -type Answer = QuestionResponsePayload['answer'] - interface DraftAnswer { selected: string[] custom: string @@ -18,19 +14,6 @@ interface DraftAnswer { skipped: boolean } -/** Actions assembled from the session object layer. */ -export interface QuestionComposerInjected { - actions: { - answer(interaction: QuestionInteraction, answer: Answer): Promise - cancel(interaction: QuestionInteraction): Promise - } -} - -/** Consumed question-composer props: the slot's owner share & the injected - * share. A strict subset of the composed props the register site proves - * (the framework session/global standard kit goes unconsumed here). */ -export type QuestionComposerProps = QuestionComposerOwnerProps & QuestionComposerInjected - /** * Split the conventional recommendation suffix without changing the answer value. * @param label - Original option label returned if selected. @@ -66,7 +49,7 @@ export function QuestionComposer(props: QuestionComposerProps) { return } -function QuestionFlow({ interaction, actions }: QuestionComposerProps) { +function QuestionFlow({ interaction, answer: submitAnswer, cancel }: QuestionComposerProps) { const questions = interaction.questions const [index, setIndex] = useState(0) const [drafts, setDrafts] = useState(() => questions.map(question => ({ @@ -81,7 +64,7 @@ function QuestionFlow({ interaction, actions }: QuestionComposerProps) { const cancelFlow = (): void => { setBusy('cancel') setError(null) - void actions.cancel(interaction).catch((cause: unknown) => { + void cancel(interaction).catch((cause: unknown) => { setBusy(null) setError(cause instanceof Error ? cause.message : String(cause)) }) @@ -122,7 +105,7 @@ function QuestionFlow({ interaction, actions }: QuestionComposerProps) { setError('请先完成这道问题。') return } - const answer: Answer = { + const answer: QuestionAnswer = { answers: questions.map((item, itemIndex) => { const value = values[itemIndex] as DraftAnswer if (value.skipped) return { id: item.id, selected: [] } @@ -136,7 +119,7 @@ function QuestionFlow({ interaction, actions }: QuestionComposerProps) { } setBusy('answer') setError(null) - void actions.answer(interaction, answer).catch((cause: unknown) => { + void submitAnswer(interaction, answer).catch((cause: unknown) => { setBusy(null) setError(cause instanceof Error ? cause.message : String(cause)) }) diff --git a/packages/client/ui-question/src/client/contract/slots.ts b/packages/client/ui-question/src/client/contract/slots.ts new file mode 100644 index 0000000000..5a6139f118 --- /dev/null +++ b/packages/client/ui-question/src/client/contract/slots.ts @@ -0,0 +1,42 @@ +/** + * 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. + */ +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 { QuestionResponsePayload } from '@deepseek-ai/dsh-client-connection/client' + +/** The pending question interaction the owner dispatches into the keyed slot. */ +export type QuestionInteraction = QuestionComposerOwnerProps['interaction'] + +/** 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. + */ +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 +} + +/** + * 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. + */ +export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & QuestionComposerInjected diff --git a/packages/client/ui-question/src/client/index.ts b/packages/client/ui-question/src/client/index.ts index 7d0e79a1eb..56104d76ca 100644 --- a/packages/client/ui-question/src/client/index.ts +++ b/packages/client/ui-question/src/client/index.ts @@ -1,50 +1,66 @@ /** - * Web question plugin, browser half: registers a composer replacement for - * pending ask_user_question requests into the conversation-declared keyed - * `conversation.composer` slot (single register API — the slot exists because - * the conversation entry's children declaration created it). + * 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. */ -import type { Context } from 'cordis' -import type { SessionId, SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import { QuestionComposer, type QuestionComposerInjected } from './QuestionComposer.tsx' +import type { ClientContext, SessionId, SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { QuestionComposerInjected } from './contract/slots.ts' +import { QuestionComposer } from './QuestionComposer.tsx' -export { QuestionComposer, parseRecommendedLabel } from './QuestionComposer.tsx' -export type { QuestionComposerInjected, QuestionComposerProps } from './QuestionComposer.tsx' +export type { + QuestionAnswer, QuestionComposerInjected, QuestionComposerProps, QuestionInteraction, +} from './contract/slots.ts' -/** Required browser services. */ +/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */ export const inject = ['slots', 'sessions'] -/** - * Register the question composer into the conversation-owned keyed slot. - * @param ctx - Browser plugin context carrying slots and sessions. - */ -export function apply(ctx: Context): void { - const slots = ctx.get('slots') as SlotsService | undefined - const sessions = ctx.get('sessions') as SessionsService | undefined - if (slots === undefined || sessions === undefined) { - throw new Error('ui-question: slots and sessions services are required') - } - slots.register({ - name: 'conversation.composer', - key: 'question', - inject: (sessionId: SessionId): QuestionComposerInjected => { - const session = sessions.manager.get(sessionId) - return { - actions: { - async answer(interaction, answer) { - const receipt = await session.answerQuestion(interaction.rpcId, answer) - if (!receipt.accepted) { - throw new Error(`question response rejected: ${receipt.reason}`) - } - }, - async cancel(interaction) { - const receipt = await session.cancelQuestion(interaction.rpcId) - if (!receipt.accepted) { - throw new Error(`question cancellation rejected: ${receipt.reason}`) - } - }, - }, - } - }, - }, QuestionComposer) +/** 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 +} + +/** + * 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. + * @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}`) + } + }, + } + } + ctx.effect( + () => slots.register( + { name: 'conversation.composer', key: 'question', inject: injectProps }, + QuestionComposer, + ), + 'ui-question: composer slot registration', + ) } diff --git a/packages/client/ui-question/tests/browser-plugin.spec.ts b/packages/client/ui-question/tests/browser-plugin.spec.ts index de500476d1..3c6308433a 100644 --- a/packages/client/ui-question/tests/browser-plugin.spec.ts +++ b/packages/client/ui-question/tests/browser-plugin.spec.ts @@ -1,12 +1,20 @@ +/** + * 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; + * 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 { PendingInteraction, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { QuestionComposerInjected, QuestionInteraction } from '../src/client/contract/slots.ts' import { apply, inject } from '../src/client/index.ts' -type QuestionInteraction = Extract - function interaction(): QuestionInteraction { return { kind: 'question', rpcId: RpcId('question-1'), @@ -14,56 +22,88 @@ function interaction(): QuestionInteraction { } } -/** Declare the conversation-owned composer slot the way production does: a - * parent entry's children table (register is the single declaration API). */ -function declareComposerSlot(slots: SlotsService): void { - slots.register({ - name: 'root', - children: { 'conversation.composer': { kind: 'keyed', scope: 'session' } }, - } as never, (() => null) as never) +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, + () => null, + ) + return { ctx, slots, get, answerQuestion, cancelQuestion } } -describe('ui-question browser plugin', () => { - it('declares its services and fails loud without them', () => { +/** 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) +} + +describe('apply', () => { + it('declares the services it binds', () => { expect(inject).toEqual(['slots', 'sessions']) - expect(() => { apply(new Context()) }).toThrow(/slots and sessions services are required/) }) - it('registers scoped answer and cancel actions, including rejected receipts', async () => { + 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). + 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() - const answerQuestion = vi.fn() - .mockResolvedValueOnce({ accepted: true }) - .mockResolvedValueOnce({ accepted: false, reason: 'not-pending' }) - const cancelQuestion = vi.fn() - .mockResolvedValueOnce({ accepted: true }) - .mockResolvedValueOnce({ accepted: false, reason: 'bad-response' }) - ctx.provide('sessions', { - manager: { get: vi.fn(() => ({ answerQuestion, cancelQuestion })) }, - }) - const slots = ctx.get('slots') as SlotsService - declareComposerSlot(slots) + 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() + 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) + }) + + it('teardown unregisters the slot entry', async () => { + const { ctx, slots } = await bench() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - - const entry = slots.entries('conversation.composer')[0] as unknown as { - options: { key: string } - inject(sessionId: SessionId): { actions: { - answer: (item: QuestionInteraction, answer: { answers: { id: string; selected: string[] }[] }) => Promise - cancel: (item: QuestionInteraction) => Promise - } } - } - expect(entry.options.key).toBe('question') - const actions = entry.inject('session-1' as SessionId).actions - const item = interaction() - const answer = { answers: [{ id: 'mode', selected: ['Fast'] }] } - - await expect(actions.answer(item, answer)).resolves.toBeUndefined() - await expect(actions.answer(item, answer)).rejects.toThrow(/not-pending/) - await expect(actions.cancel(item)).resolves.toBeUndefined() - await expect(actions.cancel(item)).rejects.toThrow(/bad-response/) - expect(answerQuestion).toHaveBeenCalledWith(item.rpcId, answer) - expect(cancelQuestion).toHaveBeenCalledWith(item.rpcId) - await ctx.fiber.dispose() + expect(slots.entries('conversation.composer')).toHaveLength(1) + await fiber.dispose() + expect(slots.entries('conversation.composer')).toHaveLength(0) }) }) diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index feb6be1d0f..0a71c0d4d1 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -1,8 +1,9 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client' +import type { PendingInteraction, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' +import type { QuestionComposerProps } from '../src/client/contract/slots.ts' import { QuestionComposer, parseQuestionTitle, parseRecommendedLabel, } from '../src/client/QuestionComposer.tsx' @@ -11,6 +12,15 @@ afterEach(cleanup) type Interaction = Extract +/** 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'], +} + function interaction(rpcId = 'question-1'): Interaction { return { kind: 'question', @@ -38,7 +48,7 @@ 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() + render() expect(screen.getByText('1 / 3')).toBeTruthy() expect(screen.getByText('推荐')).toBeTruthy() @@ -76,7 +86,7 @@ describe('QuestionComposer', () => { it('skips individual questions without discarding earlier answers', () => { const answer = vi.fn(() => Promise.resolve()) const cancel = vi.fn(() => Promise.resolve()) - render() + render() expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true) fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' })) @@ -98,7 +108,7 @@ describe('QuestionComposer', () => { it('keeps IME Enter inside the custom input until composition finishes', () => { const answer = vi.fn(() => Promise.resolve()) const cancel = vi.fn(() => Promise.resolve()) - render() + render() fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' })) const custom = screen.getByPlaceholderText('输入你的答案') @@ -119,7 +129,7 @@ describe('QuestionComposer', () => { 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() + render() fireEvent.click(screen.getByRole('button', { name: '其他,请填写自定义答案' })) expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy() @@ -143,7 +153,7 @@ describe('QuestionComposer', () => { it('surfaces explicit cancellation rejection', async () => { const answer = vi.fn(() => Promise.resolve()) const cancel = vi.fn(() => Promise.reject('取消请求失败')) - render() + render() fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' })) expect(await screen.findByText('取消请求失败')).toBeTruthy() @@ -158,11 +168,11 @@ describe('QuestionComposer', () => { const answer = vi.fn(() => Promise.reject(new Error('网络中断'))) const cancel = vi.fn(() => Promise.resolve()) const first = interaction('first') - const view = render() + const view = render() fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ })) expect(screen.getByText('2 / 3')).toBeTruthy() - view.rerender() + view.rerender() expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('false') fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))