|
| 1 | +# Agent Plan / `update_plan` Task — Refactor Spec |
| 2 | + |
| 3 | +> Status: **proposal, decisions resolved (v4)** — incorporates four review rounds. No code change yet. |
| 4 | +> Derived from a full review of the `update_plan` task feature (backend tool → agent runtime → |
| 5 | +> renderer float + inline block). v4 hard-resolves D4, covers the terminal marker across **every** |
| 6 | +> turn-exit (incl. the abort-exception early return), threads `max_steps` via `StreamState`, defines |
| 7 | +> the plan-block upsert identity, and tightens the ACP-reachability and one-builder wording. |
| 8 | +
|
| 9 | +## Problem |
| 10 | + |
| 11 | +The "计划 / Progress Checklist" task surface (the floating `AgentProgressFloat` shown during a |
| 12 | +multi-step agent turn) is functionally a port of Codex's `update_plan` tool, but it shipped with two |
| 13 | +disconnected plan representations and a cluster of lifecycle bugs that make a finished or aborted |
| 14 | +plan look stuck. |
| 15 | + |
| 16 | +1. **Two disconnected plan representations.** |
| 17 | + - **Live path (DeepChat agent mode):** `update_plan` tool → `onProgress(agent_plan)` → |
| 18 | + `chat.plan.updated` event → `agentPlan` Pinia store → `AgentProgressFloat.vue`. The store is |
| 19 | + **in-memory only and not rehydrated on reload**. The agent-runtime path **deliberately does not |
| 20 | + persist a plan block** — `test/main/.../dispatch.test.ts:299` asserts "publishes plan update |
| 21 | + events without inserting plan blocks into messages", and the `update_plan` tool-call block is |
| 22 | + hidden inline via `isInternalToolCall` (`MessageItemAssistant.vue:71`). |
| 23 | + - **Persisted/ACP path:** `AcpContentMapper.handlePlanUpdate` builds a `type:'plan'` block with |
| 24 | + `extra.plan_entries` (`acpContentMapper.ts:245`); `MessageBlockPlan.vue` renders `type:'plan'` |
| 25 | + blocks (`MessageItemAssistant.vue:69`). |
| 26 | + - **Correction vs. the original review:** the original review called the inline/ACP path |
| 27 | + "entirely dead". That is **not established**. The main `acpProvider.handleSessionUpdate` |
| 28 | + forwards only `mapped.events` (drops `mapped.blocks`), but a second mapper — |
| 29 | + `acpClientPresenter/mapper/AcpEventMapper.ts:21-27` — maps `mapped.blocks`→`content.block` and |
| 30 | + `mapped.planEntries`→`plan.updated`. `AcpEventMapper` **is instantiated** |
| 31 | + (`acpClientPresenter/index.ts:18`), but **no call site for its `mapSessionUpdate` was found** in |
| 32 | + repo grep. There are two parallel ACP subsystems and end-to-end reachability is genuinely |
| 33 | + ambiguous. **A reachability audit is required before any deletion** (see R5). |
| 34 | + |
| 35 | +2. **Lifecycle / stale-state bugs (live path).** The float can show a finished or aborted plan |
| 36 | + indefinitely: |
| 37 | + - Disappears entirely after app reload / reopening a conversation (store is in-memory only). |
| 38 | + - Re-shows a stale plan on conversation switch (the `sessionId` watcher never touches the plan |
| 39 | + store; `ChatPage.vue:782`). |
| 40 | + - `onStop` / `onMessageRetry` / `onMessageEditSave` / `onMessageContinue` never reset the store |
| 41 | + (`ChatPage.vue:1736,1746,1797`), so an aborted or regenerated turn leaves the last |
| 42 | + `in_progress` step **spinning forever** (`MessageBlockPlan.vue:139` / `AgentProgressFloat.vue` |
| 43 | + `animate-spin`). |
| 44 | + - An all-completed plan never auto-collapses; `dismiss` is not sticky. |
| 45 | + |
| 46 | +3. **Backend state hygiene.** `AgentPlanTool` keeps a process-lifetime `states` Map used only as a |
| 47 | + `revision++` counter (`agentPlanTool.ts:55,94`). `getState`/`clearState` have **zero production |
| 48 | + callers**; the Map is never cleared on `destroySession`, and a subagent's `update_plan` pollutes |
| 49 | + it with orphan keys that no UI reads. The renderer's monotonic revision gate |
| 50 | + (`agentPlan.ts:12`) silently depends on this Map never being cleared. |
| 51 | + |
| 52 | +4. **No single source of truth.** Status→presentation mapping is hand-copied across |
| 53 | + `AgentProgressFloat.vue` and `MessageBlockPlan.vue` and has **already drifted** (completed step is |
| 54 | + emerald in one, muted in the other). The status enum is hand-written in three places (TS union, |
| 55 | + tool zod schema, event-contract zod schema). |
| 56 | + |
| 57 | +5. **UX / i18n / a11y gaps.** The inline badge concatenates a standalone status word into a counter |
| 58 | + (`2/5 完了しました` in ja), completed-step text fails WCAG AA contrast, there is no `aria-live` |
| 59 | + region, the float defaults to collapsed on first appearance, and there are two redundant collapse |
| 60 | + controls. `status.failed` / `status.skipped` i18n keys are unreachable (enum has only three |
| 61 | + values). |
| 62 | + |
| 63 | +## Current trigger conditions (verified — not changing) |
| 64 | + |
| 65 | +The task fires **only** when all of these hold; firing itself is **model-decided** (no code |
| 66 | +threshold): |
| 67 | + |
| 68 | +- Chat mode is DeepChat-native `agent` (`agentToolManager.getAllToolDefinitions`: `isAgentMode` |
| 69 | + gate, line 373). Plain chat and **ACP agent mode do not expose `update_plan`**. |
| 70 | +- The tool is in the tool list, which injects the `## Progress Checklist Tool` system-prompt block |
| 71 | + (`toolPresenter.buildProgressPrompt`, lines 641-657). |
| 72 | +- The model chooses to call it mid-turn inside the agent `while(true)` loop (`process.ts:327`). |
| 73 | + There is **no turn-end event** that finalizes or clears the plan — the root cause behind the |
| 74 | + stale-float bugs. |
| 75 | + |
| 76 | +## Resolved decisions |
| 77 | + |
| 78 | +- **D1 — One persisted representation: the `type:'plan'` block.** `MessageBlockPlan.vue` (rendering |
| 79 | + `type:'plan'` blocks) is the **single visible, persisted plan renderer**. The live |
| 80 | + `AgentProgressFloat` is a **transient overlay during active generation**, rehydrated from the same |
| 81 | + persisted plan snapshot. The hidden `update_plan` tool-call block stays **transport/provenance |
| 82 | + only**. The agent-runtime path therefore **projects each plan update into a persisted |
| 83 | + `type:'plan'` block** (this intentionally changes the `dispatch.test.ts:299` contract — that test |
| 84 | + is rewritten, not worked around). |
| 85 | +- **D2 — ACP plans render through the same `type:'plan'` block.** Both the agent-runtime |
| 86 | + `update_plan` path and the ACP path converge on `type:'plan'` blocks rendered by |
| 87 | + `MessageBlockPlan.vue`, which is **kept and hardened, never deleted**. Exact ACP wiring depends on |
| 88 | + the reachability audit (R5/T1). |
| 89 | +- **D3 — Increment ordering.** The cheap terminal-state fixes (R2) and prompt closure (R7) ship |
| 90 | + **first and independently of persistence**. Persistence/rehydration (R1) is the second increment. |
| 91 | + Safe because, once R1 lands, "reload shows last state" is satisfied by the **persisted block**, not |
| 92 | + the live store — so resetting the live store on stop/switch (R2) no longer conflicts with R1. |
| 93 | +- **D4 — Accepted (hard decision): agent-mode history shows an inline `type:'plan'` block.** |
| 94 | + DeepChat agent-mode turns now render an inline plan checklist block in message history (previously |
| 95 | + only the ephemeral float showed). The float is the live overlay during generation and rehydrates |
| 96 | + from the same persisted snapshot; the inline block is the persisted history record. The rejected |
| 97 | + alternative was float-only history rehydrated from hidden tool-call params, which keeps two |
| 98 | + divergent renderers. **This is settled — not deferred to implementation.** |
| 99 | + |
| 100 | +## User stories |
| 101 | + |
| 102 | +- **U1** When a turn finishes or is stopped, the checklist reflects a terminal state — no step is |
| 103 | + left spinning as if work were still running, **including after reload**. |
| 104 | +- **U2** When I reopen a conversation or reload the app, I still see the plan the agent produced for |
| 105 | + that conversation, in its last state. |
| 106 | +- **U3** When switching conversations, each conversation shows its own plan (or none), never a stale |
| 107 | + plan bled in from another conversation or a previous turn. |
| 108 | +- **U4** When I dismiss the float, it stays dismissed for the current turn. |
| 109 | +- **U5** As a screen-reader / keyboard user, plan progress changes are announced, the disclosure |
| 110 | + control is unambiguous, and completed steps are legible (AA contrast). |
| 111 | +- **U6** As a translator, every shipped plan string is reachable and reads naturally in my locale. |
| 112 | + |
| 113 | +## Requirements & acceptance criteria |
| 114 | + |
| 115 | +### R1 — One plan model, persisted + rehydrated (U2, U3; D1) |
| 116 | +- Each plan update is persisted as a `type:'plan'` block on the assistant message (single block per |
| 117 | + turn — see plan.md AD1 for the upsert identity). The live float rehydrates from the persisted plan |
| 118 | + on session load / reopen. |
| 119 | +- AC1: After reload, reopening a conversation that ran a plan shows its last plan state (inline block |
| 120 | + always; float overlay optional). |
| 121 | +- AC2: Switching A→B→A shows A's own last plan (or none), never B's or a stale turn's. |
| 122 | +- AC3: The live store baseline is **per-turn** (Constraint C1) — rehydration and new turns never |
| 123 | + silently drop a fresh plan. |
| 124 | + |
| 125 | +### R2 — Terminal-state correctness (U1, U4) |
| 126 | +- AC4: On **any** turn exit that leaves a step `in_progress` — user `onStop`, a provider **abort |
| 127 | + raised as an exception (the early-return catch branch)**, tool terminal error, context-window |
| 128 | + error, no-model-response, a non-abort uncaught exception, interrupted-session recovery, or |
| 129 | + `MAX_TOOL_CALLS` exhaustion — the agent runtime stamps the persisted `type:'plan'` block with a |
| 130 | + terminal marker (`terminalReason: 'aborted' | 'max_steps' | 'error'`, additive — see C3 / plan.md |
| 131 | + AD6) and emits a final `chat.plan.updated`. Both the live float and the reloaded inline block then |
| 132 | + render the once-`in_progress` step **without a spinner** (a static interrupted indicator). Normal |
| 133 | + completion is covered by R7 (the model marks steps complete). **No step spins after its turn ended |
| 134 | + — on every error/abort path, including the abort-exception early return and after reload.** |
| 135 | +- AC5: A new turn (`onMessageRetry` / `onMessageEditSave` / `onMessageContinue`, matching |
| 136 | + `onSubmit`/`onSteer`) **rebaselines** the live overlay (resets the per-turn baseline) rather than |
| 137 | + blanket-deleting persisted data. |
| 138 | +- AC6: An all-completed plan auto-collapses (does not auto-delete) instead of lingering expanded. |
| 139 | +- AC7: `dismiss` is sticky for the current turn (a trailing higher-revision update does not re-pop). |
| 140 | + |
| 141 | +### R3 — Backend state hygiene |
| 142 | +- AC8: `AgentPlanTool.states` is bounded — cleared on `destroySession`. Because the live baseline is |
| 143 | + per-turn (C1), backend revision may stay process-local and even reset on restart without risk. |
| 144 | +- AC9: Dead surface removed or wired: drop `rawData.toolResult.snapshot` (only `onProgress` is |
| 145 | + consumed); remove `getState`/`clearState` unless wired by AC8. |
| 146 | +- AC10: A subagent `update_plan` no longer pollutes the parent's plan state with an orphan key. |
| 147 | +- AC11: A missing `toolCallId` no longer returns silent success with no UI effect (error or logged |
| 148 | + drop). |
| 149 | + |
| 150 | +### R4 — Single source of truth for shape & presentation |
| 151 | +- AC12: `AgentPlanStepStatus` and the plan-item shape are defined once (zod as source, `z.infer`); |
| 152 | + tool schema and event contract import it. |
| 153 | +- AC13: Status→icon/color/badge mapping, the frozen/terminal rendering, and the aria-label live in |
| 154 | + one shared composable used by both renderers; completed-step styling is identical across surfaces. |
| 155 | + |
| 156 | +### R5 — Audit + converge the inline/ACP pipeline (D1, D2) |
| 157 | +- AC14: Before any change, a reachability audit documents whether/how the `type:'plan'` block is |
| 158 | + produced and rendered today across **both** ACP subsystems (`acpProvider` and |
| 159 | + `acpClientPresenter`/`AcpEventMapper`). No `MessageBlockPlan` deletion. |
| 160 | +- AC15: After convergence, agent-runtime and ACP may keep **separate entry points**, but both call |
| 161 | + **one shared plan-block construction/normalization helper** producing **one `type:'plan'` block |
| 162 | + shape**, rendered by the **single `MessageBlockPlan`** renderer; no second, divergent builder or |
| 163 | + renderer remains. |
| 164 | + |
| 165 | +### R6 — UX / i18n / a11y |
| 166 | +- AC16: The completed counter uses one parameterized/pluralizable i18n message |
| 167 | + (`{completed}/{total}` localized), not a concatenated status word; float and inline block present |
| 168 | + the count consistently. |
| 169 | +- AC17: Completed-step text meets WCAG AA (≥4.5:1) — mute the icon and/or strike-through, keep text |
| 170 | + at `text-foreground`. |
| 171 | +- AC18: The steps container exposes `aria-live="polite"` (`role="status"`); the disclosure control |
| 172 | + is a single unambiguous control (`aria-expanded` + `aria-controls`), no duplicate tab stop. |
| 173 | +- AC19: The float defaults to expanded on first appearance. |
| 174 | +- AC20: `collapsedBySession` (and any persisted view-state) is pruned on conversation deletion. |
| 175 | +- AC21: Unreachable i18n keys (`status.failed`, `status.skipped`) are removed unless the enum is |
| 176 | + extended to produce them. |
| 177 | + |
| 178 | +### R7 — Prompt closure discipline (cheap, high ROI; borrowed from Codex) |
| 179 | +- AC22: `buildProgressPrompt` instructs the model to reconcile every step before finishing and never |
| 180 | + end a turn with a dangling `in_progress` step. This is the minimal mitigation for the "stuck |
| 181 | + spinner" symptom under normal completion, independent of R1/R2. |
| 182 | + |
| 183 | +## Non-goals |
| 184 | + |
| 185 | +- No new heavyweight planning concept (no `PLANS.md` / ExecPlans, no Plan Mode). |
| 186 | +- No change to **when** the task triggers (model-decided, agent-mode-only stays). |
| 187 | +- No step-status enum extension (`blocked`/`cancelled`) in this goal — abnormal termination uses the |
| 188 | + additive block-level `terminalReason` instead (AD6). Model-emitted closure stays the three values. |
| 189 | +- No cross-session "global task board"; plan stays scoped to its conversation. |
| 190 | +- No new dedicated DB table for plans — persistence rides the existing message/block store. |
| 191 | + |
| 192 | +## Constraints |
| 193 | + |
| 194 | +- **C1 — Revision baseline is per-turn (resolves the silent-drop hazard).** The renderer store drops |
| 195 | + snapshots with `revision <= current` (`agentPlan.ts:12`); backend revision comes from a |
| 196 | + process-local Map (`agentPlanTool.ts:94`) that resets on restart. To remove the coupling: **reset |
| 197 | + the live store baseline to 0 at the start of every turn** (submit/steer/retry/continue). Revision |
| 198 | + then only orders updates **within a single turn** (dispatch is sequential, so monotonic by |
| 199 | + construction). Backend clearing and restart-reset become harmless. No "reset both ends from one |
| 200 | + signal" handshake is needed. |
| 201 | +- **C2 — Agent-mode gating unchanged.** `update_plan` stays `agent`-mode only. |
| 202 | +- **C3 — Stored-data contract.** `type:'plan'` blocks and any `block.extra` plan fields |
| 203 | + (`plan_entries`, `plan_terminal_reason`, …) are additive; conversations persisted before this |
| 204 | + change have no plan block and rehydrate to "no plan". |
| 205 | +- **C4 — Minimal complexity.** Reuse the existing message-block + typed-event machinery; no new store |
| 206 | + or channel. Per project preference (no compatibility shims unless explicitly required), the |
| 207 | + storage-key migration is a clean rename + one-time prune, not a legacy-value translation layer. |
| 208 | + |
| 209 | +## Success criteria |
| 210 | + |
| 211 | +- No state can leave the float **or the reloaded inline block** showing a spinning `in_progress` |
| 212 | + after its turn ended (tests for stop/retry/complete transitions, live and post-reload). |
| 213 | +- Plan survives reload and conversation switch with correct per-conversation isolation (AC1–AC3), |
| 214 | + and a fresh `revision = 1` plan after restart is never dropped (C1 guard test). |
| 215 | +- Status enum and status→presentation logic each exist exactly once (grep shows a single source). |
| 216 | +- One persisted plan-block producer feeds one renderer; the ACP reachability audit is recorded. |
| 217 | +- `pnpm run format && pnpm run i18n && pnpm run lint && pnpm run typecheck` clean; new renderer/main |
| 218 | + tests cover the lifecycle transitions, the per-turn baseline, the terminal marker, and rehydration. |
0 commit comments