# Agent Note: 计划审阅是一次决定，不是一道题

Status: implemented

[English](2026-07-30-plan-review-presentation-intent.md) | 中文

## 问题

`exit_plan_mode` 通过 `ctx.userInteraction.ask()` 把写好的计划交给用户审阅，而这正是 `ask_user_question` 使用的同一个 seam。在 Web GUI 上，这导致计划审阅渲染为[ask-question Web 呈现](2026-07-29-ask-question-web-presentation.md)里的通用问题流程：一个 `1 / 1` 分页器、计划作为问题的补充说明、两个裁决作为带描述的编号单选行、一行"其他，请填写自定义答案"，以及底部的 `跳过本题` / `提交`。

这些可交互元素对这个界面而言无一正确。审阅一份计划是对一份文档做一次决定，而做题式的界面告诉用户他正在被考试，而不是被请求批准一份工作 —— 实际反馈是"让人很困惑以为在做题"。分页控件在给只有一项的集合分页。跳过并不是该工具接受的结果（它会折叠成继续规划）。最糟的是，这个界面完全没有暗示这就是计划关口，而旁边的等待审批接管早就具备了一次决定该有的形状：一条带色条带说明正在决定什么、主体是决定的对象、右对齐的操作行。

## 决定

一个问题可以声明**呈现意图（presentation intent）**，Web 输入区把已声明的意图渲染为它自己的界面。`AskUserQuestionItem` 新增 `intent?: AskUserQuestionIntent`，这是一个带标签的联合，目前唯一成员是 `{ kind: 'plan-review', approve: string }`；`plan-mode` 在审阅问题上设置它，并指明 `Approve` 是表示批准的标签。

意图只改变呈现。回答协议不变：遵循意图的 UI 回答的仍是通用 UI 会发送的那些选项标签，因此无论由哪个界面收集，`exit_plan_mode` 读到的都是同一组回答字段；而不认识某个标签的 UI 渲染通用流程，除布局之外一无所失。

`approve` 指名肯定选项，而不依赖选项顺序，因此没有任何 UI 会从位置推断裁决。意图作出的两项断言超出类型的表达能力，`UserInteractionService.ask()` 都以 `BAD_INTENT` 在提问方一侧拒绝：`approve` 未命中该问题自身的任一选项 —— 早于任何 UI 回答一个从未被提供过的选择；以及意图落在没有 `detail` 的问题上，而 `detail` 正是它自称在审阅的东西，那会让用户去批准一件看不见的事。在协议格式（wire format）上意图是可辨识联合，因此无法识别的标签是被拒绝的帧，而不是静默退回通用渲染。

`ui-question` 把该意图渲染为 `PlanReviewPanel`，沿用等待审批卡片的语言：琥珀色条带写着 `Plan review`，计划是可滚动的 markdown 主体，决定行放三个操作 —— `Chat about it`、`Refuse`、`Approve`。问题文本成为卡片的无障碍名称而非标题，因为按钮已经说明了这次决定是什么。Approve 与 Refuse 用提问方自己的选项标签回答，并把提问方的描述保留为 tooltip；`Chat about it` 取消该请求，从而让输入区归位，用户直接说他想说的话即可。所有文案在既有 `question` 命名空间下双语。

路由住在单一输入区条目内部（由 `QuestionComposer` 选择呈现），而不是第二个链式注册；`planReviewOf` 仅在卡片能够发出该请求允许的每一个答案时才接管：只有一个问题且声明了意图、以 `detail` 承载计划、提供了被指名的批准标签，且是二元单选 —— 除批准外最多一个选项，且非多选。出现第三个选项或多选批次时，其答案是两个按钮无法表达的，通用流程保留它，也保留其他任何卡片渲染不了的请求。因此"只改变呈现"是字面意义上的：意图绝不让用户失去一个可达的答案，而位于协议边界下游的客户端让每个请求都保持可回答。

放弃审阅成为面向模型的独立结果。`ASK_CANCELLED` 以前传到模型的是"the user cancelled ask_user_question"，指名了一个它从未调用的工具；现在 `exit_plan_mode` 报告用户放弃审阅是为了改用说话，并要求留在 plan mode 中等待。其余每一种 ask 失败 —— 轮次取消或提供方拆卸导致的中止，那里并没有用户会来 —— 保留它们自己的消息。

## 备选方案

**让计划审阅成为自己的待处理种类（`plan-review/requested`）。** 否决：对一个呈现问题来说尺寸不对。它换来的是诚实的响应形状（approve / decline / discuss 而非一批回答），代价是第三个 `PendingKind`、新的 requested/resolved 帧与 schema、一个 api-proxy 注册表与响应分支、客户端会话与基线重放处理，以及为一个问题协议已能表达的决定新增一个三包能力 seam。只有当计划审阅长出回答形状承载不了的结果时才值得重新考虑。

**按问题的 `id` 或 `header`（`plan-review` / `Plan review`）路由卡片。** 否决：这是跨协议边界嗅探另一个包的文案字符串，任何措辞改动都会静默破坏它。意图才是让路由可读的那个声明。

**约定选项顺序，让卡片把第 0 个位置读作批准。** 否决：这是包边界上的位置约定，在类型和协议帧里都看不见，也无法强制 —— 生产方一旦重排选项，就会颠倒用户的裁决。指名标签只花一个字符串。

**为计划卡片注册第二个输入区链条目。** 否决：两个条目会对同一个待回答问题载体做选择，使界面取决于链优先级、以及计划包的客户端半边是否被组合。一个自己挑形状的条目不会和自己抢，而通用流程正是内建的回退。

**把面板放在 `ui-plan` 里、紧挨计划状态标签。** 否决：面板的全部行为就是问题载体的回答编码（`PendingQuestion`），那是 `ui-question` 拥有的；意图是问题协议的字段，不是 plan-mode 的私有通道。渲染已声明的意图属于拥有问题渲染的那个包，正如工具渲染意图属于工具渲染方。

**与 `ui-conversation` 的 `ApprovalPanel` 抽出共享的接管卡片。** 未做：两个接管在 token 和几何上一致，但内容不一致 —— 这边的主体是可滚动 markdown，那边是一行标题加一行命令 —— 共享外壳只会剩两个元素宽。它们靠 token 保持一致，而不是靠组件。

**给 `Chat about it` 自己的协议结果。** 否决：放弃一个请求是通用流程已有的动词（取消整批的 `×`）。把它提升为带标签的按钮属于呈现；为它发明第四种协议结果不属于。

## 结果

问题协议从此带有一个呈现轴。新增第二个意图 = 联合上的一个标签、一个设置它的生产方、一个 schema 成员、一个面板 —— 不需要新的帧、服务或回答形状。代价是问题约定从此知道"呈现"这件事存在，且 `ui-question` 知道"plan"这个词；两者都是由单一条目拥有全部问题界面所要付的价钱。

计划关口读起来就像计划关口：计划是卡片的内容，裁决是两个带标签的按钮，把轮次拿回来是第三个。通用流程对其他每个问题都未受影响，其已提交的 golden 也没有变动。

客户端半边早于本次改动的部署仍然显示做题式布局 —— 正确、可回答、只是没有专门样式 —— 因为意图是增量的，而回退就是通用流程。

## 测试

`ui-question` 测试钉住收窄（单问题批、意图存在、计划作为 detail、被指名的批准标签确实被提供、二元单选、只提供批准时 decline 缺席）与面板（条带、markdown 计划、无障碍名称、无分页/单选/跳过/自定义、批准与拒绝用提问方的标签回答、放弃触发取消、一次性闭锁在回执被拒时重新武装并给出消息、tooltip 有与无、两种语言）。`user-interaction` 测试钉住两种 `BAD_INTENT` 拒绝与意图透传；`plan-mode` 测试钉住已声明的意图与其自身选项列表的一致、以及两条失败消息；apiproxy schema 测试钉住协议接受与未知标签的拒绝。

`plan-review` Web e2e 通道录制了 `/plan` 真实进入 plan mode、模型调用 `exit_plan_mode`、决定卡片接管输入区（并断言通用流程**没有**接管该请求）、以及卡片自身的 Approve 完成该轮 —— 两份无密钥 golden：等待中的卡片与批准后的会话记录。
