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