Plan review as a decision, not a question
计划审阅是一次决定,不是一道题
`exit_plan_mode` presents a finished plan for review through `ctx.userQuestions.ask()`, the same seam `ask_user_question` uses. On the Web GUI that made a plan review render as the generic question flow of [the ask-question Web presentation](2026-07-29-ask-question-web-presentation.md): a `1 / 1` pager, the plan as a question's supporting detail, the two verdicts as numbered radio rows with descriptions, an "Other —
English
Problem
exit_plan_mode presents a finished plan for review through ctx.userQuestions.ask(), the same seam ask_user_question uses. On the Web GUI that made a plan review render as the generic question flow of the ask-question Web presentation: a 1 / 1 pager, the plan as a question's supporting detail, the two verdicts as numbered radio rows with descriptions, an "Other — enter a custom answer" row, and Skip this question / Submit in the footer.
Every one of those affordances is wrong for the surface. Reviewing a plan is one decision over one document, and the quiz chrome told the user they were being examined rather than asked to approve work — reported as "让人很困惑以为在做题". The paging controls page a set of one. Skipping is not an outcome the tool accepts (it folds into keep-planning). Worst, the surface gave no hint that this was the plan gate at all, while the adjacent waiting-approval takeover already had exactly the right shape for a decision: a tinted strip naming what is being decided, the subject as the body, and a right-aligned action row.
Decision
A question may declare a presentation intent, and the Web composer renders a declared intent as its own surface. AskUserQuestionItem gains intent?: AskUserQuestionIntent, a tagged union whose one member is { kind: 'plan-review', approve: string }; plan-mode sets it on the review question, naming Approve as the label that approves.
An intent changes presentation only. The answer protocol is untouched: a UI honouring the intent answers with the same option labels a generic UI would send, so exit_plan_mode reads the same answer fields regardless of which surface collected them, and a UI that does not know a tag renders the generic flow with nothing lost but the layout.
approve names the affirmative option instead of relying on option order, so no UI infers a verdict from a position. Two assertions an intent makes are beyond the types, and UserQuestionService.ask() rejects both as BAD_INTENT at the asker: an approve naming none of that question's own options — before any UI can answer a choice never offered — and an intent on a question with no detail, the thing it declares itself a review of, which would ask the user to approve something invisible. On the wire the intent is a discriminated union, so an unrecognised tag is a rejected frame rather than a silently generic render.
ui-user-questions renders the intent as PlanReviewPanel, in the waiting-approval card language: the amber strip carries Plan review, the plan is the scrolling markdown body, and the decision row holds three actions — Chat about it, Refuse, Approve. The question text becomes the card's accessible name rather than a headline, because the buttons already say what the decision is. Approve and Refuse answer with the asker's own option labels and keep the asker's descriptions as tooltips; Chat about it cancels the request, which returns the composer so the user can simply say what they want. All copy is bilingual under the existing question namespace.
Routing lives inside the single composer entry (QuestionComposer chooses the presentation) rather than in a second chain registration, and planReviewOf claims a request only when the card can send every answer that request allows: one question declaring the intent, the plan as its detail, the named approve label offered, and a binary single choice — at most one option besides approve, and not multi-select. A third option or a multi-select batch has answers two buttons cannot express, so the generic flow keeps it, and keeps anything else the card cannot render. "Presentation only" is therefore literal: an intent never costs the user a reachable answer, and the client — downstream of a wire boundary — leaves every request answerable.
Dismissal became its own model-facing outcome. ASK_CANCELLED previously reached the model as "the user cancelled ask_user_question", naming a tool it never called; exit_plan_mode now reports that the user dismissed the review to speak instead and to stay in plan mode and wait. Every other ask failure — an abort from turn cancel or provider teardown, where no user is coming — keeps its own message.
Alternatives considered
Make plan review its own pending kind (plan-review/requested). Rejected as the wrong size for a presentation problem. It buys an honest response shape (approve / decline / discuss instead of an answer batch) at the cost of a third PendingKind, new requested/resolved frames and schemas, an api-proxy registry and respond branch, client session and baseline-replay handling, and a new three-package capability seam for a decision the question protocol already expresses. Worth revisiting only if plan review grows outcomes the answer shape cannot carry.
Route the card on the question's id or header (plan-review / Plan review). Rejected: string-sniffing a foreign package's copy across a wire boundary, which any wording change silently breaks. The intent is the declaration that makes the routing legible.
Order the options and let the card read position 0 as approve. Rejected: a positional contract at a package boundary, invisible in both the type and the wire frame, and unenforceable — a producer that reorders its options would invert a user's verdict. Naming the label costs one string.
Register a second composer-chain entry for the plan card. Rejected: two entries would select over the same pending question carrier, making the surface depend on chain priority and on whether the plan package's client half is composed at all. One entry that picks its own shape cannot race itself, and the generic flow is the built-in fallback.
Put the panel in ui-plan beside the plan chip. Rejected: the panel's whole behavior is the question carrier's answer encoding (PendingQuestion), which ui-user-questions owns; the intent is a question-protocol field, not plan-mode's private channel. Rendering declared intents belongs to the package that owns question rendering, as tool render intents belong to the tool renderer.
Extract a shared takeover card with ui-conversation's ApprovalPanel. Not done: the two takeovers agree on tokens and geometry but not on content — this body is scrolling markdown, that one a headline plus a command line — and the shared shell would be two elements wide. They are kept in step by token, not by component.
Give Chat about it its own protocol outcome. Rejected: dismissing a request is a verb the generic flow already has (the × that cancels the batch). Promoting it to a labelled button is presentation; inventing a fourth wire outcome for it is not.
Consequences
The question protocol now carries a presentation axis. Adding a second intent is a tag on the union, a producer that sets it, a schema member, and a panel — no new frame, service, or answer shape. The cost is that the question contract knows presentation exists at all, and that ui-user-questions knows the word "plan"; both are the price of one entry owning every question surface.
The plan gate reads as a plan gate: the plan is the card's content, the verdict is two labelled buttons, and taking the turn back is a third. The generic flow is untouched for every other question, and its committed goldens did not move.
A deployment whose client half predates this change still shows the quiz layout — correct, answerable, and merely unstyled — because the intent is additive and the fallback is the generic flow.
Testing
ui-user-questions tests pin the narrowing (single-question batch, intent present, plan as detail, named approve label offered, binary single choice, decline absent when only approve is offered) and the panel (strip, markdown plan, accessible name, absence of pager/radio/skip/custom, approve and decline answering with the asker's labels, dismissal cancelling, one-shot latch with re-arm and message on a rejected receipt, tooltips present and absent, both locales). user-questions tests pin both BAD_INTENT rejections and intent pass-through; plan-mode tests pin the declared intent against its own option list and both failure messages; the apiproxy schema test pins wire acceptance and an unknown tag's rejection.
The plan-review Web e2e lane records /plan entering plan mode for real, the model calling exit_plan_mode, the decision card taking the composer (asserting the generic flow did not claim the request), and the card's own Approve completing the turn — two keyless goldens, the waiting card and the approved transcript.
中文
问题
exit_plan_mode 通过 ctx.userQuestions.ask() 把写好的计划交给用户审阅,而这正是 ask_user_question 使用的同一个 seam。在 Web GUI 上,这导致计划审阅渲染为ask-question Web 呈现里的通用问题流程:一个 1 / 1 分页器、计划作为问题的补充说明、两个裁决作为带描述的编号单选行、一行「其他,请填写自定义答案」,以及底部的 跳过本题 / 提交。
这些可交互元素对这个界面而言无一正确。审阅一份计划是对一份文档做一次决定,而做题式的界面告诉用户他正在被考试,而不是被请求批准一份工作 —— 实际反馈是「让人很困惑以为在做题」。分页控件在给只有一项的集合分页。跳过并不是该工具接受的结果(它会折叠成继续规划)。最糟的是,这个界面完全没有暗示这就是计划关口,而旁边的等待审批接管早就具备了一次决定该有的形状:一条带色条带说明正在决定什么、主体是决定的对象、右对齐的操作行。
决策
一个问题可以声明呈现意图(presentation intent),Web 输入区把已声明的意图渲染为它自己的界面。AskUserQuestionItem 新增 intent?: AskUserQuestionIntent,这是一个带标签的联合,目前唯一成员是 { kind: 'plan-review', approve: string };plan-mode 在审阅问题上设置它,并指明 Approve 是表示批准的标签。
意图只改变呈现。回答协议不变:遵循意图的 UI 回答的仍是通用 UI 会发送的那些选项标签,因此无论由哪个界面收集,exit_plan_mode 读到的都是同一组回答字段;而不认识某个标签的 UI 渲染通用流程,除布局之外一无所失。
approve 指名肯定选项,而不依赖选项顺序,因此没有任何 UI 会从位置推断裁决。意图作出的两项断言超出类型的表达能力,UserQuestionService.ask() 都以 BAD_INTENT 在提问方一侧拒绝:approve 未命中该问题自身的任一选项 —— 早于任何 UI 回答一个从未被提供过的选择;以及意图落在没有 detail 的问题上,而 detail 正是它自称在审阅的东西,那会让用户去批准一件看不见的事。在协议格式(wire format)上意图是可辨识联合,因此无法识别的标签是被拒绝的帧,而不是静默退回通用渲染。
ui-user-questions 把该意图渲染为 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 失败 —— 轮次取消或提供方 teardown导致的中止,那里并没有用户会来 —— 保留它们自己的消息。
备选方案
让计划审阅成为自己的待处理种类(plan-review/requested)。 否决:对一个呈现问题来说尺寸不对。它换来的是诚实的响应形状(approve / decline / discuss 而非一批回答),代价是第三个 PendingKind、新的 requested/resolved 帧与 schema、一个 api-proxy 注册表与响应分支、客户端会话与基线重放处理,以及为一个问题协议已能表达的决定新增一个三包能力 seam。只有当计划审阅长出回答形状承载不了的结果时才值得重新考虑。
按问题的 id 或 header(plan-review / Plan review)路由卡片。 否决:这是跨协议边界嗅探另一个包的文案字符串,任何措辞改动都会静默破坏它。意图才是让路由可读的那个声明。
约定选项顺序,让卡片把第 0 个位置读作批准。 否决:这是包边界上的位置约定,在类型和协议帧里都看不见,也无法强制 —— 生产方一旦重排选项,就会颠倒用户的裁决。指名标签只花一个字符串。
为计划卡片注册第二个输入区链条目。 否决:两个条目会对同一个待回答问题载体做选择,使界面取决于链优先级、以及计划包的客户端半边是否被组合。一个自己挑形状的条目不会和自己抢,而通用流程正是内建的回退。
把面板放在 ui-plan 里、紧挨计划状态标签。 否决:面板的全部行为就是问题载体的回答编码(PendingQuestion),那是 ui-user-questions 拥有的;意图是问题协议的字段,不是 plan-mode 的私有通道。渲染已声明的意图属于拥有问题渲染的那个包,正如工具渲染意图属于工具渲染方。
与 ui-conversation 的 ApprovalPanel 抽出共享的接管卡片。 未做:两个接管在 token 和几何上一致,但内容不一致 —— 这边的主体是可滚动 markdown,那边是一行标题加一行命令 —— 共享外壳只会剩两个元素宽。它们靠 token 保持一致,而不是靠组件。
给 Chat about it 自己的协议结果。 否决:放弃一个请求是通用流程已有的动词(取消整批的 ×)。把它提升为带标签的按钮属于呈现;为它发明第四种协议结果不属于。
结果
问题协议从此带有一个呈现轴。新增第二个意图 = 联合上的一个标签、一个设置它的生产方、一个 schema 成员、一个面板 —— 不需要新的帧、服务或回答形状。代价是问题约定从此知道「呈现」这件事存在,且 ui-user-questions 知道「plan」这个词;两者都是由单一条目拥有全部问题界面所要付的价钱。
计划关口读起来就像计划关口:计划是卡片的内容,裁决是两个带标签的按钮,把轮次拿回来是第三个。通用流程对其他每个问题都未受影响,其已提交的 golden 也没有变动。
客户端半边早于本次改动的部署仍然显示做题式布局 —— 正确、可回答、只是没有专门样式 —— 因为意图是增量的,而回退就是通用流程。
测试
ui-user-questions 测试钉住收窄(单问题批、意图存在、计划作为 detail、被指名的批准标签确实被提供、二元单选、只提供批准时 decline 缺席)与面板(条带、markdown 计划、无障碍名称、不显示分页器、单选行、跳过项和自定义项、批准与拒绝用提问方的标签回答、放弃触发取消、一次性闭锁,以及在回执被拒时重新武装并给出消息、tooltip 有与无、两种语言)。user-questions 测试钉住两种 BAD_INTENT 拒绝与意图透传;plan-mode 测试钉住已声明的意图与其自身选项列表的一致、以及两条失败消息;apiproxy schema 测试钉住协议接受与未知标签的拒绝。
plan-review Web e2e 通道录制了 /plan 真实进入 plan mode、模型调用 exit_plan_mode、决定卡片接管输入区(并断言通用流程没有接管该请求)、以及卡片自身的 Approve 完成该轮 —— 两份无密钥 golden:等待中的卡片与批准后的 transcript(文本记录)。