Skip to content

Commit 3f4b96d

Browse files
authored
feat(agent-plan): persist plan blocks in history (#1817)
1 parent d874ee2 commit 3f4b96d

61 files changed

Lines changed: 2643 additions & 299 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# ACP Plan Reachability Audit
2+
3+
## Summary
4+
5+
- Subsystem A (`llmProviderPresenter/providers/acpProvider.ts`) is the active ACP provider stream
6+
path. It calls `AcpContentMapper.map(notification)` and pushes `mapped.events` into the provider
7+
`EventQueue`.
8+
- Subsystem A previously dropped `mapped.blocks`. This refactor adds an internal `LLMCoreStreamEvent`
9+
`type:'plan'` variant and accumulator handling that upserts the same shared `type:'plan'` block
10+
shape, so active ACP provider streams can persist plan blocks without inventing an IPC channel.
11+
- Subsystem B (`acpClientPresenter/mapper/AcpEventMapper.ts`) maps `mapped.blocks` to
12+
`content.block` and `mapped.planEntries` to `plan.updated`, but repo grep finds no
13+
`mapSessionUpdate` call site. It is instantiated by `acpClientPresenter/index.ts`, but not proven
14+
live end-to-end.
15+
- `MessageBlockPlan` remains the single renderer. `AcpContentMapper.handlePlanUpdate` now uses the
16+
shared plan-block builder, so ACP subsystem A, ACP subsystem B, and the agent-runtime path produce
17+
the same `type:'plan'` block shape.
18+
19+
## Decision For This Refactor
20+
21+
- Do not delete `MessageBlockPlan`.
22+
- Keep the new internal `plan` stream event scoped to provider-to-accumulator transport.
23+
- Keep subsystem B's block-capable mapper on the shared builder shape.
24+
- Do not add a public IPC channel or a dedicated plan table for ACP plans.

docs/architecture/agent-plan-task-refactor/plan.md

Lines changed: 300 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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

Comments
 (0)