diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8e4030c2fe..6ab505b3fb 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -908,7 +908,7 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop Types: [Agent](../core-data-structures/core.md) -Source: [`packages/plan/plan-mode/src/index.ts:179`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:182`](../../packages/plan/plan-mode/src/index.ts) ## `ctx.pty` — `PtyService` @@ -2284,7 +2284,7 @@ async ask(request: AskUserQuestionRequest): Promise Types: [AskUserQuestionAnswer](../core-data-structures/user-interaction.md) · [AskUserQuestionRequest](../core-data-structures/user-interaction.md) · [UserInteractionProvider](../core-data-structures/user-interaction.md) -Source: [`packages/ui/user-interaction/src/index.ts:50`](../../packages/ui/user-interaction/src/index.ts) +Source: [`packages/ui/user-interaction/src/index.ts:51`](../../packages/ui/user-interaction/src/index.ts) ## `ctx.web` — `WebService` diff --git a/docs/core-data-structures/user-interaction.i18n.yaml b/docs/core-data-structures/user-interaction.i18n.yaml index 66cb12815e..ee739a59d3 100644 --- a/docs/core-data-structures/user-interaction.i18n.yaml +++ b/docs/core-data-structures/user-interaction.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -user-interaction.md: 798a9790f424683775284a98421be08e6e1399e3 -user-interaction.zh.md: 12bfcffe4fe4caaacb54e90126eac55e333d64a5 +# pnpm run verify-translation-pairing --write docs/core-data-structures/user-interaction.md +user-interaction.md: 4ebaf66131b3ab7a7c205bef1ba533e9321d4f0b +user-interaction.zh.md: 8e5542b995bbf061b097d650ab79505e93151fa8 diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index 798a9790f4..4ebaf66131 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -20,6 +20,30 @@ interface AskUserQuestionOption { } ``` +## Presentation intent + +`AskUserQuestionIntent` is the optional declaration that a question IS a decision of a known shape. It is tagged on `kind` so intents can be added; a UI that does not recognise a tag renders the generic option list. An intent shapes presentation only — a UI honouring it answers with the same option labels a generic UI would send, so the caller reads one answer shape either way. `approve` names the affirmative option instead of relying on option order, and `ask()` rejects an `approve` naming none of its own question's options. + +```ts type-equiv +/** + * 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. + */ +type AskUserQuestionIntent = { + /** A plan submitted for review: `detail` is the plan markdown, 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 +} +``` + ## Question item `AskUserQuestionItem` is one question in a request. The caller supplies a stable `id`, which is echoed back with the answer so batched questions remain routable. Optional `detail` carries supporting text that providers render with the question but keep out of selectable option labels. @@ -39,6 +63,8 @@ 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 } ``` diff --git a/docs/core-data-structures/user-interaction.zh.md b/docs/core-data-structures/user-interaction.zh.md index 12bfcffe4f..8e5542b995 100644 --- a/docs/core-data-structures/user-interaction.zh.md +++ b/docs/core-data-structures/user-interaction.zh.md @@ -20,6 +20,30 @@ interface AskUserQuestionOption { } ``` +## 呈现意图 + +`AskUserQuestionIntent` 是一项可选声明:某个问题本身就是一次已知形状的决定。它按 `kind` 打标签,因此意图可以扩充;不认识某个标签的 UI 渲染通用选项列表。意图只塑造呈现 —— 遵循它的 UI 回答的仍是通用 UI 会发送的那些 option label,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名肯定选项,而不依赖选项顺序;`ask()` 会拒绝未命中该问题自身任一选项的 `approve`。 + +```ts type-equiv +/** + * 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. + */ +type AskUserQuestionIntent = { + /** A plan submitted for review: `detail` is the plan markdown, 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 +} +``` + ## 问题条目 `AskUserQuestionItem` 是请求中的一个问题。调用方提供稳定的 `id`,它会随答案原样返回,使批量问题仍可路由。可选的 `detail` 携带辅助文本;提供方会将其随问题渲染,但不会放入可选 option label。 @@ -39,6 +63,8 @@ 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 } ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d7cdc4e253..74d66a5631 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1503,9 +1503,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AskUserQuestionAnswerItem', declaration: 'export interface AskUserQuestionAnswerItem {\n id: string;\n selected: string[];\n custom?: string;\n}', }, + { + name: 'AskUserQuestionIntent', + declaration: 'export type AskUserQuestionIntent = {\n kind: \'plan-review\';\n approve: string;\n};', + }, { name: 'AskUserQuestionItem', - declaration: 'export interface AskUserQuestionItem {\n id: string;\n question: string;\n detail?: string;\n header?: string;\n options?: AskUserQuestionOption[];\n multiSelect?: boolean;\n}', + declaration: 'export interface AskUserQuestionItem {\n id: string;\n question: string;\n detail?: string;\n header?: string;\n options?: AskUserQuestionOption[];\n multiSelect?: boolean;\n intent?: AskUserQuestionIntent;\n}', }, { name: 'AskUserQuestionOption', diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 4e17b1f403..43b7a147e4 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -23,6 +23,11 @@ export const askUserQuestionItemSchema = z.object({ detail: z.string().optional(), options: z.array(z.object({ label: z.string(), description: z.string().optional() })).optional(), multiSelect: z.boolean().optional(), + // Presentation intent: a tagged union on the wire, so an unknown tag is a + // rejected frame rather than a silently generic render. + intent: z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('plan-review'), approve: z.string() }), + ]).optional(), }) satisfies z.ZodType> /** Unified message envelope carried by transient queue frames. */ diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 80cd0baf4c..0b4d0b78ee 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -399,6 +399,17 @@ describe('events frame schemas', () => { expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow() }) + it('carries a question presentation intent through, and rejects an unknown one', () => { + const intent = { kind: 'plan-review', approve: 'Approve' } + expect(askUserQuestionItemSchema.parse({ + id: 'plan-review', question: 'Approve?', detail: '# Plan', options: [{ label: 'Approve' }], intent, + }).intent).toEqual(intent) + // An unrecognised tag is a rejected frame, not a silently generic render. + for (const invalid of [{ kind: 'plan-review' }, { kind: 'poll', approve: 'Approve' }, { approve: 'Approve' }]) { + expect(() => askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?', intent: invalid })).toThrow() + } + }) + it('rejects a queue snapshot with malformed items', () => { expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow() expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', message: {} }] })).toThrow() diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index c5a13bee7e..a4b6723b98 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/plan/plan-mode/README.md -README.md: d3c2c14fe616e1c9b4e33b716570b084db6474cf -README.zh.md: 6d6878c4b0300a716ad16be60fd86bc79f1514ba +README.md: 6f0a9ac477b49b96ddfc2ce667e3556dec727569 +README.zh.md: 922b153aa1b08e1a6003f63736ea402787bff1dd diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index d3c2c14fe6..6f0a9ac477 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -14,6 +14,8 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, direc While active, `plan:policy` renders the configured `section`. The plugin always registers `exit_plan_mode`, keeping tool schemas stable across the transition; its execute path accepts only active plan mode and leaves it only after an exact user approval through `ctx.userInteraction`. +The review question declares the `plan-review` presentation intent, naming `Approve` as the label that approves it, so a capable UI presents the plan as a decision instead of a generic question; the answer the tool reads is the same either way. A dismissed review — the user closing the request to speak instead — is reported to the model as such, telling it to stay in plan mode and wait for the message; every other review failure keeps the seam's own message. + When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request. The TUI consumes the plugin-owned `/plan` command; other front doors may drive the same service directly without defining a second mode vocabulary. @@ -77,7 +79,7 @@ The user block is append-only conversation growth. Entering or leaving plan mode #### What the model sees -The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the canonical `{ approved: true }` value and renders the existing confirmation text. Rejection remains a failed call carrying review feedback. +The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the canonical `{ approved: true }` value and renders the existing confirmation text. Rejection remains a failed call carrying review feedback, and a dismissed review a failed call naming the user's takeover. #### Token effect @@ -92,4 +94,5 @@ Mode transitions do not change the tool catalog; plan arguments and review resul - Plan mode guides rather than enforces; deployments needing a hard boundary must combine independent sandbox and approval controls. - A pending selection made while idle is lost if the process exits before the next boundary, so the UI must reapply it. - Forked agents inherit logged plan state, while newly spawned agents begin inactive; there is no creation-time plan option. -- The `exit_plan_mode` review arc (submit → human review → approved flip or rejected feedback) is covered by package tests only; its assembled-application snapshot left with the retired ACP UI scenarios ([automation-only ACP](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)) and the TUI keyless scenarios exercise only `/plan` entry and `/plan off` exit. +- The `exit_plan_mode` review arc has one assembled-application snapshot, the Web `plan-review` e2e lane (submit → decision card → approved flip). The rejected-feedback and dismissed branches are covered by package tests only, and the TUI keyless scenarios exercise only `/plan` entry and `/plan off` exit. +- Only the Web UI renders the `plan-review` intent; the TUI presents the review through its generic question flow, which is answerable but does not read as a plan gate. diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 6d6878c4b0..922b153aa1 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -14,6 +14,8 @@ 激活时,`plan:policy` 会渲染已配置的 `section`。插件始终注册 `exit_plan_mode`,使工具 schema 在转换期间保持稳定;其 execute 路径只接受已激活的 plan mode,且只有通过 `ctx.userInteraction` 获得精确用户批准后才退出。 +评审问题声明 `plan-review` 呈现意图,并指名 `Approve` 为表示批准的标签,因此有能力的 UI 会把计划呈现为一次决定而非通用问题;两种情况下该工具读到的回答完全相同。放弃审阅 —— 用户关掉请求改用说话 —— 会如实报告给模型,要求它留在 plan mode 中等待那条消息;其余每一种评审失败都保留 seam 自身的消息。 + 组合 `ctx.commands` 时,该包(package)会注册 `/plan [message]`,并保留精确参数 `off` 用于直接退出。不带参数的 `/plan` 选择 plan mode;任何其他非空参数都会先选择 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 选择未激活状态,不发送模型输入;它还可以在 plan mode 进入选择到达请求之前取消该待生效选择。 TUI 消费插件拥有的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。 @@ -77,7 +79,7 @@ You are in plan mode. Explore and design before presenting the complete plan thr #### 模型所见内容 -[`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在 plan mode 外执行会失败,而 plan mode 内经批准的评审会返回规范 `{ approved: true }` 值,并渲染现有确认文本。拒绝仍是携带评审反馈的失败调用。 +[`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在 plan mode 外执行会失败,而 plan mode 内经批准的评审会返回规范 `{ approved: true }` 值,并渲染现有确认文本。拒绝仍是携带评审反馈的失败调用,放弃审阅则是一次指明用户接手的失败调用。 #### Token 影响 @@ -92,4 +94,5 @@ Mode 转换不改变工具目录;plan 参数与评审结果按常规方式扩 - Plan mode 只进行引导,而不强制执行;需要硬边界的部署必须组合独立的沙箱与批准控制。 - 如果进程在下一个边界之前退出,空闲时作出的待生效选择会丢失,因此 UI 必须重新应用它。 - Fork 的 agent 会继承已记录的 plan 状态,新 spawn 的 agent 则从未激活状态开始;不存在创建时 plan 选项。 -- `exit_plan_mode` 评审弧(提交 → 人类评审 → 已批准切换或已拒绝反馈)仅由包测试覆盖;其组装应用快照随已退役 ACP UI 场景一起离开([仅面向自动化的 ACP](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)),TUI 无密钥场景只演练 `/plan` 进入和 `/plan off` 退出。 +- `exit_plan_mode` 评审弧有一个组装应用快照,即 Web `plan-review` e2e 通道(提交 → 决定卡片 → 已批准切换)。已拒绝反馈与放弃审阅两个分支仅由包测试覆盖,TUI 无密钥场景只演练 `/plan` 进入和 `/plan off` 退出。 +- 只有 Web UI 渲染 `plan-review` 意图;TUI 通过其通用问题流程呈现该评审,可以回答,但读起来不像一个计划关口。 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index dc6b788825..b2a5f6076d 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -29,7 +29,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { defineTool } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-system-prompt' -import type {} from '@deepseek-ai/dsh-user-interaction' +import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction' // Type-only edge: resolves `ctx.commands` for the optional command child. import type {} from '@deepseek-ai/dsh-commands' // Type-only: resolves ctx.sessionProjections for the optional unit child. @@ -70,6 +70,9 @@ export interface PlanModeConfig { section: string } +/** The review question's id, echoed in the answer this tool reads. */ +const REVIEW_ID = 'plan-review' + /** The review question's approve option label. */ const APPROVE_LABEL = 'Approve' @@ -317,7 +320,7 @@ export class PlanModeService extends Service { } const answer = await interaction.ask({ questions: [{ - id: 'plan-review', + id: REVIEW_ID, header: 'Plan review', question: 'Approve this plan and leave plan mode?', detail: args.plan, @@ -325,16 +328,31 @@ export class PlanModeService extends Service { { label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' }, { label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' }, ], + // Presentation only: a capable UI renders the plan as a review + // decision instead of a generic question, and answers with one of + // the labels above either way. + intent: { kind: 'plan-review', approve: APPROVE_LABEL }, }], agent, signal: exec.signal, + }).catch((cause: unknown) => { + // A dismissed review is not a failed one: the user took the turn back + // to say something the two options do not cover. Say so, because the + // generic channel message names ask_user_question, which the model + // never called. An abort (turn cancel, provider teardown) keeps its + // own message — there is no user to wait for. + if (cause instanceof UserInteractionError && cause.code === 'ASK_CANCELLED') { + throw new Error('The user dismissed the plan review to speak instead; ' + + 'stay in plan mode, stop here, and wait for their message.') + } + throw cause }) // A review may outlive this plugin fiber. Without boundary listeners, // an approved result could never land, so fail and keep planning. if (disposed) { throw new Error('the plan-mode service was reloaded while the plan was under review; present the plan again') } - const reviewItems = answer.answers.filter(entry => entry.id === 'plan-review') + const reviewItems = answer.answers.filter(entry => entry.id === REVIEW_ID) const item = reviewItems.length === 1 ? reviewItems[0] : undefined if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== undefined) { const feedback = item?.custom ?? '' diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 72d273d20f..6fa165cbb0 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -6,7 +6,9 @@ import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek import { Session, SessionId } from '@deepseek-ai/dsh-session' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' -import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction' +import UserInteractionService, { + UserInteractionError, type AskUserQuestionRequest, +} from '@deepseek-ai/dsh-user-interaction' import CommandService from '@deepseek-ai/dsh-commands' import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime' import PlanModeService, { EXIT_PLAN_MODE, foldPlanMode, resolveConfig } from '../src/index.ts' @@ -894,6 +896,40 @@ describe('exit_plan_mode', () => { expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }]) }) + it('declares the plan-review presentation intent naming its approve option', async () => { + const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] }) + await callExit(ctx, agent) + const question = asked[0]?.questions[0] + expect(question?.intent).toEqual({ kind: 'plan-review', approve: 'Approve' }) + // The named label is one this same question offers, so a UI honouring the + // intent answers a choice this tool accepts. + expect(question?.options?.map(option => option.label)).toContain(question?.intent?.approve) + }) + + it('reads a dismissed review as the user taking the turn back, not as a failure', async () => { + const { ctx, agent } = await setupWithReview() + ctx.userInteraction.registerProvider({ + ask: () => Promise.reject(new UserInteractionError( + 'the user cancelled ask_user_question', 'ASK_CANCELLED')), + }) + const result = await callExit(ctx, agent) + expect(result.isError).toBe(true) + expect(result.content).toEqual([{ type: 'text', text: 'Error: The user dismissed the plan review to speak instead; stay in plan mode, stop here, and wait for their message.' }]) + expect(foldPlanMode(agent.session.events)).toBe(true) + }) + + it('leaves every other review failure its own message', async () => { + const { ctx, agent } = await setupWithReview() + ctx.userInteraction.registerProvider({ + ask: () => Promise.reject(new UserInteractionError( + 'ask_user_question was aborted before the user answered', 'ASK_ABORTED')), + }) + const result = await callExit(ctx, agent) + expect(result.isError).toBe(true) + expect(result.content).toEqual([{ type: 'text', text: 'Error: ask_user_question was aborted before the user answered' }]) + expect(foldPlanMode(agent.session.events)).toBe(true) + }) + it('forwards the execution abort signal to the review question', async () => { const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] }) const controller = new AbortController() diff --git a/packages/ui/user-interaction/README.i18n.yaml b/packages/ui/user-interaction/README.i18n.yaml index 98e40aa0d3..96973400df 100644 --- a/packages/ui/user-interaction/README.i18n.yaml +++ b/packages/ui/user-interaction/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/user-interaction/README.md -README.md: d234d6677bdd772f1bbd2c979c0d41f90aef5c32 -README.zh.md: c89210b6955a661313ca9e0e82e43da5a4d1db79 +README.md: f134e912149190efb81ea9f64c5d63632ce8c626 +README.zh.md: ab6432dcbddc36d3ef9ca957b3901cb76257539a diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index d234d6677b..f134e91214 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -13,14 +13,19 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod ### Key Types -- `AskUserQuestionRequest` — `{ questions: [{ id, question, detail?, header?, options?, multiSelect? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label. +- `AskUserQuestionRequest` — `{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label. - `AskUserQuestionOption` — `{ label, description? }`. +- `AskUserQuestionIntent` — `{ kind: 'plan-review', approve }`; the tagged presentation intent below. - `AskUserQuestionAnswer` — `{ answers: [{ id, selected, custom? }] }`. - `UserInteractionProvider` — UI implementation with `ask(request)`. -- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`. +- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `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. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch. +### Presentation intent + +`intent` declares that a question IS a decision of a known shape, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent shapes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read one answer shape either way. `approve` names the label that approves rather than relying on option order; `ask()` rejects an intent whose `approve` names none of that question's own options with `BAD_INTENT`, since no type can tie the two together. + ## Role This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; `dsh-tui` and the host runtime provide interactive implementations. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop. diff --git a/packages/ui/user-interaction/README.zh.md b/packages/ui/user-interaction/README.zh.md index c89210b695..ab6432dcbd 100644 --- a/packages/ui/user-interaction/README.zh.md +++ b/packages/ui/user-interaction/README.zh.md @@ -13,14 +13,19 @@ ### 关键类型 -- `AskUserQuestionRequest`:`{ questions: [{ id, question, detail?, header?, options?, multiSelect? }], agent?, signal? }`;`detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。 +- `AskUserQuestionRequest`:`{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`;`detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。 - `AskUserQuestionOption`:`{ label, description? }`。 +- `AskUserQuestionIntent`:`{ kind: 'plan-review', approve }`;即下文的带标签呈现意图。 - `AskUserQuestionAnswer`:`{ answers: [{ id, selected, custom? }] }`。 - `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。 -- `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`NO_PROVIDER`、`DUPLICATE_PROVIDER` 和 `ASK_ABORTED` 等代码。 +- `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`DUPLICATE_PROVIDER` 和 `ASK_ABORTED` 等代码。 当回答包含 `custom` 时,`selected` 为空;自定义文本是所选选项的替代,而不是补充。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 +### 呈现意图 + +`intent` 声明某个问题本身就是一次已知形状的决定,因此认识该标签的 UI 可以照此呈现 —— `plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只塑造呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名表示批准的标签,而不依赖选项顺序;由于没有类型能把两者绑定起来,`ask()` 会以 `BAD_INTENT` 拒绝 `approve` 未命中该问题自身任一选项的意图。 + ## 职责 这是接口包(package)。`@deepseek-ai/dsh-tool-ask-user` 等面向模型的消费方依赖此 seam;`dsh-tui` 和宿主运行时提供交互式实现。循环保持不变:工具调用等待 Promise,工具结果随后恢复正常的 agent loop(智能体循环)。 diff --git a/packages/ui/user-interaction/src/index.ts b/packages/ui/user-interaction/src/index.ts index 4b5caf6a55..af0aa40e7b 100644 --- a/packages/ui/user-interaction/src/index.ts +++ b/packages/ui/user-interaction/src/index.ts @@ -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') } diff --git a/packages/ui/user-interaction/src/types.ts b/packages/ui/user-interaction/src/types.ts index ddf3e43489..15747e371c 100644 --- a/packages/ui/user-interaction/src/types.ts +++ b/packages/ui/user-interaction/src/types.ts @@ -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, 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. */ diff --git a/packages/ui/user-interaction/tests/user-interaction.spec.ts b/packages/ui/user-interaction/tests/user-interaction.spec.ts index adfbc9d4bb..e9fca39761 100644 --- a/packages/ui/user-interaction/tests/user-interaction.spec.ts +++ b/packages/ui/user-interaction/tests/user-interaction.spec.ts @@ -83,4 +83,45 @@ describe('UserInteractionService', () => { .rejects.toMatchObject({ name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' }) expect(p.ask).not.toHaveBeenCalled() }) + + it('rejects an intent whose approve label names none of its own options', async () => { + const ctx = new Context() + await ctx.plugin(UserInteractionService) + const p = { ask: vi.fn(async () => ({ answers: [] })) } + ctx.userInteraction.registerProvider(p) + const question = { id: 'plan-review', question: 'Approve?', detail: '# Plan' } + + // A wrong label among offered options, and no options offered at all. + for (const options of [[{ label: 'Approve' }], undefined]) { + await expect(ctx.userInteraction.ask({ + questions: [{ + ...question, + ...(options === undefined ? {} : { options }), + intent: { kind: 'plan-review', approve: 'Ship it' }, + }], + })).rejects.toMatchObject({ name: 'UserInteractionError', code: 'BAD_INTENT' }) + } + expect(p.ask).not.toHaveBeenCalled() + }) + + it('passes an intent through once its approve label names an offered option', async () => { + const ctx = new Context() + await ctx.plugin(UserInteractionService) + const p = provider('Approve') + ctx.userInteraction.registerProvider(p) + const intent = { kind: 'plan-review', approve: 'Approve' } as const + + const result = await ctx.userInteraction.ask({ + questions: [ + { id: 'plain', question: 'Proceed?' }, + { + id: 'plan-review', question: 'Approve?', detail: '# Plan', + options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent, + }, + ], + }) + + expect(result.answers).toEqual([{ id: 'plain', selected: ['Approve'] }]) + expect(p.seen[0]?.questions[1]?.intent).toEqual(intent) + }) }) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index d886b33df2..8a5eebdd7d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -694,6 +694,11 @@ "symbol": "AskUserQuestionOption", "source": "packages/ui/user-interaction/src/types.ts" }, + { + "doc": "docs/core-data-structures/user-interaction.md", + "symbol": "AskUserQuestionIntent", + "source": "packages/ui/user-interaction/src/types.ts" + }, { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionItem",