From 2ecc89a1867e17383f93f5f218ddf3e4ac5616ed Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:28:43 +0800 Subject: [PATCH] chore(web): drop plan RPC residue and stale carriers after merge Sweep the silently auto-merged leftovers of the superseded #587 wire layer: the PlanModeState interface and planMode/setPlanMode entries in the sessions API and rpc map, the fixture plan RPC spec, the composer.controls README paragraph and spec assertions, the old-format plan-mode snapshot, and the two Agent Notes written against the RPC design (to be rewritten for the projection reattachment). ChatView/PendingCard keep the approval-only pending rendering; QuestionComposer improvements ride along unchanged. --- ...6-07-24-web-plan-mode-projection.i18n.yaml | 6 -- .../2026-07-24-web-plan-mode-projection.md | 54 ---------------- .../2026-07-24-web-plan-mode-projection.zh.md | 54 ---------------- .../2026-07-24-web-plan-mode.i18n.yaml | 6 -- .../feature/2026-07-24-web-plan-mode.md | 61 ------------------- .../feature/2026-07-24-web-plan-mode.zh.md | 61 ------------------- apps/web/tests/snapshots/plan-mode.json | 44 ------------- .../client/connection/tests/fixture.spec.ts | 53 ---------------- packages/client/ui-conversation/README.md | 4 -- .../ui-conversation/tests/chat-apply.spec.tsx | 2 - packages/host/apiproxy/README.md | 2 - packages/host/apiproxy/src/api/rpc-map.ts | 2 - packages/host/apiproxy/src/api/sessions.ts | 37 +---------- .../apiproxy/tests/client-handler.spec.ts | 2 - 14 files changed, 2 insertions(+), 386 deletions(-) delete mode 100644 .agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.i18n.yaml delete mode 100644 .agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.md delete mode 100644 .agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.zh.md delete mode 100644 .agents/notes/implemented/feature/2026-07-24-web-plan-mode.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-24-web-plan-mode.md delete mode 100644 .agents/notes/implemented/feature/2026-07-24-web-plan-mode.zh.md delete mode 100644 apps/web/tests/snapshots/plan-mode.json diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.i18n.yaml deleted file mode 100644 index 874b70bb9c..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 -2026-07-24-web-plan-mode-projection.md: 57bf397fd6b8b3fcefc04ba3faebbc6d8648bf5a -2026-07-24-web-plan-mode-projection.zh.md: 9d868c8494abe988955d328e5482fefff9c6ceec diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.md b/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.md deleted file mode 100644 index 57bf397fd6..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.md +++ /dev/null @@ -1,54 +0,0 @@ -# Agent Note: Project plan mode through the web host boundary - -Status: implemented - -English | [中文](2026-07-24-web-plan-mode-projection.zh.md) - -## Problem - -The plan service owned durable state and boundary timing, but the web host contract had no way to discover or select it. A browser could inspect the tail history page for `plan/mode`, yet that page may omit the latest relevant event after pagination or hold no event for the empty-log inactive state. It also cannot reveal a selection waiting for the next model-request boundary. A client-only toggle would therefore drift from resumed sessions, exit-tool transitions, and selections made by another surface. - -The host does not mount plan mode for every product composition. The wire must distinguish an unavailable capability from a supported session whose committed state is inactive. Switching mode is also independent of cancelling an in-progress request: the existing service intentionally applies the latest selection at the next boundary. - -## Decision - -The session RPC domain exposes `session.planMode({ sessionId })` and `session.setPlanMode({ sessionId, active })`. Their shared value is `null | { active: boolean, pending?: boolean }`. `null` means the optional `ctx.planMode` service is absent; `{ active: false }` means the service is available and inactive. Both methods resume a cold session through the same host-owned path as history and prompt before reading or changing state. `session.prompt` additionally accepts an optional `planMode` target, letting a client bind the selected target to the prompt it submits. - -The host adapter delegates selection and folding to `ctx.planMode`; it does not append events or duplicate boundary logic. `active` is the last committed logged value. When present, `pending` is the selected target value awaiting a model-request boundary and differs from `active`; its presence, rather than its boolean value, identifies a user-visible pending transition. Re-selecting the committed value can leave an internal cleanup intent in the service, but the adapter canonicalizes that net-zero state to `{ active }`. The boundary then removes the intent without logging a redundant state event. The wire schema rejects equal `active` and `pending` values. The RPC does not cancel a running request, so a selection made during generation leaves that request unchanged and shapes the next one. - -The browser session object queries the complete state after history opens and on reconnect. A failed plan query is fail-soft: history remains usable and the last known capability state is retained. A reconnect generation fence prevents a superseded open from overwriting the newer result. One monotonic plan-request fence covers both queries and selections, so an older unary response cannot replace the result of a newer request. A separate local event-version fence prevents a current query or selection response from overwriting a `plan/mode` commit that overtook it on the mux stream; an early commit remains private until a successful query confirms capability presence. Successful selections otherwise update the snapshot only from the host-confirmed response, while business and transport failures leave the prior state intact. - -Prompt admission waits for the latest selector request and follows any newer overlapping selection that supersedes the one it was awaiting. The latest outcome is retained after settlement so an already-completed failure cannot be missed. That failure rejects the prompt locally, letting the composer restore its draft instead of sending under an uncertain mode. After selection succeeds, the browser attaches the confirmed `pending ?? active` target to `session.prompt`. The host applies that target immediately before the synchronous `send` or `steer` admission, with no await where another request could interleave; unavailable plan capability fails closed, and a synchronous admission rejection restores the preceding target. Model generation begins only after admission and remains independently cancellable. - -Committed `plan/mode` session events remain the live notification. When the host advertised the capability, a valid event replaces `active` and clears `pending`. Both the append path and a history replacement window observe the newest valid plan event by sequence, so gap repair applies a recovered commit even when the buffered triggering frame becomes replay overlap. The object layer ignores malformed events and does not infer capability from a raw event alone. This keeps full-state reads authoritative while preserving the existing logged event stream as the commit signal. - -## State and timing - -| Starting state | Selection | Immediate RPC state | Next request boundary | -|---|---|---|---| -| Inactive | Plan | `{ active: false, pending: true }` | Logs `plan/mode: true`; snapshot becomes active | -| Active | Default | `{ active: true, pending: false }` | Logs `plan/mode: false`; snapshot becomes inactive | -| Inactive with pending Plan | Default | `{ active: false }` | No state event is needed | -| Capability absent | Either | `null` | No plan behavior is introduced | - -Stopping generation remains a separate session operation. A pending selection survives cancellation and applies when the next prompt or continuation reaches the service boundary. - -## Alternatives considered - -**Fold only the currently loaded history page.** Rejected because message-boundary pagination can omit the latest mode event, and a page cannot represent pending intent or distinguish empty-log inactive from capability absence. - -**Keep an optimistic browser boolean.** Rejected because tool-approved exit, another client, resume, and append failure can all disagree with the speculative value. The browser displays only the state returned by the owner. - -**Add a dedicated plan control frame.** Rejected because committed state already has the logged `plan/mode` event. A full-state unary query covers open and reconnect without adding a second live event vocabulary. - -**Expose generic named collaboration modes.** Rejected by the plan-specific state decision: ACP may keep a generic adapter vocabulary, but the product currently owns one concrete boolean domain. - -## Verification - -- API schemas reject invalid request and state shapes, including equal committed and pending values, and both fetch directions dispatch the two methods. -- Host runtime tests cover capability absence, real-service pending state, canonical net-zero cancellation, cold-session errors, atomic prompt admission, admission rollback, and shared RPC semantics. -- Client object tests cover open, selection success, prompt waiting and failure containment, overlapping selection response order, business and transport failure, committed live events, gap-repaired commits, malformed and unavailable events, fail-soft queries, reconnect refresh, superseded-query fencing, and mux commits overtaking unary responses. - -## Consequences - -Web UI packages can discover plan mode without importing its host implementation, display boundary-pending state without duplicating the plan service, and bind one submitted prompt to the selected target. Other clients may use the same optional projection. The contract deliberately does not combine switching with stop, invent generic mode identifiers, or make plan capability mandatory for every host composition. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.zh.md deleted file mode 100644 index 9d868c8494..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.zh.md +++ /dev/null @@ -1,54 +0,0 @@ -# Agent Note: 通过 Web 宿主边界投影 plan mode - -Status: implemented - -[English](2026-07-24-web-plan-mode-projection.md) | 中文 - -## 问题 - -plan 服务拥有持久状态和边界时序,但 Web 宿主契约无法发现或选择 plan mode。浏览器可以检查尾部历史页中的 `plan/mode`,但分页后,该页面可能遗漏最近的相关事件;在空日志、未激活状态下,也可能完全没有对应事件。它同样无法体现一项等待下一次模型请求边界生效的选择。因此,只在客户端维护切换状态会与恢复后的会话、退出工具触发的状态转换,以及其他界面作出的选择发生偏差。 - -并非每一种产品组合都会在宿主中挂载 plan mode。协议必须区分不可用的功能与已支持但提交状态为未激活的会话。切换模式也与取消正在执行的请求互不影响:现有服务会按设计在下一个边界应用最近一次选择。 - -## 决策 - -会话 RPC 域公开 `session.planMode({ sessionId })` 和 `session.setPlanMode({ sessionId, active })`。两者返回相同的值类型:`null | { active: boolean, pending?: boolean }`。`null` 表示可选的 `ctx.planMode` 服务不存在;`{ active: false }` 表示该服务可用,但当前未激活。读取或修改状态前,这两个方法都会通过宿主用于历史记录与提示词请求的同一路径恢复冷会话。`session.prompt` 还接受可选的 `planMode` 目标,使客户端可以把所选目标绑定到其提交的提示词。 - -宿主适配器把选择与折叠工作交给 `ctx.planMode`,不会自行追加事件或重复实现边界逻辑。`active` 是最近一次已提交并记录到日志的值。`pending` 存在时,其值是等待模型请求边界生效的所选目标,且必定不同于 `active`;表示用户可见的待生效转换的是该字段是否存在,而不是其布尔值。重新选择已提交值时,服务内部可能仍留有一项清理意图,但适配器会把这一无净变化状态规范化为 `{ active }`。随后,边界会移除该意图,且不会记录多余的状态事件。协议 schema 会拒绝 `active` 与 `pending` 相等的值。该 RPC 不会取消正在执行的请求,因此生成期间作出的选择不会改变本次请求,只会影响下一次请求。 - -浏览器会话对象在历史记录加载完成后以及重连时查询完整状态。plan 查询失败不会阻断其他功能:历史记录仍可使用,并保留最近一次已知的功能状态。重连使用代际围栏,避免已被取代的打开流程覆盖较新的结果。一个单调递增的 plan 请求围栏同时覆盖查询和选择,因此较早的 unary 响应无法替换较新请求的结果。另一道独立的本地事件版本围栏会阻止当前查询或选择响应覆盖 mux 流中已抢先到达的 `plan/mode` 提交;提前到达的提交会保持为内部状态,直到查询成功并确认该功能存在。除上述情况外,选择成功后,只有宿主确认的响应才会更新快照;业务错误和传输失败都会保留先前状态。 - -提示词准入会等待最近一次选择器请求;若有更新的重叠选择取代原先等待的请求,准入也会转而跟随该选择。请求完成后仍会保留最新结果,因此不会漏掉已经完成的失败结果。该失败会在本地拒绝提示词,使输入区可以恢复草稿,而不会在模式不确定时发送。选择成功后,浏览器会把已确认的 `pending ?? active` 目标附加到 `session.prompt`。宿主会在同步 `send` 或 `steer` 准入之前立即应用该目标;期间不会执行 await,因此其他请求无法插入。plan 功能不可用时默认拒绝,同步准入遭拒时则会恢复先前目标。模型生成仅在准入完成后开始,并仍可独立取消。 - -已提交的 `plan/mode` 会话事件仍作为实时通知。当宿主已公布该功能时,有效事件会替换 `active` 并清除 `pending`。追加路径和历史替换窗口都会按序号采用最新的有效 plan 事件,因此即使缓冲的触发帧在回放时已与窗口重叠,缺口回补仍会应用恢复出的提交。对象层会忽略格式错误的事件,也不会仅凭一条原始事件推断功能是否可用。这样既以完整状态读取为真源,又保留现有的日志事件流作为提交信号。 - -## 状态与时序 - -| 起始状态 | 选择 | RPC 即时状态 | 下一请求边界 | -|---|---|---|---| -| 未激活 | Plan | `{ active: false, pending: true }` | 记录 `plan/mode: true`;快照变为已激活 | -| 已激活 | Default | `{ active: true, pending: false }` | 记录 `plan/mode: false`;快照变为未激活 | -| 未激活,Plan 待生效 | Default | `{ active: false }` | 无需记录状态事件 | -| 功能不存在 | 任一选择 | `null` | 不会引入 plan 行为 | - -停止生成仍是单独的会话操作。待生效的选择会在取消后保留,并在下一条提示词或 continuation 到达服务边界时应用。 - -## 考虑过的替代方案 - -**仅折叠当前加载的历史页。** 不予采纳,因为按消息边界分页时,页面可能遗漏最近的模式事件;单个页面也无法表达待生效的意图,或区分空日志的未激活状态与功能不存在。 - -**在浏览器中维护乐观布尔值。** 不予采纳,因为工具审批通过的退出、其他客户端、恢复以及追加失败都可能与推测值不一致。浏览器只显示归属服务返回的状态。 - -**增加专用的 plan 控制帧。** 不予采纳,因为已提交状态已有记录到日志的 `plan/mode` 事件。完整状态的单次查询足以覆盖打开与重连场景,无需增加第二套实时事件词汇。 - -**公开通用具名协作模式。** 根据 plan 专用状态决策,不予采纳。ACP 可以保留通用适配器词汇,但产品目前只拥有一个具体的布尔值领域。 - -## 验证 - -- API schema 拒绝无效的请求与状态结构,包括 `active` 与 `pending` 相等的情况;两个 fetch 方向均可分派这两个方法。 -- 宿主运行时测试覆盖功能不存在、真实服务的待生效状态、取消时无净变化状态的规范化、冷会话错误、原子化提示词准入、准入回滚,以及共享 RPC 语义。 -- 客户端对象测试覆盖打开、选择成功、提示词等待与失败隔离、重叠选择请求的响应顺序、业务错误与传输失败、已提交的实时事件、经缺口回补的提交、格式错误及功能不可用时的事件、查询失败时的容错、重连刷新、针对已被取代查询的围栏保护,以及 mux 提交抢先于 unary 响应到达的情况。 - -## 后果 - -Web UI 包(package)无需导入 plan mode 的宿主实现即可发现该功能,也能在不重复实现 plan 服务的情况下显示边界处的待生效状态,并把一次提示词提交绑定到所选目标。其他客户端也可以使用同一个可选投影。该契约有意不把模式切换与停止操作合并,不发明通用模式标识符,也不要求每一种宿主组合都必须提供 plan 功能。 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.i18n.yaml deleted file mode 100644 index c64bf50899..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 -2026-07-24-web-plan-mode.md: a2e68e4d31498da06c6dce0966f255bce2e7dc4e -2026-07-24-web-plan-mode.zh.md: 7cdf53d963e38cb76d63b89384ff7f1473b384fc diff --git a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.md b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.md deleted file mode 100644 index a2e68e4d31..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.md +++ /dev/null @@ -1,61 +0,0 @@ -# Agent Note: Compose plan mode into the Web product - -Status: implemented - -English | [中文](2026-07-24-web-plan-mode.zh.md) - -## Problem - -The Web host can project plan state through the [optional session RPC contract](../architecture/2026-07-24-web-plan-mode-projection.md), but the shipped Web product did not mount the plan service or expose a mode control. Mounting only a browser toggle would leave the model without plan guidance and `exit_plan_mode`; mounting only the host service would make the feature undiscoverable and hide state waiting for the next request boundary. - -Plan selection is not a stop command. The plan service deliberately queues the latest target for a model-request boundary so the current request remains internally consistent. The UI must show both the committed state and that pending target, including `pending: false`, without speculating past the host response. The model-driven exit also needs the existing structured plan review rather than a second Web-only approval path. - -## Decision - -`@deepseek-ai/dsh-client-ui-plan` is one Web feature plugin with lifecycle-coupled host and browser entries. Its host entry mounts `@deepseek-ai/dsh-plan-mode` with the Web product's complete planning policy. Its browser entry registers `PlanModeControl` into the session-scoped `conversation.composer.controls` list slot. The `dsh web` roster selects the package once; plugin discovery loads the browser entry while the same roster mount supplies the host behavior. - -The policy is product-owned configuration at this composition boundary. It tells the model to inspect before planning, avoid mutation while planning, resolve discoverable facts without asking, make the plan decision-complete, and submit it as the only final `exit_plan_mode` call. The plan package continues to own logged state, boundary timing, prompt-section activation, the stable exit-tool schema, and review semantics. The Web plugin does not copy any of those mechanisms. - -`ui-conversation` owns and renders the new additive controls slot to the left of the primary composer action. It provides no business payload; entries receive the standard session kit. Whole-composer replacements remain on the separate selector-routed `conversation.composer` chain, so a pending question replaces the InputBar and its controls without either feature importing the other. - -The control is absent when `planMode` is `null`. Otherwise, its selected value is `pending ?? active`, while pending presentation tests field presence rather than truthiness. It displays `计划 · 待生效` or `默认 · 待生效` until a logged commit replaces the snapshot. The transparent native select mirrors focus onto the visible chip and references a dynamic accessible description that distinguishes committed mode from the pending target. A selection disables only the selector while its own RPC is in flight. Generation does not disable it: selecting during a running turn neither calls cancel nor changes that turn, and cancelling generation does not clear the pending target. - -## Interaction semantics - -| Observed state | Control | User action | Result | -|---|---|---|---| -| `{ active: false }` | Default | Select Plan | Host confirms `{ active: false, pending: true }` | -| `{ active: false, pending: true }` | Plan, pending | Send a prompt | Boundary logs `plan/mode: true`; control becomes committed Plan | -| `{ active: true }`, running | Plan | Select Default | Turn continues; control shows Default pending | -| `{ active: true, pending: false }` | Default, pending | Stop | Pending target survives; the next prompt commits Default | -| `null` | Hidden | — | Composition exposes no unsupported control | - -Business and transport failures leave the confirmed snapshot unchanged, re-enable the selector, and render a compact visible failure beside it. The component guards asynchronous completion after unmount so switching sessions cannot update a retired control. - -Sending waits for the latest selector request and any newer selection that supersedes it. During that admission-only interval, the composer clears its draft and disables duplicate sends; it does not wait for model generation. Host acceptance releases the admission lock, after which the ordinary running state keeps Stop available. A selection or admission failure sends no prompt and restores the submitted draft only when replacement text has not appeared. - -## Exit review - -`exit_plan_mode` remains registered in both modes for request-cache stability. In plan mode the model submits the complete Markdown plan through that tool. The plan service asks through `ctx.userInteraction`, and the already-composed Web question plugin presents the plan detail with Approve, Keep planning, and the free-text answer channel. Question detail reuses the assistant-output Markdown primitive and its untrusted-content policy. The capped question card keeps its title, navigation, and submission actions fixed while the complete plan and choices share an internal scroll region. The chat flow omits its generic pending-question placeholder because the composer takeover is the sole presentation of that wait. - -Approval queues inactive mode for the next step; it does not rewrite the current tool batch. Keep planning or custom feedback leaves plan mode active and returns corrective feedback to the model. If the review channel is unavailable or aborted, the tool fails closed and the policy tells the model to ask the user to switch modes manually. - -## Product composition and evidence - -Fixture mode implements the same pending and boundary behavior in memory so browser acceptance tests exercise the assembled product without a key. The keyless browser flow selects Plan, commits it with a prompt, selects Default during generation, stops without losing the pending target, and commits Default with the next prompt. A file snapshot records each user-visible state. A real `dsh web` process with a mock provider additionally proves that the roster mounts plan mode, the state RPC reports capability, and the active Web policy reaches the provider request alongside workspace instructions. - -## Alternatives considered - -**Put the selector directly in `ui-conversation`.** Rejected because the conversation skeleton would acquire plan-domain knowledge and a host dependency. The additive slot keeps feature ownership in `ui-plan` and leaves room for independent controls. - -**Mount plan mode unconditionally in the host runtime.** Rejected because plan mode is a product composition choice. The optional RPC contract must continue to represent hosts that do not select it. - -**Optimistically flip a local boolean.** Rejected because a host append failure, another surface, resume, or tool-reviewed exit can disagree. The control displays only host-confirmed committed and pending state. - -**Disable switching while generation runs or make switching stop the turn.** Rejected because it changes the plan service's boundary contract and couples collaboration state to cancellation. The pending state exists specifically to keep those operations independent. - -**Create a Web-specific plan approval component.** Rejected because plan review is already a structured user-interaction question. Reusing the question composer preserves one review protocol and the free-text correction path. - -## Consequences - -The Web product now exposes the same plan interaction model as the current terminal and ACP compositions while retaining its plugin boundaries. Selecting the feature adds one host policy/tool owner and one browser slot entry; removing its fiber removes both. The model tool catalog stays stable across mode changes, but the active system-prompt section changes at a plan boundary and therefore changes the request prefix. Plan mode remains guidance, not an execution sandbox: deployments that require enforced read-only planning still compose the independent sandbox and approval policies. diff --git a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.zh.md deleted file mode 100644 index 7cdf53d963..0000000000 --- a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.zh.md +++ /dev/null @@ -1,61 +0,0 @@ -# Agent Note: 将 plan mode 组合进 Web 产品 - -Status: implemented - -[English](2026-07-24-web-plan-mode.md) | 中文 - -## 问题 - -Web 宿主可以通过[可选会话 RPC 契约](../architecture/2026-07-24-web-plan-mode-projection.md)投影 plan 状态,但已交付的 Web 产品既未挂载 plan 服务,也未公开模式控件。若只挂载浏览器切换控件,模型将缺少 plan 引导和 `exit_plan_mode`;若只挂载宿主服务,该功能将难以发现,也无法显示正在等待下一请求边界的状态。 - -选择 plan mode 并不是停止命令。plan 服务会有意将最新目标排到模型请求边界生效,使当前请求在内部保持一致。UI 必须同时显示已提交状态和待生效目标,包括 `pending: false`,且不能越过宿主响应进行推测。由模型发起的退出也必须沿用现有的结构化 plan 评审,不能再建立一条仅供 Web 使用的审批路径。 - -## 决策 - -`@deepseek-ai/dsh-client-ui-plan` 是一个 Web 功能插件,包含生命周期耦合的宿主入口和浏览器入口。宿主入口使用 Web 产品的完整 plan 策略挂载 `@deepseek-ai/dsh-plan-mode`。浏览器入口则把 `PlanModeControl` 注册到会话作用域的 `conversation.composer.controls` 列表槽。`dsh web` 的插件清单只需选择该包一次:插件发现机制会加载浏览器入口,同一次清单挂载则提供宿主行为。 - -该策略是此组合边界上由产品拥有的配置。它要求模型先检查再规划、规划期间避免修改、无需询问即可自行查明能够发现的事实、使 plan 包含完成决策所需的全部信息,并把 plan 作为唯一且最终的 `exit_plan_mode` 调用提交。plan 包(package)继续拥有已记录的状态、边界时序、提示词段激活、稳定的退出工具 schema 和评审语义。Web 插件不会复制其中任何机制。 - -`ui-conversation` 拥有并渲染新增的可叠加控件槽,其位置在 composer 主操作左侧。该槽不提供业务载荷;各入口接收标准会话注入项。替换整个 composer 的功能仍走另一条由选择器路由的 `conversation.composer` 链,因此待处理的问题会替换 InputBar 及其控件,两个功能均无需导入对方。 - -当 `planMode` 为 `null` 时,控件不会出现。否则,其选中值为 `pending ?? active`,而待生效状态的呈现依据是字段是否存在,而非字段真值。在日志提交替换快照前,控件会显示 `计划 · 待生效` 或 `默认 · 待生效`。透明的原生 select 会将焦点状态映射到可见的 chip,并引用一条动态无障碍描述,以区分已提交模式和待生效目标。选择操作只会在自身 RPC 执行期间禁用选择器。生成过程不会禁用该控件:在运行中的轮次里选择模式,既不会调用取消,也不会改变该轮次;取消生成也不会清除待生效目标。 - -## 交互语义 - -| 观察到的状态 | 控件 | 用户操作 | 结果 | -|---|---|---|---| -| `{ active: false }` | 默认 | 选择「计划」 | 宿主确认 `{ active: false, pending: true }` | -| `{ active: false, pending: true }` | 计划,待生效 | 发送提示词 | 边界记录 `plan/mode: true`;控件变为已提交的「计划」 | -| `{ active: true }`,运行中 | 计划 | 选择「默认」 | 轮次继续;控件显示「默认,待生效」 | -| `{ active: true, pending: false }` | 默认,待生效 | 停止 | 待生效目标保留;下一条提示词提交「默认」 | -| `null` | 隐藏 | — | 产品组合不会公开不受支持的控件 | - -业务故障和传输故障都会保持已确认的快照不变,重新启用选择器,并在其旁边渲染一条紧凑且可见的失败信息。组件会防止卸载后完成的异步操作继续更新状态,因此切换会话不会更新已经退出使用的控件。 - -发送操作会等待最近一次选择器请求,以及此后任何取代该请求的更新选择。在这个仅涵盖受理阶段的区间内,composer 会清空草稿并禁用重复发送;它不会等待模型生成。宿主受理请求即释放受理锁,此后普通的运行状态仍会使「停止」可用。选择或受理失败时不会发送提示词,并且仅当输入框尚未出现替代文本时,才会恢复已提交的草稿。 - -## 退出评审 - -`exit_plan_mode` 在两种模式下都会保持注册,以维持请求缓存稳定性。在 plan mode 中,模型通过该工具提交完整的 Markdown plan。plan 服务经由 `ctx.userInteraction` 发起询问,已组合进 Web 的问题插件会展示 plan 详情,并提供「批准」、「继续规划」和自由文本回答渠道。问题详情会复用 assistant 输出所用的 Markdown 基础组件及其不受信任内容策略。设有高度上限的问题卡片会固定显示标题、导航操作和提交操作,而完整 plan 与选项共享同一个内部滚动区域。由于 composer 接管是该等待状态的唯一呈现方式,聊天流程不会渲染通用的待处理问题占位块。 - -批准会将未激活模式排到下一步骤生效,不会重写当前工具批次。选择继续规划或提供自定义反馈时,plan mode 保持激活,并向模型返回修正反馈。若评审渠道不可用或已中止,工具会采取失败关闭策略,策略则要求模型请用户手动切换模式。 - -## 产品组合与证据 - -fixture(测试前置数据)模式在内存中实现相同的待生效与边界行为,因此浏览器验收测试无需密钥即可覆盖组合后的产品。无密钥浏览器流程依次选择「计划」、通过提示词提交该模式、在生成期间选择「默认」、停止生成且不丢失待生效目标,再通过下一条提示词提交「默认」。文件快照记录每个用户可见状态。另一个使用 mock 提供方的真实 `dsh web` 进程还证明:插件清单会挂载 plan mode,状态 RPC 会报告该功能,并且激活的 Web 策略会随工作区指令一同进入提供方请求。 - -## 考虑过的替代方案 - -**把选择器直接放进 `ui-conversation`。** 不予采纳,因为会话骨架将因此获得 plan 领域知识和宿主依赖。新增的可叠加槽让 `ui-plan` 保持对功能的归属,并为彼此独立的控件留出空间。 - -**在宿主运行时无条件挂载 plan mode。** 不予采纳,因为 plan mode 是产品组合选择。可选 RPC 契约必须继续表达未选择该功能的宿主。 - -**乐观地翻转本地布尔值。** 不予采纳,因为宿主追加失败、其他界面、恢复或经工具评审的退出都可能与该值不一致。控件只显示宿主确认的已提交状态和待生效状态。 - -**生成运行期间禁用切换,或让切换停止当前轮次。** 不予采纳,因为这会改变 plan 服务的边界契约,并将协作状态与取消操作耦合。待生效状态的存在,正是为了让这两项操作彼此独立。 - -**创建 Web 专用的 plan 审批组件。** 不予采纳,因为 plan 评审已经是一项结构化的用户交互问题。复用问题 composer 可以保留单一评审协议和自由文本修正路径。 - -## 后果 - -Web 产品现在会在保留插件边界的同时,公开与现有终端和 ACP(Agent Client Protocol)产品组合相同的 plan 交互模型。选择该功能会增加一个宿主策略/工具所有者和一个浏览器槽入口;移除其 fiber 会同时移除二者。模型工具目录在模式切换期间保持稳定,但激活的系统提示词段会在 plan 边界发生变化,因此请求前缀也会改变。Plan mode 仍是引导机制,而非执行沙箱:需要强制只读规划的部署仍需组合彼此独立的沙箱策略和审批策略。 diff --git a/apps/web/tests/snapshots/plan-mode.json b/apps/web/tests/snapshots/plan-mode.json deleted file mode 100644 index 1978768813..0000000000 --- a/apps/web/tests/snapshots/plan-mode.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "initial": { - "stage": "initial", - "label": "默认", - "value": "default", - "title": "当前为默认模式", - "primary": "发送" - }, - "planPending": { - "stage": "plan-pending", - "label": "计划 · 待生效", - "value": "plan", - "title": "当前为默认模式;计划模式将在下一次模型请求时生效", - "primary": "发送" - }, - "planCommitted": { - "stage": "plan-committed", - "label": "计划", - "value": "plan", - "title": "当前为计划模式", - "primary": "停止" - }, - "defaultPendingWhileRunning": { - "stage": "default-pending-running", - "label": "默认 · 待生效", - "value": "default", - "title": "当前为计划模式;默认模式将在下一次模型请求时生效", - "primary": "停止" - }, - "defaultPendingAfterStop": { - "stage": "default-pending-stopped", - "label": "默认 · 待生效", - "value": "default", - "title": "当前为计划模式;默认模式将在下一次模型请求时生效", - "primary": "发送" - }, - "defaultCommitted": { - "stage": "default-committed", - "label": "默认", - "value": "default", - "title": "当前为默认模式", - "primary": "停止" - } -} diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index b74fcdf428..2d8015506f 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -48,59 +48,6 @@ describe('createFixtureApi', () => { expect(response.result.value.items[1]?.parentSessionId).toBe('fx-alpha') // lineage material }) - it('queues fixture plan selections, commits them at prompt boundaries, and preserves pending state on cancel', async () => { - const client = new FixtureApiClient() - expect((await client.sessions.planMode({ sessionId: sid('fx-alpha') })).result).toEqual({ - ok: true, value: { active: false }, - }) - expect((await client.sessions.setPlanMode({ sessionId: sid('fx-alpha'), active: true })).result).toEqual({ - ok: true, value: { active: false, pending: true }, - }) - expect((await client.sessions.setPlanMode({ sessionId: sid('fx-alpha'), active: true })).result).toEqual({ - ok: true, value: { active: false, pending: true }, - }) - expect((await client.sessions.cancel({ sessionId: sid('fx-alpha') })).result).toEqual({ - ok: true, value: { accepted: true }, - }) - expect((await client.sessions.planMode({ sessionId: sid('fx-alpha') })).result).toEqual({ - ok: true, value: { active: false, pending: true }, - }) - await client.sessions.prompt({ - sessionId: sid('fx-alpha'), mode: 'queue', content: [{ type: 'text', text: 'commit plan' }], - }) - expect((await client.sessions.planMode({ sessionId: sid('fx-alpha') })).result).toEqual({ - ok: true, value: { active: true }, - }) - expect((await client.sessions.setPlanMode({ sessionId: sid('fx-alpha'), active: false })).result).toEqual({ - ok: true, value: { active: true, pending: false }, - }) - expect((await client.sessions.planMode({ sessionId: sid('missing') })).result).toMatchObject({ - ok: false, error: { code: 'session-not-found' }, - }) - expect((await client.sessions.setPlanMode({ sessionId: sid('missing'), active: true })).result).toMatchObject({ - ok: false, error: { code: 'session-not-found' }, - }) - - const created = await client.sessions.create({}) - if (!created.result.ok) throw new Error('create failed') - const createdId = created.result.value.sessionId - await client.sessions.setPlanMode({ sessionId: createdId, active: true }) - expect((await client.sessions.setPlanMode({ sessionId: createdId, active: false })).result).toEqual({ - ok: true, value: { active: false }, - }) - // Selecting the committed target clears pending immediately, and repeating - // that selection remains a no-op without a redundant plan/mode event. - expect((await client.sessions.setPlanMode({ sessionId: createdId, active: false })).result).toEqual({ - ok: true, value: { active: false }, - }) - await client.sessions.prompt({ - sessionId: createdId, mode: 'queue', content: [{ type: 'text', text: 'remain default' }], - }) - expect((await client.sessions.planMode({ sessionId: createdId })).result).toEqual({ - ok: true, value: { active: false }, - }) - }) - it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => { const api = createFixtureApi() const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 })) diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 4e88ec951b..51ddecf932 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -18,10 +18,6 @@ Per-session UI state for selection and the active view lives in the declared cha The composer bar declares session-scoped single seats for `'conversation.input.plan'` and `'conversation.input.model'`, plus list slots for overlay, dock, left, and right input extensions. InputBar renders the model seat immediately before its pending indicator and send/stop button. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats. -The default composer's bottom row exposes the session-scoped `'conversation.composer.controls'` list slot to the left of the primary action. Mode and policy features contribute controls through that slot; whole-composer takeovers such as questions remain selector-routed entries of the separate `'conversation.composer'` chain. Pending questions render only through that takeover and are omitted from chat-flow placeholders, while approvals remain visible until their own Web response surface exists. - -Prompt submission has a short local admission phase distinct from model generation. The composer clears the draft, prevents a duplicate send, and waits while the session settles the latest mode selection and the Host accepts the prompt. Admission success releases that lock immediately; the independently streamed running state then keeps Stop available for the model turn. Admission failure restores the submitted draft only when the user has not supplied replacement text and surfaces through the ordinary prompt-error strip. - `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). ## Model Experience diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 4b6f2d2f00..afafafa3dc 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -91,7 +91,6 @@ describe('apply wiring', () => { // Declaring is claiming: the chat entry's registration put the hole on // the ledger with the contract's kind/scope. expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' }) - expect(b.slots.spec('conversation.composer.controls')).toEqual({ kind: 'list', scope: 'session' }) }) it('occupies the slots + the ring; session entries share one store handle', async () => { @@ -133,7 +132,6 @@ describe('apply wiring', () => { expect(b.slots.entries('conversation.view')).toHaveLength(0) expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0) expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined() - expect(b.slots.spec('conversation.composer.controls')).toBeUndefined() expect(b.slots.entries('details')).toHaveLength(0) expect(b.ctx.get('conversation')).toBeUndefined() }) diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 20a3a91ab0..c20887b73b 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -24,8 +24,6 @@ Workspace and Session lists are separate reconnect baselines. `workspace.create` The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. -Plan mode uses two unary methods instead of deriving current state from a history page: `session.planMode` returns the committed state plus any boundary-pending selection, and `session.setPlanMode` records a selection and returns the same authoritative shape. A present `pending` target must differ from `active`; a net-zero service cleanup intent projects as `{ active }`, and the wire schema rejects equal values. Both methods return `null` when the optional host service is absent; `null` is capability absence, while `{ active: false }` is a supported inactive session. Committed changes still arrive through the raw logged `plan/mode` session event. `session.prompt` may carry a `planMode` target so the host records that selection immediately before accepting the prompt; it fails when the capability is absent and restores the prior target after a synchronous prompt rejection. - ## Carrier layer (`/client` + root) `AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless. diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index b9616868cd..7beabd2696 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -24,8 +24,6 @@ export interface RpcMethodMap { 'session.selectModel': SessionsApi['selectModel'] 'session.prompt': SessionsApi['prompt'] 'session.cancel': SessionsApi['cancel'] - 'session.planMode': SessionsApi['planMode'] - 'session.setPlanMode': SessionsApi['setPlanMode'] 'host.describe': HostApi['describe'] 'host.pickDirectory': HostApi['pickDirectory'] 'host.openPath': HostApi['openPath'] diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 2367c511f4..8c7be54440 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -145,16 +145,6 @@ export interface SessionSummary { cwd?: string } -/** - * Plan collaboration state exposed to clients. `active` is the logged state - * shaping the current request; `pending`, when present, is the user's - * next-boundary selection and differs from `active`. - */ -export interface PlanModeState { - active: boolean - pending?: boolean -} - /** Session-domain unary methods (the map keys session.* of RpcMethodMap). */ export interface SessionsApi { /** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */ @@ -204,33 +194,10 @@ export interface SessionsApi { }>): Promise> - /** - * Sends a message. `content` is core's ContentBlock[] verbatim and `mode` - * maps 1:1 — queue→send, steer→steer. An optional `planMode` target is - * admitted atomically with the prompt. - */ - prompt(request: RpcRequest<{ - sessionId: SessionId - mode: 'queue' | 'steer' - content: ContentBlock[] - planMode?: boolean - }>): + /** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */ + prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>): Promise> /** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */ cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise> - - /** - * Reads plan collaboration state. `null` means the host did not compose the - * optional plan-mode service; it is distinct from inactive state. - */ - planMode(request: RpcRequest<{ sessionId: SessionId }>): Promise> - - /** - * Selects plan collaboration state for the next model-request boundary. - * The returned state exposes the still-committed value and pending target; - * `null` means plan mode is unavailable on this host. - */ - setPlanMode(request: RpcRequest<{ sessionId: SessionId; active: boolean }>): - Promise> } diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 0ffd5ea065..2794aa13d2 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -45,8 +45,6 @@ function scriptedApi(overrides: { }), prompt: r => ok(r, { accepted: true as const }), cancel: r => ok(r, { accepted: true as const }), - planMode: r => ok(r, null), - setPlanMode: r => ok(r, null), ...overrides.sessions, }, host: {