fix review findings: polish ask-user question

This commit is contained in:
Yichen Jiang
2026-07-07 14:57:06 +08:00
parent 510e80e447
commit cebf781d69
51 files changed
+665 -419

No files matched your search

+1 -1
View File
@@ -215,7 +215,7 @@ registerProvider(provider: UserInteractionProvider): () => void
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
```
Source: [`packages/core/user-interaction/src/index.ts:70`](../../packages/core/user-interaction/src/index.ts)
Source: [`packages/core/user-interaction/src/index.ts:82`](../../packages/core/user-interaction/src/index.ts)
## `ctx.web` — `WebService`
+34 -16
View File
@@ -6,35 +6,44 @@ Source: [`packages/core/user-interaction/src/index.ts`](../../packages/core/user
## Question options
`AskUserQuestionOption` is the selectable-choice shape. `label` is user-facing, while `value` is the model-facing answer returned when the option is selected; when omitted, providers use the label.
`AskUserQuestionOption` is the selectable-choice shape. `label` is the user-facing option text and also the model-facing selected value; `description` is optional UI help text.
```ts type-equiv
interface AskUserQuestionOption {
/** User-facing label. */
label: string
/** Value returned to the model when selected. Defaults to `label`. */
value?: string
/** Optional extra context rendered by capable UIs. */
description?: string
/** Marks the recommended/default option. */
recommended?: boolean
}
```
## Ask request
## Question item
`AskUserQuestionRequest` is the cross-package request. `options` being absent means free-form input; an optionless request remains free-form even when a caller sets `allowCustom: false`, because there is no selectable option to constrain the answer to.
`AskUserQuestionItem` is one question in a request. The model supplies a stable `id`, which is echoed back with the answer so batched questions remain routable.
```ts type-equiv
interface AskUserQuestionRequest {
interface AskUserQuestionItem {
/** Stable model-provided question id, echoed in the answer. */
id: string
/** The question to display. */
question: string
/** Optional short heading/group label. */
header?: string
/** Optional choices the UI can render as a menu. */
options?: AskUserQuestionOption[]
/** Whether free-form answers are accepted. Defaults to `true`. */
allowCustom?: boolean
/** Whether more than one option may be selected. Defaults to single-select. */
multiSelect?: boolean
}
```
## Ask request
`AskUserQuestionRequest` is the cross-package request. `questions` is an array so a UI can present related prompts in one flow while preserving a stable id per answer.
```ts type-equiv
interface AskUserQuestionRequest {
/** Questions to display. */
questions: AskUserQuestionItem[]
/** Calling agent, when the request came from an agent tool call. */
agent?: Agent
/** Abort signal for the owning tool/step. */
@@ -44,14 +53,23 @@ interface AskUserQuestionRequest {
## Answer
Providers return the model-facing `answer` text and optionally echo the chosen option as metadata. Consumers should use `answer`; the option is for UI/session metadata and diagnostics.
Providers return one answer per answered question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices.
```ts type-equiv
interface AskUserQuestionAnswerItem {
/** The answered question id. */
id: string
/** Selected option labels. Empty when the answer is purely custom text. */
selected: string[]
/** Optional free-text "Other" answer. */
custom?: string
}
```
```ts type-equiv
interface AskUserQuestionAnswer {
/** Model-facing answer text. */
answer: string
/** The selected option, when the answer came from `options`. */
option?: AskUserQuestionOption
/** Structured answers keyed by question id. */
answers: AskUserQuestionAnswerItem[]
}
```
@@ -67,7 +85,7 @@ interface UserInteractionProvider {
## Errors
`UserInteractionError` extends `HarnessError`, so `ctx.tools.execute()` preserves `{ name, code }` for model-facing tool failures such as `NO_PROVIDER`, `ASK_ABORTED`, or ACP-side cancellation.
`UserInteractionError` extends `HarnessError`, so `ctx.tools.execute()` preserves `{ name, code }` for model-facing tool failures such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `ASK_ABORTED`, or ACP-side cancellation.
```ts type-equiv
class UserInteractionError extends HarnessError {
@@ -10,19 +10,19 @@ This is a user-facing capability, but it also crosses package boundaries. A mode
## Decision
Introduce `dsh-user-interaction` as the core interface package for `ctx.userInteraction`, and keep the model-facing consumer `dsh-tool-ask-user` under `packages/ui/tool-ask-user` rather than the core spine. The split is intentional: core owns the abstract seam and stable request/answer/error vocabulary; UI product surfaces own the affordance that asks a human and the concrete provider that collects the answer. The tool registers `ask_user_question`, forwards `{ question, header, options, allowCustom, agent, signal }`, and returns the provider-computed `answer` as the tool result.
Introduce `dsh-user-interaction` as the core interface package for `ctx.userInteraction`, and keep the model-facing consumer `dsh-tool-ask-user` under `packages/ui/tool-ask-user` rather than the core spine. The split is intentional: core owns the abstract seam and stable request/answer/error vocabulary; UI product surfaces own the affordance that asks a human and the concrete provider that collects the answer. The tool registers `ask_user_question`, forwards `{ questions, agent, signal }`, and returns the provider-computed structured answers as the tool result.
The request vocabulary supports a short `header`, the required `question`, optional mutually exclusive `options`, `description` for each option, a `recommended` marker, and `allowCustom`. `label` is user-facing display text; `value` is the model-facing answer for a selected option and defaults to `label`. Providers return `AskUserQuestionAnswer.answer` as the single source of truth; the selected `option` is metadata. The tool schema exposes `description` only, not the synonym `desc`, to keep the model-facing surface small.
The model-facing request vocabulary is deliberately aligned with the product-research schema: `ask_user_question({ questions: [{ id, question, header?, options?: [{ label, description? }], multi_select? }] })`. `id` is supplied per question and echoed in the result so a batch can be routed without relying on question text. `label` is both user-facing display text and the selected value returned to the model; there is no separate `value`, no `recommended`, no `allow_custom`, and no `desc` alias.
Optionless questions are always free-form, even if a caller passes `allowCustom: false`. The opposite would create an unanswerable prompt: with no option to select and free-form input disallowed, every human answer would be rejected forever. Providers therefore treat "no options" as the free-form shape.
Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is always an array of selected option labels, so single-select and `multi_select` answers share one result shape. `custom` carries a free-text "Other" answer; optionless questions collect `custom` directly. When `custom` is present, it overrides any selected choices and `selected` is empty.
`UserInteractionError` extends `HarnessError`, so failures such as `NO_PROVIDER`, `ASK_ABORTED`, ACP cancellation, or missing session routing survive `ctx.tools.execute()` as machine-routable `{ name, code }` tool errors. This matches the structured-error taxonomy and lets the model or a wrapping plugin distinguish "user cancelled" from a generic thrown exception.
## UI mappings
`dsh-stdio-agent`'s in-package readline module renders the question, sorts recommended options first, shows each option's `description` on the next line, accepts the recommended option on an empty answer, and rejects pending questions on abort, provider disposal, or stdin EOF. The stdio provider serializes multiple simultaneous questions with an internal queue so only one prompt owns stdin at a time.
`dsh-stdio-agent`'s in-package readline module renders each question, shows each option's `description` on the next line, supports comma/space-separated numeric choices for `multi_select`, accepts free-form custom answers, and rejects pending questions on abort, provider disposal, or stdin EOF. A batched request is asked in order and resolved as one answer object. The stdio provider serializes simultaneous requests with an internal queue so only one prompt owns stdin at a time.
`dsh-acp` provides the same seam for ACP sessions. It routes an ask request from the calling `Agent` through the bridge's `agent→sessionId` reverse map and calls ACP `unstable_createElicitation` with a session-scoped form. Option choices become a `choice` single-select field with the recommended option as the schema default; free-form answers use `answer` for optionless questions and `custom_answer` when options plus custom input are allowed. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s.
`dsh-acp` provides the same seam for ACP sessions. It routes an ask request from the calling `Agent` through the bridge's `agent→sessionId` reverse map and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s.
The ACP mapping deliberately uses elicitation, not `session/request_permission`. `request_permission` is still reserved for the separate permission gate: it is a yes/no-or-policy authorization protocol around tool execution. `ask_user_question` is a general information-gathering tool with optional free-form answers, so ACP form elicitation is the closer protocol fit. The bridge's session routing is shared with the future permission gate, but the user intent is different.
@@ -46,4 +46,4 @@ The feature gives the model a powerful pause primitive, so prompt guidance matte
## Testing
Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, structured tool errors through `ctx.tools.execute()`, option labels/values, and the model schema including the removal of `desc`. `dsh-stdio-agent` tests cover recommended-first display, descriptions, queued questions, EOF/abort cleanup, and optionless free-form input even with `allowCustom: false`. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify both selected-option and optionless free-form elicitation paths continue the agent loop.
Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. `dsh-stdio-agent` tests cover option descriptions, queued requests, EOF/abort cleanup, optionless free-form input, invalid option reprompts, duplicate multi-select numbers, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop.
+34 -25
View File
@@ -26,55 +26,64 @@ This table connects model-visible tool names to the plugin package and service s
### `ask_user_question`
Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Use options when possible; mark the recommended option when one is safest.
Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer.
```json
{
"type": "object",
"properties": {
"header": {
"type": "string",
"description": "Optional short heading for the question, such as \"Confirm\" or \"Choose Mode\"."
},
"question": {
"type": "string",
"description": "The specific question to ask the user."
},
"options": {
"questions": {
"type": "array",
"description": "Optional mutually exclusive choices to show the user.",
"description": "Questions to ask the user before continuing.",
"items": {
"type": "object",
"properties": {
"label": {
"id": {
"type": "string",
"description": "Short user-facing option label."
"description": "Stable id for this question; echoed in the answer."
},
"value": {
"question": {
"type": "string",
"description": "Answer text returned to you if this option is selected. Defaults to label."
"description": "The specific question to ask the user."
},
"description": {
"header": {
"type": "string",
"description": "One sentence explaining the tradeoff or impact."
"description": "Optional short heading for the question, such as \"Confirm\" or \"Choose Mode\"."
},
"recommended": {
"options": {
"type": "array",
"description": "Optional choices to show the user.",
"items": {
"type": "object",
"properties": {
"label": {
"type": "string",
"description": "Short user-facing option label."
},
"description": {
"type": "string",
"description": "One sentence explaining the tradeoff or impact."
}
},
"required": [
"label"
]
}
},
"multi_select": {
"type": "boolean",
"description": "True for the recommended/default option."
"description": "Whether the user may select more than one option. Defaults to false."
}
},
"required": [
"label"
"id",
"question"
]
}
},
"allow_custom": {
"type": "boolean",
"description": "Whether the user may type a free-form answer instead of selecting an option. Defaults to true."
}
},
"required": [
"question"
"questions"
]
}
```
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
+6 -4
View File
@@ -11,11 +11,13 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod
### Key Types
- `AskUserQuestionRequest``{ question, header?, options?, allowCustom?, agent?, signal? }`.
- `AskUserQuestionOption``{ label, value?, description?, recommended? }`.
- `AskUserQuestionAnswer``{ answer, option? }`.
- `AskUserQuestionRequest``{ questions: [{ id, question, header?, options?, multiSelect? }], agent?, signal? }`.
- `AskUserQuestionOption``{ label, description? }`.
- `AskUserQuestionAnswer``{ answers: [{ id, selected, custom? }] }`.
- `UserInteractionProvider` — UI implementation with `ask(request)`.
- `UserInteractionError``HarnessError` subclass with codes such as `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
- `UserInteractionError``HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices.
## Role
+28 -13
View File
@@ -21,36 +21,48 @@ declare module 'cordis' {
export interface AskUserQuestionOption {
/** User-facing label. */
label: string
/** Value returned to the model when selected. Defaults to `label`. */
value?: string
/** Optional extra context rendered by capable UIs. */
description?: string
/** Marks the recommended/default option. */
recommended?: boolean
}
/** Request for a human answer. */
export interface AskUserQuestionRequest {
/** One question in an ask_user_question request. */
export interface AskUserQuestionItem {
/** Stable model-provided question id, echoed in the answer. */
id: string
/** The question to display. */
question: string
/** Optional short heading/group label. */
header?: string
/** Optional choices the UI can render as a menu. */
options?: AskUserQuestionOption[]
/** Whether free-form answers are accepted. Defaults to `true`. */
allowCustom?: boolean
/** Whether more than one option may be selected. Defaults to single-select. */
multiSelect?: boolean
}
/** Request for a human answer. */
export interface AskUserQuestionRequest {
/** Questions to display. */
questions: AskUserQuestionItem[]
/** Calling agent, when the request came from an agent tool call. */
agent?: Agent
/** Abort signal for the owning tool/step. */
signal?: AbortSignal
}
/** Answer to one question. */
export interface AskUserQuestionAnswerItem {
/** The answered question id. */
id: string
/** Selected option labels. Empty when the answer is purely custom text. */
selected: string[]
/** Optional free-text "Other" answer. */
custom?: string
}
/** The human's answer. */
export interface AskUserQuestionAnswer {
/** Model-facing answer text. */
answer: string
/** The selected option, when the answer came from `options`. */
option?: AskUserQuestionOption
/** Structured answers keyed by question id. */
answers: AskUserQuestionAnswerItem[]
}
/** UI-side provider for user questions. */
@@ -96,13 +108,16 @@ export class UserInteractionService extends Service {
/**
* Ask the active UI provider and wait for the user's answer.
*
* @param request Question, options, owner agent, and abort signal.
* @param request Questions, owner agent, and abort signal.
* @returns The answer chosen or typed by the human.
*/
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
if (request.signal?.aborted) {
throw new UserInteractionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')
}
if (request.questions.length === 0) {
throw new UserInteractionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
}
if (this.provider === undefined) {
throw new UserInteractionError('no user-interaction provider is registered', 'NO_PROVIDER')
}
@@ -12,7 +12,7 @@ function provider(answer = 'approved'): UserInteractionProvider & { seen: AskUse
seen,
async ask(request) {
seen.push(request)
return { answer }
return { answers: [{ id: request.questions[0]?.id ?? 'missing', selected: [answer] }] }
},
}
}
@@ -24,17 +24,17 @@ describe('UserInteractionService', () => {
const p = provider('yes')
ctx.userInteraction.registerProvider(p)
const result = await ctx.userInteraction.ask({ question: 'Proceed?' })
const result = await ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] })
expect(result).toEqual({ answer: 'yes' })
expect(p.seen).toEqual([{ question: 'Proceed?' }])
expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] })
expect(p.seen).toEqual([{ questions: [{ id: 'confirm', question: 'Proceed?' }] }])
})
it('rejects ask requests when no provider is registered', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
await expect(ctx.userInteraction.ask({ question: 'Proceed?' }))
await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_PROVIDER' })
})
@@ -47,7 +47,7 @@ describe('UserInteractionService', () => {
dispose()
dispose()
await expect(ctx.userInteraction.ask({ question: 'Proceed?' }))
await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
})
@@ -63,13 +63,24 @@ describe('UserInteractionService', () => {
it('fails before reaching the provider when the signal is already aborted', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = { ask: vi.fn(async () => ({ answer: 'too late' })) }
const p = { ask: vi.fn(async () => ({ answers: [{ id: 'confirm', selected: ['too late'] }] })) }
ctx.userInteraction.registerProvider(p)
const controller = new AbortController()
controller.abort()
await expect(ctx.userInteraction.ask({ question: 'Proceed?', signal: controller.signal }))
await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }], signal: controller.signal }))
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
expect(p.ask).not.toHaveBeenCalled()
})
it('rejects empty question batches before reaching the provider', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
await expect(ctx.userInteraction.ask({ questions: [] }))
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' })
expect(p.ask).not.toHaveBeenCalled()
})
})
+1 -1
View File
@@ -30,7 +30,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) |
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` — owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) |
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` requests to ACP form elicitations; recommended options become defaults, option descriptions are shown in enum titles, optionless requests remain free-form even when `allowCustom` is false |
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
## Multi-session
+61 -57
View File
@@ -77,6 +77,8 @@ import type {} from '@deepseek-ai/dsh-session-persistence'
import {
UserInteractionError,
type AskUserQuestionAnswer,
type AskUserQuestionAnswerItem,
type AskUserQuestionItem,
type AskUserQuestionOption,
type AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
@@ -120,27 +122,12 @@ function sameWorkspaceCwd(left: string, right: string): boolean {
return resolvePath(left) === resolvePath(right)
}
function optionAnswer(option: AskUserQuestionOption): string {
return option.value ?? option.label
}
function orderedOptions(options: readonly AskUserQuestionOption[] | undefined): AskUserQuestionOption[] {
return [...(options ?? [])].sort((a, b) => Number(Boolean(b.recommended)) - Number(Boolean(a.recommended)))
}
function optionDescription(option: AskUserQuestionOption): string {
return option.description === undefined
? option.label
: `${option.label}: ${option.description}`
}
function selectedOption(
options: readonly AskUserQuestionOption[],
answer: string,
): AskUserQuestionOption | undefined {
return options.find(option => optionAnswer(option) === answer)
}
function requireStringContent(
content: Record<string, ElicitationContentValue> | null | undefined,
key: string,
@@ -177,62 +164,74 @@ function withAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Pro
function elicitationForQuestion(
sessionId: SessionId,
request: AskUserQuestionRequest,
question: AskUserQuestionItem,
options: AskUserQuestionOption[],
): CreateElicitationRequest {
const allowCustom = options.length === 0 || (request.allowCustom ?? true)
const title = request.header ?? 'Question'
const title = question.header ?? 'Question'
if (options.length === 0) {
return {
sessionId,
mode: 'form',
message: request.question,
message: question.question,
requestedSchema: {
type: 'object',
title,
properties: {
answer: { type: 'string', title: request.question },
custom: { type: 'string', title: question.question },
},
required: ['answer'],
required: ['custom'],
},
}
}
const choiceOptions: EnumOption[] = options.map(option => ({
const: optionAnswer(option),
const: option.label,
title: optionDescription(option),
}))
const recommended = options.find(option => option.recommended)
const choice = question.multiSelect === true
? {
type: 'array' as const,
title: question.question,
description: 'Choose one or more options, or fill a custom answer below.',
items: {
anyOf: choiceOptions,
},
}
: {
type: 'string' as const,
title: question.question,
description: 'Choose one option, or fill a custom answer below.',
oneOf: choiceOptions,
}
return {
sessionId,
mode: 'form',
message: request.question,
message: question.question,
requestedSchema: {
type: 'object',
title,
properties: {
choice: {
choice,
custom: {
type: 'string',
title: request.question,
description: allowCustom ? 'Choose one option, or fill a custom answer below.' : 'Choose one option.',
oneOf: choiceOptions,
...recommended !== undefined ? { default: optionAnswer(recommended) } : {},
title: 'Custom answer',
description: 'Optional free-form answer. Leave empty to use the selected option.',
},
...allowCustom
? {
custom_answer: {
type: 'string' as const,
title: 'Custom answer',
description: 'Optional free-form answer. Leave empty to use the selected option.',
},
}
: {},
},
required: allowCustom ? [] : ['choice'],
required: [],
},
}
}
function stringArrayContent(
content: Record<string, ElicitationContentValue> | null | undefined,
key: string,
): string[] {
const value = content?.[key]
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string' && item.length > 0)
return typeof value === 'string' && value.length > 0 ? [value] : []
}
/** Plugin config: the agent template ACP sessions are created from. */
export interface AcpConfig {
/** Model name for created agents (must have a registered adapter). */
@@ -373,25 +372,30 @@ export function apply(ctx: Context, config: AcpConfig): void {
if (sessionId === undefined) {
throw new UserInteractionError('ACP user question has no matching session', 'NO_SESSION')
}
const options = orderedOptions(request.options)
const response = await withAbort(conn.unstable_createElicitation(
elicitationForQuestion(sessionId, request, options),
), request.signal).catch((error: unknown) => {
if (error instanceof UserInteractionError) throw error
throw new UserInteractionError('ACP elicitation request failed', 'ASK_FAILED', { cause: error })
})
if (response.action !== 'accept') {
throw new UserInteractionError('ask_user_question was cancelled by the user', 'ASK_CANCELLED')
const answers: AskUserQuestionAnswerItem[] = []
for (const question of request.questions) {
const options = question.options ?? []
const response = await withAbort(conn.unstable_createElicitation(
elicitationForQuestion(sessionId, question, options),
), request.signal).catch((error: unknown) => {
if (error instanceof UserInteractionError) throw error
throw new UserInteractionError('ACP elicitation request failed', 'ASK_FAILED', { cause: error })
})
if (response.action !== 'accept') {
throw new UserInteractionError('ask_user_question was cancelled by the user', 'ASK_CANCELLED')
}
const custom = requireStringContent(response.content, 'custom')
const selected = stringArrayContent(response.content, 'choice')
if (custom === undefined && selected.length === 0) {
throw new UserInteractionError('ask_user_question returned no answer', 'NO_ANSWER')
}
answers.push({
id: question.id,
selected: custom === undefined ? selected : [],
...custom !== undefined ? { custom } : {},
})
}
const customAnswer = requireStringContent(response.content, 'custom_answer')
if (customAnswer !== undefined) return { answer: customAnswer }
const answer = requireStringContent(response.content, options.length === 0 ? 'answer' : 'choice')
if (answer === undefined) {
throw new UserInteractionError('ask_user_question returned no answer', 'NO_ANSWER')
}
const option = selectedOption(options, answer)
return option === undefined ? { answer } : { answer, option }
return { answers }
},
})
+65 -39
View File
@@ -59,18 +59,20 @@ describe('acp bridge', () => {
withAskUser: true,
script: [
toolCallResponse('ask-1', 'ask_user_question', {
header: 'Project config',
question: 'Which language should I use?',
options: [
{ label: 'TypeScript', value: 'ts', description: 'Good for UI apps' },
{ label: 'Python', value: 'py', description: 'Good for scripts', recommended: true },
],
allow_custom: false,
questions: [{
id: 'language',
header: 'Project config',
question: 'Which language should I use?',
options: [
{ label: 'TypeScript', description: 'Good for UI apps' },
{ label: 'Python', description: 'Good for scripts' },
],
}],
}),
textResponse('Python it is.'),
],
})
harness.onElicitation = () => ({ action: 'accept', content: { choice: 'py' } })
harness.onElicitation = () => ({ action: 'accept', content: { choice: 'Python' } })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -86,18 +88,20 @@ describe('acp bridge', () => {
title: 'Project config',
properties: {
choice: {
default: 'py',
oneOf: [
{ const: 'py', title: 'Python: Good for scripts' },
{ const: 'ts', title: 'TypeScript: Good for UI apps' },
{ const: 'TypeScript', title: 'TypeScript: Good for UI apps' },
{ const: 'Python', title: 'Python: Good for scripts' },
],
},
custom: { type: 'string' },
},
required: ['choice'],
required: [],
},
})
const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result')
expect(JSON.stringify(toolResult)).toContain('py')
const toolResultBlock = toolResult?.type === 'tool/result' ? toolResult.data.content[0] : undefined
const toolResultText = toolResultBlock?.type === 'text' ? toolResultBlock.text : undefined
expect(toolResultText).toBe('{"answers":[{"id":"language","selected":["Python"]}]}')
})
it('routes optionless ask_user_question through an ACP free-form answer field', async () => {
@@ -106,13 +110,12 @@ describe('acp bridge', () => {
withAskUser: true,
script: [
toolCallResponse('ask-1', 'ask_user_question', {
question: 'What should I name it?',
allow_custom: false,
questions: [{ id: 'name', question: 'What should I name it?' }],
}),
textResponse('Name recorded.'),
],
})
harness.onElicitation = () => ({ action: 'accept', content: { answer: 'apollo' } })
harness.onElicitation = () => ({ action: 'accept', content: { custom: 'apollo' } })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -120,8 +123,8 @@ describe('acp bridge', () => {
expect(harness.elicitationRequests[0]).toMatchObject({
requestedSchema: {
properties: { answer: { type: 'string', title: 'What should I name it?' } },
required: ['answer'],
properties: { custom: { type: 'string', title: 'What should I name it?' } },
required: ['custom'],
},
})
const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result')
@@ -130,18 +133,21 @@ describe('acp bridge', () => {
it('supports ACP custom answers alongside choices', async () => {
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
harness.onElicitation = () => ({ action: 'accept', content: { custom_answer: 'Use Zig' } })
harness.onElicitation = () => ({ action: 'accept', content: { custom: 'Use Zig' } })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
const result = await harness.ctx.userInteraction.ask({
agent,
question: 'Which language?',
options: [{ label: 'TypeScript' }],
questions: [{
id: 'language',
question: 'Which language?',
options: [{ label: 'TypeScript' }],
}],
})
expect(result).toEqual({ answer: 'Use Zig' })
expect(result).toEqual({ answers: [{ id: 'language', selected: [], custom: 'Use Zig' }] })
expect(harness.elicitationRequests[0]).toMatchObject({
requestedSchema: {
properties: {
@@ -149,26 +155,46 @@ describe('acp bridge', () => {
description: 'Choose one option, or fill a custom answer below.',
oneOf: [{ const: 'TypeScript', title: 'TypeScript' }],
},
custom_answer: { type: 'string' },
custom: { type: 'string' },
},
required: [],
},
})
})
it('returns raw ACP answers when they do not match a provided option', async () => {
it('treats ACP custom answers as overriding selected choices', async () => {
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
harness.onElicitation = () => ({ action: 'accept', content: { choice: 'something else' } })
harness.onElicitation = () => ({ action: 'accept', content: { choice: 'TypeScript', custom: 'Use Zig' } })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
await expect(harness.ctx.userInteraction.ask({
agent,
question: 'Pick',
options: [{ label: 'A', value: 'a' }],
allowCustom: false,
})).resolves.toEqual({ answer: 'something else' })
questions: [{
id: 'language',
question: 'Which language?',
options: [{ label: 'TypeScript' }],
}],
})).resolves.toEqual({ answers: [{ id: 'language', selected: [], custom: 'Use Zig' }] })
})
it('supports ACP multi-select answers', async () => {
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
harness.onElicitation = () => ({ action: 'accept', content: { choice: ['Tests', 'Docs'] } })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
await expect(harness.ctx.userInteraction.ask({
agent,
questions: [{
id: 'targets',
question: 'Pick',
options: [{ label: 'Tests' }, { label: 'Docs' }],
multiSelect: true,
}],
})).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Tests', 'Docs'] }] })
})
it('reports ACP ask-user routing and answer failures as structured errors', async () => {
@@ -177,21 +203,21 @@ describe('acp bridge', () => {
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
await expect(harness.ctx.userInteraction.ask({ question: 'No agent?' }))
await expect(harness.ctx.userInteraction.ask({ questions: [{ id: 'x', question: 'No agent?' }] }))
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_AGENT' })
await expect(harness.ctx.userInteraction.ask({ agent: { id: 'other' } as typeof agent, question: 'No session?' }))
await expect(harness.ctx.userInteraction.ask({ agent: { id: 'other' } as typeof agent, questions: [{ id: 'x', question: 'No session?' }] }))
.rejects.toMatchObject({ code: 'NO_SESSION' })
harness.onElicitation = () => ({ action: 'cancel' })
await expect(harness.ctx.userInteraction.ask({ agent, question: 'Cancel?' }))
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Cancel?' }] }))
.rejects.toMatchObject({ code: 'ASK_CANCELLED' })
harness.onElicitation = () => ({ action: 'accept', content: {} })
await expect(harness.ctx.userInteraction.ask({ agent, question: 'Empty?' }))
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Empty?' }] }))
.rejects.toMatchObject({ code: 'NO_ANSWER' })
harness.onElicitation = () => { throw new Error('client boom') }
await expect(harness.ctx.userInteraction.ask({ agent, question: 'Client fails?', signal: new AbortController().signal }))
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Client fails?' }], signal: new AbortController().signal }))
.rejects.toMatchObject({ code: 'ASK_FAILED' })
})
@@ -203,7 +229,7 @@ describe('acp bridge', () => {
const alreadyAborted = new AbortController()
alreadyAborted.abort()
await expect(harness.ctx.userInteraction.ask({ agent, question: 'Already?', signal: alreadyAborted.signal }))
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Already?' }], signal: alreadyAborted.signal }))
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
let abortedReads = 0
@@ -216,18 +242,18 @@ describe('acp bridge', () => {
reason: undefined,
throwIfAborted() {},
} as AbortSignal
await expect(harness.ctx.userInteraction.ask({ agent, question: 'Raced?', signal: racingAbort }))
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Raced?' }], signal: racingAbort }))
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
let release: ((value: { action: 'accept'; content: { answer: string } }) => void) | undefined
let release: ((value: { action: 'accept'; content: { custom: string } }) => void) | undefined
harness.onElicitation = () => new Promise((resolve) => { release = resolve })
const pendingAbort = new AbortController()
const ask = harness.ctx.userInteraction.ask({ agent, question: 'Pending?', signal: pendingAbort.signal })
const ask = harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Pending?' }], signal: pendingAbort.signal })
await new Promise(resolve => setImmediate(resolve))
pendingAbort.abort()
await expect(ask).rejects.toMatchObject({ code: 'ASK_ABORTED' })
release?.({ action: 'accept', content: { answer: 'too late' } })
release?.({ action: 'accept', content: { custom: 'too late' } })
})
it('allows multiple concurrent sessions, each with a distinct id', async () => {
+60 -36
View File
@@ -23,6 +23,8 @@ import { AgentId } from '@deepseek-ai/dsh-agent'
import {
UserInteractionError,
type AskUserQuestionAnswer,
type AskUserQuestionAnswerItem,
type AskUserQuestionItem,
type AskUserQuestionOption,
type AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
@@ -63,27 +65,20 @@ function isTTYPair(input: Readable, output: Writable): boolean {
return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY)
}
function optionAnswer(option: AskUserQuestionOption): string {
return option.value ?? option.label
}
function displayOptions(options: AskUserQuestionOption[] = []): AskUserQuestionOption[] {
return options
.map((option, index) => ({ option, index }))
.sort((left, right) => {
if (left.option.recommended === right.option.recommended) return left.index - right.index
return left.option.recommended ? -1 : 1
})
.map(({ option }) => option)
}
interface PendingQuestion {
request: AskUserQuestionRequest
questionIndex: number
answers: AskUserQuestionAnswerItem[]
resolve(answer: AskUserQuestionAnswer): void
reject(error: unknown): void
onAbort: () => void
}
type OptionSelection =
| { kind: 'selected'; options: AskUserQuestionOption[] }
| { kind: 'custom' }
| { kind: 'invalid' }
/**
* The plugin body, parameterized over its I/O runtime. `apply` is the thin
* production wrapper that binds the real `process` streams; tests call this
@@ -205,12 +200,16 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
if (status === 'idle') maybeExit()
})
const activeQuestionItem = (pending: PendingQuestion): AskUserQuestionItem =>
pending.request.questions[pending.questionIndex] as AskUserQuestionItem
const renderQuestion = (pending: PendingQuestion): void => {
const { request } = pending
const question = activeQuestionItem(pending)
const options = question.options ?? []
output.write('\n')
output.write(request.header ? `[${request.header}] ${request.question}\n` : `[question] ${request.question}\n`)
displayOptions(request.options).forEach((option, index) => {
output.write(` ${index + 1}. ${option.label}${option.recommended ? ' (recommended)' : ''}\n`)
output.write(question.header ? `[${question.header}] ${question.question}\n` : `${question.question}\n`)
options.forEach((option, index) => {
output.write(` ${index + 1}. ${option.label}\n`)
if (option.description) output.write(` ${option.description}\n`)
})
output.write('> ')
@@ -249,41 +248,64 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
}
}
const finishQuestion = (pending: PendingQuestion, answer: AskUserQuestionAnswer): void => {
removeAbortListener(pending)
const finishQuestion = (pending: PendingQuestion): void => {
activeQuestion = undefined
pending.resolve(answer)
removeAbortListener(pending)
pending.resolve({ answers: pending.answers })
output.write('\n')
startNextQuestion()
}
const answerCurrentQuestion = (pending: PendingQuestion, answer: AskUserQuestionAnswerItem): void => {
pending.answers.push(answer)
pending.questionIndex += 1
if (pending.questionIndex >= pending.request.questions.length) {
finishQuestion(pending)
return
}
renderQuestion(pending)
}
const selectedOptions = (text: string, options: AskUserQuestionOption[], multiSelect: boolean): OptionSelection => {
if (text === '') return { kind: 'invalid' }
if (!multiSelect) {
if (!/^\d+$/.test(text)) return { kind: 'custom' }
const selected = options[Number(text) - 1]
return selected === undefined ? { kind: 'invalid' } : { kind: 'selected', options: [selected] }
}
const indices = text.split(/[,\s]+/).filter(Boolean)
if (indices.length === 0) return { kind: 'invalid' }
if (indices.some(part => !/^\d+$/.test(part))) return { kind: 'custom' }
const uniqueIndices = [...new Set(indices)]
const selected = uniqueIndices.map(part => options[Number(part) - 1])
return selected.some(option => option === undefined)
? { kind: 'invalid' }
: { kind: 'selected', options: selected as AskUserQuestionOption[] }
}
const answerQuestion = (line: string): void => {
const pending = activeQuestion as PendingQuestion
const question = activeQuestionItem(pending)
const text = line.trim()
const options = displayOptions(pending.request.options)
const selectedIndex = /^\d+$/.test(text) ? Number(text) - 1 : -1
const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined
if (selected !== undefined) {
finishQuestion(pending, { answer: optionAnswer(selected), option: selected })
const options = question.options ?? []
const selection = options.length > 0
? selectedOptions(text, options, question.multiSelect ?? false)
: { kind: text === '' ? 'invalid' : 'custom' } as OptionSelection
if (selection.kind === 'selected') {
answerCurrentQuestion(pending, { id: question.id, selected: selection.options.map(option => option.label) })
return
}
const recommended = options.find(option => option.recommended)
if (text === '' && recommended !== undefined) {
finishQuestion(pending, { answer: optionAnswer(recommended), option: recommended })
return
}
const allowCustom = options.length === 0 || (pending.request.allowCustom ?? true)
if (allowCustom && text !== '') {
finishQuestion(pending, { answer: text })
if (selection.kind === 'custom' && text !== '') {
answerCurrentQuestion(pending, { id: question.id, selected: [], custom: text })
return
}
output.write(options.length > 0
? 'Please enter one of the option numbers'
+ (allowCustom ? ' or a custom answer' : '')
+ (question.multiSelect ? ' (comma or space separated)' : '')
+ ' or a custom answer'
+ '.\n> '
: 'Please enter an answer.\n> ')
}
@@ -298,6 +320,8 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
const pending: PendingQuestion = {
request,
questionIndex: 0,
answers: [],
resolve,
reject,
onAbort: () => {
+187 -84
View File
@@ -1,4 +1,4 @@
import { Readable } from 'node:stream'
import { Readable, Writable } from 'node:stream'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
@@ -107,6 +107,30 @@ describe('createStdioChat rendering', () => {
// And it drives the default agent id 'main'.
})
it('detects readline terminal mode from both stream TTY flags', async () => {
for (const [inputTTY, outputTTY] of [[true, false], [true, true]] as const) {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
let text = ''
const output = new Writable({
write(chunk, _encoding, callback) {
text += String(chunk)
callback()
},
}) as Writable & { isTTY?: boolean }
const { runtime } = makeRuntime({ output })
;(runtime.input as Readable & { isTTY?: boolean }).isTTY = inputTTY
output.isTTY = outputTTY
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
createStdioChat(inner, CONFIG, runtime)
}, { inject: ['agents', 'userInteraction'] }))
expect(text).toContain('hi there')
await fiber.dispose()
}
})
it('renders text-delta chunks verbatim', async () => {
const { ctx, out } = await setup()
ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'text-delta', index: 0, text: 'hello' }))
@@ -273,150 +297,229 @@ describe('createStdioChat input', () => {
ctx.agents.register(agent)
const answer = ctx.userInteraction.ask({
header: 'Confirm',
question: 'Proceed with the edit?',
options: [{ label: 'Yes', value: 'Proceed', description: 'Apply the edit now.', recommended: true }],
questions: [{
id: 'confirm',
header: 'Confirm',
question: 'Proceed with the edit?',
options: [{ label: 'Yes', description: 'Apply the edit now.' }],
}],
})
await new Promise(r => setImmediate(r))
input.feed('Use a smaller change')
await expect(answer).resolves.toEqual({ answer: 'Use a smaller change' })
await expect(answer).resolves.toEqual({ answers: [{ id: 'confirm', selected: [], custom: 'Use a smaller change' }] })
expect(agent.sent).toEqual([])
expect(out.text()).toContain('[Confirm] Proceed with the edit?')
expect(out.text()).toContain('1. Yes (recommended)')
expect(out.text()).toContain('1. Yes')
expect(out.text()).toContain('Apply the edit now.')
})
it('answers a pending user question by numeric option selection', async () => {
const { ctx, input } = await setup()
const answer = ctx.userInteraction.ask({
question: 'Which mode?',
options: [
{ label: 'Safe', value: 'Use safe mode', recommended: true },
{ label: 'Fast', value: 'Use fast mode' },
],
allowCustom: false,
questions: [{
id: 'mode',
question: 'Which mode?',
options: [
{ label: 'Safe' },
{ label: 'Fast' },
],
}],
})
await new Promise(r => setImmediate(r))
input.feed('2')
await expect(answer).resolves.toEqual({
answer: 'Use fast mode',
option: { label: 'Fast', value: 'Use fast mode' },
answers: [{ id: 'mode', selected: ['Fast'] }],
})
})
it('renders recommended options first and selects by displayed number', async () => {
it('renders options in input order and selects by displayed number', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
question: 'Which topic?',
options: [
{ label: 'Hobbies', value: 'hobbies' },
{ label: 'Work', value: 'work', description: 'Questions about current projects.' },
{ label: 'Casual', value: 'casual', recommended: true, description: 'Easy conversation.' },
],
allowCustom: false,
questions: [{
id: 'topic',
question: 'Which topic?',
options: [
{ label: 'Hobbies' },
{ label: 'Work', description: 'Questions about current projects.' },
{ label: 'Casual', description: 'Easy conversation.' },
],
}],
})
await new Promise(r => setImmediate(r))
expect(out.text()).toContain([
'[question] Which topic?',
' 1. Casual (recommended)',
' Easy conversation.',
' 2. Hobbies',
' 3. Work',
'Which topic?',
' 1. Hobbies',
' 2. Work',
' Questions about current projects.',
' 3. Casual',
' Easy conversation.',
].join('\n'))
input.feed('1')
input.feed('3')
await expect(answer).resolves.toEqual({
answer: 'casual',
option: { label: 'Casual', value: 'casual', recommended: true, description: 'Easy conversation.' },
answers: [{ id: 'topic', selected: ['Casual'] }],
})
})
it('uses the recommended option when the user submits an empty answer', async () => {
it('answers a multi-select question with multiple numeric selections', async () => {
const { ctx, input } = await setup()
const answer = ctx.userInteraction.ask({
question: 'Continue?',
options: [
{ label: 'No' },
{ label: 'Yes', value: 'Continue', recommended: true },
],
allowCustom: false,
questions: [{
id: 'targets',
question: 'What should I update?',
options: [{ label: 'Tests' }, { label: 'Docs' }, { label: 'Code' }],
multiSelect: true,
}],
})
await new Promise(r => setImmediate(r))
input.feed('')
input.feed('1 1, 3')
await expect(answer).resolves.toEqual({
answer: 'Continue',
option: { label: 'Yes', value: 'Continue', recommended: true },
answers: [{ id: 'targets', selected: ['Tests', 'Code'] }],
})
})
it('re-prompts when options are required and the input is invalid', async () => {
const { ctx, input, out } = await setup()
it('accepts non-numeric multi-select input as a custom answer', async () => {
const { ctx, input } = await setup()
const answer = ctx.userInteraction.ask({
question: 'Which mode?',
options: [{ label: 'Safe' }],
allowCustom: false,
questions: [{
id: 'targets',
question: 'What should I update?',
options: [{ label: 'Tests' }, { label: 'Docs' }],
multiSelect: true,
}],
})
await new Promise(r => setImmediate(r))
input.feed('custom')
input.feed('the release notes')
await expect(answer).resolves.toEqual({
answers: [{ id: 'targets', selected: [], custom: 'the release notes' }],
})
})
it('asks every question in a batch and returns answers by id', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
questions: [
{ id: 'language', question: 'Which language?', options: [{ label: 'Python' }, { label: 'TypeScript' }] },
{ id: 'note', question: 'Any note?' },
],
})
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter one of the option numbers.')
input.feed('2')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('\nAny note?\n')
input.feed('ship today')
await expect(answer).resolves.toEqual({
answers: [
{ id: 'language', selected: ['TypeScript'] },
{ id: 'note', selected: [], custom: 'ship today' },
],
})
})
it('re-prompts when option input is invalid', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'mode',
question: 'Which mode?',
options: [{ label: 'Safe' }],
multiSelect: true,
}],
})
await new Promise(r => setImmediate(r))
input.feed('2')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter one of the option numbers (comma or space separated) or a custom answer.')
input.feed('1')
await expect(answer).resolves.toEqual({
answer: 'Safe',
option: { label: 'Safe' },
answers: [{ id: 'mode', selected: ['Safe'] }],
})
})
it('re-prompts with custom-answer guidance when options also allow free-form input', async () => {
it('re-prompts when single-select option input is out of range', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
question: 'Which mode?',
options: [{ label: 'Safe' }],
questions: [{
id: 'mode',
question: 'Which mode?',
options: [{ label: 'Safe' }],
}],
})
await new Promise(r => setImmediate(r))
input.feed('2')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.')
input.feed('1')
await expect(answer).resolves.toEqual({
answers: [{ id: 'mode', selected: ['Safe'] }],
})
})
it('re-prompts when multi-select input contains no option numbers', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'mode',
question: 'Which mode?',
options: [{ label: 'Safe' }],
multiSelect: true,
}],
})
await new Promise(r => setImmediate(r))
input.feed(',')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter one of the option numbers (comma or space separated) or a custom answer.')
input.feed('1')
await expect(answer).resolves.toEqual({
answers: [{ id: 'mode', selected: ['Safe'] }],
})
})
it('re-prompts when an option question receives an empty answer', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'mode',
question: 'Which mode?',
options: [{ label: 'Safe' }],
}],
})
await new Promise(r => setImmediate(r))
input.feed('')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.')
input.feed('Use custom mode')
input.feed('1')
await expect(answer).resolves.toEqual({ answer: 'Use custom mode' })
await expect(answer).resolves.toEqual({
answers: [{ id: 'mode', selected: ['Safe'] }],
})
})
it('re-prompts when a free-form question receives an empty answer', async () => {
it('re-prompts when a question receives an empty answer', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({ question: 'What should I use?' })
const answer = ctx.userInteraction.ask({ questions: [{ id: 'path', question: 'What should I use?' }] })
await new Promise(r => setImmediate(r))
input.feed('')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter an answer.')
input.feed('Use defaults')
await expect(answer).resolves.toEqual({ answer: 'Use defaults' })
})
it('accepts free-form input for an optionless question even when allowCustom is false', async () => {
const { ctx, input } = await setup()
const answer = ctx.userInteraction.ask({
question: 'Choose?',
allowCustom: false,
})
await new Promise(r => setImmediate(r))
input.feed('Use the default path')
await expect(answer).resolves.toEqual({ answer: 'Use the default path' })
await expect(answer).resolves.toEqual({ answers: [{ id: 'path', selected: [], custom: 'Use defaults' }] })
})
it('rejects an active question when its signal aborts', async () => {
const { ctx } = await setup()
const controller = new AbortController()
const answer = ctx.userInteraction.ask({ question: 'Continue?', signal: controller.signal })
const answer = ctx.userInteraction.ask({ questions: [{ id: 'continue', question: 'Continue?' }], signal: controller.signal })
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await new Promise(r => setImmediate(r))
@@ -428,40 +531,40 @@ describe('createStdioChat input', () => {
it('continues to the next queued question when the active question aborts', async () => {
const { ctx, input, out } = await setup()
const controller = new AbortController()
const first = ctx.userInteraction.ask({ question: 'First?', signal: controller.signal })
const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }], signal: controller.signal })
const firstRejected = expect(first).rejects.toMatchObject({ code: 'ASK_ABORTED' })
const second = ctx.userInteraction.ask({ question: 'Second?' })
const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }] })
await new Promise(r => setImmediate(r))
controller.abort()
await firstRejected
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('[question] Second?')
expect(out.text()).toContain('\nSecond?\n')
input.feed('second answer')
await expect(second).resolves.toEqual({ answer: 'second answer' })
await expect(second).resolves.toEqual({ answers: [{ id: 'second', selected: [], custom: 'second answer' }] })
})
it('skips a queued question whose signal aborted before it became active', async () => {
const { ctx, input, out } = await setup()
const controller = new AbortController()
const first = ctx.userInteraction.ask({ question: 'First?' })
const second = ctx.userInteraction.ask({ question: 'Second?', signal: controller.signal })
const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] })
const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal })
const secondRejected = expect(second).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await new Promise(r => setImmediate(r))
controller.abort()
input.feed('first answer')
await expect(first).resolves.toEqual({ answer: 'first answer' })
await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] })
await secondRejected
expect(out.text()).not.toContain('[question] Second?')
expect(out.text()).not.toContain('\nSecond?\n')
})
it('rejects active and queued questions when the UI is disposed', async () => {
const { ctx, fiber } = await setup()
const active = ctx.userInteraction.ask({ question: 'Active?' })
const queued = ctx.userInteraction.ask({ question: 'Queued?' })
const active = ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] })
const queued = ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] })
const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await new Promise(r => setImmediate(r))
@@ -474,8 +577,8 @@ describe('createStdioChat input', () => {
it('rejects active and queued questions when stdin closes before the user answers', async () => {
const { ctx, input, exit } = await setup()
const active = ctx.userInteraction.ask({ question: 'Active?' })
const queued = ctx.userInteraction.ask({ question: 'Queued?' })
const active = ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] })
const queued = ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] })
const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await new Promise(r => setImmediate(r))
@@ -494,7 +597,7 @@ describe('createStdioChat input', () => {
await new Promise(r => setImmediate(r))
const before = out.text()
const answer = ctx.userInteraction.ask({ question: 'Too late?' })
const answer = ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Too late?' }] })
await expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
expect(out.text()).toBe(before)
+6 -4
View File
@@ -6,12 +6,14 @@ Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the mo
`ask_user_question` accepts:
- `question` — required question text.
- `questions` — required non-empty array of question objects.
- `id` — required stable id on each question, echoed in the answer.
- `question` — required question text for each question.
- `header` — optional short heading.
- `options` — optional choices with `label`, `value`, `description`, and `recommended`.
- `allow_custom` — whether free-form answers are allowed; defaults to the provider's normal `true` behavior.
- `options` — optional choices with `label` and `description`.
- `multi_select` — whether that question may return more than one selected option.
The tool calls `ctx.userInteraction.ask()` and returns the selected option value or custom answer as a text tool result.
The tool calls `ctx.userInteraction.ask()` and returns JSON text shaped as `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices.
## Role
+34 -26
View File
@@ -8,56 +8,64 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-user-interaction'
import '@deepseek-ai/dsh-user-interaction'
export const name = 'tool-ask-user'
export const inject = ['tools', 'userInteraction']
const description = 'Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. '
+ 'Use options when possible; mark the recommended option when one is safest.'
+ 'Send one or more questions, each with a stable id that will be echoed in the answer.'
export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'ask_user_question',
description,
parameters: {
header: {
type: 'string',
description: 'Optional short heading for the question, such as "Confirm" or "Choose Mode".',
},
question: {
type: 'string',
required: true,
description: 'The specific question to ask the user.',
},
options: {
questions: {
type: 'array',
description: 'Optional mutually exclusive choices to show the user.',
required: true,
description: 'Questions to ask the user before continuing.',
items: {
type: 'object',
properties: {
label: { type: 'string', required: true, description: 'Short user-facing option label.' },
value: { type: 'string', description: 'Answer text returned to you if this option is selected. Defaults to label.' },
description: { type: 'string', description: 'One sentence explaining the tradeoff or impact.' },
recommended: { type: 'boolean', description: 'True for the recommended/default option.' },
id: { type: 'string', required: true, description: 'Stable id for this question; echoed in the answer.' },
question: { type: 'string', required: true, description: 'The specific question to ask the user.' },
header: {
type: 'string',
description: 'Optional short heading for the question, such as "Confirm" or "Choose Mode".',
},
options: {
type: 'array',
description: 'Optional choices to show the user.',
items: {
type: 'object',
properties: {
label: { type: 'string', required: true, description: 'Short user-facing option label.' },
description: { type: 'string', description: 'One sentence explaining the tradeoff or impact.' },
},
},
},
multi_select: {
type: 'boolean',
description: 'Whether the user may select more than one option. Defaults to false.',
},
},
},
},
allow_custom: {
type: 'boolean',
description: 'Whether the user may type a free-form answer instead of selecting an option. Defaults to true.',
},
},
async execute(args, exec) {
const result = await ctx.userInteraction.ask({
question: args.question,
...args.header !== undefined ? { header: args.header } : {},
...args.options !== undefined ? { options: args.options } : {},
...args.allow_custom !== undefined ? { allowCustom: args.allow_custom } : {},
questions: args.questions.map(question => ({
id: question.id,
question: question.question,
...question.header !== undefined ? { header: question.header } : {},
...question.options !== undefined ? { options: question.options } : {},
...question.multi_select !== undefined ? { multiSelect: question.multi_select } : {},
})),
...exec.agent !== undefined ? { agent: exec.agent } : {},
...exec.signal !== undefined ? { signal: exec.signal } : {},
})
return [{ type: 'text', text: result.answer }]
return [{ type: 'text', text: JSON.stringify(result) }]
},
}))
}
@@ -9,9 +9,15 @@ import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
interface OptionSchemaShape {
properties: {
options: {
questions: {
items: {
properties: Record<string, { type: string }>
properties: {
options: {
items: {
properties: Record<string, { type: string }>
}
}
} & Record<string, unknown>
}
}
}
@@ -36,29 +42,35 @@ describe('ask_user_question tool', () => {
parameters: {
type: 'object',
properties: {
question: { type: 'string' },
options: { type: 'array' },
allow_custom: { type: 'boolean' },
questions: { type: 'array' },
},
required: ['question'],
required: ['questions'],
},
})
const parameters = schema?.parameters as unknown as OptionSchemaShape
expect(parameters.properties.options.items.properties).toMatchObject({
description: { type: 'string' },
recommended: { type: 'boolean' },
expect(parameters.properties.questions.items.properties).toMatchObject({
id: { type: 'string' },
question: { type: 'string' },
header: { type: 'string' },
options: { type: 'array' },
multi_select: { type: 'boolean' },
})
expect(parameters.properties.options.items.properties).not.toHaveProperty('desc')
expect(parameters.properties.questions.items.properties.options.items.properties).toMatchObject({
label: { type: 'string' },
description: { type: 'string' },
})
expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('value')
expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('recommended')
expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('preview')
})
it('asks the registered user-interaction provider and returns the answer text', async () => {
it('asks the registered user-interaction provider and projects structured answers to text', async () => {
const ctx = await setup()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
async ask(request) {
seen.push(request)
const option = request.options?.[0]
return option === undefined ? { answer: 'Use pnpm' } : { answer: 'Use pnpm', option }
return { answers: [{ id: 'pkg', selected: ['pnpm'] }] }
},
})
@@ -66,20 +78,59 @@ describe('ask_user_question tool', () => {
callId: CallId('ask-1'),
name: 'ask_user_question',
arguments: {
question: 'Which package manager should I use?',
options: [{ label: 'pnpm', value: 'Use pnpm', recommended: true }],
allow_custom: false,
questions: [{
id: 'pkg',
question: 'Which package manager should I use?',
options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }],
}],
},
})
expect(result).toMatchObject({
isError: false,
content: [{ type: 'text', text: 'Use pnpm' }],
content: [{ type: 'text', text: '{"answers":[{"id":"pkg","selected":["pnpm"]}]}' }],
})
expect(seen).toMatchObject([{
question: 'Which package manager should I use?',
options: [{ label: 'pnpm', value: 'Use pnpm', recommended: true }],
allowCustom: false,
questions: [{
id: 'pkg',
question: 'Which package manager should I use?',
options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }],
}],
}])
})
it('projects custom answers and multi-select choices', async () => {
const ctx = await setup()
ctx.userInteraction.registerProvider({
async ask() {
return {
answers: [
{ id: 'targets', selected: ['tests', 'docs'] },
{ id: 'notes', selected: [], custom: 'ship today' },
],
}
},
})
const result = await ctx.tools.execute({
callId: CallId('ask-multi'),
name: 'ask_user_question',
arguments: {
questions: [
{
id: 'targets',
question: 'What should I update?',
options: [{ label: 'tests' }, { label: 'docs' }],
multi_select: true,
},
{ id: 'notes', question: 'Any note?' },
],
},
})
expect(result.content).toEqual([{
type: 'text',
text: '{"answers":[{"id":"targets","selected":["tests","docs"]},{"id":"notes","selected":[],"custom":"ship today"}]}',
}])
})
@@ -89,7 +140,7 @@ describe('ask_user_question tool', () => {
ctx.userInteraction.registerProvider({
async ask(request) {
seen.push(request)
return { answer: 'ok' }
return { answers: [{ id: 'continue', selected: ['ok'] }] }
},
})
const controller = new AbortController()
@@ -97,7 +148,7 @@ describe('ask_user_question tool', () => {
await ctx.tools.execute({
callId: CallId('ask-2'),
name: 'ask_user_question',
arguments: { question: 'Continue?' },
arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
signal: controller.signal,
})
@@ -110,7 +161,7 @@ describe('ask_user_question tool', () => {
ctx.userInteraction.registerProvider({
async ask(request) {
seen.push(request)
return { answer: 'ok' }
return { answers: [{ id: 'continue', selected: ['ok'] }] }
},
})
const agent = { id: 'main' } as unknown as Agent
@@ -118,12 +169,12 @@ describe('ask_user_question tool', () => {
const result = await ctx.tools.execute({
callId: CallId('ask-3'),
name: 'ask_user_question',
arguments: { header: 'Confirm', question: 'Continue?' },
arguments: { questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }] },
agent,
})
expect(result.content).toEqual([{ type: 'text', text: 'ok' }])
expect(seen[0]).toMatchObject({ header: 'Confirm', agent })
expect(result.content).toEqual([{ type: 'text', text: '{"answers":[{"id":"continue","selected":["ok"]}]}' }])
expect(seen[0]).toMatchObject({ questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }], agent })
})
it('returns structured user-interaction errors through tool execution', async () => {
@@ -132,7 +183,7 @@ describe('ask_user_question tool', () => {
const result = await ctx.tools.execute({
callId: CallId('ask-no-provider'),
name: 'ask_user_question',
arguments: { question: 'Continue?' },
arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
})
expect(result).toMatchObject({
@@ -141,48 +192,19 @@ describe('ask_user_question tool', () => {
})
})
it('uses an option label when the selected option has no explicit value', async () => {
it('returns a structured error for empty question batches', async () => {
const ctx = await setup()
ctx.userInteraction.registerProvider({
async ask(request) {
const option = request.options?.[0]
if (option === undefined) throw new Error('missing option')
return { answer: option.label, option }
},
})
const result = await ctx.tools.execute({
callId: CallId('ask-4'),
callId: CallId('ask-empty'),
name: 'ask_user_question',
arguments: {
question: 'Pick one',
options: [{ label: 'Fallback label' }],
},
arguments: { questions: [] },
})
expect(result.content).toEqual([{ type: 'text', text: 'Fallback label' }])
})
it('returns the provider-computed answer even when option metadata is present', async () => {
const ctx = await setup()
ctx.userInteraction.registerProvider({
async ask(request) {
const option = request.options?.[0]
if (option === undefined) throw new Error('missing option')
return { answer: `selected ${option.value}`, option }
},
expect(result).toMatchObject({
isError: true,
error: { name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' },
})
const result = await ctx.tools.execute({
callId: CallId('ask-5'),
name: 'ask_user_question',
arguments: {
question: 'Pick one',
options: [{ label: 'A', value: 'a' }],
},
})
expect(result.content).toEqual([{ type: 'text', text: 'selected a' }])
})
it('unregisters the tool when its plugin fiber is disposed', async () => {
+2
View File
@@ -45,7 +45,9 @@
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionOption", "source": "packages/core/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionItem", "source": "packages/core/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionRequest", "source": "packages/core/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionAnswerItem", "source": "packages/core/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionAnswer", "source": "packages/core/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "UserInteractionProvider", "source": "packages/core/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "UserInteractionError", "source": "packages/core/user-interaction/src/index.ts" },