Merge remote-tracking branch 'origin/master' into worktree/fix-multi-select-custom-answer

# Conflicts:
#	apps/web/tests/snapshots/question-composer/answered.expected.md
#	apps/web/tests/snapshots/question-composer/session.jsonl
#	docs/core-data-structures/user-interaction.i18n.yaml
#	packages/client/ui-question/README.i18n.yaml
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/ui/tui/README.i18n.yaml
#	packages/ui/user-interaction/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-08-03 16:09:17 +08:00
2065 files changed
+168375 -14240

No files matched your search

+24 -1
View File
@@ -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,28 @@ 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 asserts two things the types cannot: that the
// named approve label is one of this question's own options, and that a
// plan-review carries the plan it is a review of. A UI honouring the
// intent answers with that label, and shows that detail as the plan, so
// either gap would put a choice the asker never offered — or an approval of
// something invisible — in front of the user. Caught at the asker, where
// the mistake is, rather than in each 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 (question.detail === undefined) {
throw new UserInteractionError(
`question ${question.id} declares intent ${intent.kind} without the detail it reviews`,
'BAD_INTENT')
}
}
if (this.provider === undefined) {
throw new UserInteractionError('no user-interaction provider is registered', 'NO_PROVIDER')
}
+20
View File
@@ -13,6 +13,24 @@ export interface AskUserQuestionOption {
description?: string
}
/**
* A caller-declared presentation intent: the question IS a decision of this
* shape, so a UI that recognises the tag may present it as such instead of as a
* generic option list. Tagged so further intents can be added; a UI that does
* not know a tag renders the generic flow, and the answer encoding is identical
* either way — an intent shapes presentation only, never the protocol.
*/
export type AskUserQuestionIntent = {
/** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */
kind: 'plan-review'
/**
* The option label that approves the plan; every other option declines it.
* Named rather than positional so no UI infers the verdict from option order.
* An `approve` naming no option of its own question is rejected at `ask()`.
*/
approve: string
}
/** One question in a user-interaction request. */
export interface AskUserQuestionItem {
/** Stable caller-provided question id, echoed in the answer. */
@@ -27,6 +45,8 @@ export interface AskUserQuestionItem {
options?: AskUserQuestionOption[]
/** Whether more than one option may be selected. Defaults to single-select. */
multiSelect?: boolean
/** Optional presentation intent for capable UIs; absent asks for the generic option list. */
intent?: AskUserQuestionIntent
}
/** Answer to one question. */