From 049170c6d004d17995dee800bf5b3e28c1497ff9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:18:53 +0800 Subject: [PATCH] refactor(interaction): move Approval and Question into UI owners --- .../runtime/src/client/sessions/pending.ts | 144 -------- packages/client/ui-approval/package.json | 90 +++++ .../src/client/ApprovalPanel.module.css | 74 ++++ .../ui-approval/src/client/ApprovalPanel.tsx | 55 +++ .../ui-approval/src/client/contract/slots.ts | 138 +++++++ .../client/ui-approval/src/client/index.ts | 79 ++++ .../client/ui-approval/src/client/locales.ts | 22 ++ .../client/ui-approval/src/css-modules.d.ts | 4 + packages/client/ui-approval/src/index.ts | 4 + packages/client/ui-approval/src/invariant.ts | 23 ++ .../tests/ui-approval.client.spec.tsx | 345 ++++++++++++++++++ packages/client/ui-approval/tsconfig.json | 48 +++ packages/client/ui-approval/tsdown.config.ts | 3 + packages/client/ui-plan/package.json | 12 +- packages/client/ui-plan/src/client/index.ts | 5 +- .../tests/browser-plugin.client.spec.ts | 4 +- .../tests/plan-mode-control.client.spec.tsx | 2 +- packages/client/ui-plan/tsconfig.json | 9 +- .../tests/ask-question-row.client.spec.tsx | 2 +- .../client/ui-user-questions/package.json | 26 +- .../src/client/QuestionComposer.tsx | 7 +- .../src/client/contract/slots.ts | 152 +++++--- .../ui-user-questions/src/client/index.ts | 91 +++-- .../tests/browser-plugin.client.spec.ts | 297 +++++++++------ .../tests/plan-review-panel.client.spec.tsx | 201 ++++++---- .../user-questions-composer.client.spec.tsx | 287 +++++++++------ .../client/ui-user-questions/tsconfig.json | 19 +- .../tool-ask-user/tests/tool-ask-user.spec.ts | 25 +- .../interaction/user-questions/src/index.ts | 85 ++--- .../interaction/user-questions/src/types.ts | 2 +- .../tests/user-questions.spec.ts | 121 +++--- .../plan/plan-mode/tests/plan-mode.spec.ts | 30 +- 32 files changed, 1711 insertions(+), 695 deletions(-) delete mode 100644 packages/client/runtime/src/client/sessions/pending.ts create mode 100644 packages/client/ui-approval/package.json create mode 100644 packages/client/ui-approval/src/client/ApprovalPanel.module.css create mode 100644 packages/client/ui-approval/src/client/ApprovalPanel.tsx create mode 100644 packages/client/ui-approval/src/client/contract/slots.ts create mode 100644 packages/client/ui-approval/src/client/index.ts create mode 100644 packages/client/ui-approval/src/client/locales.ts create mode 100644 packages/client/ui-approval/src/css-modules.d.ts create mode 100644 packages/client/ui-approval/src/index.ts create mode 100644 packages/client/ui-approval/src/invariant.ts create mode 100644 packages/client/ui-approval/tests/ui-approval.client.spec.tsx create mode 100644 packages/client/ui-approval/tsconfig.json create mode 100644 packages/client/ui-approval/tsdown.config.ts diff --git a/packages/client/runtime/src/client/sessions/pending.ts b/packages/client/runtime/src/client/sessions/pending.ts deleted file mode 100644 index 46ace229d7..0000000000 --- a/packages/client/runtime/src/client/sessions/pending.ts +++ /dev/null @@ -1,144 +0,0 @@ -// PendingWait: the legacy render-facing carrier retained until UI owners consume Remote events directly. - -import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' - -/** One selectable answer offered by the legacy question renderer. */ -export interface PendingQuestionOption { - readonly label: string - readonly description?: string -} - -/** One question rendered by the legacy question composer. */ -export interface PendingQuestionItem { - readonly id: string - readonly question: string - readonly detail?: string - readonly header?: string - readonly options?: readonly PendingQuestionOption[] - readonly multiSelect?: boolean - readonly intent?: { readonly kind: 'plan-review'; readonly approve: string } -} - -/** Structured answer returned by the legacy question composer. */ -export interface PendingQuestionAnswer { - answers: { - id: string - selected: string[] - custom?: string - }[] -} - -/** Kind-keyed payload map: the requested frame's domain fields (envelope fields stripped). */ -export interface PendingPayloads { - approval: { - readonly approvalId: string - readonly toolName: string - readonly callId?: string - readonly reason?: string - } - question: { readonly questions: readonly PendingQuestionItem[] } -} - -interface PendingResponseValues { - approval: { - readonly sessionId: SessionId - readonly approvalId: string - readonly outcome: 'allowed-once' | 'rejected' - } - question: { readonly sessionId: SessionId; readonly answer: PendingQuestionAnswer } -} - -type PendingInteractionResult = - | { readonly ok: true; readonly value: PendingResponseValues[K] } - | { - readonly ok: false - readonly error: { readonly code: string; readonly message: string; readonly details: Readonly> } - } - -/** Receipt returned by the legacy response carrier. */ -export interface PendingRespondReceipt { - readonly accepted: boolean - readonly reason?: string -} - -interface PendingRespondRequest { - readonly interactionId: string - readonly result: PendingInteractionResult -} - -/** Pending-interaction discriminant (the keys of PendingPayloads). */ -export type PendingKind = keyof PendingPayloads - -/** Session-list summary of the user action currently blocking progress. */ -export type PendingInteractionStatus = 'approval' | 'plan-review' | 'question' - -/** Kind-discriminated union of concrete waits: narrowing on `kind` types `payload`. */ -export type PendingInteraction = { [K in PendingKind]: PendingWait }[PendingKind] - -/** Key prefixes, one per kind (the key doubles as the Session pending-map key). */ -const KEY_PREFIX: Record = { approval: 'a', question: 'q' } - -/** - * One pending host-owned interaction wait: an immutable render face - * (kind/key/sessionId/payload) plus the response carrier. respond() addresses - * the Host's opaque interaction identity. Settlement is expressed only by pending-list - * membership (the settled flag is a fail-loud guard, not a render input). - */ -export class PendingWait { - /** Interaction kind (union discriminant). */ - readonly kind: K - /** Opaque render identity, stable across baseline replay and usable as a React key. */ - readonly key: string - /** Owning session. */ - readonly sessionId: SessionId - /** The requested frame's domain fields, verbatim. */ - readonly payload: PendingPayloads[K] - #settled = false - readonly #interactionId: string - readonly #respond: (request: PendingRespondRequest) => Promise> - - /** - * Minted by Session on a requested frame (public construction is the test-fixture path). - * @param kind - interaction kind. - * @param interactionId - the Host-minted stable interaction identity. - * @param sessionId - owning session. - * @param payload - the requested frame's domain fields. - * @param respond - Session Controller response method. - */ - constructor( - kind: K, interactionId: string, sessionId: SessionId, payload: PendingPayloads[K], - respond: (request: PendingRespondRequest) => Promise>, - ) { - this.kind = kind - this.key = `${KEY_PREFIX[kind]}:${interactionId}` - this.sessionId = sessionId - this.payload = payload - this.#interactionId = interactionId - this.#respond = respond - } - - /** - * Send a result for this wait. Throws synchronously once settled and rejects - * when the generated Remote call itself fails. - * @param result - the result shell (ok value / error envelope), domain-encoded by the caller. - * @returns the carrier receipt. - */ - respond(result: PendingInteractionResult): Promise { - if (this.#settled) throw new Error(`pending wait ${this.key} is already settled`) - return this.send(result) - } - - private async send(result: PendingInteractionResult): Promise { - const response = await this.#respond({ interactionId: this.#interactionId, result }) - if (!response.ok) { - throw new Error(`session interaction response failed: ${response.error.code}: ${response.error.message}`) - } - return response.value - } - - /** Session-only settlement mark (the authoritative resolved frame arrived); respond() throws afterwards. */ - markSettled(): void { - this.#settled = true - } -} diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json new file mode 100644 index 0000000000..649b855553 --- /dev/null +++ b/packages/client/ui-approval/package.json @@ -0,0 +1,90 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-approval", + "description": "Approval composer takeover over the scoped Remote Event waterfall", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-approval" + }, + "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" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-session-controller/client", + "@deepseek-ai/dsh-client-ui-conversation/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@types/react": "~18.3.1", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.module.css b/packages/client/ui-approval/src/client/ApprovalPanel.module.css new file mode 100644 index 0000000000..12ea9bb631 --- /dev/null +++ b/packages/client/ui-approval/src/client/ApprovalPanel.module.css @@ -0,0 +1,74 @@ +.root { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px calc(var(--dsh-composer-side-clearance) + 16px) 12px; +} + +.card { + overflow: hidden; + width: 100%; + max-width: var(--dsh-chat-content-width); + border: 1px solid var(--dsw-alias-state-warn-secondary); + border-radius: 20px; + background: var(--dsw-specific-input-major); + box-shadow: var(--dsw-shadow-lv2); + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + +.strip { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: var(--dsw-alias-state-warn-tertiary); + color: var(--dsw-alias-state-warn-primary); + font-size: 13px; + line-height: 18px; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--dsw-alias-state-warn-primary); +} + +.body { + display: flex; + flex-direction: column; + gap: 6px; + box-sizing: border-box; + max-height: var(--dsh-composer-text-max-height); + overflow-y: auto; + padding: 12px 16px 0; +} + +.headline { + color: var(--dsw-alias-label-primary); + font-size: 15px; + font-weight: 500; + line-height: 24px; +} + +.command { + color: var(--dsw-alias-label-tertiary); + font-family: var(--ds-font-family-code); + font-size: 13px; + line-height: 20px; + word-break: break-all; +} + +.actionRow { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 14px 16px; +} + +.reject:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); + color: var(--dsw-alias-state-error-primary); + border-color: transparent; +} diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.tsx b/packages/client/ui-approval/src/client/ApprovalPanel.tsx new file mode 100644 index 0000000000..dca3692899 --- /dev/null +++ b/packages/client/ui-approval/src/client/ApprovalPanel.tsx @@ -0,0 +1,55 @@ +/** Composer takeover for one pending approval waterfall. */ +import { useState, type ReactNode } from 'react' +import { Button } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ApprovalComposerProps, PendingApproval } from './contract/slots.ts' +import css from './ApprovalPanel.module.css' + +/** + * Render one pending approval and its optional Tool-owned detail. + * @param props - selector-matched request and standard Slot props. + * @returns The approval composer takeover. + */ +export function ApprovalPanel(props: ApprovalComposerProps) { + const approval = props.matched + const detail = approval.callId === undefined + ? null + : props.renderSlot('conversation.approval.detail', { callId: approval.callId }) + return +} + +function ApprovalFlow({ pending, detail, t }: { + pending: PendingApproval + detail: ReactNode + t: ApprovalComposerProps['t'] +}) { + const [answered, setAnswered] = useState(false) + const answer = (outcome: 'allowed-once' | 'rejected'): void => { + setAnswered(true) + void pending.answer(outcome).catch(() => { setAnswered(false) }) + } + return ( +
+
+
{t('waiting')}
+
+
{pending.reason ?? t('escalation', { toolName: pending.toolName })}
+ {detail !== null &&
{detail}
} +
+
+ + +
+
+
+ ) +} diff --git a/packages/client/ui-approval/src/client/contract/slots.ts b/packages/client/ui-approval/src/client/contract/slots.ts new file mode 100644 index 0000000000..3f8d481767 --- /dev/null +++ b/packages/client/ui-approval/src/client/contract/slots.ts @@ -0,0 +1,138 @@ +/** Approval composer and optional correlated-detail contracts. */ +import type { CallId } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { + PropsLocale, PropsRenderSlots, PropsRuntime, +} from '@deepseek-ai/dsh-client-ui-slots' +import { settlePendingComposer } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ApprovalKey } from '../locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-session/client' { + interface SessionPendingInteractionMap { + /** Pending approval request. */ + approval: PendingApproval + } +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Approval prompt copy. */ + approval: ApprovalKey + } + + interface SlotMap { + /** Optional detail for the Tool call correlated with an approval request. */ + 'conversation.approval.detail': { + kind: 'single' + scope: 'session' + owner: ApprovalDetailOwnerProps + } + } +} + +/** Stable identity handed to an optional approval-detail renderer. */ +export interface ApprovalDetailOwnerProps { + /** Tool call correlated with the request. */ + callId: CallId +} + +/** Client-visible fields of an approval request projected through Remote Events. */ +export interface ApprovalPresentationRequest { + /** Tool requesting the decision. */ + readonly toolName: string + /** Tool call correlated with the request. */ + readonly callId?: CallId + /** Human-readable reason supplied by the requester. */ + readonly reason?: string + /** Cancellation projected from the Host waterfall. */ + readonly signal?: AbortSignal +} + +/** Decisions this interactive Client presentation can return. */ +export type ApprovalDecision = 'allowed-once' | 'rejected' + +let nextApprovalKey = 0 + +/** One answerable Client presentation of a pending Host waterfall. */ +export class PendingApproval { + /** Domain discriminator used by Session pending-interaction consumers. */ + readonly kind = 'approval' as const + /** Opaque render identity and one-shot remount axis. */ + readonly key: string + /** Tool requesting the decision. */ + readonly toolName: string + /** Correlated Tool call, when supplied by the asker. */ + readonly callId: CallId | undefined + /** Human-readable reason supplied by the asker. */ + readonly reason: string | undefined + /** Result returned by the Remote Event listener to the Host waterfall. */ + readonly result: Promise + + readonly #resolve: (outcome: ApprovalDecision) => void + readonly #reject: (reason: unknown) => void + readonly #signal: AbortSignal | undefined + readonly #onAbort: (() => void) | undefined + #settled = false + + /** + * @param sessionId - Agent/Session identity owning the scoped request. + * @param request - Host approval request projected through the Remote Event. + */ + constructor(readonly sessionId: SessionId, request: ApprovalPresentationRequest) { + nextApprovalKey += 1 + this.key = `approval:${String(nextApprovalKey)}` + this.toolName = request.toolName + this.callId = request.callId + this.reason = request.reason + const completion = Promise.withResolvers() + this.result = completion.promise + this.#resolve = completion.resolve + this.#reject = completion.reject + this.#signal = request.signal + if (request.signal === undefined) { + this.#onAbort = undefined + return + } + const onAbort = (): void => { + this.abort(request.signal?.reason ?? new Error('approval request was aborted')) + } + this.#onAbort = onAbort + request.signal.addEventListener('abort', onAbort, { once: true }) + if (request.signal.aborted) onAbort() + } + + /** + * Resolve the Host waterfall with the user's decision. + * @param outcome - supported interactive decision. + */ + answer(outcome: ApprovalDecision): Promise { + return settlePendingComposer(() => { + this.finish(() => { this.#resolve(outcome) }) + }, 'pending approval settlement failed') + } + + /** + * End an unanswered presentation when its transport, scope, or plugin lifetime ends. + * @param reason - rejection exposed to the waiting Remote Event listener. + */ + abort(reason: unknown): void { + if (this.#settled) return + this.finish(() => { this.#reject(reason) }) + } + + private finish(settle: () => void): void { + if (this.#settled) throw new Error(`pending approval ${this.key} is already settled`) + this.#settled = true + if (this.#signal !== undefined && this.#onAbort !== undefined) { + this.#signal.removeEventListener('abort', this.#onAbort) + } + settle() + } +} + +/** Full props of the approval composer takeover. */ +export type ApprovalComposerProps = + PropsRuntime<'conversation.composer'> + & PropsRenderSlots<'conversation.approval.detail'> + & { matched: PendingApproval } + & PropsLocale<'approval'> diff --git a/packages/client/ui-approval/src/client/index.ts b/packages/client/ui-approval/src/client/index.ts new file mode 100644 index 0000000000..a333924cfa --- /dev/null +++ b/packages/client/ui-approval/src/client/index.ts @@ -0,0 +1,79 @@ +/** Browser approval consumer over the existing scoped Remote Event waterfall. */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' +import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type { TypertClientEventListener } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { ApprovalPanel } from './ApprovalPanel.tsx' +import { PendingApproval } from './contract/slots.ts' +import { en, zh } from './locales.ts' + +export { PendingApproval } from './contract/slots.ts' +export type { + ApprovalComposerProps, + ApprovalDecision, + ApprovalDetailOwnerProps, + ApprovalPresentationRequest, +} from './contract/slots.ts' +export type { ApprovalKey } from './locales.ts' + +/** Required services: Agent scopes, Remote Events, Session UI, Slot registry, and copy. */ +export const inject = ['sessions', 'remote', 'uiSession', 'slots', 'locale'] + +const NS = 'approval' + +type ApprovalListener = TypertClientEventListener<'approval/request'> +type ClientApprovalRequest = Parameters[0] +type ClientApprovalNext = Parameters[1] +type ClientApprovalOutcome = Awaited> + +/** Present one request until the user answers or its lifetime ends. */ +async function answerApproval( + ctx: ClientContext, + owner: ClientContext, + request: ClientApprovalRequest, + next: ClientApprovalNext, + attend: (pending: PendingApproval) => () => void, +): Promise { + const sessionId = ctx.sessions.scopeOf(owner) + if (sessionId === undefined) return next() + const pending = new PendingApproval(sessionId, { + toolName: request.toolName, + ...(request.callId === undefined + ? {} + : { callId: request.callId }), + ...(request.reason === undefined ? {} : { reason: request.reason }), + ...(request.signal === undefined ? {} : { signal: request.signal }), + }) + const remove = attend(pending) + try { + return await pending.result + } finally { + remove() + } +} + +/** + * Install approval copy and the scoped waterfall consumer. + * @param ctx - Client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-approval: dictionaries') + const attend = ctx.uiSession.attend(() => 0) + ctx.slots.inject('conversation.composer', () => ctx.slots.register({ + name: 'conversation.composer', + priority: 1, + select: ({ pendingInteraction }: ComposerChainProps): PendingApproval | null => + pendingInteraction instanceof PendingApproval ? pendingInteraction : null, + locale: NS, + children: { + 'conversation.approval.detail': { kind: 'single', scope: 'session' }, + }, + }, ApprovalPanel)) + ctx.remote.$on('approval/request', function (request, next) { + return answerApproval(ctx, this, request, next, attend) + }) +} diff --git a/packages/client/ui-approval/src/client/locales.ts b/packages/client/ui-approval/src/client/locales.ts new file mode 100644 index 0000000000..e28f2ce74a --- /dev/null +++ b/packages/client/ui-approval/src/client/locales.ts @@ -0,0 +1,22 @@ +/** `approval` namespace dictionaries. */ + +/** Simplified Chinese dictionary and key-set source of truth. */ +export const zh = { + waiting: '等待审批', + 'detail.aria': '审批详情', + escalation: '工具 {toolName} 请求越权执行', + reject: '拒绝', + allowOnce: '允许一次', +} satisfies Record + +/** Approval dictionary key union. */ +export type ApprovalKey = keyof typeof zh + +/** English dictionary, checked against the Chinese key set. */ +export const en = { + waiting: 'Waiting for approval', + 'detail.aria': 'Approval details', + escalation: 'Tool {toolName} requests privileged execution', + reject: 'Reject', + allowOnce: 'Allow once', +} satisfies Record diff --git a/packages/client/ui-approval/src/css-modules.d.ts b/packages/client/ui-approval/src/css-modules.d.ts new file mode 100644 index 0000000000..24a27bda3f --- /dev/null +++ b/packages/client/ui-approval/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Readonly> + export default classes +} diff --git a/packages/client/ui-approval/src/index.ts b/packages/client/ui-approval/src/index.ts new file mode 100644 index 0000000000..f7a2e2b689 --- /dev/null +++ b/packages/client/ui-approval/src/index.ts @@ -0,0 +1,4 @@ +/** Browser-only approval presentation plugin; the Host capability is composed independently. */ + +/** Node plugin body. */ +export function apply(): void {} diff --git a/packages/client/ui-approval/src/invariant.ts b/packages/client/ui-approval/src/invariant.ts new file mode 100644 index 0000000000..833270df30 --- /dev/null +++ b/packages/client/ui-approval/src/invariant.ts @@ -0,0 +1,23 @@ +/** Package-owned invariant companion for the approval presentation plugin. */ +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-approval' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-approval-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: registries own and observe the Remote listener and temporary Slot entry. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns The installed registration's disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx new file mode 100644 index 0000000000..c4828b2f29 --- /dev/null +++ b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx @@ -0,0 +1,345 @@ +// @vitest-environment jsdom +import { Context } from '@deepseek-ai/cordis' +import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import type { CallId } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApprovalPanel } from '../src/client/ApprovalPanel.tsx' +import type { ApprovalComposerProps } from '../src/client/contract/slots.ts' +import { PendingApproval } from '../src/client/contract/slots.ts' +import { apply, inject } from '../src/client/index.ts' +import { apply as nodeApply } from '../src/index.ts' +import * as ApprovalInvariant from '../src/invariant.ts' + +type ApprovalListener = ( + this: Context, + request: { + toolName: string + callId?: string + reason?: string + signal?: AbortSignal + }, + next: () => Promise<'unavailable'>, +) => Promise + +interface PluginBench { + readonly ctx: Context + readonly listener: ApprovalListener + readonly pending: { getSnapshot(): readonly PendingApproval[] } + readonly attend: ReturnType + readonly disposeSlot: ReturnType + readonly disposeLocale: ReturnType + readonly register: ReturnType + readonly injectSlot: ReturnType + registration(): { + options: { + select(props: { pendingInteraction: PendingApproval | undefined }): PendingApproval | null + } + component: unknown + } +} + +function setupPlugin(): PluginBench { + const ctx = new Context() + let listener: ApprovalListener | undefined + let registration: { + options: { + select(props: { pendingInteraction: PendingApproval | undefined }): PendingApproval | null + } + component: unknown + } | undefined + const disposeSlot = vi.fn() + const disposeLocale = vi.fn() + let pending: readonly PendingApproval[] = [] + const attend = vi.fn((_precedence: (value: PendingApproval) => number) => ( + value: PendingApproval, + ) => { + pending = [...pending, value] + return () => { pending = pending.filter(candidate => candidate !== value) } + }) + const register = vi.fn(( + options: NonNullable['options'], + component: unknown, + ) => { + registration = { options, component } + return disposeSlot + }) + const injectSlot = vi.fn((_name: string, mount: () => () => void) => { + const dispose = ctx.effect(() => mount()) + return () => { void dispose() } + }) + ctx.provide('remote', { + $on: (_event: string, callback: ApprovalListener) => { + listener = callback + return () => {} + }, + } as never) + ctx.provide('sessions', { scopeOf } as never) + ctx.provide('uiSession', { attend } as never) + ctx.provide('slots', { inject: injectSlot, register } as never) + ctx.provide('locale', { + register: vi.fn(() => disposeLocale), + } as never) + + apply(ctx) + if (listener === undefined) throw new Error('approval listener was not registered') + return { + ctx, + listener, + pending: { getSnapshot: () => pending }, + attend, + disposeSlot, + disposeLocale, + register, + injectSlot, + registration: () => { + if (registration === undefined) throw new Error('approval slot was not registered') + return registration + }, + } +} + +const id = (value: string): SessionId => value as SessionId + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('PendingApproval', () => { + it('resolves once, removes its abort listener, and ignores later abort cleanup', async () => { + const controller = new AbortController() + const remove = vi.spyOn(controller.signal, 'removeEventListener') + const pending = new PendingApproval(id('s1'), { + toolName: 'bash', + callId: 'call-1' as CallId, + reason: 'needs access', + signal: controller.signal, + }) + + await pending.answer('allowed-once') + + await expect(pending.result).resolves.toBe('allowed-once') + expect(pending.sessionId).toBe(id('s1')) + expect(pending.toolName).toBe('bash') + expect(pending.callId).toBe('call-1') + expect(pending.reason).toBe('needs access') + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)) + expect(() => { pending.abort(new Error('late')) }).not.toThrow() + await expect(pending.answer('rejected')).rejects.toThrow(/already settled/) + }) + + it('rejects with an already-aborted signal reason', async () => { + const controller = new AbortController() + const reason = new Error('host cancelled') + controller.abort(reason) + + const pending = new PendingApproval(id('s1'), { + toolName: 'read', + signal: controller.signal, + }) + + await expect(pending.result).rejects.toBe(reason) + }) + + it('uses a stable fallback when an abort signal supplies no reason', async () => { + const signal = { + aborted: true, + reason: undefined, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as AbortSignal + + const pending = new PendingApproval(id('s1'), { toolName: 'read', signal }) + + await expect(pending.result).rejects.toThrow('approval request was aborted') + }) + + it('rejects an unanswered request explicitly without an AbortSignal', async () => { + const pending = new PendingApproval(id('s1'), { toolName: 'write' }) + const reason = new Error('scope released') + + pending.abort(reason) + + await expect(pending.result).rejects.toBe(reason) + }) + + it('wraps a non-Error answer settlement failure with its cause', async () => { + const failure = 'resolve failed' + const completion = Promise.withResolvers<'allowed-once' | 'rejected'>() + const withResolvers = vi.spyOn(Promise, 'withResolvers').mockImplementationOnce(() => ({ + promise: completion.promise, + resolve: () => { throw failure }, + reject: completion.reject, + })) + const pending = new PendingApproval(id('s1'), { toolName: 'write' }) + withResolvers.mockRestore() + + const settlement = await pending.answer('allowed-once').catch((error: unknown) => error) + + expect(settlement).toBeInstanceOf(Error) + expect(settlement).toMatchObject({ + message: 'pending approval settlement failed', + cause: failure, + }) + completion.resolve('allowed-once') + await expect(pending.result).resolves.toBe('allowed-once') + }) +}) + +describe('approval Remote Event consumer', () => { + it('delegates an event that has no Agent scope', async () => { + const bench = setupPlugin() + const next = vi.fn(() => Promise.resolve<'unavailable'>('unavailable')) + + await expect(bench.listener.call(bench.ctx, { toolName: 'bash' }, next)) + .resolves.toBe('unavailable') + expect(next).toHaveBeenCalledOnce() + expect(bench.pending.getSnapshot()).toEqual([]) + expect(bench.register).toHaveBeenCalledOnce() + }) + + it('publishes one scoped takeover, returns the answer, and keeps stable registrations', async () => { + const bench = setupPlugin() + const scope = createScope(bench.ctx, id('s1')) + await scope.fiber.await() + const controller = new AbortController() + const next = vi.fn(() => Promise.resolve<'unavailable'>('unavailable')) + const result = bench.listener.call(scope.ctx, { + toolName: 'bash', + callId: 'call-1', + reason: 'needs access', + signal: controller.signal, + }, next) + const pending = bench.pending.getSnapshot()[0]! + const { options, component } = bench.registration() + + expect(component).toBe(ApprovalPanel) + expect(options.select({ pendingInteraction: undefined })).toBeNull() + expect(options.select({ pendingInteraction: pending })).toBe(pending) + expect(pending).toMatchObject({ + kind: 'approval', + sessionId: id('s1'), + toolName: 'bash', + callId: 'call-1', + reason: 'needs access', + }) + + await pending.answer('allowed-once') + + await expect(result).resolves.toBe('allowed-once') + expect(next).not.toHaveBeenCalled() + expect(bench.pending.getSnapshot()).toEqual([]) + expect(bench.register).toHaveBeenCalledOnce() + expect(bench.disposeSlot).not.toHaveBeenCalled() + await scope.fiber.dispose() + }) + + it('propagates request cancellation after removing the pending object', async () => { + const bench = setupPlugin() + const scope = createScope(bench.ctx, id('s1')) + await scope.fiber.await() + const controller = new AbortController() + const reason = new Error('cancelled by host') + const result = bench.listener.call(scope.ctx, { + toolName: 'bash', + signal: controller.signal, + }, () => Promise.resolve('unavailable')) + expect(bench.pending.getSnapshot()).toHaveLength(1) + + controller.abort(reason) + + await expect(result).rejects.toBe(reason) + expect(bench.pending.getSnapshot()).toEqual([]) + expect(bench.disposeSlot).not.toHaveBeenCalled() + await scope.fiber.dispose() + }) + + it('removes stable registrations with the plugin lifetime', async () => { + const bench = setupPlugin() + await bench.ctx.fiber.dispose() + expect(bench.disposeSlot).toHaveBeenCalledOnce() + expect(bench.disposeLocale).toHaveBeenCalledOnce() + }) +}) + +function panelProps( + pending: PendingApproval, + renderSlot: ApprovalComposerProps['renderSlot'] = vi.fn(() => null), +): ApprovalComposerProps { + const messages: Record = { + waiting: 'Waiting', + 'detail.aria': 'Approval details', + escalation: `Tool ${pending.toolName} asks`, + reject: 'Reject', + allowOnce: 'Allow once', + } + return { + matched: pending, + renderSlot, + t: (key: string) => messages[key] ?? key, + } as unknown as ApprovalComposerProps +} + +describe('ApprovalPanel', () => { + it('renders fallback copy without detail and returns rejection', async () => { + const pending = new PendingApproval(id('s1'), { toolName: 'bash' }) + const props = panelProps(pending) + render() + + expect(screen.getByText('Tool bash asks')).toBeTruthy() + expect(screen.getByRole('group', { name: 'Approval details' })).toBeTruthy() + expect(props.renderSlot).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Reject' })) + + await expect(pending.result).resolves.toBe('rejected') + expect(screen.getByRole('button', { name: 'Reject' }).disabled).toBe(true) + expect(screen.getByRole('button', { name: 'Allow once' }).disabled).toBe(true) + }) + + it('renders correlated detail and returns allow-once', async () => { + const pending = new PendingApproval(id('s1'), { + toolName: 'bash', + callId: 'call-1' as CallId, + reason: 'Run this exact command', + }) + const renderSlot = vi.fn(() => pnpm test) + render() + + expect(screen.getByText('Run this exact command')).toBeTruthy() + expect(screen.getByText('pnpm test')).toBeTruthy() + expect(renderSlot).toHaveBeenCalledWith('conversation.approval.detail', { + callId: 'call-1', + }) + fireEvent.click(screen.getByRole('button', { name: 'Allow once' })) + + await expect(pending.result).resolves.toBe('allowed-once') + }) + + it('re-enables actions when answering fails', async () => { + const pending = new PendingApproval(id('s1'), { toolName: 'bash' }) + vi.spyOn(pending, 'answer').mockRejectedValue(new Error('transport closed')) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Allow once' })) + expect(screen.getByRole('button', { name: 'Allow once' }).disabled).toBe(true) + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Allow once' }).disabled).toBe(false) + }) + pending.abort(new Error('test cleanup')) + await pending.result.catch(() => {}) + }) +}) + +describe('package entries', () => { + it('declares its service edges, keeps the Host half inert, and registers its invariant', async () => { + expect(inject).toEqual(['sessions', 'remote', 'uiSession', 'slots', 'locale']) + expect(() => { nodeApply() }).not.toThrow() + const ctx = new Context() + await ctx.plugin(InvariantRegistry, { enabled: true }) + + await expect(ctx.plugin(ApprovalInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/client/ui-approval/tsconfig.json b/packages/client/ui-approval/tsconfig.json new file mode 100644 index 0000000000..f7b719a444 --- /dev/null +++ b/packages/client/ui-approval/tsconfig.json @@ -0,0 +1,48 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../locale" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, + { + "path": "../ui-slots" + } + ] +} diff --git a/packages/client/ui-approval/tsdown.config.ts b/packages/client/ui-approval/tsdown.config.ts new file mode 100644 index 0000000000..f7e798c609 --- /dev/null +++ b/packages/client/ui-approval/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-approval', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index d9849f1101..8c3f87f136 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -47,16 +47,17 @@ "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -65,7 +66,10 @@ "@deepseek-ai/dsh-plan-mode": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index a19a028bb6..42d080da76 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -8,13 +8,16 @@ * plan state. */ import type {} from '@deepseek-ai/dsh-api-remotes/client' -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' // Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: pulls the `plan` SessionProjectionMap merge for useProjection. import type {} from '@deepseek-ai/dsh-plan-mode/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { PlanChip } from './PlanModeControl.tsx' import { en, zh, type PlanKey } from './locales.ts' diff --git a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts index 347f38d552..9fe26757fa 100644 --- a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts @@ -7,8 +7,8 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { PlanChip } from '../src/client/PlanModeControl.tsx' import type { PlanChipInjected } from '../src/client/index.ts' diff --git a/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx b/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx index 04ec113ef3..827361bbd0 100644 --- a/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx +++ b/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx @@ -7,7 +7,7 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import type { PlanProjection } from '@deepseek-ai/dsh-plan-mode/client' import { PlanChip, type PlanChipProps } from '../src/client/PlanModeControl.tsx' diff --git a/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json index 7b234d08e7..6cfe159b48 100644 --- a/packages/client/ui-plan/tsconfig.json +++ b/packages/client/ui-plan/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../runtime" - }, { "path": "../locale" }, @@ -26,6 +23,12 @@ { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx index 7bc7c46447..976ba3df53 100644 --- a/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx @@ -9,7 +9,7 @@ */ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' // Export discipline: packages/client/AGENTS.md. diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 6b671b32ec..788b8b6e2c 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -31,11 +31,16 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-client-ui-conversation/client" + ], "inject": [ "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session" ], "platform": "web" } @@ -51,15 +56,21 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^" + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-user-questions": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", @@ -68,10 +79,13 @@ "@types/react": "~18.3.1", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "react": "^18.2.0", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^" + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index b2085cc151..03bf89757e 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -6,9 +6,10 @@ import { IconEditOutline16, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import { - PendingQuestion, planReviewOf, + planReviewOf, type QuestionAnswer, type QuestionComposerProps, } from './contract/slots.ts' +import type { PendingQuestion } from './contract/slots.ts' import { PlanReviewPanel } from './PlanReviewPanel.tsx' import css from './QuestionComposer.module.css' @@ -114,9 +115,7 @@ function AnswerField(props: AnswerFieldProps) { * @returns The question flow, or the intent's own surface, 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]) + const question = props.matched const review = useMemo(() => planReviewOf(question.questions), [question]) return review === undefined ? diff --git a/packages/client/ui-user-questions/src/client/contract/slots.ts b/packages/client/ui-user-questions/src/client/contract/slots.ts index 21070c46f5..cec6e85fb8 100644 --- a/packages/client/ui-user-questions/src/client/contract/slots.ts +++ b/packages/client/ui-user-questions/src/client/contract/slots.ts @@ -1,27 +1,24 @@ -/** - * 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. - */ +/** Question composer props and one pending Remote waterfall response. */ import type { PropsLocale, 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' +// The client module declares the conversation.composer SlotMap entry required by PropsRuntime. +import { settlePendingComposer } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { - PendingQuestionAnswer, PendingWait, -} from '@deepseek-ai/dsh-client-runtime/client' + AskUserQuestionAnswer, AskUserQuestionItem, +} from '@deepseek-ai/dsh-user-questions' -/** The pending question carrier the owner dispatches into the composer slot. */ -export type QuestionWait = PendingWait<'question'> +declare module '@deepseek-ai/dsh-client-ui-session/client' { + interface SessionPendingInteractionMap { + /** Pending question or plan-review request. */ + question: PendingQuestion + } +} /** One structured answer batch covering every question of the request. */ -export type QuestionAnswer = PendingQuestionAnswer +export type QuestionAnswer = AskUserQuestionAnswer -/** One question of the request, as the carrier payload carries it. */ -type QuestionItem = QuestionWait['payload']['questions'][number] +/** One question of the request. */ +type QuestionItem = AskUserQuestionItem /** One option the asker offered on a question. */ type QuestionOption = NonNullable[number] @@ -85,54 +82,105 @@ export function planReviewOf(questions: readonly QuestionItem[]): PlanReview | u } } -/** - * Question domain face over the carrier: render identity and questions - * transparently forwarded; answer/cancel own the wire encoding (the success - * fields 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). - */ +let nextQuestionKey = 0 + +/** Create a wire-preserved user-question rejection. */ +function questionError(message: string, code: 'ASK_ABORTED' | 'ASK_CANCELLED'): Error { + const error = new Error(message) as Error & { code: string } + error.name = 'UserQuestionError' + error.code = code + return error +} + +/** One answerable Client presentation of a pending Host waterfall. */ export class PendingQuestion { + /** Presentation discriminator used by Session pending-interaction consumers. */ + readonly kind: 'question' | 'plan-review' + /** Opaque render identity and local-draft remount axis. */ + readonly key: string + /** The request's question list. */ + readonly questions: readonly AskUserQuestionItem[] + /** Result returned by the Remote Event listener to the Host waterfall. */ + readonly result: Promise + + readonly #resolve: (answer: QuestionAnswer) => void + readonly #reject: (reason: unknown) => void + readonly #signal: AbortSignal | undefined + readonly #onAbort: (() => void) | undefined + #settled = false + /** - * @param wait - the runtime carrier for one pending question request. + * @param sessionId - Agent/Session identity owning the scoped request. + * @param questions - complete question batch. + * @param signal - Host request and delivery lifetime. */ - 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 + constructor( + readonly sessionId: SessionId, + questions: readonly AskUserQuestionItem[], + signal?: AbortSignal, + ) { + nextQuestionKey += 1 + this.key = `question:${String(nextQuestionKey)}` + this.questions = questions + this.kind = planReviewOf(questions) === undefined ? 'question' : 'plan-review' + const completion = Promise.withResolvers() + this.result = completion.promise + this.#resolve = completion.resolve + this.#reject = completion.reject + this.#signal = signal + if (signal === undefined) { + this.#onAbort = undefined + return + } + const onAbort = (): void => { + this.abort(questionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')) + } + this.#onAbort = onAbort + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) onAbort() } /** - * Deliver the whole answer batch; a rejected carrier receipt throws. + * Resolve the Host waterfall with the whole answer batch. * @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}`) - } + answer(answer: QuestionAnswer): Promise { + return settlePendingComposer(() => { + this.finish(() => { this.#resolve(answer) }) + }, 'pending question settlement failed') } - /** 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}`) + /** Reject the Host waterfall because the user closed the question. */ + cancel(): Promise { + return settlePendingComposer(() => { + this.finish(() => { + this.#reject(questionError('the user cancelled ask_user_question', 'ASK_CANCELLED')) + }) + }, 'pending question cancellation failed') + } + + /** + * End an unanswered presentation when its transport, scope, or plugin lifetime ends. + * @param reason - rejection exposed to the waiting Remote Event listener. + */ + abort(reason: unknown): void { + if (this.#settled) return + this.finish(() => { this.#reject(reason) }) + } + + private finish(settle: () => void): void { + if (this.#settled) throw new Error(`pending question ${this.key} is already settled`) + this.#settled = true + if (this.#signal !== undefined && this.#onAbort !== undefined) { + this.#signal.removeEventListener('abort', this.#onAbort) } + settle() } } +/** Pending value returned by the composer-chain selector. */ +export type QuestionWait = PendingQuestion + /** * Full component props: the framework runtime share (chain currency + * session/global standard kit) plus the chain `matched` share — the entry's diff --git a/packages/client/ui-user-questions/src/client/index.ts b/packages/client/ui-user-questions/src/client/index.ts index e107a28750..a6b6646d3e 100644 --- a/packages/client/ui-user-questions/src/client/index.ts +++ b/packages/client/ui-user-questions/src/client/index.ts @@ -12,13 +12,16 @@ * separate chain entry per shape would race the same carrier, so the shape * choice lives inside this entry — see QuestionComposer. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type { TypertClientEventListener } from '@deepseek-ai/dsh-typert-protocol' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' -import { planReviewOf, type QuestionWait } from './contract/slots.ts' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' +import { PendingQuestion } from './contract/slots.ts' import { QuestionComposer } from './QuestionComposer.tsx' import { en, zh, type QuestionKey } from './locales.ts' @@ -38,12 +41,31 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin. */ const NS = 'question' -/** Required services: the slot registry and the question composer's copy. */ -export const inject = ['slots', 'sessions', 'remote', 'conversation', 'locale'] +type QuestionListener = TypertClientEventListener<'user-questions/request'> +type ClientQuestionRequest = Parameters[0] +type ClientQuestionNext = Parameters[1] +type ClientQuestionAnswer = Awaited> -/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */ -function selectQuestion({ pendingInteraction }: ComposerChainProps): QuestionWait | null { - return pendingInteraction?.kind === 'question' ? pendingInteraction : null +/** Required services: Agent scopes, Remote Events, Session UI, Slot registry, and copy. */ +export const inject = ['sessions', 'remote', 'uiSession', 'slots', 'locale'] + +/** Present one request until the user answers, cancels, or its lifetime ends. */ +async function answerQuestion( + ctx: ClientContext, + owner: ClientContext, + request: ClientQuestionRequest, + next: ClientQuestionNext, + attend: (pending: PendingQuestion) => () => void, +): Promise { + const sessionId = ctx.sessions.scopeOf(owner) + if (sessionId === undefined) return next() + const pending = new PendingQuestion(sessionId, request.questions, request.signal) + const remove = attend(pending) + try { + return await pending.result + } finally { + remove() + } } /** @@ -54,50 +76,19 @@ function selectQuestion({ pendingInteraction }: ComposerChainProps): QuestionWai */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-user-questions: dictionaries') - + const attend = ctx.uiSession.attend( + pending => pending.kind === 'plan-review' ? 2 : 1, + ) ctx.slots.inject('conversation.composer', () => ctx.slots.register( - { name: 'conversation.composer', select: selectQuestion, locale: NS }, + { + name: 'conversation.composer', + select: ({ pendingInteraction }: ComposerChainProps): PendingQuestion | null => + pendingInteraction instanceof PendingQuestion ? pendingInteraction : null, + locale: NS, + }, QuestionComposer, )) - - let nextQuestionKey = 0 ctx.remote.$on('user-questions/request', function (request, next) { - const sessionId = ctx.sessions.scopeOf(this) - if (sessionId === undefined) return next() - nextQuestionKey += 1 - const interactionId = `remote-${String(nextQuestionKey)}` - const completion = Promise.withResolvers>>() - const wait = new PendingWait('question', interactionId, sessionId, { - questions: request.questions, - }, (response) => { - if (response.result.ok) { - completion.resolve(response.result.value.answer) - } else { - const error = new Error(response.result.error.message) as Error & { code: string } - error.name = 'UserQuestionError' - error.code = response.result.error.code === 'cancelled' - ? 'ASK_CANCELLED' - : response.result.error.code - completion.reject(error) - } - return Promise.resolve({ ok: true, value: { accepted: true } }) - }) - const status = planReviewOf(request.questions) === undefined ? 'question' : 'plan-review' - const remove = ctx.conversation.pendingInteractions.present( - wait, - status, - status === 'plan-review' ? 2 : 1, - ) - const signal = request.signal - if (signal === undefined) return completion.promise.finally(remove) - const abort = (): void => { - completion.reject(signal.reason) - } - signal.addEventListener('abort', abort, { once: true }) - if (signal.aborted) abort() - return completion.promise.finally(() => { - signal.removeEventListener('abort', abort) - remove() - }) + return answerQuestion(ctx, this, request, next, attend) }) } diff --git a/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts b/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts index dbf42c9972..5940bc0fba 100644 --- a/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts @@ -1,27 +1,36 @@ /** Scoped Remote Event wiring for the browser question consumer. */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import type { PendingWait, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { QuestionComposer } from '../src/client/QuestionComposer.tsx' import { PendingQuestion } from '../src/client/contract/slots.ts' import { apply, inject } from '../src/client/index.ts' const SESSION_ID = 'session-question' as SessionId +const SESSION_SCOPE = Symbol('question-session-scope') +const QUESTIONS = [{ id: 'mode', question: 'Choose a mode' }] as const +const PLAN_QUESTIONS: PendingQuestion['questions'] = [{ + id: 'plan', + question: 'Approve this plan?', + detail: '# Plan', + options: [{ label: 'Approve' }, { label: 'Keep planning' }], + intent: { kind: 'plan-review' as const, approve: 'Approve' }, +}] const ANSWER = { answers: [{ id: 'mode', selected: ['Fast'] }] } -const QUESTIONS = [{ id: 'mode', question: 'Choose a mode' }] type QuestionRequest = { - questions: typeof QUESTIONS + questions: PendingQuestion['questions'] signal?: AbortSignal } -type QuestionNext = () => Promise +type QuestionAnswer = typeof ANSWER +type QuestionNext = () => Promise type QuestionListener = ( this: Context, request: QuestionRequest, next: QuestionNext, -) => Promise +) => Promise async function bench(declare = true) { const ctx = new Context() @@ -33,28 +42,21 @@ async function bench(declare = true) { () => null, ) } - ctx.provide('locale', new LocaleRuntime(ctx)) - const owner = ctx.extend() - const scopeOf = vi.fn((candidate: Context) => candidate === owner ? SESSION_ID : undefined) + const locale = new LocaleRuntime(ctx) + ctx.provide('locale', locale) + const agent = ctx.extend({ [SESSION_SCOPE]: SESSION_ID }) + const scopeOf = vi.fn((candidate: Context) => ( + candidate as Context & { [SESSION_SCOPE]?: SessionId } + )[SESSION_SCOPE]) ctx.provide('sessions', { scopeOf } as never) - - let presented: PendingWait<'question'> | undefined - const remove = vi.fn(() => { - presented?.markSettled() - presented = undefined + let pending: readonly PendingQuestion[] = [] + const attend = vi.fn((_precedence: (value: PendingQuestion) => number) => ( + value: PendingQuestion, + ) => { + pending = [...pending, value] + return () => { pending = pending.filter(candidate => candidate !== value) } }) - const present = vi.fn((wait: PendingWait<'question'>) => { - presented = wait - return remove - }) - ctx.provide('conversation', { - pendingInteractions: { - present, - statuses: { getSnapshot: () => new Map(), subscribe: () => () => {} }, - forSession: () => ({ getSnapshot: () => [], subscribe: () => () => {} }), - }, - } as never) - + ctx.provide('uiSession', { attend } as never) let listener: QuestionListener | undefined const on = vi.fn((event: string, value: QuestionListener) => { expect(event).toBe('user-questions/request') @@ -64,151 +66,208 @@ async function bench(declare = true) { ctx.provide('remote', { $on: on } as never) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - + const invoke = ( + owner: Context, + request: QuestionRequest, + next: QuestionNext, + ): Promise => { + if (listener === undefined) throw new Error('question listener was not installed') + return listener.call(owner, request, next) + } return { ctx, slots, - owner, + locale, + agent, scopeOf, - present, - remove, + pending: { getSnapshot: () => pending }, + attend, on, fiber, - presented: () => presented, - invoke(request: QuestionRequest, next: QuestionNext, target = owner): Promise { - if (listener === undefined) throw new Error('question listener was not installed') - return listener.call(target, request, next) - }, + invoke, } } describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slots', 'sessions', 'remote', 'conversation', 'locale']) + expect(inject).toEqual(['sessions', 'remote', 'uiSession', 'slots', 'locale']) }) - it('installs the Remote Event listener and waits for the composer declaration', async () => { + it('installs the Remote Event listener and delegates an unscoped request', async () => { const b = await bench(false) + const next = vi.fn(async () => ANSWER) + + await expect(b.invoke(b.ctx, { questions: QUESTIONS }, next)).resolves.toBe(ANSWER) + expect(b.on).toHaveBeenCalledOnce() - expect(b.slots.entries('conversation.composer')).toHaveLength(0) - - b.slots.register( - { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never, - () => null, - ) - await Promise.resolve() - expect(b.slots.entries('conversation.composer')).toHaveLength(1) - }) - - it('delegates a request whose Client Context has no Session', async () => { - const b = await bench() - const next = vi.fn(async () => ANSWER) - - await expect(b.invoke({ questions: QUESTIONS }, next, b.ctx)).resolves.toBe(ANSWER) - expect(next).toHaveBeenCalledOnce() - expect(b.present).not.toHaveBeenCalled() + expect(b.slots.entries('conversation.composer')).toHaveLength(0) + expect(b.pending.getSnapshot()).toEqual([]) }) - it('publishes one scoped wait and returns its structured answer', async () => { + it('projects a scoped request through one stable composer and returns its answer', async () => { const b = await bench() const next = vi.fn(async () => ANSWER) - const result = b.invoke({ questions: QUESTIONS }, next) - const wait = b.presented() - if (wait === undefined) throw new Error('question wait was not presented') - const entry = b.slots.entries('conversation.composer')[0]! - const select = entry.select as ( - owner: { pendingInteraction: PendingWait<'question'> | undefined }, - ) => PendingWait<'question'> | null + const result = b.invoke(b.agent, { questions: QUESTIONS }, next) + await Promise.resolve() + const entry = b.slots.entries('conversation.composer')[0]! expect(entry.component).toBe(QuestionComposer) expect(entry.inject).toBeUndefined() expect(entry.locale).toBe('question') + const pending = b.pending.getSnapshot()[0]! + const select = entry.select as ( + owner: { pendingInteraction: PendingQuestion | undefined }, + ) => PendingQuestion | null expect(select({ pendingInteraction: undefined })).toBeNull() - expect(select({ pendingInteraction: wait })).toBe(wait) - expect(b.present).toHaveBeenCalledWith(wait, 'question', 1) + expect(select({ pendingInteraction: pending })).toBe(pending) + expect(pending).toMatchObject({ kind: 'question', sessionId: SESSION_ID, questions: QUESTIONS }) - await new PendingQuestion(wait).answer(ANSWER) + await pending.answer(ANSWER) await expect(result).resolves.toBe(ANSWER) expect(next).not.toHaveBeenCalled() - expect(b.remove).toHaveBeenCalledOnce() - expect(b.presented()).toBeUndefined() + expect(b.pending.getSnapshot()).toEqual([]) + expect(b.slots.entries('conversation.composer')).toHaveLength(1) }) - it('uses plan-review precedence and preserves ASK_CANCELLED', async () => { + it('preserves ASK_CANCELLED as a rejected waterfall result', async () => { const b = await bench() - const questions = [{ - id: 'plan', - question: 'Approve?', - detail: '# Plan', - options: [{ label: 'Approve' }, { label: 'Keep planning' }], - intent: { kind: 'plan-review' as const, approve: 'Approve' }, - }] - const result = b.invoke({ questions }, async () => ANSWER) - const wait = b.presented() - if (wait === undefined) throw new Error('plan review wait was not presented') - - expect(b.present).toHaveBeenCalledWith(wait, 'plan-review', 2) + const result = b.invoke(b.agent, { questions: QUESTIONS }, async () => ANSWER) + await Promise.resolve() + const pending = b.pending.getSnapshot()[0]! const rejection = expect(result).rejects.toMatchObject({ name: 'UserQuestionError', code: 'ASK_CANCELLED', + message: 'the user cancelled ask_user_question', }) - await new PendingQuestion(wait).cancel() + + await pending.cancel() await rejection - expect(b.remove).toHaveBeenCalledOnce() + expect(b.pending.getSnapshot()).toEqual([]) + expect(b.slots.entries('conversation.composer')).toHaveLength(1) }) - it('preserves a non-cancellation question rejection', async () => { + it('publishes a plan-review request with its distinct interaction kind', async () => { const b = await bench() - const result = b.invoke({ questions: QUESTIONS }, async () => ANSWER) - const wait = b.presented() - if (wait === undefined) throw new Error('question wait was not presented') + const result = b.invoke(b.agent, { questions: PLAN_QUESTIONS }, async () => ANSWER) + await Promise.resolve() + const pending = b.pending.getSnapshot()[0]! - const rejection = expect(result).rejects.toMatchObject({ - name: 'UserQuestionError', - code: 'provider-failed', - message: 'provider failed', - }) - await wait.respond({ - ok: false, - error: { code: 'provider-failed', message: 'provider failed', details: {} }, - }) - await rejection - expect(b.remove).toHaveBeenCalledOnce() + expect(pending.kind).toBe('plan-review') + await pending.answer(ANSWER) + await expect(result).resolves.toBe(ANSWER) + expect(b.pending.getSnapshot()).toEqual([]) }) - it('removes an aborted request and its signal listener', async () => { + it('removes a cancelled request while preserving the stable composer', async () => { const b = await bench() const controller = new AbortController() - const removeEventListener = vi.spyOn(controller.signal, 'removeEventListener') - const reason = new DOMException('aborted by Host', 'AbortError') - const result = b.invoke({ questions: QUESTIONS, signal: controller.signal }, async () => ANSWER) - expect(b.presented()).toBeDefined() + const result = b.invoke(b.agent, { questions: QUESTIONS, signal: controller.signal }, async () => ANSWER) + await Promise.resolve() + expect(b.pending.getSnapshot()).toHaveLength(1) - controller.abort(reason) - - await expect(result).rejects.toBe(reason) - expect(removeEventListener).toHaveBeenCalledWith('abort', expect.any(Function)) - expect(b.remove).toHaveBeenCalledOnce() - expect(b.presented()).toBeUndefined() - }) - - it('removes a request whose signal was already aborted', async () => { - const b = await bench() - const controller = new AbortController() controller.abort() - const result = b.invoke({ questions: QUESTIONS, signal: controller.signal }, async () => ANSWER) - - await expect(result).rejects.toBe(controller.signal.reason) - expect(b.remove).toHaveBeenCalledOnce() - expect(b.presented()).toBeUndefined() + await expect(result).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + expect(b.pending.getSnapshot()).toEqual([]) + expect(b.slots.entries('conversation.composer')).toHaveLength(1) }) - it('teardown unregisters the stable composer entry', async () => { + it('removes the stable composer with the plugin lifetime', async () => { const b = await bench() expect(b.slots.entries('conversation.composer')).toHaveLength(1) + await b.fiber.dispose() + expect(b.slots.entries('conversation.composer')).toHaveLength(0) }) }) + +describe('PendingQuestion', () => { + it('preserves an already-aborted request signal as ASK_ABORTED', async () => { + const lifetime = new AbortController() + lifetime.abort() + const pending = new PendingQuestion(SESSION_ID, QUESTIONS, lifetime.signal) + + await expect(pending.result).rejects.toMatchObject({ + name: 'UserQuestionError', + code: 'ASK_ABORTED', + message: 'ask_user_question was aborted before the user answered', + }) + }) + + it('rejects on later request cancellation and removes the listener after settlement', async () => { + const lifetime = new AbortController() + const remove = vi.spyOn(lifetime.signal, 'removeEventListener') + const pending = new PendingQuestion(SESSION_ID, QUESTIONS, lifetime.signal) + const rejected = expect(pending.result).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + + lifetime.abort() + + await rejected + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)) + }) + + it('ignores a lifecycle abort after the answer already settled', async () => { + const lifetime = new AbortController() + const pending = new PendingQuestion(SESSION_ID, QUESTIONS, lifetime.signal) + + await pending.answer(ANSWER) + await expect(pending.result).resolves.toBe(ANSWER) + pending.abort(new Error('late disposal')) + }) + + it('rejects an unanswered request with its caller-owned lifecycle reason', async () => { + const pending = new PendingQuestion(SESSION_ID, QUESTIONS) + const reason = new Error('scope released') + const rejected = expect(pending.result).rejects.toBe(reason) + + pending.abort(reason) + + await rejected + }) + + it('wraps a non-Error answer settlement failure with its cause', async () => { + const failure = 'resolve failed' + const completion = Promise.withResolvers() + const withResolvers = vi.spyOn(Promise, 'withResolvers').mockImplementationOnce(() => ({ + promise: completion.promise, + resolve: () => { throw failure }, + reject: completion.reject, + })) + const pending = new PendingQuestion(SESSION_ID, QUESTIONS) + withResolvers.mockRestore() + + const settlement = await pending.answer(ANSWER).catch((error: unknown) => error) + + expect(settlement).toBeInstanceOf(Error) + expect(settlement).toMatchObject({ + message: 'pending question settlement failed', + cause: failure, + }) + completion.resolve(ANSWER) + await expect(pending.result).resolves.toBe(ANSWER) + }) + + it('wraps a non-Error cancellation settlement failure with its cause', async () => { + const failure = 'reject failed' + const completion = Promise.withResolvers() + const withResolvers = vi.spyOn(Promise, 'withResolvers').mockImplementationOnce(() => ({ + promise: completion.promise, + resolve: completion.resolve as (value: T | PromiseLike) => void, + reject: () => { throw failure }, + })) + const pending = new PendingQuestion(SESSION_ID, QUESTIONS) + withResolvers.mockRestore() + + const settlement = await pending.cancel().catch((error: unknown) => error) + + expect(settlement).toBeInstanceOf(Error) + expect(settlement).toMatchObject({ + message: 'pending question cancellation failed', + cause: failure, + }) + completion.resolve(ANSWER) + await expect(pending.result).resolves.toBe(ANSWER) + }) +}) diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 9c48979c8d..4faba04c00 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -1,12 +1,10 @@ // @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, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' -import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' -import { planReviewOf, type QuestionComposerProps, type QuestionWait } from '../src/client/contract/slots.ts' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { + PendingQuestion, planReviewOf, type QuestionComposerProps, type QuestionWait, +} from '../src/client/contract/slots.ts' import { QuestionComposer } from '../src/client/QuestionComposer.tsx' import { en, zh } from '../src/client/locales.ts' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' @@ -15,29 +13,113 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts afterEach(cleanup) const SID = 's1' as SessionId -const interactionId = (value: string): string => value -type QuestionRespond = ConstructorParameters>[4] const seatOver = (dict: Record, common: Record): QuestionComposerProps['t'] => (key => dict[key] ?? common[key] ?? key) +type SessionState = Parameters[0]>[0] +type ConversationState = Parameters[0]>[0] +type ChatState = Parameters[0]>[0] +type TrajectoryState = Parameters[0]>[0] +type InputState = Parameters[0]>[0] +type AttentionState = Parameters[0]>[0] + +const sessionState: SessionState = { + sessionId: SID, + queue: [], + running: false, + subagent: null, + removed: false, + openState: 'open', + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: false, + lastAgentError: null, + promptAttempted: false, + awaitingFirstTurn: false, +} +const sessionList = { + ids: [SID], + byId: { [SID]: { id: SID, displayTitle: 'Session', running: false, blank: false, updatedAt: 0 } }, + current: SID, + phase: 'ready' as const, + subagentsByParent: {}, + jobsBySession: {}, + currentAddress: undefined, +} +const attentionState: AttentionState = new Map() +const workspaceState = { + items: [], + archivedSessionIds: [], + state: 'idle' as const, + phase: 'ready' as const, + error: null, +} +const conversationState: ConversationState = { + views: { get: () => undefined }, + activeTargets: new Set(), +} +const emptyKeys: readonly string[] = [] +const chatState: ChatState = { + order: emptyKeys, + nodes: { get: () => undefined, values: () => [] }, + locations: { getTurn: () => emptyKeys, getStep: () => emptyKeys }, + timeline: { turnOrder: [], turns: new Map() }, + legacy: { + nodes: [], + turnTimings: new Map(), + turnEnds: new Map(), + partial: null, + runningCalls: [], + }, +} +const trajectoryState: TrajectoryState = { + eventNodes: [], + eventLocations: new Map(), + requests: [], + callSchemas: new Map(), + partial: null, + runningCalls: [], +} +const inputState: InputState = { + draft: '', + imageIds: [], + draftRev: 0, + phase: 'plain', + occurrences: [], + queue: [], +} + /** Framework standard-kit stubs: the panel consumes only the locale seat. */ -const kit = { +const kit: Omit = { sessionId: SID, session: undefined, - useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, - useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, - useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, - useProjection: (() => undefined) as never, - useInput: (() => { throw new Error('unused') }) as never, - inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never, + pendingInteraction: undefined, + useSession: selector => selector(sessionState), + useSessions: selector => selector(sessionList), + useSessionPendingInteraction: selector => selector(attentionState), + useWorkspaces: selector => selector(workspaceState), + useConversation: selector => selector(conversationState), + useChat: selector => selector(chatState), + useTrajectory: selector => selector(trajectoryState), + useProjection: (() => undefined), + useInput: selector => selector(inputState), + inputActions: { + setDraft: () => { throw new Error('unused') }, + addImages: () => { throw new Error('unused') }, + removeImage: () => { throw new Error('unused') }, + pruneImages: () => { throw new Error('unused') }, + submit: () => { throw new Error('unused') }, + }, t: seatOver(zh, commonZh), } const PLAN = '# Ship the picker\n\n- read the store\n- render the rows\n' /** The plan-mode request shape: one question, the plan as detail, approve named. */ -const questions = (): QuestionWait['payload']['questions'] => [{ +const questions = (): QuestionWait['questions'] => [{ id: 'plan-review', header: 'Plan review', question: 'Approve this plan and leave plan mode?', @@ -49,24 +131,16 @@ const questions = (): QuestionWait['payload']['questions'] => [{ intent: { kind: 'plan-review', approve: 'Approve' }, }] -/** Carrier fixture over a scripted respond carrier. */ -function wait( - payload: QuestionWait['payload'] = { questions: questions() }, - respond: QuestionRespond = vi.fn(() => Promise.resolve({ - ok: true as const, - value: { accepted: true as const }, - })), -) { - return { carrier: new PendingWait('question', interactionId('q-1'), SID, payload, respond), respond } +/** Pending waterfall fixture with observable Client response methods. */ +function wait(items: QuestionWait['questions'] = questions()) { + const carrier = new PendingQuestion(SID, items) + const answer = vi.spyOn(carrier, 'answer') + const cancel = vi.spyOn(carrier, 'cancel') + void carrier.result.catch(() => {}) + return { carrier, answer, cancel } } -/** The Session Controller response request emitted for a decision. */ -function decidedEnvelope(label: string) { - return { - interactionId: interactionId('q-1'), - result: { ok: true, value: { sessionId: SID, answer: { answers: [{ id: 'plan-review', selected: [label] }] } } }, - } -} +const decision = (label: string) => ({ answers: [{ id: 'plan-review', selected: [label] }] }) describe('planReviewOf', () => { it('narrows a plan-review request to its decision, options included', () => { @@ -113,9 +187,9 @@ describe('planReviewOf', () => { describe('PlanReviewPanel', () => { it('renders the plan under a review strip, with none of the quiz affordances', () => { const { carrier } = wait() - render() + render() - expect(document.querySelector('[data-plan-review-key="q:q-1"]')).toBeTruthy() + expect(document.querySelector('[data-plan-review-key]')?.getAttribute('data-plan-review-key')).toBe(carrier.key) expect(screen.getByText(zh['plan.header'])).toBeTruthy() // The plan renders as markdown, so its heading is a heading. expect(screen.getByRole('heading', { name: 'Ship the picker' })).toBeTruthy() @@ -131,72 +205,61 @@ describe('PlanReviewPanel', () => { }) it('answers with the asker\'s approve label and keeps its description as the tooltip', () => { - const { carrier, respond } = wait() - render() + const { carrier, answer } = wait() + render() const approve = screen.getByRole('button', { name: zh['plan.approve'] }) expect(approve.getAttribute('title')).toBe('Leave plan mode; the plan is carried out from the next step.') fireEvent.click(approve) - expect(respond).toHaveBeenCalledWith(decidedEnvelope('Approve')) + expect(answer).toHaveBeenCalledWith(decision('Approve')) // One-shot: every action locks until the host's resolved frame lands. expect(approve.hasAttribute('disabled')).toBe(true) expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('disabled')).toBe(true) fireEvent.click(approve) - expect(respond).toHaveBeenCalledTimes(1) + expect(answer).toHaveBeenCalledTimes(1) }) it('answers with the asker\'s decline label', () => { - const { carrier, respond } = wait() - render() + const { carrier, answer } = wait() + render() fireEvent.click(screen.getByRole('button', { name: zh['plan.decline'] })) - expect(respond).toHaveBeenCalledWith(decidedEnvelope('Keep planning')) + expect(answer).toHaveBeenCalledWith(decision('Keep planning')) }) it('dismisses the request so the composer returns for a plain message', () => { - const { carrier, respond } = wait() - render() + const { carrier, cancel } = wait() + render() fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] })) - expect(respond).toHaveBeenCalledWith({ - interactionId: interactionId('q-1'), - result: { - ok: false, - error: { code: 'cancelled', message: 'the user closed this question request', details: {} }, - }, - }) + expect(cancel).toHaveBeenCalledWith() }) it('omits the tooltip for an option carrying no description', () => { - const { carrier } = wait({ questions: [{ + const { carrier } = wait([{ ...questions()[0] as object, options: [{ label: 'Approve' }, { label: 'Keep planning' }], - }] as never }) - render() + }] as never) + render() expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('title')).toBe(false) expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('title')).toBe(false) }) it('hides the decline action when the asker offered approve alone', () => { - const { carrier } = wait({ questions: [{ + const { carrier } = wait([{ ...questions()[0] as object, options: [{ label: 'Approve' }], - }] as never }) - render() + }] as never) + render() expect(screen.queryByRole('button', { name: zh['plan.decline'] })).toBeNull() expect(screen.getByRole('button', { name: zh['plan.approve'] })).toBeTruthy() }) it('re-arms the actions and says why when the decision does not land', async () => { - const { carrier, respond } = wait( - { questions: questions() }, - vi.fn(() => Promise.resolve({ - ok: true as const, - value: { accepted: false as const, reason: 'not-pending' as const }, - })), - ) - render() + const { carrier, answer } = wait() + answer.mockRejectedValue(new Error('question response rejected: not-pending')) + render() fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] })) const failure = await screen.findByText('question response rejected: not-pending') @@ -204,15 +267,15 @@ describe('PlanReviewPanel', () => { // Re-armed for the retry: a lost click must not leave a dead card. expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('disabled')).toBe(false) fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] })) - expect(respond).toHaveBeenCalledTimes(2) + expect(answer).toHaveBeenCalledTimes(2) }) it('reports a non-Error transport failure as its stringified value', async () => { // A non-Error rejection is the case under test: a carrier can reject with // anything, and the panel must still show the user something. - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercises non-Error rejections - const { carrier } = wait({ questions: questions() }, vi.fn(() => Promise.reject('socket gone'))) - render() + const { carrier, cancel } = wait() + cancel.mockRejectedValue('socket gone') + render() fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] })) expect(await screen.findByText('socket gone')).toBeTruthy() @@ -220,7 +283,7 @@ describe('PlanReviewPanel', () => { it('carries the same decision surface in English', () => { const { carrier } = wait() - render() + render() expect(screen.getByText('Plan review')).toBeTruthy() expect(screen.getByRole('button', { name: 'Approve' })).toBeTruthy() diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index f97affab19..a650dd6a5b 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -1,11 +1,7 @@ // @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, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' -import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts' import { QuestionComposer, parseRecommendedLabel } from '../src/client/QuestionComposer.tsx' import { en, zh } from '../src/client/locales.ts' @@ -15,29 +11,113 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts afterEach(cleanup) const SID = 's1' as SessionId -const interactionId = (value: string): string => value -type QuestionRespond = ConstructorParameters>[4] const seatOver = (dict: Record, common: Record): QuestionComposerProps['t'] => (key => dict[key] ?? common[key] ?? key) +type SessionState = Parameters[0]>[0] +type ConversationState = Parameters[0]>[0] +type ChatState = Parameters[0]>[0] +type TrajectoryState = Parameters[0]>[0] +type InputState = Parameters[0]>[0] +type AttentionState = Parameters[0]>[0] + +const sessionState: SessionState = { + sessionId: SID, + queue: [], + running: false, + subagent: null, + removed: false, + openState: 'open', + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: false, + lastAgentError: null, + promptAttempted: false, + awaitingFirstTurn: false, +} +const sessionList = { + ids: [SID], + byId: { [SID]: { id: SID, displayTitle: 'Session', running: false, blank: false, updatedAt: 0 } }, + current: SID, + phase: 'ready' as const, + subagentsByParent: {}, + jobsBySession: {}, + currentAddress: undefined, +} +const attentionState: AttentionState = new Map() +const workspaceState = { + items: [], + archivedSessionIds: [], + state: 'idle' as const, + phase: 'ready' as const, + error: null, +} +const conversationState: ConversationState = { + views: { get: () => undefined }, + activeTargets: new Set(), +} +const emptyKeys: readonly string[] = [] +const chatState: ChatState = { + order: emptyKeys, + nodes: { get: () => undefined, values: () => [] }, + locations: { getTurn: () => emptyKeys, getStep: () => emptyKeys }, + timeline: { turnOrder: [], turns: new Map() }, + legacy: { + nodes: [], + turnTimings: new Map(), + turnEnds: new Map(), + partial: null, + runningCalls: [], + }, +} +const trajectoryState: TrajectoryState = { + eventNodes: [], + eventLocations: new Map(), + requests: [], + callSchemas: new Map(), + partial: null, + runningCalls: [], +} +const inputState: InputState = { + draft: '', + imageIds: [], + draftRev: 0, + phase: 'plain', + occurrences: [], + queue: [], +} + /** Framework standard-kit stubs: the composer consumes only the locale seat; * the composed props type mandates delivery of the rest (framework hooks are * plain stubs per the client testing discipline). */ -const kit = { +const kit: Omit = { session: undefined, sessionId: SID, - useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, - useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, - useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, - useProjection: (() => undefined) as never, - useInput: (() => { throw new Error('unused') }) as never, - inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never, + pendingInteraction: undefined, + useSession: selector => selector(sessionState), + useSessions: selector => selector(sessionList), + useSessionPendingInteraction: selector => selector(attentionState), + useWorkspaces: selector => selector(workspaceState), + useConversation: selector => selector(conversationState), + useChat: selector => selector(chatState), + useTrajectory: selector => selector(trajectoryState), + useProjection: (() => undefined), + useInput: selector => selector(inputState), + inputActions: { + setDraft: () => { throw new Error('unused') }, + addImages: () => { throw new Error('unused') }, + removeImage: () => { throw new Error('unused') }, + pruneImages: () => { throw new Error('unused') }, + submit: () => { throw new Error('unused') }, + }, // The seat's key domain is question ∪ common. t: seatOver(zh, commonZh), } -const QUESTIONS = [ +const QUESTIONS: PendingQuestion['questions'] = [ { id: 'profile', header: '偏好', question: '选择候选人类型', detail: '按当前空缺岗位的优先级选择。', @@ -55,31 +135,21 @@ const QUESTIONS = [ }, ] -/** Carrier fixture: a real PendingWait over a scripted respond carrier. */ -function wait( - id = 'question-1', - respond: QuestionRespond = vi.fn(() => Promise.resolve({ - ok: true as const, - value: { accepted: true as const }, - })), -) { - const carrier = new PendingWait( - 'question', interactionId(id), SID, { questions: QUESTIONS }, respond) - return { carrier, respond } +/** Pending waterfall fixture with observable Client response methods. */ +function wait(questions: PendingQuestion['questions'] = QUESTIONS) { + const carrier = new PendingQuestion(SID, questions) + const answer = vi.spyOn(carrier, 'answer') + const cancel = vi.spyOn(carrier, 'cancel') + void carrier.result.catch(() => {}) + return { carrier, answer, cancel } } -/** The Session Controller response request emitted for an answer batch. */ -function answeredEnvelope(id: string, answers: object[]) { - return { - interactionId: interactionId(id), - result: { ok: true, value: { sessionId: SID, answer: { answers } } }, - } -} +const answerBatch = (answers: object[]) => ({ answers }) describe('QuestionComposer', () => { it('collects single, custom, and multi-select answers before one batch submit', () => { - const { carrier, respond } = wait() - render() + const { carrier, answer } = wait() + render() expect(screen.getByText('偏好')).toBeTruthy() expect(screen.getByText('1 / 3')).toBeTruthy() @@ -91,7 +161,7 @@ describe('QuestionComposer', () => { expect(scrollRegion?.contains(screen.getByRole('radio', { name: /工程落地型/ }))).toBe(true) expect(scrollRegion?.contains(screen.getByText('下一题').closest('button'))).toBe(false) fireEvent.keyDown(screen.getByRole('radio', { name: /工程落地型/ }), { key: 'Enter' }) - expect(respond).not.toHaveBeenCalled() + expect(answer).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ })) expect(screen.getByText('2 / 3')).toBeTruthy() @@ -118,7 +188,7 @@ describe('QuestionComposer', () => { fireEvent.keyDown(multiCustom, { key: 'Enter' }) // The domain face encoded the whole batch into one carrier envelope. - expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [ + expect(answer).toHaveBeenCalledWith(answerBatch([ { id: 'profile', selected: ['工程落地型 (Recommended)'] }, { id: 'detail', selected: [], custom: '要能独立排查线上问题' }, { id: 'signals', selected: ['系统设计', '代码质量', '产品判断'], custom: '沟通能力' }, @@ -127,21 +197,13 @@ describe('QuestionComposer', () => { }) it('renders plan detail through the shared assistant Markdown primitive', () => { - const carrier = new PendingWait( - 'question', - interactionId('markdown-plan'), - SID, - { - questions: [{ - id: 'plan', - question: '批准这个计划吗?', - detail: '# 实施计划\n\n- **先验证**现状\n- 修改 `QuestionComposer`', - options: [{ label: '批准' }], - }], - }, - vi.fn(), - ) - const view = render() + const { carrier } = wait([{ + id: 'plan', + question: '批准这个计划吗?', + detail: '# 实施计划\n\n- **先验证**现状\n- 修改 `QuestionComposer`', + options: [{ label: '批准' }], + }]) + const view = render() expect(screen.getByRole('heading', { level: 1, name: '实施计划' })).toBeTruthy() expect(view.container.querySelector('strong')?.textContent).toBe('先验证') @@ -150,8 +212,8 @@ describe('QuestionComposer', () => { }) it('skips individual questions without discarding earlier answers', () => { - const { carrier, respond } = wait() - render() + const { carrier, answer } = wait() + render() expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true) fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' })) @@ -160,7 +222,7 @@ describe('QuestionComposer', () => { expect(screen.getByText('3 / 3')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: '跳过本题' })) - expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [ + expect(answer).toHaveBeenCalledWith(answerBatch([ { id: 'profile', selected: ['研究潜力型'] }, { id: 'detail', selected: [] }, { id: 'signals', selected: [] }, @@ -168,8 +230,8 @@ describe('QuestionComposer', () => { }) it('keeps IME Enter inside the custom input until composition finishes', () => { - const { carrier, respond } = wait() - render() + const { carrier, answer } = wait() + render() fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' })) const custom = screen.getByPlaceholderText('输入你的答案') @@ -177,19 +239,19 @@ describe('QuestionComposer', () => { fireEvent.keyDown(custom, { key: 'Enter', isComposing: true }) expect(screen.getByText('2 / 3')).toBeTruthy() - expect(respond).not.toHaveBeenCalled() + expect(answer).not.toHaveBeenCalled() fireEvent.keyDown(custom, { key: 'Enter', keyCode: 229 }) expect(screen.getByText('2 / 3')).toBeTruthy() - expect(respond).not.toHaveBeenCalled() + expect(answer).not.toHaveBeenCalled() fireEvent.keyDown(custom, { key: 'Enter' }) expect(screen.getByText('3 / 3')).toBeTruthy() }) it('shows the inline custom input, reports missing answers, and supports pager navigation', () => { - const { carrier, respond } = wait() - render() + const { carrier, answer } = wait() + render() expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy() fireEvent.click(screen.getByRole('radio', { name: '工程落地型' })) @@ -206,12 +268,12 @@ describe('QuestionComposer', () => { expect(screen.getByText('2 / 3')).toBeTruthy() fireEvent.click(screen.getByLabelText('上一题')) expect(screen.getByText('1 / 3')).toBeTruthy() - expect(respond).not.toHaveBeenCalled() + expect(answer).not.toHaveBeenCalled() }) it('answers over multiple lines: both fields grow with the draft and keep Shift+Enter a newline', () => { - const { carrier, respond } = wait() - render() + const { carrier, answer } = wait() + render() // Both question shapes answer into a textarea, so the engine soft-wraps a // long answer and Shift+Enter breaks the line natively. @@ -239,40 +301,39 @@ describe('QuestionComposer', () => { fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('button', { name: '提交' })) // Line breaks reach the model verbatim: nothing along the way flattens them. - expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [ + expect(answer).toHaveBeenCalledWith(answerBatch([ { id: 'profile', selected: [], custom: multiline }, { id: 'detail', selected: [], custom: multiline }, { id: 'signals', selected: ['系统设计'] }, ])) }) - it('surfaces cancellation failures: rejected receipt text and raw transport reasons', async () => { - const respond = vi.fn() - .mockResolvedValueOnce({ ok: true, value: { accepted: false, reason: 'bad-response' } }) + it('surfaces cancellation failures and re-arms the controls', async () => { + const { carrier, cancel } = wait() + cancel + .mockRejectedValueOnce(new Error('第一次取消失败')) .mockRejectedValueOnce(new Error('第二次取消失败')) - const { carrier } = wait('question-1', respond) - render() + render() - // 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(await screen.findByText('第一次取消失败')).toBeTruthy() expect(screen.getByRole('button', { name: '跳过本题' }).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() + it('surfaces answer rejection and resets local drafts for a different request', async () => { + const first = wait() + const view = render() fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ })) expect(screen.getByText('2 / 3')).toBeTruthy() - const second = wait('second', respond) - view.rerender() + const second = wait() + second.answer + .mockRejectedValueOnce(new Error('网络中断')) + .mockRejectedValueOnce('字符串错误') + view.rerender() expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('false') fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ })) @@ -281,7 +342,7 @@ describe('QuestionComposer', () => { fireEvent.keyDown(custom, { key: 'Enter' }) fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('button', { name: '提交' })) - expect(respond).toHaveBeenNthCalledWith(1, answeredEnvelope('second', [ + expect(second.answer).toHaveBeenNthCalledWith(1, answerBatch([ { id: 'profile', selected: ['工程落地型 (Recommended)'] }, { id: 'detail', selected: [], custom: 'x' }, { id: 'signals', selected: ['系统设计'] }, @@ -294,64 +355,54 @@ describe('QuestionComposer', () => { }) it('renders chrome copy through the English dictionary', () => { - const respond = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) - const carrier = new PendingWait( - 'question', interactionId('solo'), SID, { questions: [{ id: 'detail', question: '补充你的要求' }] }, respond) - render() + const { carrier } = wait([{ id: 'detail', question: '补充你的要求' }]) + render() expect(screen.getByLabelText('Dismiss all questions')).toBeTruthy() expect(screen.getByRole('button', { name: 'Skip this question' })).toBeTruthy() expect(screen.getByPlaceholderText('Type your answer')).toBeTruthy() }) - it('same-key carrier replacement (baseline replay) keeps drafts', () => { - const first = wait('same-id') - const view = render() + it('keeps drafts when the same pending request rerenders', () => { + const pending = wait() + 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() + 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({ ok: true, value: { accepted: true } }) - .mockResolvedValueOnce({ ok: true, value: { accepted: false, reason: 'not-pending' } }) - const question = new PendingQuestion(wait('rq', respond).carrier) + it('resolves the waterfall result with the answer batch and settles once', async () => { + const question = new PendingQuestion(SID, QUESTIONS) 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/) + await expect(question.result).resolves.toBe(batch) + await expect(question.answer(batch)).rejects.toThrow(/already settled/) }) - it('encodes cancellation as the cancelled error envelope and throws on a rejected receipt', async () => { - const respond = vi.fn() - .mockResolvedValueOnce({ ok: true, value: { accepted: true } }) - .mockResolvedValueOnce({ ok: true, value: { accepted: false, reason: 'bad-response' } }) - const question = new PendingQuestion(wait('rc', respond).carrier) + it('rejects the waterfall result with ASK_CANCELLED and settles once', async () => { + const question = new PendingQuestion(SID, QUESTIONS) + const result = question.result.catch((error: unknown) => error) await expect(question.cancel()).resolves.toBeUndefined() - expect(respond).toHaveBeenCalledWith({ - interactionId: interactionId('rc'), - result: { - ok: false, - error: { code: 'cancelled', message: 'the user closed this question request', details: {} }, - }, + await expect(result).resolves.toMatchObject({ + name: 'UserQuestionError', + code: 'ASK_CANCELLED', + message: 'the user cancelled ask_user_question', }) - await expect(question.cancel()).rejects.toThrow(/question cancellation rejected: bad-response/) + await expect(question.cancel()).rejects.toThrow(/already settled/) }) - 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) + it('exposes its Client render identity and scoped request values', () => { + const question = new PendingQuestion(SID, QUESTIONS) + expect(question.key).toMatch(/^question:\d+$/) + expect(question.sessionId).toBe(SID) + expect(question.questions).toBe(QUESTIONS) }) it('collapses the card to the header strip and expands it back', () => { const { carrier } = wait() - render() + render() // Expanded: the option list is visible. expect(screen.getByRole('radiogroup')).toBeTruthy() // Collapse: options leave the tree; the title and minimize toggle stay. @@ -366,8 +417,8 @@ describe('PendingQuestion domain face', () => { }) it('keeps the collapse toggle out of the cancel path and preserves drafts across collapse', () => { - const { carrier, respond } = wait() - render() + const { carrier, answer } = wait() + render() fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ })) // Single-select auto-advances to the second question; collapse and expand // must not lose either the picked option or the current position. @@ -381,7 +432,7 @@ describe('PendingQuestion domain face', () => { fireEvent.click(screen.getByLabelText('下一题')) fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('button', { name: '提交' })) - expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [ + expect(answer).toHaveBeenCalledWith(answerBatch([ { id: 'profile', selected: ['工程落地型 (Recommended)'] }, { id: 'detail', custom: '要能独立排查线上问题', selected: [] }, { id: 'signals', selected: ['系统设计'] }, diff --git a/packages/client/ui-user-questions/tsconfig.json b/packages/client/ui-user-questions/tsconfig.json index 88bc189297..e9d731d713 100644 --- a/packages/client/ui-user-questions/tsconfig.json +++ b/packages/client/ui-user-questions/tsconfig.json @@ -15,10 +15,19 @@ "path": "../../../vendor/cordis" }, { - "path": "../locale" + "path": "../../api/session-controller/tsconfig.client.json" }, { - "path": "../runtime" + "path": "../../core/session" + }, + { + "path": "../../interaction/user-questions" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../locale" }, { "path": "../ui-conversation" @@ -29,6 +38,12 @@ { "path": "../ui-slots" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts index 0c28660c64..2b63067475 100644 --- a/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts @@ -4,11 +4,22 @@ import { CallId } from '@deepseek-ai/dsh-llm' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' -import UserQuestionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-questions' +import UserQuestionService, { + type AskUserQuestionAnswer, + type AskUserQuestionRequest, +} from '@deepseek-ai/dsh-user-questions' import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' const testToolSignal = new AbortController().signal +interface QuestionAnswerer { + ask(request: AskUserQuestionRequest): Promise +} + +function registerQuestionAnswerer(ctx: Context, answerer: QuestionAnswerer): () => void { + return ctx.on('user-questions/request', request => answerer.ask(request)) +} + interface OptionSchemaShape { properties: { questions: { @@ -78,7 +89,7 @@ describe('ask_user_question tool', () => { it('asks the registered user-questions provider and projects structured answers to text', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { async ask(request) { seen.push(request) return { answers: [{ id: 'pkg', selected: ['pnpm'] }] } @@ -114,7 +125,7 @@ describe('ask_user_question tool', () => { it('passes recommended option labels through without adding schema fields', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { async ask(request) { seen.push(request) return { answers: [{ id: 'pkg', selected: ['pnpm (Recommended)'] }] } @@ -145,7 +156,7 @@ describe('ask_user_question tool', () => { it('projects custom answers and multi-select choices', async () => { const ctx = await setup() - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { async ask() { return { answers: [ @@ -198,7 +209,7 @@ describe('ask_user_question tool', () => { it('passes the tool abort signal to the user-questions request', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { async ask(request) { seen.push(request) return { answers: [{ id: 'continue', selected: ['ok'] }] } @@ -219,7 +230,7 @@ describe('ask_user_question tool', () => { it('passes optional header and a resumed runtime root through to the user-questions request', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { async ask(request) { seen.push(request) return { answers: [{ id: 'continue', selected: ['ok'] }] } @@ -259,7 +270,7 @@ describe('ask_user_question tool', () => { it('rejects a live runtime-owned agent with a structured DELEGATED_CALLER error', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { async ask(request) { seen.push(request) return { answers: [{ id: 'continue', selected: ['ok'] }] } diff --git a/packages/interaction/user-questions/src/index.ts b/packages/interaction/user-questions/src/index.ts index 9c72db4c79..b51722d089 100644 --- a/packages/interaction/user-questions/src/index.ts +++ b/packages/interaction/user-questions/src/index.ts @@ -1,14 +1,13 @@ /** * Service Definition for the user-questions capability seam (`ctx.userQuestions`): a UI-backed service for * pausing an agent tool call until the human answers a question. The model- - * facing tool lives in `@deepseek-ai/dsh-tool-ask-user`; UI packages provide - * the single active provider. + * facing tool lives in `@deepseek-ai/dsh-tool-ask-user`; UI packages compose + * answerers on the Agent-scoped Cordis waterfall. * * @module @deepseek-ai/dsh-user-questions */ import { Context, Service } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' import { HarnessError } from '@deepseek-ai/dsh-llm' import { scopeTarget } from '@deepseek-ai/dsh-scope' @@ -19,7 +18,7 @@ declare module '@deepseek-ai/cordis' { } import type { - AskUserQuestionAnswer, AskUserQuestionItem, + AskUserQuestionAnswer, AskUserQuestionRequestEvent, } from './types.ts' export type { @@ -28,19 +27,7 @@ export type { } from './types.ts' /** Request for a human answer. */ -export interface AskUserQuestionRequest { - /** Questions to display. */ - questions: AskUserQuestionItem[] - /** Exact live calling agent, when the request came from an agent tool call. */ - agent?: Agent - /** Abort signal for the owning tool/step. */ - signal?: AbortSignal -} - -/** UI-side provider for user questions. */ -export interface UserQuestionProvider { - ask(request: AskUserQuestionRequest): Promise -} +export interface AskUserQuestionRequest extends AskUserQuestionRequestEvent {} /** Stable error taxonomy for user-questions failures. */ export class UserQuestionError extends HarnessError { @@ -58,35 +45,29 @@ function abortedQuestion(cause?: unknown): UserQuestionError { ) } -/** `ctx.userQuestions`: one active UI provider plus an `ask()` API. */ -export class UserQuestionService extends Service { - private provider: UserQuestionProvider | undefined +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} +function restoreUserQuestionError(reason: unknown): unknown { + if (reason instanceof UserQuestionError) return reason + if (isRecord(reason) + && reason.name === 'UserQuestionError' + && typeof reason.message === 'string' + && typeof reason.code === 'string') { + return new UserQuestionError(reason.message, reason.code, { cause: reason }) + } + return reason +} + +/** `ctx.userQuestions`: validation plus the scoped answerer waterfall. */ +export class UserQuestionService extends Service { constructor(ctx: Context) { super(ctx, 'userQuestions') } /** - * Register the UI provider. Only one provider may be active in a context. - * - * @param provider UI-side implementation that collects answers. - * @returns Disposer that unregisters this provider. - */ - registerProvider(provider: UserQuestionProvider): () => void { - const dispose = this.ctx.effect(function* (this: UserQuestionService) { - if (this.provider !== undefined) { - throw new UserQuestionError('a user-questions provider is already registered', 'DUPLICATE_PROVIDER') - } - this.provider = provider - yield () => { - this.provider = undefined - } - }.bind(this), 'userInteraction.registerProvider()') - return () => void dispose() - } - - /** - * Ask the active UI provider and wait for the user's answer. + * Ask the scoped answerer waterfall and wait for the user's answer. * * When a caller supplies an agent, human interaction is valid only for the * exact live runtime root. Runtime ownership, not durable session lineage, @@ -145,36 +126,28 @@ export class UserQuestionService extends Service { 'BAD_INTENT') } } - const askProvider = () => this.provider === undefined - ? Promise.reject(new UserQuestionError('no user-questions provider is registered', 'NO_PROVIDER')) - : this.provider.ask(request) + const noAnswerer = () => Promise.reject(new UserQuestionError( + 'no user-questions answerer accepted the request', + 'NO_PROVIDER', + )) try { return await (agent === undefined - ? askProvider() + ? this.ctx.waterfall('user-questions/request', request, noAnswerer) : this.ctx.waterfall( scopeTarget(agent, agent), 'user-questions/request', { ...request, agent }, - askProvider, + noAnswerer, )) } catch (error) { - if (error instanceof UserQuestionError) throw error const restored = restoreUserQuestionError(error) - if (restored !== undefined) throw restored + if (restored instanceof UserQuestionError) throw restored if (request.signal?.aborted) { throw abortedQuestion(error) } - throw error + throw restored } } } -function restoreUserQuestionError(reason: unknown): UserQuestionError | undefined { - if (!(reason instanceof Error) || reason.name !== 'UserQuestionError') return undefined - const code: unknown = (reason as Error & { readonly code?: unknown }).code - return typeof code === 'string' - ? new UserQuestionError(reason.message, code, { cause: reason }) - : undefined -} - export default UserQuestionService diff --git a/packages/interaction/user-questions/src/types.ts b/packages/interaction/user-questions/src/types.ts index 64acfae089..1818a63d91 100644 --- a/packages/interaction/user-questions/src/types.ts +++ b/packages/interaction/user-questions/src/types.ts @@ -68,7 +68,7 @@ export interface AskUserQuestionRequestEvent { /** Questions to display. */ questions: AskUserQuestionItem[] /** Agent identity projected to the corresponding Client Context in transit. */ - agent: Agent + agent?: Agent /** Cancellation lifetime of the pending request. */ signal?: AbortSignal } diff --git a/packages/interaction/user-questions/tests/user-questions.spec.ts b/packages/interaction/user-questions/tests/user-questions.spec.ts index a436d9bd7c..87e5666593 100644 --- a/packages/interaction/user-questions/tests/user-questions.spec.ts +++ b/packages/interaction/user-questions/tests/user-questions.spec.ts @@ -3,17 +3,27 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import UserQuestionService, { UserQuestionError, + type AskUserQuestionAnswer, type AskUserQuestionRequest, - type UserQuestionProvider, } from '@deepseek-ai/dsh-user-questions' -function provider(answer = 'approved'): UserQuestionProvider & { seen: AskUserQuestionRequest[] } { +interface QuestionAnswerer { + ask(request: AskUserQuestionRequest): Promise +} + +function registerAnswerer(ctx: Context, answerer: QuestionAnswerer): () => void { + return ctx.on('user-questions/request', request => answerer.ask(request)) +} + +function provider(answer = 'approved'): QuestionAnswerer & { seen: AskUserQuestionRequest[] } { const seen: AskUserQuestionRequest[] = [] return { seen, async ask(request) { seen.push(request) - return { answers: [{ id: request.questions[0]?.id ?? 'missing', selected: [answer] }] } + return { + answers: request.questions.map(question => ({ id: question.id, selected: [answer] })), + } }, } } @@ -31,12 +41,13 @@ describe('UserQuestionService', () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = provider('yes') - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) + const questions = [{ id: 'confirm', question: 'Proceed?', options: [{ label: 'yes' }] }] - const result = await ctx.userQuestions.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }) + const result = await ctx.userQuestions.ask({ questions }) expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] }) - expect(p.seen).toEqual([{ questions: [{ id: 'confirm', question: 'Proceed?' }] }]) + expect(p.seen).toEqual([{ questions }]) }) it('rejects ask requests when no provider is registered', async () => { @@ -51,7 +62,7 @@ describe('UserQuestionService', () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = provider() - const dispose = ctx.userQuestions.registerProvider(p) + const dispose = registerAnswerer(ctx, p) dispose() dispose() @@ -60,20 +71,28 @@ describe('UserQuestionService', () => { .rejects.toMatchObject({ code: 'NO_PROVIDER' }) }) - it('rejects duplicate providers instead of replacing the active UI', async () => { + it('delegates through composed answerers', async () => { const ctx = new Context() await ctx.plugin(UserQuestionService) - ctx.userQuestions.registerProvider(provider('first')) + const delegated = vi.fn() + ctx.on('user-questions/request', (_request, next) => { + delegated() + return next() + }) + const p = provider('second') + registerAnswerer(ctx, p) - expect(() => ctx.userQuestions.registerProvider(provider('second'))) - .toThrow(UserQuestionError) + await expect(ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?', options: [{ label: 'second' }] }], + })).resolves.toEqual({ answers: [{ id: 'confirm', selected: ['second'] }] }) + expect(delegated).toHaveBeenCalledOnce() }) it('fails before reaching the provider when the signal is already aborted', async () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [{ id: 'confirm', selected: ['too late'] }] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) const controller = new AbortController() controller.abort() @@ -86,7 +105,7 @@ describe('UserQuestionService', () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const pending = Promise.withResolvers() - ctx.userQuestions.registerProvider({ ask: () => pending.promise }) + registerAnswerer(ctx, { ask: () => pending.promise }) const controller = new AbortController() const abortReason = new DOMException('This operation was aborted', 'AbortError') @@ -109,7 +128,7 @@ describe('UserQuestionService', () => { await ctx.plugin(UserQuestionService) const controller = new AbortController() const cancelled = new UserQuestionError('the user cancelled ask_user_question', 'ASK_CANCELLED') - ctx.userQuestions.registerProvider({ + registerAnswerer(ctx, { ask: () => { controller.abort() return Promise.reject(cancelled) @@ -166,7 +185,7 @@ describe('UserQuestionService', () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) await expect(ctx.userQuestions.ask({ questions: [] })) .rejects.toMatchObject({ name: 'UserQuestionError', code: 'EMPTY_QUESTIONS' }) @@ -178,7 +197,7 @@ describe('UserQuestionService', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) const root = stubAgent('root', 0) const child = stubAgent('child', 0) ctx.agents.enter(root, undefined) @@ -200,42 +219,23 @@ describe('UserQuestionService', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const p = provider('yes') - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) const agent = stubAgent('resumed-root', 1) ctx.agents.enter(agent, undefined) const result = await ctx.userQuestions.ask({ - questions: [{ id: 'confirm', question: 'Proceed?' }], + questions: [{ id: 'confirm', question: 'Proceed?', options: [{ label: 'yes' }] }], agent, }) expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] }) }) - it('offers an Agent-scoped waterfall before the provider fallback', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) - const p = provider('fallback') - ctx.userQuestions.registerProvider(p) - const agent = stubAgent('root') - ctx.agents.enter(agent, undefined) - ctx.on('user-questions/request', request => Promise.resolve({ - answers: request.questions.map(question => ({ id: question.id, selected: ['remote'] })), - })) - - await expect(ctx.userQuestions.ask({ - questions: [{ id: 'confirm', question: 'Proceed?' }], - agent, - })).resolves.toEqual({ answers: [{ id: 'confirm', selected: ['remote'] }] }) - expect(p.seen).toEqual([]) - }) - it('rejects a supplied agent when no live registry can attest it', async () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) await expect(ctx.userQuestions.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }], @@ -249,7 +249,7 @@ describe('UserQuestionService', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) const live = stubAgent('same-id') ctx.agents.enter(live, undefined) @@ -260,11 +260,41 @@ describe('UserQuestionService', () => { expect(p.ask).not.toHaveBeenCalled() }) + it('restores a transported UserQuestionError to the public error class', async () => { + const ctx = new Context() + await ctx.plugin(UserQuestionService) + const transported = Object.assign(new Error('the user cancelled ask_user_question'), { + name: 'UserQuestionError', + code: 'ASK_CANCELLED', + }) + registerAnswerer(ctx, { ask: () => Promise.reject(transported) }) + + const failure = await ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + }).then(() => undefined, (error: unknown) => error) + + expect(failure).toBeInstanceOf(UserQuestionError) + expect(failure).toMatchObject({ + name: 'UserQuestionError', code: 'ASK_CANCELLED', cause: transported, + }) + }) + + it('preserves a provider rejection outside the UserQuestionError taxonomy', async () => { + const ctx = new Context() + await ctx.plugin(UserQuestionService) + const failure = new Error('provider failed') + registerAnswerer(ctx, { ask: () => Promise.reject(failure) }) + + await expect(ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + })).rejects.toBe(failure) + }) + it('rejects an intent whose approve label names none of its own options', async () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) const question = { id: 'plan-review', question: 'Approve?', detail: '# Plan' } // A wrong label among offered options, and no options offered at all. @@ -284,7 +314,7 @@ describe('UserQuestionService', () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) // Detail IS the plan for this intent, so a UI honouring it would ask the // user to approve something they cannot see. @@ -302,12 +332,12 @@ describe('UserQuestionService', () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = provider('Approve') - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) const intent = { kind: 'plan-review', approve: 'Approve' } as const const result = await ctx.userQuestions.ask({ questions: [ - { id: 'plain', question: 'Proceed?' }, + { id: 'plain', question: 'Proceed?', options: [{ label: 'Approve' }] }, { id: 'plan-review', question: 'Approve?', detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent, @@ -315,7 +345,10 @@ describe('UserQuestionService', () => { ], }) - expect(result.answers).toEqual([{ id: 'plain', selected: ['Approve'] }]) + expect(result.answers).toEqual([ + { id: 'plain', selected: ['Approve'] }, + { id: 'plan-review', selected: ['Approve'] }, + ]) expect(p.seen[0]?.questions[1]?.intent).toEqual(intent) }) }) diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 79f57cb10c..ec6eec8167 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -7,7 +7,7 @@ import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' import UserQuestionService, { - UserQuestionError, type AskUserQuestionRequest, + UserQuestionError, type AskUserQuestionAnswer, type AskUserQuestionRequest, } from '@deepseek-ai/dsh-user-questions' import CommandRuntime from '@deepseek-ai/dsh-commands' import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime' @@ -17,6 +17,14 @@ import type { PlanModeConfig } from '../src/index.ts' const TEST_PLAN_SECTION = 'Test plan mode instructions.' const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig +interface QuestionAnswerer { + ask(request: AskUserQuestionRequest): Promise +} + +function registerQuestionAnswerer(ctx: Context, answerer: QuestionAnswerer): () => void { + return ctx.on('user-questions/request', request => answerer.ask(request)) +} + /** * Drives the REAL plugin: mounts `dsh-plan-mode` beside real `SystemPrompt` and * `ToolRuntime` services, with fake Agents carrying real `Session`s and a @@ -733,7 +741,7 @@ describe('exit_plan_mode', () => { await ctx.plugin(UserQuestionService) const asked: AskUserQuestionRequest[] = [] if (answer !== undefined) { - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { ask: (request) => { asked.push(request) return Promise.resolve({ answers: [{ id: 'plan-review', ...answer }] }) @@ -803,7 +811,7 @@ describe('exit_plan_mode', () => { const { ctx, agent } = await setupWithReview() const result = await callExit(ctx, agent) expect(result.isError).toBe(true) - expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-questions provider is registered' }]) + expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-questions answerer accepted the request' }]) expect(foldPlanMode(agent.session.events)).toBe(true) }) @@ -812,7 +820,7 @@ describe('exit_plan_mode', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const ask = vi.fn(async () => ({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })) - ctx.userQuestions.registerProvider({ ask }) + registerQuestionAnswerer(ctx, { ask }) const root = await agentWithSession(ctx, 'review-root') const child = await agentWithSession(ctx, 'review-child', { active: true, owner: root }) @@ -865,7 +873,7 @@ describe('exit_plan_mode', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const asked: AskUserQuestionRequest[] = [] - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { ask: (request) => { asked.push(request) return Promise.resolve({ answers: [{ id: 'plan-review', selected: ['Approve'] }] }) @@ -964,7 +972,7 @@ describe('exit_plan_mode', () => { it('treats duplicate review answer items as non-consent', async () => { const { ctx, agent } = await setupWithReview() - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { ask: () => Promise.resolve({ answers: [ { id: 'plan-review', selected: ['Approve'] }, { id: 'plan-review', selected: ['Keep planning'] }, @@ -978,7 +986,7 @@ describe('exit_plan_mode', () => { it('a missing answer item reads as keep-planning', async () => { const { ctx, agent } = await setupWithReview() - ctx.userQuestions.registerProvider({ ask: () => Promise.resolve({ answers: [] }) }) + registerQuestionAnswerer(ctx, { ask: () => Promise.resolve({ answers: [] }) }) const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }]) @@ -996,7 +1004,7 @@ describe('exit_plan_mode', () => { it('reads a dismissed review as the user taking the turn back, not as a failure', async () => { const { ctx, agent } = await setupWithReview() - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { ask: () => Promise.reject(Object.assign( new Error('the user cancelled ask_user_question'), { name: 'UserQuestionError', code: 'ASK_CANCELLED' }, @@ -1010,7 +1018,7 @@ describe('exit_plan_mode', () => { it('leaves every other review failure its own message', async () => { const { ctx, agent } = await setupWithReview() - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { ask: () => Promise.reject(new UserQuestionError( 'ask_user_question was aborted before the user answered', 'ASK_ABORTED')), }) @@ -1042,7 +1050,7 @@ describe('exit_plan_mode', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) let answer!: (value: { answers: { id: string; selected: string[] }[] }) => void - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { ask: () => new Promise((resolve) => { answer = resolve }), }) const agent = await agentWithSession(ctx, 'agent-1', { active: true }) @@ -1061,7 +1069,7 @@ describe('exit_plan_mode', () => { it('a throwing provider surfaces as the corrective isError and the mode stays plan', async () => { const { ctx, agent } = await setupWithReview() - ctx.userQuestions.registerProvider({ ask: () => { throw new Error('review aborted') } }) + registerQuestionAnswerer(ctx, { ask: () => { throw new Error('review aborted') } }) const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: review aborted' }])