fix review findings: polish ask-user question
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user