refactor(interaction): move Approval and Question into UI owners

This commit is contained in:
imccyu
2026-08-23 16:28:17 +08:00
parent c7d8e32aec
commit 049170c6d0
32 changed files with 1711 additions and 695 deletions
@@ -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<K extends PendingKind> =
| { readonly ok: true; readonly value: PendingResponseValues[K] }
| {
readonly ok: false
readonly error: { readonly code: string; readonly message: string; readonly details: Readonly<Record<string, unknown>> }
}
/** Receipt returned by the legacy response carrier. */
export interface PendingRespondReceipt {
readonly accepted: boolean
readonly reason?: string
}
interface PendingRespondRequest<K extends PendingKind> {
readonly interactionId: string
readonly result: PendingInteractionResult<K>
}
/** 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<K> }[PendingKind]
/** Key prefixes, one per kind (the key doubles as the Session pending-map key). */
const KEY_PREFIX: Record<PendingKind, string> = { 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<K extends PendingKind = PendingKind> {
/** 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<K>) => Promise<RemoteResult<PendingRespondReceipt>>
/**
* 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<K>) => Promise<RemoteResult<PendingRespondReceipt>>,
) {
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<K>): Promise<PendingRespondReceipt> {
if (this.#settled) throw new Error(`pending wait ${this.key} is already settled`)
return this.send(result)
}
private async send(result: PendingInteractionResult<K>): Promise<PendingRespondReceipt> {
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
}
}
+90
View File
@@ -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"
]
}
@@ -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;
}
@@ -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 <ApprovalFlow key={approval.key} pending={approval} detail={detail} t={props.t} />
}
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 (
<div className={css.root} data-approval-key={pending.key}>
<div className={css.card}>
<div className={css.strip}><span className={css.dot} />{t('waiting')}</div>
<div
className={css.body}
data-approval-scroll=""
tabIndex={0}
role="group"
aria-label={t('detail.aria')}
>
<div className={css.headline}>{pending.reason ?? t('escalation', { toolName: pending.toolName })}</div>
{detail !== null && <div className={css.command}>{detail}</div>}
</div>
<div className={css.actionRow}>
<Button variant="outline" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
{t('reject')}
</Button>
<Button variant="primary" disabled={answered} onClick={() => { answer('allowed-once') }}>
{t('allowOnce')}
</Button>
</div>
</div>
</div>
)
}
@@ -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<ApprovalDecision>
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<ApprovalDecision>()
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<void> {
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'>
@@ -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<ApprovalListener>[0]
type ClientApprovalNext = Parameters<ApprovalListener>[1]
type ClientApprovalOutcome = Awaited<ReturnType<ApprovalListener>>
/** 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<ClientApprovalOutcome> {
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<PendingApproval>(() => 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)
})
}
@@ -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<string, string>
/** 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<ApprovalKey, string>
+4
View File
@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Readonly<Record<string, string>>
export default classes
}
+4
View File
@@ -0,0 +1,4 @@
/** Browser-only approval presentation plugin; the Host capability is composed independently. */
/** Node plugin body. */
export function apply(): void {}
@@ -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 */
@@ -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<unknown>
interface PluginBench {
readonly ctx: Context
readonly listener: ApprovalListener
readonly pending: { getSnapshot(): readonly PendingApproval[] }
readonly attend: ReturnType<typeof vi.fn>
readonly disposeSlot: ReturnType<typeof vi.fn>
readonly disposeLocale: ReturnType<typeof vi.fn>
readonly register: ReturnType<typeof vi.fn>
readonly injectSlot: ReturnType<typeof vi.fn>
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<typeof registration>['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<string, string> = {
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(<ApprovalPanel {...props} />)
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<HTMLButtonElement>('button', { name: 'Reject' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('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(() => <code>pnpm test</code>)
render(<ApprovalPanel {...panelProps(pending, renderSlot)} />)
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(<ApprovalPanel {...panelProps(pending)} />)
fireEvent.click(screen.getByRole('button', { name: 'Allow once' }))
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Allow once' }).disabled).toBe(true)
await waitFor(() => {
expect(screen.getByRole<HTMLButtonElement>('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()
})
})
+48
View File
@@ -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"
}
]
}
@@ -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'])
+8 -4
View File
@@ -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",
+4 -1
View File
@@ -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'
@@ -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'
@@ -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'
+6 -3
View File
@@ -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"
},
@@ -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.
+20 -6
View File
@@ -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",
@@ -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
? <QuestionFlow key={question.key} pending={question} t={props.t} />
@@ -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<QuestionItem['options']>[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<QuestionAnswer>
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<QuestionAnswer>()
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<void> {
const receipt = await this.wait.respond({
ok: true, value: { sessionId: this.wait.sessionId, answer },
})
if (!receipt.accepted) {
throw new Error(`question response rejected: ${receipt.reason}`)
}
answer(answer: QuestionAnswer): Promise<void> {
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<void> {
const receipt = await this.wait.respond({
ok: false,
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
})
if (!receipt.accepted) {
throw new Error(`question cancellation rejected: ${receipt.reason}`)
/** Reject the Host waterfall because the user closed the question. */
cancel(): Promise<void> {
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
@@ -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<QuestionListener>[0]
type ClientQuestionNext = Parameters<QuestionListener>[1]
type ClientQuestionAnswer = Awaited<ReturnType<QuestionListener>>
/** 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<ClientQuestionAnswer> {
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<PendingQuestion>(
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<Awaited<ReturnType<typeof next>>>()
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)
})
}
@@ -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<typeof ANSWER>
type QuestionAnswer = typeof ANSWER
type QuestionNext = () => Promise<QuestionAnswer>
type QuestionListener = (
this: Context,
request: QuestionRequest,
next: QuestionNext,
) => Promise<typeof ANSWER>
) => Promise<QuestionAnswer>
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<QuestionAnswer> => {
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<typeof ANSWER> {
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<QuestionAnswer>()
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<QuestionAnswer>()
const withResolvers = vi.spyOn(Promise, 'withResolvers').mockImplementationOnce(<T>() => ({
promise: completion.promise,
resolve: completion.resolve as (value: T | PromiseLike<T>) => 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)
})
})
@@ -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<typeof PendingWait<'question'>>[4]
const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
(key => dict[key] ?? common[key] ?? key)
type SessionState = Parameters<Parameters<QuestionComposerProps['useSession']>[0]>[0]
type ConversationState = Parameters<Parameters<QuestionComposerProps['useConversation']>[0]>[0]
type ChatState = Parameters<Parameters<QuestionComposerProps['useChat']>[0]>[0]
type TrajectoryState = Parameters<Parameters<QuestionComposerProps['useTrajectory']>[0]>[0]
type InputState = Parameters<Parameters<QuestionComposerProps['useInput']>[0]>[0]
type AttentionState = Parameters<Parameters<QuestionComposerProps['useSessionPendingInteraction']>[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<QuestionComposerProps, 'matched'> = {
sessionId: SID,
session: undefined,
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
render(<QuestionComposer matched={carrier} {...kit} />)
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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
const { carrier, answer } = wait()
render(<QuestionComposer matched={carrier} {...kit} />)
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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
const { carrier, answer } = wait()
render(<QuestionComposer matched={carrier} {...kit} />)
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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
const { carrier, cancel } = wait()
render(<QuestionComposer matched={carrier} {...kit} />)
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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
}] as never)
render(<QuestionComposer matched={carrier} {...kit} />)
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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
}] as never)
render(<QuestionComposer matched={carrier} {...kit} />)
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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
const { carrier, answer } = wait()
answer.mockRejectedValue(new Error('question response rejected: not-pending'))
render(<QuestionComposer matched={carrier} {...kit} />)
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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
const { carrier, cancel } = wait()
cancel.mockRejectedValue('socket gone')
render(<QuestionComposer matched={carrier} {...kit} />)
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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} t={seatOver(en, commonEn)} />)
render(<QuestionComposer matched={carrier} {...kit} t={seatOver(en, commonEn)} />)
expect(screen.getByText('Plan review')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Approve' })).toBeTruthy()
@@ -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<typeof PendingWait<'question'>>[4]
const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
(key => dict[key] ?? common[key] ?? key)
type SessionState = Parameters<Parameters<QuestionComposerProps['useSession']>[0]>[0]
type ConversationState = Parameters<Parameters<QuestionComposerProps['useConversation']>[0]>[0]
type ChatState = Parameters<Parameters<QuestionComposerProps['useChat']>[0]>[0]
type TrajectoryState = Parameters<Parameters<QuestionComposerProps['useTrajectory']>[0]>[0]
type InputState = Parameters<Parameters<QuestionComposerProps['useInput']>[0]>[0]
type AttentionState = Parameters<Parameters<QuestionComposerProps['useSessionPendingInteraction']>[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<QuestionComposerProps, 'matched'> = {
session: undefined,
sessionId: SID,
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
const { carrier, answer } = wait()
render(<QuestionComposer matched={carrier} {...kit} />)
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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
const { carrier } = wait([{
id: 'plan',
question: '批准这个计划吗?',
detail: '# 实施计划\n\n- **先验证**现状\n- 修改 `QuestionComposer`',
options: [{ label: '批准' }],
}])
const view = render(<QuestionComposer matched={carrier} {...kit} />)
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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
const { carrier, answer } = wait()
render(<QuestionComposer matched={carrier} {...kit} />)
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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
const { carrier, answer } = wait()
render(<QuestionComposer matched={carrier} {...kit} />)
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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
const { carrier, answer } = wait()
render(<QuestionComposer matched={carrier} {...kit} />)
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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
const { carrier, answer } = wait()
render(<QuestionComposer matched={carrier} {...kit} />)
// 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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
render(<QuestionComposer matched={carrier} {...kit} />)
// Receipt rejection surfaces through the domain face's thrown message.
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy()
expect(await screen.findByText('第一次取消失败')).toBeTruthy()
expect(screen.getByRole<HTMLButtonElement>('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(<QuestionComposer matched={first.carrier} pendingInteraction={first.carrier} {...kit} />)
it('surfaces answer rejection and resets local drafts for a different request', async () => {
const first = wait()
const view = render(<QuestionComposer matched={first.carrier} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
expect(screen.getByText('2 / 3')).toBeTruthy()
const second = wait('second', respond)
view.rerender(<QuestionComposer matched={second.carrier} pendingInteraction={second.carrier} {...kit} />)
const second = wait()
second.answer
.mockRejectedValueOnce(new Error('网络中断'))
.mockRejectedValueOnce('字符串错误')
view.rerender(<QuestionComposer matched={second.carrier} {...kit} />)
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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} t={seatOver(en, commonEn)} />)
const { carrier } = wait([{ id: 'detail', question: '补充你的要求' }])
render(<QuestionComposer matched={carrier} {...kit} t={seatOver(en, commonEn)} />)
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(<QuestionComposer matched={first.carrier} pendingInteraction={first.carrier} {...kit} />)
it('keeps drafts when the same pending request rerenders', () => {
const pending = wait()
const view = render(<QuestionComposer matched={pending.carrier} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
expect(screen.getByText('2 / 3')).toBeTruthy()
// Replay mints a NEW carrier for the same request; same key = no remount.
const replayed = wait('same-id')
view.rerender(<QuestionComposer matched={replayed.carrier} pendingInteraction={replayed.carrier} {...kit} />)
view.rerender(<QuestionComposer matched={pending.carrier} {...kit} />)
expect(screen.getByText('2 / 3')).toBeTruthy()
})
})
describe('PendingQuestion domain face', () => {
it('encodes the answer batch into the ok envelope and throws on a rejected receipt', async () => {
const respond = vi.fn()
.mockResolvedValueOnce({ 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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
render(<QuestionComposer matched={carrier} {...kit} />)
// 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(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
const { carrier, answer } = wait()
render(<QuestionComposer matched={carrier} {...kit} />)
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: ['系统设计'] },
@@ -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"
}
@@ -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<AskUserQuestionAnswer>
}
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'] }] }
@@ -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<AskUserQuestionAnswer>
}
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<string, unknown> {
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
@@ -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
}
@@ -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<AskUserQuestionAnswer>
}
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<never>()
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)
})
})
+19 -11
View File
@@ -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<AskUserQuestionAnswer>
}
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' }])