feat(user-interaction): declare a plan-review presentation intent on questions
A question may now carry `intent`, a tagged declaration that it IS a decision
of a known shape, so a UI that recognises the tag can present it as such
instead of as a generic option list. The one member is
`{ kind: 'plan-review', approve }`, which plan-mode sets on the exit_plan_mode
review.
An intent shapes presentation only: a UI honouring it answers with the same
option labels a generic UI would send, so the tool reads one answer shape
either way, and a UI that does not know the tag renders the generic flow.
`approve` names the affirmative option rather than relying on option order;
since no type can tie that label to the question's own option list, `ask()`
rejects a mismatch as BAD_INTENT, and the wire schema rejects an unknown tag
outright rather than silently rendering generic.
plan-mode also stops reporting a dismissed review as "the user cancelled
ask_user_question" — a tool it never called. A dismissal now tells the model
the user took the turn back to speak, and to stay in plan mode and wait; every
other ask failure keeps its own message.
This commit is contained in:
@@ -20,7 +20,8 @@ declare module 'cordis' {
|
||||
import type { AskUserQuestionAnswer, AskUserQuestionItem } from './types.ts'
|
||||
|
||||
export type {
|
||||
AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionOption,
|
||||
AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionIntent, AskUserQuestionItem,
|
||||
AskUserQuestionOption,
|
||||
} from './types.ts'
|
||||
|
||||
/** Request for a human answer. */
|
||||
@@ -86,6 +87,20 @@ export class UserInteractionService extends Service {
|
||||
if (request.questions.length === 0) {
|
||||
throw new UserInteractionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
|
||||
}
|
||||
// A presentation intent names an option label the types cannot pin to its
|
||||
// own question's option list. A UI honouring the intent answers with that
|
||||
// label, so a name matching nothing would answer a choice the asker never
|
||||
// offered — caught here, at the asker, rather than in a UI.
|
||||
for (const question of request.questions) {
|
||||
const intent = question.intent
|
||||
if (intent === undefined) continue
|
||||
if (!(question.options ?? []).some(option => option.label === intent.approve)) {
|
||||
throw new UserInteractionError(
|
||||
`question ${question.id} declares intent ${intent.kind} whose approve label `
|
||||
+ `${JSON.stringify(intent.approve)} names none of its options`,
|
||||
'BAD_INTENT')
|
||||
}
|
||||
}
|
||||
if (this.provider === undefined) {
|
||||
throw new UserInteractionError('no user-interaction provider is registered', 'NO_PROVIDER')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user