mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
refactor(interaction): move Approval and Question into UI owners
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Readonly<Record<string, string>>
|
||||
export default classes
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' }])
|
||||
|
||||
Reference in New Issue
Block a user