detached dfb1f93

feat(plan): persisted agent-owned plan tool (#213)

* feat(plan): replace update_plan with a persisted plan tool

The checklist becomes agent-owned state persisted in the session file instead of
a turn-scoped rewrite of the whole list on every call.

- core owns the plan: SetPlan/CurrentPlan, a pure previewPlanOperation that
  validates and materializes the next list, and commitPlanUpdate that stores
  exactly the list that was persisted
- the plan tool replaces update_plan with six actions (set, add, update, remove,
  clear, show); it only parses arguments, while core validates against the live
  plan and produces the model-visible text and errors
- persistence runs through the tool-result commit path, so every mode with a
  session stores the plan and a failed write surfaces as a tool error instead of
  leaving memory and disk disagreeing
- SessionMeta.Plan is appended as a meta line, normalized on read, and carried by
  fork and import; a fork that cuts the transcript short replays the plan calls in
  that prefix and skips calls whose result failed, was denied, or is missing
- invariants: at most one in_progress step, set requires at least one step, no
  step-count limit
- resume seeds the plan into the agent, /clear drops it in the session and on
  disk, and the TUI renders per-call checklists from the plan event, rebuilding
  historical checklists by replaying the full transcript
- resident subagents cannot use the tool, and the system prompt states when to
  plan and how to update it cheaply
- compaction carries a bounded plan block into the retained internal context so
  indexed updates still resolve after a summarization

Breaking: the tool is renamed from update_plan to plan, and both names are now
reserved for extensions. Sessions written before the rename stay readable and
their checklists still render.

* docs(plan): document the plan tool and its persistence

Replace the remaining update_plan references in the README, the RPC event table,
and the extension reserved-name list, and describe the six actions, the
one-in_progress rule, the set/clear semantics, session-scoped persistence, fork
and import behaviour, and the optional id on the plan_update event.
commit
dfb1f93a318b4fcfb67cba0f813daa19022d2bfb
author
bnema · · 2026-09-19 08:49 UTC
committer
GitHub · · 2026-09-19 08:49 UTC
parent
a89c9e2
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
51 files · +3978 -358
M README.md +2 -2
@@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
99
1010 - one static binary.
1111 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.
12-- seventeen core built-in tools (read, write, edit, bash, python, worktree, grep, glob, ast, lsp, web_search, web_open, web_find, web_click, update_goal, update_plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries.
12+- seventeen core built-in tools (read, write, edit, bash, python, worktree, grep, glob, ast, lsp, web_search, web_open, web_find, web_click, update_goal, plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries.
1313 - three run modes (interactive tui, print, json).
1414 - built-in telegram bot.
1515 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).
@@ -272,7 +272,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
272272 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md).
273273 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
274274 - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Pausing, resuming, and clearing missions remain user-controlled through `/goal`.
275-- `update_plan`: maintain a turn-scoped checklist of `pending`, `in_progress`, and `completed` steps. Successful updates appear inline as an Updated Plan checklist and emit `plan_update` events in JSON and RPC modes.
275+- `plan`: maintain a session-scoped checklist of `pending`, `in_progress`, and `completed` steps through `set` (replace every step), `add`, `update` one step, `remove` one step, `clear`, and `show`. At most one step may be `in_progress`. `set` requires at least one step; `clear` is how every step is removed. There is no step-count limit, but a plan carried across compaction shows only its first 12 steps. The plan is persisted in every mode through the tool-result commit path, inherited by forks and imports, and cleared by `/clear`. Successful updates appear inline as a checklist and emit `plan_update` events in JSON and RPC modes.
276276 - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool.
277277
278278 Tool results carry resource provenance in the durable session. Before each provider request, Zut replaces an older read only when an equivalent newer view supersedes it, and replaces a multi-file search only after every discovered file has been consumed or mutated. This provider-only projection removes known-stale context without rewriting session history. Because an older projected result changes, prompt caching restarts from that point; compact deterministic markers limit the cost.
M docs/extensions.md +2 -2
@@ -226,10 +226,10 @@ describing the tool's args (the same shape Anthropic and OpenAI accept).
226226
227227 Tool names live in the same namespace as built-in tools (`read`,
228228 `write`, `edit`, `bash`, `python`, `worktree`, `grep`, `ast`, `lsp`, `web_search`,
229-`web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `update_plan`,
229+`web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `plan`,
230230 `skill`). Conflicts with active built-ins are silently shadowed by the built-in.
231231 Every native public-web capability name, plus `grep`, `ast`, `python`, `schedule`,
232-`update_goal`, and `update_plan`, is reserved even when it is unavailable, so an
232+`update_goal`, and `plan`, is reserved even when it is unavailable, so an
233233 extension cannot claim or replace it.
234234
235235 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:
M docs/rpc.md +2 -2
@@ -204,7 +204,7 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`.
204204 | `tool_call` | `id`, `name`, `args` | The model proposed a tool call |
205205 | `tool_execution_started` | `id`, `name` | Guard and confirmation checks passed; the tool is about to execute |
206206 | `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs |
207-| `plan_update` | nullable `explanation`, `plan` | The built-in `update_plan` tool replaced the current turn checklist. Each plan item has `step` and `status`. |
207+| `plan_update` | optional `id`, nullable `explanation`, `plan` | The built-in `plan` tool changed the session checklist. Each plan item has `step` and `status`. `id` is the tool-call id that produced the update; it is omitted when empty. |
208208 | `tool_result` | `id`, `is_error`, `content` | Tool finished |
209209 | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends |
210210 | `usage` | `input`, `output`, `reasoning`, `cache_read`, `cache_write`, `cache_measured_prompt`, `cache_measured_read`, `cost_usd`, `cumulative` | Per-turn + cumulative tokens / cost. Cache ratios use only the measured cache fields; `reasoning` is null when unavailable. |
@@ -256,4 +256,4 @@ See `examples/rpc/` for working implementations in:
256256
257257 ## Versioning
258258
259-The `protocol_version` field in the `hello` response is the major version of this schema. Backwards-incompatible changes bump it. The set of supported events and commands within a major version only grows.
259+The `protocol_version` field in the `hello` response is the major version of this schema. Backwards-incompatible changes bump it. Within a major version the schema only grows: new events, commands, and optional event fields may appear — such as the `id` field on `plan_update` — so consumers must ignore fields they do not recognize.
M packages/agent/botcmd.go +13 -0
@@ -315,12 +315,25 @@ func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version st
315315 }
316316 ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
317317 ag.SetMessages(msgs)
318+ // Seed the agent-owned plan from the resumed session so show and indexed
319+ // updates see the persisted checklist instead of empty core state.
320+ ag.SetPlan(s.Meta.PlanSteps())
321+ // Route tool-result commits through the session so plan (and goal)
322+ // mutations the bot makes are durable, matching the other modes.
323+ ag.CommitToolResult = func(_ string, result core.ToolResult) error {
324+ return persistToolResultState(nil, s, result)
325+ }
318326 return s, nil, nil
319327 }
320328 }
321329 s, err := core.NewSession(ZutHome(), args.CWD, r.Provider, r.Model, version)
322330 if err == nil {
323331 ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
332+ // Route tool-result commits through the session so plan (and goal)
333+ // mutations the bot makes are durable, matching the other modes.
334+ ag.CommitToolResult = func(_ string, result core.ToolResult) error {
335+ return persistToolResultState(nil, s, result)
336+ }
324337 }
325338 return s, nil, err
326339 }
M packages/agent/build.go +3 -3
@@ -123,7 +123,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
123123 // Web capability and other native names remain reserved even when their
124124 // policy excludes the current session. An extension must not turn a
125125 // normal CLI opt-out into a differently implemented capability.
126- if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "glob" || info.Name == "ast" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.UpdatePlanToolName {
126+ if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "glob" || info.Name == "ast" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.PlanToolName {
127127 continue
128128 }
129129 if _, exists := r.ToolRegistry[info.Name]; exists {
@@ -1375,7 +1375,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
13751375 "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
13761376 "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
13771377 "update_goal": &tools.UpdateGoalTool{},
1378- "update_plan": &tools.UpdatePlanTool{},
1378+ "plan": &tools.PlanTool{},
13791379 }
13801380 if webSearchAllowedForRegistry(args) {
13811381 for name, tool := range tools.NewWebTools() {
@@ -1526,7 +1526,7 @@ func subagentActionAllowed(args Args, action string) bool {
15261526 return false
15271527 }
15281528
1529-var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}
1529+var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "plan"}
15301530
15311531 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
15321532 var out []ToolSummary
M packages/agent/build_grep_test.go +6 -6
@@ -30,19 +30,19 @@ func TestBuildToolRegistryWebCapabilityBundle(t *testing.T) {
3030 }
3131 }
3232
33-func TestBuildToolRegistryIncludesUpdatePlanByDefaultAndExplicitSelection(t *testing.T) {
33+func TestBuildToolRegistryIncludesPlanByDefaultAndExplicitSelection(t *testing.T) {
3434 cwd := t.TempDir()
3535 defaultRegistry := buildToolRegistry(Args{}, cwd, nil, false, false, false)
36- if _, ok := defaultRegistry[tools.UpdatePlanToolName].(*tools.UpdatePlanTool); !ok {
37- t.Fatalf("default update_plan has type %T", defaultRegistry[tools.UpdatePlanToolName])
36+ if _, ok := defaultRegistry[tools.PlanToolName].(*tools.PlanTool); !ok {
37+ t.Fatalf("default plan has type %T", defaultRegistry[tools.PlanToolName])
3838 }
3939
40- selected := buildToolRegistry(Args{Tools: []string{tools.UpdatePlanToolName}}, cwd, nil, false, false, false)
40+ selected := buildToolRegistry(Args{Tools: []string{tools.PlanToolName}}, cwd, nil, false, false, false)
4141 if len(selected) != 1 {
4242 t.Fatalf("selected registry has %d tools, want one", len(selected))
4343 }
44- if _, ok := selected[tools.UpdatePlanToolName].(*tools.UpdatePlanTool); !ok {
45- t.Fatalf("selected update_plan has type %T", selected[tools.UpdatePlanToolName])
44+ if _, ok := selected[tools.PlanToolName].(*tools.PlanTool); !ok {
45+ t.Fatalf("selected plan has type %T", selected[tools.PlanToolName])
4646 }
4747 }
4848
M packages/agent/cli.go +96 -0
@@ -425,6 +425,10 @@ type sessionResumeCandidate struct {
425425 provider string
426426 model string
427427 rebuilt bool
428+ // planSnapshots is the per-call checklist reconstruction over the full
429+ // on-disk transcript. The resumed agent only keeps the trimmed tail, so
430+ // the interactive must seed from this rather than replay its messages.
431+ planSnapshots map[string]core.PlanUpdate
428432 }
429433
430434 // prepareSessionResume opens and validates a selected session without
@@ -472,6 +476,10 @@ func prepareSessionResumeWithOptions(path string, current *core.Agent, currentPr
472476
473477 fullMessageCount := len(msgs)
474478 resumedMessages := trimMessagesForResume(msgs, 100)
479+ // Reconstruct the historical checklists from the untrimmed messages: a
480+ // plan delta inside the trim window needs the pre-window base to rebuild
481+ // the right list, and the view must keep using the trimmed transcript.
482+ planSnapshots := core.PlanUpdateSnapshots(msgs)
475483 storedProvider := strings.TrimSpace(sess.Meta.Provider)
476484 storedModel := strings.TrimSpace(sess.Meta.Model)
477485 currentProvider = strings.TrimSpace(currentProvider)
@@ -486,6 +494,7 @@ func prepareSessionResumeWithOptions(path string, current *core.Agent, currentPr
486494 lastTurn: lastTurn,
487495 provider: currentProvider,
488496 model: currentModel,
497+ planSnapshots: planSnapshots,
489498 }
490499
491500 // Old sessions may have either field absent. Only a complete stored
@@ -586,6 +595,7 @@ func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, cur
586595 if len(ag.Messages()) == 0 &&
587596 strings.TrimSpace(sess.Meta.Provider) == strings.TrimSpace(currentProvider) &&
588597 strings.TrimSpace(sess.Meta.Model) == strings.TrimSpace(currentModel) {
598+ ag.SetPlan(sess.Meta.PlanSteps())
589599 return candidate, nil
590600 }
591601 candidate, err := prepareSessionResumeWithOptions(sess.Path, ag, currentProvider, currentModel, explicitProvider, explicitModel, false, buildAgentFor)
@@ -606,6 +616,12 @@ func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, cur
606616 return sessionResumeCandidate{}, errors.Join(fmt.Errorf("close session: %w", closeErr), joinSessionCloseError(nil, candidate.session))
607617 }
608618 candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset)
619+ // Seed the agent-owned plan from the loaded session so a resumed session's
620+ // core state matches its file: the transcript holds steps while core would
621+ // otherwise hold none, which makes show answer "Plan is empty" and indexed
622+ // update/remove fail. Done after the candidate is fully hydrated but before
623+ // the caller commits it.
624+ candidate.agent.SetPlan(candidate.session.Meta.PlanSteps())
609625 return candidate, nil
610626 }
611627
@@ -708,6 +724,9 @@ var (
708724 errGoalStaleUpdate = errors.New("stale goal update")
709725 errGoalStorageUnavailable = errors.New("goal storage unavailable")
710726 errGoalStateWrite = errors.New("goal state write")
727+
728+ errPlanStorageUnavailable = errors.New("plan storage unavailable")
729+ errPlanStateWrite = errors.New("plan state write")
711730 )
712731
713732 func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result core.ToolResult, goalMaxTokenBudget ...*uint64) error {
@@ -715,6 +734,9 @@ func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result
715734 if _, ok := tools.GoalUpdateFromResult(result); ok {
716735 return goalCommitError(errGoalStorageUnavailable)
717736 }
737+ if result.PlanOperation != nil && !result.IsError {
738+ return planCommitError(errPlanStorageUnavailable)
739+ }
718740 return nil
719741 }
720742 if details, ok := result.Details.(extensions.ToolResultDetails); ok && details.Extension != "" && len(details.State) > 0 {
@@ -728,6 +750,9 @@ func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result
728750 if err := persistGoalToolResult(sess, result, goalMaxTokenBudget...); err != nil {
729751 return goalCommitError(err)
730752 }
753+ if err := persistPlanToolResult(sess, result); err != nil {
754+ return planCommitError(err)
755+ }
731756 return nil
732757 }
733758
@@ -752,6 +777,21 @@ func safeGoalCommitMessage(err error) string {
752777 }
753778 }
754779
780+func planCommitError(err error) error {
781+ return &core.ToolResultCommitError{Message: safePlanCommitMessage(err), Err: fmt.Errorf("plan state: %w", err)}
782+}
783+
784+func safePlanCommitMessage(err error) string {
785+ switch {
786+ case errors.Is(err, errPlanStorageUnavailable):
787+ return "plan state unavailable: session persistence is disabled"
788+ case errors.Is(err, errPlanStateWrite):
789+ return "plan state could not be saved"
790+ default:
791+ return "plan update could not be persisted"
792+ }
793+}
794+
755795 func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTokenBudget ...*uint64) error {
756796 if sess == nil {
757797 return nil
@@ -809,6 +849,23 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTo
809849 return nil
810850 }
811851
852+// persistPlanToolResult writes the materialized checklist for a validated plan
853+// mutation. show carries neither Details nor PlanOperation, and error results
854+// were already rejected, so both are skipped here.
855+func persistPlanToolResult(sess *core.Session, result core.ToolResult) error {
856+ if sess == nil || result.IsError || result.PlanOperation == nil {
857+ return nil
858+ }
859+ update, ok := result.Details.(core.PlanUpdate)
860+ if !ok {
861+ return nil
862+ }
863+ if err := sess.UpdatePlan(&core.SessionPlan{Steps: update.Plan}); err != nil {
864+ return fmt.Errorf("%w: %w", errPlanStateWrite, err)
865+ }
866+ return nil
867+}
868+
812869 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
813870 if mgr == nil {
814871 return
@@ -1863,6 +1920,10 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
18631920
18641921 var sessBaselineMsgs int // messages already on disk when current session opened
18651922 var sessionTitlePending bool
1923+ // initialPlanSnapshots is reconstructed from the untrimmed resumed
1924+ // transcript. The interactive seeds from it because the live agent only
1925+ // receives the trim window after a resume.
1926+ var initialPlanSnapshots map[string]core.PlanUpdate
18661927 if !args.NoSess && ag != nil {
18671928 var sessErr error
18681929 sess, sessErr = openOrCreateSession(ctx, args, r, ag, version)
@@ -1883,6 +1944,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
18831944 sess = candidate.session
18841945 ag = candidate.agent
18851946 sessBaselineMsgs = candidate.fullMessageCount
1947+ initialPlanSnapshots = candidate.planSnapshots
18861948 activeProvider = candidate.provider
18871949 activeModel = candidate.model
18881950 r.Provider = candidate.provider
@@ -2037,6 +2099,29 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
20372099 }
20382100 return append([]core.SessionGoal(nil), sess.Meta.GoalHistory...)
20392101 }
2102+ currentPlan := func() []core.PlanStep {
2103+ return liveInteractiveAgent(iv, ag).CurrentPlan()
2104+ }
2105+ persistedPlan := func() *core.SessionPlan {
2106+ persistMu.Lock()
2107+ defer persistMu.Unlock()
2108+ if sess == nil || sess.Meta.Plan == nil {
2109+ return nil
2110+ }
2111+ plan := *sess.Meta.Plan
2112+ plan.Steps = append([]core.PlanStep(nil), sess.Meta.Plan.Steps...)
2113+ return &plan
2114+ }
2115+ persistPlan := func(plan *core.SessionPlan) error {
2116+ sessionTransitionMu.RLock()
2117+ defer sessionTransitionMu.RUnlock()
2118+ persistMu.Lock()
2119+ defer persistMu.Unlock()
2120+ if sess == nil {
2121+ return errors.New("session persistence is disabled")
2122+ }
2123+ return sess.UpdatePlan(plan)
2124+ }
20402125 bindAgentToolResultSession := func(a *core.Agent, boundSession *core.Session) {
20412126 if a == nil {
20422127 return
@@ -2242,6 +2327,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
22422327 }
22432328 sess = candidate.session
22442329 bindAgentToolResultSession(candidate.agent, candidate.session)
2330+ // Seed core plan state from the loaded session before the swap publishes
2331+ // the agent; see applySessionResume for why this is required on resume.
2332+ candidate.agent.SetPlan(candidate.session.Meta.PlanSteps())
22452333 candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset)
22462334 sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint
22472335 // The live agent only receives a compact resume window, but the
@@ -2282,6 +2370,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
22822370 persistMu.Unlock()
22832371 if iv != nil {
22842372 iv.RefreshGoal()
2373+ iv.RefreshPlan(candidate.planSnapshots)
22852374 }
22862375 if candidate.session.Meta.Parent != "" {
22872376 extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)
@@ -2468,6 +2557,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
24682557 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZutHome()))
24692558 iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)
24702559 iv.RefreshGoal()
2560+ // A new cwd starts a fresh session with no plan; reset the counters
2561+ // and snapshot map so the previous workspace's checklist is gone.
2562+ iv.RefreshPlan(nil)
24712563 }
24722564
24732565 // Re-scope the subagent dashboard to the new session.
@@ -2741,6 +2833,10 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
27412833 PersistGoalRuntime: persistGoalRuntime,
27422834 CurrentGoal: currentGoal,
27432835 CurrentGoalHistory: currentGoalHistory,
2836+ CurrentPlan: currentPlan,
2837+ PersistPlan: persistPlan,
2838+ PersistedPlan: persistedPlan,
2839+ InitialPlanSnapshots: initialPlanSnapshots,
27442840 EnsureMission: ensureMission,
27452841 CurrentSessionPath: func() string {
27462842 persistMu.Lock()
M packages/agent/ext/ext.go +2 -2
@@ -422,10 +422,10 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
422422 //
423423 // Conflicts with active built-in tool names (read, write, edit, bash,
424424 // python, worktree, grep, glob, ast, lsp, web_search, web_open,
425-// web_find, web_click, schedule, update_goal, update_plan, and skill) are
425+// web_find, web_click, schedule, update_goal, plan, and skill) are
426426 // silently shadowed by the built-in. Every native public-web name, plus
427427 // grep, glob, ast, python, schedule,
428-// update_goal, and update_plan, is reserved even when unavailable, so an
428+// update_goal, and plan, is reserved even when unavailable, so an
429429 // extension cannot claim or replace it.
430430 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
431431 e.registerTool(name, description, schema, false, fn)
M packages/agent/modes/btw_dialog.go +4 -0
@@ -352,6 +352,10 @@ func newBtwAgent(main *core.Agent, system, model, origin string) *core.Agent {
352352 agent.RetryBaseDelay = main.RetryBaseDelay
353353 agent.OnEvent = main.OnEvent
354354 agent.SetMessages(main.Messages())
355+ // The side agent shares the main registry (which contains plan) and the main
356+ // transcript, so it must also carry the main plan: otherwise `show` answers
357+ // empty and an indexed update fails on a checklist the transcript displays.
358+ agent.SetPlan(main.CurrentPlan())
355359 return agent
356360 }
357361
M packages/agent/modes/btw_dialog_test.go +26 -0
@@ -3,6 +3,7 @@ package modes
33 import (
44 "context"
55 "encoding/json"
6+ "slices"
67 "strings"
78 "sync"
89 "sync/atomic"
@@ -325,3 +326,28 @@ func TestBtwToolConfirmationRoutesThroughSideAgent(t *testing.T) {
325326 t.Fatal("main confirmation was not retained after resolving the side-chat call")
326327 }
327328 }
329+
330+// TestNewBtwAgentCarriesMainPlan guards GO-003: the /btw side agent shares the
331+// main registry (which contains plan) and the main transcript, so it must also
332+// carry the main plan. Otherwise `show` answers empty and an indexed update
333+// fails on a checklist the side transcript displays.
334+func TestNewBtwAgentCarriesMainPlan(t *testing.T) {
335+ main := core.NewAgent(nil, "model", "system", nil)
336+ want := []core.PlanStep{
337+ {Step: "one", Status: core.PlanPending},
338+ {Step: "two", Status: core.PlanInProgress},
339+ }
340+ main.SetPlan(want)
341+
342+ side := newBtwAgent(main, "system", "model", "btw")
343+ if got := side.CurrentPlan(); !slices.Equal(got, want) {
344+ t.Fatalf("side agent plan = %#v, want %#v", got, want)
345+ }
346+
347+ // The side plan must be an independent copy.
348+ got := side.CurrentPlan()
349+ got[0].Step = "mutated"
350+ if again := main.CurrentPlan(); again[0].Step != "one" {
351+ t.Fatalf("side plan aliases the main plan: %#v", again)
352+ }
353+}
M packages/agent/modes/interactive.go +20 -0
@@ -394,6 +394,22 @@ type InteractiveConfig struct {
394394 // PersistGoalRuntime records execution bookkeeping without adding a mission
395395 // transition to goal history.
396396 PersistGoalRuntime func(goal *core.SessionGoal) error
397+ // CurrentPlan returns a copy of the agent-owned checklist. PersistPlan
398+ // writes it as a session meta transition. They let the TUI seed its plan
399+ // counters and snapshot map on resume and clear them on /clear without
400+ // owning session state.
401+ CurrentPlan func() []core.PlanStep
402+ PersistPlan func(plan *core.SessionPlan) error
403+ // PersistedPlan reports the plan stored on the bound session, or nil. It
404+ // lets /clear drop a file plan that diverged from core state.
405+ PersistedPlan func() *core.SessionPlan
406+ // InitialPlanSnapshots, when non-nil, seeds the historical plan checklist
407+ // snapshots at construction. The CLI reconstructs them from the session's
408+ // full transcript, which reaches further back than the resumed agent's
409+ // trimmed message window: replaying a delta against an empty base would
410+ // rebuild the wrong checklist. When nil, the constructor derives snapshots
411+ // from the agent's own messages.
412+ InitialPlanSnapshots map[string]core.PlanUpdate
397413 // GoalMaxTokenBudget is optional. Nil means autonomous goals are unlimited.
398414 GoalMaxTokenBudget *uint64
399415
@@ -479,6 +495,7 @@ type chatCacheKey struct {
479495 welcomeShowVer bool
480496 expandAll bool
481497 tailLimit int
498+ planRev uint64
482499 renderedMessageCount int
483500 viewCacheRev uint64
484501 }
@@ -727,6 +744,8 @@ type Interactive struct {
727744 goalStatus core.GoalStatus
728745 planCurrent int
729746 planTotal int
747+ planSnapshots map[string]core.PlanUpdate
748+ planRevision uint64
730749 goalRun *goalContinuationRun
731750 reloadStatusSeq uint64
732751 extStatuses map[string]map[string]extensionStatus
@@ -1154,6 +1173,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
11541173 if len(i.view.Messages) > initialResumeTailLimit {
11551174 i.view.TailLimit = initialResumeTailLimit
11561175 }
1176+ i.seedPlanState()
11571177 }
11581178 i.sessionTitle = core.NormalizeSessionTitle(cfg.InitialSessionTitle)
11591179 if cfg.InitialSessionTitlePending {
M packages/agent/modes/interactive_commands.go +29 -1
@@ -218,10 +218,20 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
218218 case "/exit":
219219 return true
220220 case "/clear":
221+ // The bound session may still hold a plan core no longer has (a failed or
222+ // skipped seed, a stray write). Read it before taking i.mu: the callback
223+ // takes the host persistence lock, which must not nest under the mode
224+ // lock. It is part of the clear decision so a divergence still clears the
225+ // file.
226+ persistedPlan := i.cfg.PersistedPlan != nil && i.cfg.PersistedPlan() != nil
227+ i.mu.Lock()
228+ // Read i.agent under i.mu, the same field the locked Agent accessor
229+ // returns; the lock-free read this replaced could race a session swap.
230+ clearPlan := i.agent != nil && len(i.agent.CurrentPlan()) > 0
221231 if i.agent != nil {
222232 i.agent.SetMessages(nil)
233+ i.agent.SetPlan(nil)
223234 }
224- i.mu.Lock()
225235 i.toolCalls = map[string]*tui.ToolCallView{}
226236 i.toolOrder = nil
227237 i.toolGate = map[string]int{}
@@ -234,12 +244,24 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
234244 i.scrollOffset = 0
235245 i.extNotes = nil
236246 i.reloadErrors = nil
247+ i.planSnapshots = nil
248+ i.planCurrent = 0
249+ i.planTotal = 0
250+ i.planRevision++
237251 handoff, persistHandoff := i.resetCompactContinuationLocked()
238252 i.view.InvalidateRenderCache()
239253 i.mu.Unlock()
240254 if persistHandoff {
241255 i.persistCompactHandoff(handoff)
242256 }
257+ // Persist outside the lock: session writes are file I/O. Only write when
258+ // a plan existed in core or on disk, so /clear in --no-session mode stays
259+ // a no-op there.
260+ if (clearPlan || persistedPlan) && i.cfg.PersistPlan != nil {
261+ if err := i.cfg.PersistPlan(nil); err != nil {
262+ i.ReportError(fmt.Errorf("clear plan: %w", err))
263+ }
264+ }
243265 case "/help":
244266 i.mu.Lock()
245267 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
@@ -1202,9 +1224,11 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
12021224 // cross-provider /model swap.
12031225 var carryMsgs []provider.Message
12041226 var carryCost provider.Usage
1227+ var carryPlan []core.PlanStep
12051228 if i.agent != nil {
12061229 carryMsgs = i.agent.Messages()
12071230 carryCost = i.agent.Cost()
1231+ carryPlan = i.agent.CurrentPlan()
12081232 }
12091233
12101234 ag, p, md, err := builder(m.Provider, m.ID)
@@ -1224,6 +1248,10 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
12241248 if len(carryMsgs) > 0 {
12251249 ag.SetMessages(carryMsgs)
12261250 }
1251+ // A provider rebuild is a fresh agent; carry the session-scoped plan over so
1252+ // the checklist survives a cross-provider /model swap, matching the way the
1253+ // transcript and cumulative cost are handed off.
1254+ ag.SetPlan(carryPlan)
12271255 ag.SeedCost(carryCost)
12281256
12291257 if i.agent != nil && i.agent != ag {
M packages/agent/modes/interactive_events.go +12 -0
@@ -197,6 +197,18 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
197197 }
198198 case core.EvPlanUpdate:
199199 i.planCurrent, i.planTotal = planProgress(e.Update.Plan)
200+ // Copy-on-write: the live view may still reference the previous map while
201+ // a render reads it. Replacing the map instead of mutating it in place
202+ // keeps every published map immutable, so no reader can race a map write.
203+ next := make(map[string]core.PlanUpdate, len(i.planSnapshots)+1)
204+ for id, update := range i.planSnapshots {
205+ next[id] = update
206+ }
207+ next[e.CallID] = e.Update
208+ i.planSnapshots = next
209+ // A checklist can arrive after the tool-result message was first cached.
210+ // Bumping the revision invalidates those rows through msgVisualKey.
211+ i.planRevision++
200212 case core.EvUsage:
201213 i.cumUsage = e.Cumulative
202214 if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 {
A packages/agent/modes/interactive_plan.go +66 -0
@@ -0,0 +1,66 @@
1+package modes
2+
3+import "github.com/bnema/zut/packages/core"
4+
5+// RefreshPlan reseeds the plan counters and the per-call checklist snapshot map
6+// after a session switch. The CLI seeds the agent's core plan state before it
7+// commits a loaded session; this reads it back and installs the snapshots the
8+// CLI reconstructed from the session's full transcript.
9+//
10+// It mirrors RefreshGoal: the CLI owns agent/session swapping and calls this
11+// once the swap is committed. snapshots must come from the untrimmed transcript:
12+// a resumed agent only holds the trim window, and replaying a delta against an
13+// empty base rebuilds the wrong checklist.
14+func (i *Interactive) RefreshPlan(snapshots map[string]core.PlanUpdate) {
15+ var steps []core.PlanStep
16+ if i.cfg.CurrentPlan != nil {
17+ // CurrentPlan reaches back into the live agent, so read it before
18+ // taking i.mu to keep the lock order one-directional.
19+ steps = i.cfg.CurrentPlan()
20+ }
21+ i.applyPlanState(steps, snapshots)
22+}
23+
24+// applyPlanState installs the plan counters and snapshot map. The revision bump
25+// is part of msgVisualKey, so every cached checklist row is invalidated.
26+func (i *Interactive) applyPlanState(steps []core.PlanStep, snapshots map[string]core.PlanUpdate) {
27+ i.mu.Lock()
28+ i.planSnapshots = snapshots
29+ i.planRevision++
30+ i.planCurrent, i.planTotal = planProgress(steps)
31+ i.mu.Unlock()
32+ i.invalidate()
33+}
34+
35+// PlanStatus reports the session-scoped plan counters along with the number of
36+// reconstructed per-call checklist snapshots. Hosts and tests read the plan
37+// view through it instead of reaching into the private render state.
38+func (i *Interactive) PlanStatus() (current, total, snapshots int) {
39+ i.mu.Lock()
40+ defer i.mu.Unlock()
41+ return i.planCurrent, i.planTotal, len(i.planSnapshots)
42+}
43+
44+// seedPlanState populates the initial plan counters and snapshot map during
45+// construction, before the first render. cfg.CurrentPlan reads the live agent
46+// (not i), so this may run while the Interactive is still private.
47+//
48+// InitialPlanSnapshots, when supplied by the CLI, was reconstructed from the
49+// session's full transcript; it is the only correct source after a resume whose
50+// trim window starts mid-plan. Without it (embedders, tests) the agent's own
51+// messages are the best available transcript.
52+func (i *Interactive) seedPlanState() {
53+ if i.agent != nil {
54+ snapshots := i.cfg.InitialPlanSnapshots
55+ if snapshots == nil {
56+ snapshots = core.PlanUpdateSnapshots(i.agent.Messages())
57+ }
58+ i.planSnapshots = snapshots
59+ // Advance rather than assign so a re-seed on a live instance cannot
60+ // move the revision backwards past a render that already observed it.
61+ i.planRevision++
62+ }
63+ if i.cfg.CurrentPlan != nil {
64+ i.planCurrent, i.planTotal = planProgress(i.cfg.CurrentPlan())
65+ }
66+}
M packages/agent/modes/interactive_plan_test.go +339 -6
@@ -1,27 +1,360 @@
11 package modes
22
33 import (
4+ "context"
5+ "fmt"
6+ "slices"
7+ "sync"
48 "testing"
9+ "time"
510
611 "github.com/bnema/zut/packages/core"
12+ "github.com/bnema/zut/packages/provider"
713 )
814
15+func interactivePlanCounters(i *Interactive) (current, total int) {
16+ i.mu.Lock()
17+ defer i.mu.Unlock()
18+ return i.planCurrent, i.planTotal
19+}
20+
21+func interactivePlanSnapshotCount(i *Interactive) int {
22+ i.mu.Lock()
23+ defer i.mu.Unlock()
24+ return len(i.planSnapshots)
25+}
26+
27+func interactivePlanSnapshot(i *Interactive, callID string) core.PlanUpdate {
28+ i.mu.Lock()
29+ defer i.mu.Unlock()
30+ return i.planSnapshots[callID]
31+}
32+
933 func TestInteractiveTracksCurrentPlanStep(t *testing.T) {
1034 interactive := &Interactive{}
11- interactive.handleEvent(core.EvPlanUpdate{Update: core.PlanUpdate{Plan: []core.PlanStep{
35+ interactive.handleEvent(core.EvPlanUpdate{CallID: "plan-1", Update: core.PlanUpdate{Plan: []core.PlanStep{
1236 {Step: "Inspect", Status: core.PlanCompleted},
1337 {Step: "Implement", Status: core.PlanInProgress},
1438 {Step: "Verify", Status: core.PlanPending},
1539 }}})
1640
17- if interactive.planCurrent != 2 || interactive.planTotal != 3 {
18- t.Fatalf("plan progress = %d/%d, want 2/3", interactive.planCurrent, interactive.planTotal)
41+ if current, total := interactivePlanCounters(interactive); current != 2 || total != 3 {
42+ t.Fatalf("plan progress = %d/%d, want 2/3", current, total)
43+ }
44+ if got := interactivePlanSnapshot(interactive, "plan-1").Plan; len(got) != 3 {
45+ t.Fatalf("plan snapshot = %#v, want the three-step checklist", got)
1946 }
2047
21- interactive.handleEvent(core.EvPlanUpdate{Update: core.PlanUpdate{Plan: []core.PlanStep{
48+ interactive.handleEvent(core.EvPlanUpdate{CallID: "plan-2", Update: core.PlanUpdate{Plan: []core.PlanStep{
2249 {Step: "Inspect", Status: core.PlanCompleted},
2350 }}})
24- if interactive.planCurrent != 0 || interactive.planTotal != 1 {
25- t.Fatalf("completed plan progress = %d/%d, want hidden current with total 1", interactive.planCurrent, interactive.planTotal)
51+ if current, total := interactivePlanCounters(interactive); current != 0 || total != 1 {
52+ t.Fatalf("completed plan progress = %d/%d, want hidden current with total 1", current, total)
53+ }
54+}
55+
56+// TestInteractivePlanCountersSurviveSecondTurn guards the removed per-turn reset:
57+// a new turn must not wipe the session-scoped counters.
58+func TestInteractivePlanCountersSurviveSecondTurn(t *testing.T) {
59+ ag := core.NewAgent(alertBlockingClient{}, "test-model", "", nil)
60+ i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: &alertTestTerminal{}})
61+ i.handleEvent(core.EvPlanUpdate{CallID: "plan-1", Update: core.PlanUpdate{Plan: []core.PlanStep{
62+ {Step: "one", Status: core.PlanCompleted},
63+ {Step: "two", Status: core.PlanInProgress},
64+ }}})
65+
66+ ctx, cancel := context.WithCancel(context.Background())
67+ defer cancel()
68+ i.runCtx = ctx
69+ go i.runStreamPacer(ctx)
70+ i.startTurn(ctx, "second turn")
71+
72+ // Wait until the turn owns the busy slot, which is after the turn-start
73+ // reset site that used to clear planCurrent/planTotal.
74+ deadline := time.Now().Add(time.Second)
75+ started := false
76+ for time.Now().Before(deadline) {
77+ i.mu.Lock()
78+ busy := i.busy
79+ i.mu.Unlock()
80+ if busy {
81+ started = true
82+ break
83+ }
84+ time.Sleep(time.Millisecond)
85+ }
86+ if !started {
87+ t.Fatal("second turn did not start")
88+ }
89+ if current, total := interactivePlanCounters(i); current != 2 || total != 2 {
90+ t.Fatalf("plan counters after a new turn = %d/%d, want 2/2", current, total)
91+ }
92+ i.CancelTurn()
93+}
94+
95+// TestInteractiveRefreshPlanSeedsCountersAndSnapshots covers the resume path:
96+// the CLI seeds the agent's core plan from the loaded session, and the host
97+// derives its counters and historical checklists from it.
98+func TestInteractiveRefreshPlanSeedsCountersAndSnapshots(t *testing.T) {
99+ ag := core.NewAgent(alertBlockingClient{}, "test-model", "", nil)
100+ ag.SetMessages([]provider.Message{
101+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
102+ ID: "legacy-1", Name: "update_plan", Arguments: []byte(`{"plan":[{"step":"one","status":"completed"},{"step":"two","status":"in_progress"}]}`),
103+ }}},
104+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
105+ CallID: "legacy-1", Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
106+ }}},
107+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
108+ ID: "delta-1", Name: "plan", Arguments: []byte(`{"action":"add","steps":[{"step":"three"}]}`),
109+ }}},
110+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
111+ CallID: "delta-1", Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
112+ }}},
113+ })
114+ wantPlan := []core.PlanStep{
115+ {Step: "one", Status: core.PlanCompleted},
116+ {Step: "two", Status: core.PlanInProgress},
117+ {Step: "three", Status: core.PlanPending},
118+ }
119+ ag.SetPlan(wantPlan)
120+
121+ i := NewInteractive(InteractiveConfig{Agent: ag, CurrentPlan: ag.CurrentPlan})
122+ // Clear the construction-time seed so RefreshPlan is what rebuilds it.
123+ i.mu.Lock()
124+ i.planSnapshots = nil
125+ i.planRevision = 0
126+ i.mu.Unlock()
127+
128+ i.RefreshPlan(core.PlanUpdateSnapshots(ag.Messages()))
129+
130+ if current, total := interactivePlanCounters(i); current != 2 || total != 3 {
131+ t.Fatalf("refreshed counters = %d/%d, want 2/3", current, total)
132+ }
133+ i.mu.Lock()
134+ legacy := i.planSnapshots["legacy-1"]
135+ delta := i.planSnapshots["delta-1"]
136+ i.mu.Unlock()
137+ if !slices.Equal(legacy.Plan, []core.PlanStep{
138+ {Step: "one", Status: core.PlanCompleted},
139+ {Step: "two", Status: core.PlanInProgress},
140+ }) {
141+ t.Fatalf("legacy snapshot = %#v", legacy.Plan)
142+ }
143+ if !slices.Equal(delta.Plan, []core.PlanStep{
144+ {Step: "one", Status: core.PlanCompleted},
145+ {Step: "two", Status: core.PlanInProgress},
146+ {Step: "three", Status: core.PlanPending},
147+ }) {
148+ t.Fatalf("delta snapshot = %#v", delta.Plan)
149+ }
150+}
151+
152+// TestInteractiveClearDropsPlanEverywhere covers /clear: core state, the
153+// counters, the snapshot map, and the persisted session file.
154+func TestInteractiveClearDropsPlanEverywhere(t *testing.T) {
155+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "test")
156+ if err != nil {
157+ t.Fatal(err)
158+ }
159+ t.Cleanup(func() { _ = sess.Close() })
160+ if err := sess.AppendMessage(provider.Message{
161+ Role: provider.RoleUser,
162+ Content: []provider.Content{provider.TextBlock{Text: "hello"}},
163+ }); err != nil {
164+ t.Fatal(err)
165+ }
166+ steps := []core.PlanStep{{Step: "one", Status: core.PlanPending}}
167+ if err := sess.UpdatePlan(&core.SessionPlan{Steps: steps}); err != nil {
168+ t.Fatal(err)
169+ }
170+
171+ ag := core.NewAgent(alertBlockingClient{}, "model", "", nil)
172+ ag.SetPlan(steps)
173+ persisted := 0
174+ i := NewInteractive(InteractiveConfig{
175+ Agent: ag,
176+ CurrentPlan: ag.CurrentPlan,
177+ PersistPlan: func(plan *core.SessionPlan) error {
178+ persisted++
179+ return sess.UpdatePlan(plan)
180+ },
181+ })
182+ i.handleEvent(core.EvPlanUpdate{CallID: "plan-1", Update: core.PlanUpdate{Plan: steps}})
183+
184+ i.runSlash(context.Background(), "/clear")
185+
186+ if got := ag.CurrentPlan(); got != nil {
187+ t.Fatalf("core plan after /clear = %#v, want nil", got)
188+ }
189+ if current, total := interactivePlanCounters(i); current != 0 || total != 0 {
190+ t.Fatalf("counters after /clear = %d/%d, want 0/0", current, total)
191+ }
192+ if got := interactivePlanSnapshotCount(i); got != 0 {
193+ t.Fatalf("snapshot map after /clear has %d entries, want 0", got)
194+ }
195+ if persisted != 1 {
196+ t.Fatalf("PersistPlan calls = %d, want 1", persisted)
197+ }
198+ if sess.Meta.Plan != nil {
199+ t.Fatalf("in-memory session plan after /clear = %#v, want nil", sess.Meta.Plan)
200+ }
201+ reopened, _, err := core.OpenSession(sess.Path)
202+ if err != nil {
203+ t.Fatal(err)
204+ }
205+ defer reopened.Close()
206+ if reopened.Meta.Plan != nil {
207+ t.Fatalf("persisted session plan after /clear = %#v, want nil", reopened.Meta.Plan)
208+ }
209+}
210+
211+// TestInteractiveClearWithoutPlanDoesNotPersist keeps /clear a no-op when there
212+// is nothing to drop, so a --no-session run never surfaces a persistence error.
213+func TestInteractiveClearWithoutPlanDoesNotPersist(t *testing.T) {
214+ ag := core.NewAgent(alertBlockingClient{}, "model", "", nil)
215+ called := false
216+ i := NewInteractive(InteractiveConfig{
217+ Agent: ag,
218+ CurrentPlan: ag.CurrentPlan,
219+ PersistPlan: func(*core.SessionPlan) error {
220+ called = true
221+ return nil
222+ },
223+ })
224+ i.runSlash(context.Background(), "/clear")
225+ if called {
226+ t.Fatal("/clear persisted an empty plan")
227+ }
228+}
229+
230+// TestInteractiveClearPersistsWhenCoreAndFileDiverge guards GO-005: /clear must
231+// clear the file plan even when core state lost it, because the bound session
232+// still carries it and a stale file would seed the next resume.
233+func TestInteractiveClearPersistsWhenCoreAndFileDiverge(t *testing.T) {
234+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "test")
235+ if err != nil {
236+ t.Fatal(err)
237+ }
238+ t.Cleanup(func() { _ = sess.Close() })
239+ if err := sess.UpdatePlan(&core.SessionPlan{Steps: []core.PlanStep{{Step: "one", Status: core.PlanPending}}}); err != nil {
240+ t.Fatal(err)
241+ }
242+
243+ // Core state is empty while the session file still holds a plan.
244+ ag := core.NewAgent(alertBlockingClient{}, "model", "", nil)
245+ persisted := 0
246+ i := NewInteractive(InteractiveConfig{
247+ Agent: ag,
248+ CurrentPlan: ag.CurrentPlan,
249+ PersistedPlan: func() *core.SessionPlan {
250+ if sess.Meta.Plan == nil {
251+ return nil
252+ }
253+ plan := *sess.Meta.Plan
254+ return &plan
255+ },
256+ PersistPlan: func(plan *core.SessionPlan) error {
257+ persisted++
258+ return sess.UpdatePlan(plan)
259+ },
260+ })
261+
262+ i.runSlash(context.Background(), "/clear")
263+
264+ if persisted != 1 {
265+ t.Fatalf("PersistPlan calls = %d, want 1 for a divergent session plan", persisted)
266+ }
267+ if sess.Meta.Plan != nil {
268+ t.Fatalf("session plan after /clear = %#v, want nil", sess.Meta.Plan)
269+ }
270+}
271+
272+// TestInteractivePlanSnapshotsRaceWithRender publishes plan updates from one
273+// goroutine while another renders a frame. Run under -race, it guards the
274+// copy-on-write snapshot map and the render clone that carries it.
275+func TestInteractivePlanSnapshotsRaceWithRender(t *testing.T) {
276+ ag := core.NewAgent(alertBlockingClient{}, "test-model", "", nil)
277+ ag.SetMessages([]provider.Message{
278+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "plan-1", Name: "plan", Arguments: []byte(`{"action":"set"}`)}}},
279+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "plan-1", Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}}}},
280+ })
281+ i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: &alertTestTerminal{}})
282+ i.mu.Lock()
283+ i.planSnapshots = map[string]core.PlanUpdate{
284+ "plan-1": {Plan: []core.PlanStep{{Step: "one", Status: core.PlanPending}}},
285+ }
286+ i.mu.Unlock()
287+
288+ stop := make(chan struct{})
289+ var wg sync.WaitGroup
290+ wg.Add(2)
291+ go func() {
292+ defer wg.Done()
293+ for n := 0; ; n++ {
294+ select {
295+ case <-stop:
296+ return
297+ default:
298+ }
299+ i.handleEvent(core.EvPlanUpdate{
300+ CallID: fmt.Sprintf("plan-%d", n%4),
301+ Update: core.PlanUpdate{Plan: []core.PlanStep{{Step: fmt.Sprintf("step-%d", n), Status: core.PlanInProgress}}},
302+ })
303+ }
304+ }()
305+ go func() {
306+ defer wg.Done()
307+ for {
308+ select {
309+ case <-stop:
310+ return
311+ default:
312+ }
313+ i.redraw()
314+ }
315+ }()
316+
317+ deadline := time.Now().Add(250 * time.Millisecond)
318+ for time.Now().Before(deadline) {
319+ time.Sleep(5 * time.Millisecond)
320+ }
321+ close(stop)
322+ done := make(chan struct{})
323+ go func() { wg.Wait(); close(done) }()
324+ select {
325+ case <-done:
326+ case <-time.After(5 * time.Second):
327+ t.Fatal("plan publish/render did not finish")
328+ }
329+}
330+
331+// TestModelSwapCarriesPlanAcrossProviderRebuild covers the other agent rebuild
332+// that D4 calls out: a cross-provider /model swap builds a fresh agent, so the
333+// session-scoped plan has to be carried like the transcript and cost.
334+func TestModelSwapCarriesPlanAcrossProviderRebuild(t *testing.T) {
335+ targets := provider.ModelsForProvider("anthropic")
336+ if len(targets) == 0 {
337+ t.Skip("anthropic catalog unavailable")
338+ }
339+ target := targets[0]
340+
341+ ag := core.NewAgent(alertBlockingClient{}, "start-model", "", nil)
342+ ag.SetMessages([]provider.Message{{
343+ Role: provider.RoleUser,
344+ Content: []provider.Content{provider.TextBlock{Text: "hello"}},
345+ }})
346+ want := []core.PlanStep{{Step: "one", Status: core.PlanPending}}
347+ ag.SetPlan(want)
348+
349+ i := NewInteractive(InteractiveConfig{Agent: ag, Provider: "openai", Model: "start-model"})
350+ rebuilt := core.NewAgent(alertBlockingClient{}, target.ID, "", nil)
351+ replaced, ok := i.swapModelUnserialized(target.Provider, target.ID, func(string, string) (*core.Agent, string, string, error) {
352+ return rebuilt, target.Provider, target.ID, nil
353+ }, false)
354+ if !ok || !replaced {
355+ t.Fatalf("cross-provider swap replaced=%v ok=%v", replaced, ok)
356+ }
357+ if got := rebuilt.CurrentPlan(); !slices.Equal(got, want) {
358+ t.Fatalf("plan after provider rebuild = %#v, want %#v", got, want)
26359 }
27360 }
M packages/agent/modes/interactive_rendering.go +6 -0
@@ -72,6 +72,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey {
7272 welcomeShowVer: showVer,
7373 expandAll: i.view.ExpandAll,
7474 tailLimit: i.view.TailLimit,
75+ planRev: i.planRevision,
7576 renderedMessageCount: len(i.view.Messages),
7677 viewCacheRev: i.view.RenderCacheRevision,
7778 }
@@ -245,6 +246,11 @@ func (i *Interactive) buildChatLocked(cols int) []string {
245246 i.view.Messages = nil
246247 i.view.MessagesRevision = 0
247248 }
249+ // Plan checklists render from the snapshot map, keyed by tool-call id. Clone
250+ // for the render goroutine deep-copies it; the revision is part of the render
251+ // cache key so a late snapshot invalidates stale rows.
252+ i.view.PlanUpdates = i.planSnapshots
253+ i.view.PlanRevision = i.planRevision
248254 // Transcript rewinds and compaction can remove messages that an info block
249255 // was anchored after. Clamp those anchors once so later turns still append
250256 // below the block instead of making it jump through the conversation.
M packages/agent/modes/interactive_turn.go +0 -4
@@ -216,8 +216,6 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
216216 i.toolCalls = map[string]*tui.ToolCallView{}
217217 i.toolOrder = nil
218218 i.toolGate = map[string]int{}
219- i.planCurrent = 0
220- i.planTotal = 0
221219 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
222220 i.scrollOffset = 0 // jump back to the bottom on new turn
223221 // Lift the resume tail cap once the user starts interacting. The
@@ -452,8 +450,6 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
452450 }
453451 }
454452 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || hasScheduled || agentQueued > 0 || continueGoal || interruptedReturn, offer, recoverContextOverflow, shouldAutoCompact)
455- i.planCurrent = 0
456- i.planTotal = 0
457453 // interruptedReturn already reserves the turn slot (like the
458454 // compact handoff path below): the reserved continuation owns
459455 // busy and must not re-enter the idle gate.
M packages/agent/modes/json.go +5 -0
@@ -110,6 +110,11 @@ func EventToJSON(ev core.AgentEvent) map[string]any {
110110 m["id"] = e.ID
111111 m["text"] = e.Text
112112 case core.EvPlanUpdate:
113+ // Omit an empty call id, matching the SDK's omitempty so both wire
114+ // projections agree.
115+ if e.CallID != "" {
116+ m["id"] = e.CallID
117+ }
113118 if e.Update.Explanation != nil {
114119 m["explanation"] = *e.Update.Explanation
115120 } else {
M packages/agent/modes/json_activity_test.go +5 -2
@@ -85,11 +85,11 @@ func TestEventToJSONCacheDiagnosticsAreSanitized(t *testing.T) {
8585
8686 func TestEventToJSONPlanUpdate(t *testing.T) {
8787 explanation := "Discovery complete"
88- event := EventToJSON(core.EvPlanUpdate{Update: core.PlanUpdate{
88+ event := EventToJSON(core.EvPlanUpdate{CallID: "call-7", Update: core.PlanUpdate{
8989 Explanation: &explanation,
9090 Plan: []core.PlanStep{{Step: "Implement", Status: core.PlanInProgress}},
9191 }})
92- if event["type"] != "plan_update" || event["explanation"] != "Discovery complete" {
92+ if event["type"] != "plan_update" || event["explanation"] != "Discovery complete" || event["id"] != "call-7" {
9393 t.Fatalf("plan event = %#v", event)
9494 }
9595 plan, ok := event["plan"].([]core.PlanStep)
@@ -100,6 +100,9 @@ func TestEventToJSONPlanUpdate(t *testing.T) {
100100 if withoutExplanation["explanation"] != nil {
101101 t.Fatalf("missing explanation = %#v, want null", withoutExplanation["explanation"])
102102 }
103+ if _, ok := withoutExplanation["id"]; ok {
104+ t.Fatalf("missing call id = %#v, want omitted", withoutExplanation["id"])
105+ }
103106 }
104107
105108 func TestEventToJSONActivityLifecycle(t *testing.T) {
A packages/agent/plan_modes_persist_test.go +136 -0
@@ -0,0 +1,136 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "net/http/httptest"
10+ "sync/atomic"
11+ "testing"
12+
13+ "github.com/bnema/zut/packages/agent/scheduler"
14+ "github.com/bnema/zut/packages/core"
15+ "github.com/bnema/zut/packages/provider"
16+)
17+
18+// TestBotSessionPersistsPlanMutation guards GO-002 for the bot mode: the bot
19+// session must persist a plan mutation through the tool-result commit path so
20+// the next resume seeds from the file instead of an empty core state.
21+func TestBotSessionPersistsPlanMutation(t *testing.T) {
22+ t.Setenv("ZUT_HOME", t.TempDir())
23+ ag := core.NewAgent(nil, "model", "", nil)
24+
25+ sess, _, err := openOrCreateSessionForBot(Args{}, Resolved{Provider: "provider", Model: "model"}, ag, "test")
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ t.Cleanup(func() { _ = sess.Close() })
30+ if ag.CommitToolResult == nil {
31+ t.Fatal("bot session did not wire CommitToolResult")
32+ }
33+
34+ want := []core.PlanStep{{Step: "one", Status: core.PlanPending}}
35+ if err := ag.CommitToolResult("plan-call", planMutationResult(want)); err != nil {
36+ t.Fatal(err)
37+ }
38+ if sess.Meta.Plan == nil {
39+ t.Fatal("plan mutation did not write SessionMeta.Plan")
40+ }
41+
42+ path := sess.Path
43+ if err := sess.Close(); err != nil {
44+ t.Fatal(err)
45+ }
46+ reopened, _, err := core.OpenSession(path)
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ defer reopened.Close()
51+ if reopened.Meta.Plan == nil {
52+ t.Fatal("reopened bot session lost the persisted plan")
53+ }
54+}
55+
56+// TestScheduledSessionPersistsPlanMutation guards GO-002 for the scheduled mode:
57+// a scheduled turn that calls plan must persist the mutation to the session it
58+// reconstructed.
59+func TestScheduledSessionPersistsPlanMutation(t *testing.T) {
60+ home := t.TempDir()
61+ t.Setenv("ZUT_HOME", home)
62+ server := scheduledPlanProvider(t)
63+ defer server.Close()
64+
65+ sess, err := core.NewSession(home, t.TempDir(), "openai", "gpt-5", "test")
66+ if err != nil {
67+ t.Fatal(err)
68+ }
69+ if err := sess.AppendMessage(provider.Message{
70+ Role: provider.RoleUser,
71+ Content: []provider.Content{provider.TextBlock{Text: "scheduled task"}},
72+ }); err != nil {
73+ t.Fatal(err)
74+ }
75+ id := sess.Meta.ID
76+ path := sess.Path
77+ if err := sess.Close(); err != nil {
78+ t.Fatal(err)
79+ }
80+
81+ args := Args{Provider: "openai", Model: "gpt-5", BaseURL: server.URL, APIKey: "test-key"}
82+ base, err := Resolve(args, true)
83+ if err != nil {
84+ t.Fatal(err)
85+ }
86+ task := scheduler.Task{ID: "task-1", SessionID: id, Message: "scheduled task"}
87+ if err := runScheduledSession(context.Background(), task, args, base, nil, nil); err != nil {
88+ t.Fatal(err)
89+ }
90+
91+ reopened, _, err := core.OpenSession(path)
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ defer reopened.Close()
96+ if reopened.Meta.Plan == nil {
97+ t.Fatal("scheduled session did not persist the plan mutation")
98+ }
99+}
100+
101+// scheduledPlanProvider answers the first request with a plan tool call and the
102+// follow-up with a final answer, matching the OpenAI streaming shape the
103+// provider parses.
104+func scheduledPlanProvider(t *testing.T) *httptest.Server {
105+ t.Helper()
106+ var calls atomic.Int32
107+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
108+ _, _ = io.ReadAll(r.Body)
109+ w.Header().Set("content-type", "text/event-stream")
110+ if calls.Add(1) == 1 {
111+ arguments := `{"action":"set","steps":[{"step":"one","status":"pending"}]}`
112+ chunk := map[string]any{
113+ "choices": []any{map[string]any{
114+ "index": 0,
115+ "delta": map[string]any{"tool_calls": []any{map[string]any{
116+ "index": 0,
117+ "id": "plan-call-1",
118+ "type": "function",
119+ "function": map[string]string{"name": "plan", "arguments": arguments},
120+ }}},
121+ "finish_reason": "tool_calls",
122+ }},
123+ "usage": map[string]int{"prompt_tokens": 8, "completion_tokens": 2},
124+ }
125+ data, _ := json.Marshal(chunk)
126+ _, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", data)
127+ return
128+ }
129+ chunk := map[string]any{
130+ "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "done"}, "finish_reason": "stop"}},
131+ "usage": map[string]int{"prompt_tokens": 8, "completion_tokens": 2},
132+ }
133+ data, _ := json.Marshal(chunk)
134+ _, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", data)
135+ }))
136+}
A packages/agent/plan_persist_test.go +111 -0
@@ -0,0 +1,111 @@
1+package agent
2+
3+import (
4+ "errors"
5+ "os"
6+ "slices"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/zut/packages/core"
11+ "github.com/bnema/zut/packages/provider"
12+)
13+
14+func newPlanPersistSession(t *testing.T) *core.Session {
15+ t.Helper()
16+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ if err := sess.AppendMessage(provider.Message{
21+ Role: provider.RoleUser,
22+ Content: []provider.Content{provider.TextBlock{Text: "hello"}},
23+ }); err != nil {
24+ t.Fatal(err)
25+ }
26+ t.Cleanup(func() { _ = sess.Close() })
27+ return sess
28+}
29+
30+// planMutationResult builds the result shape core produces for a validated
31+// plan mutation: materialized details plus the pending operation.
32+func planMutationResult(plan []core.PlanStep) core.ToolResult {
33+ op := core.PlanOperation{Action: "set"}
34+ return core.ToolResult{
35+ Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
36+ Details: core.PlanUpdate{Plan: plan},
37+ PlanOperation: &op,
38+ }
39+}
40+
41+func TestPersistPlanToolResultWritesMetaRow(t *testing.T) {
42+ sess := newPlanPersistSession(t)
43+ want := []core.PlanStep{
44+ {Step: "one", Status: core.PlanPending},
45+ {Step: "two", Status: core.PlanInProgress},
46+ }
47+ if err := persistToolResultState(nil, sess, planMutationResult(want)); err != nil {
48+ t.Fatal(err)
49+ }
50+ if sess.Meta.Plan == nil || !slices.Equal(sess.Meta.Plan.Steps, want) {
51+ t.Fatalf("plan = %#v, want %#v", sess.Meta.Plan, want)
52+ }
53+}
54+
55+// TestPersistPlanToolResultSkipsShowAndErrors covers the two results that must
56+// not append a meta row: show (no materialized state) and any error.
57+func TestPersistPlanToolResultSkipsShowAndErrors(t *testing.T) {
58+ errorResult := planMutationResult([]core.PlanStep{{Step: "one", Status: core.PlanPending}})
59+ errorResult.IsError = true
60+ tests := []struct {
61+ name string
62+ result core.ToolResult
63+ }{
64+ {name: "show", result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "Plan is empty"}}}},
65+ {name: "error", result: errorResult},
66+ }
67+ for _, test := range tests {
68+ t.Run(test.name, func(t *testing.T) {
69+ sess := newPlanPersistSession(t)
70+ if err := persistToolResultState(nil, sess, test.result); err != nil {
71+ t.Fatal(err)
72+ }
73+ if sess.Meta.Plan != nil {
74+ t.Fatalf("plan = %#v, want nil", sess.Meta.Plan)
75+ }
76+ raw, err := os.ReadFile(sess.Path)
77+ if err != nil {
78+ t.Fatal(err)
79+ }
80+ if strings.Contains(string(raw), `"plan"`) {
81+ t.Fatalf("session file gained a plan meta row: %s", raw)
82+ }
83+ })
84+ }
85+}
86+
87+func TestPersistPlanToolResultWriteFailureSurfacesSafeError(t *testing.T) {
88+ sess := newPlanPersistSession(t)
89+ if err := sess.Close(); err != nil {
90+ t.Fatal(err)
91+ }
92+ err := persistToolResultState(nil, sess, planMutationResult([]core.PlanStep{{Step: "one", Status: core.PlanPending}}))
93+ var safe *core.ToolResultCommitError
94+ if !errors.As(err, &safe) {
95+ t.Fatalf("persist error = %T %v, want safe tool result commit error", err, err)
96+ }
97+ if safe.Message != "plan state could not be saved" {
98+ t.Fatalf("safe message = %q", safe.Message)
99+ }
100+}
101+
102+func TestPersistPlanToolResultWithoutSessionSurfacesSafeError(t *testing.T) {
103+ err := persistToolResultState(nil, nil, planMutationResult([]core.PlanStep{{Step: "one", Status: core.PlanPending}}))
104+ var safe *core.ToolResultCommitError
105+ if !errors.As(err, &safe) {
106+ t.Fatalf("persist error = %T %v, want safe tool result commit error", err, err)
107+ }
108+ if safe.Message != "plan state unavailable: session persistence is disabled" {
109+ t.Fatalf("safe message = %q", safe.Message)
110+ }
111+}
A packages/agent/plan_resume_test.go +258 -0
@@ -0,0 +1,258 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "slices"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/zut/packages/agent/modes"
11+ "github.com/bnema/zut/packages/core"
12+ "github.com/bnema/zut/packages/provider"
13+)
14+
15+// sessionWithPlan writes a session whose persisted meta carries steps, then
16+// closes it and returns the path for a resume.
17+func sessionWithPlan(t *testing.T, providerName, model string, steps []core.PlanStep) string {
18+ t.Helper()
19+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), providerName, model, "test")
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ if err := sess.AppendMessage(provider.Message{
24+ Role: provider.RoleUser,
25+ Content: []provider.Content{provider.TextBlock{Text: "resumed transcript"}},
26+ }); err != nil {
27+ t.Fatal(err)
28+ }
29+ if err := sess.UpdatePlan(&core.SessionPlan{Steps: steps}); err != nil {
30+ t.Fatal(err)
31+ }
32+ if err := sess.Close(); err != nil {
33+ t.Fatal(err)
34+ }
35+ return sess.Path
36+}
37+
38+// TestResumeSeedsAgentPlanFromSessionMeta covers the merge-blocking bug: without
39+// seeding, a resumed session's file holds steps while core holds none.
40+func TestResumeSeedsAgentPlanFromSessionMeta(t *testing.T) {
41+ want := []core.PlanStep{
42+ {Step: "one", Status: core.PlanPending},
43+ {Step: "two", Status: core.PlanInProgress},
44+ }
45+ path := sessionWithPlan(t, "stored-provider", "stored-model", want)
46+ sess, _, err := core.OpenSession(path)
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ defer sess.Close()
51+
52+ current := core.NewAgent(nil, "current-model", "", nil)
53+ candidate, err := applySessionResume(sess, current, "current-provider", "current-model", false, false, func(providerName, model string) (*core.Agent, string, string, error) {
54+ return core.NewAgent(nil, model, "", nil), providerName, model, nil
55+ })
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ defer candidate.session.Close()
60+ if !candidate.rebuilt {
61+ t.Fatal("resume did not rebuild the agent")
62+ }
63+ if got := candidate.agent.CurrentPlan(); !slices.Equal(got, want) {
64+ t.Fatalf("resumed plan = %#v, want %#v", got, want)
65+ }
66+}
67+
68+// TestResumeSeedsReusedAgentPlan covers the same-provider path, where the live
69+// agent is reused rather than rebuilt: it still has to be seeded.
70+func TestResumeSeedsReusedAgentPlan(t *testing.T) {
71+ want := []core.PlanStep{{Step: "only", Status: core.PlanInProgress}}
72+ path := sessionWithPlan(t, "provider", "model", want)
73+ sess, _, err := core.OpenSession(path)
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ defer sess.Close()
78+
79+ current := core.NewAgent(nil, "model", "", nil)
80+ candidate, err := applySessionResume(sess, current, "provider", "model", false, false, func(string, string) (*core.Agent, string, string, error) {
81+ t.Fatal("same provider/model unexpectedly rebuilt the agent")
82+ return nil, "", "", nil
83+ })
84+ if err != nil {
85+ t.Fatal(err)
86+ }
87+ defer candidate.session.Close()
88+ if candidate.agent != current {
89+ t.Fatalf("reused candidate agent = %p, want the live agent %p", candidate.agent, current)
90+ }
91+ if got := current.CurrentPlan(); !slices.Equal(got, want) {
92+ t.Fatalf("reused agent plan = %#v, want %#v", got, want)
93+ }
94+}
95+
96+func TestRPCClearDropsPlanState(t *testing.T) {
97+ var out bytes.Buffer
98+ ag := &core.Agent{}
99+ ag.SetPlan([]core.PlanStep{{Step: "one", Status: core.PlanPending}})
100+ s := &rpcServer{agent: ag, out: &out}
101+
102+ s.dispatch("clear", "1", []byte(`{"type":"clear","id":"1"}`))
103+
104+ if got := ag.CurrentPlan(); got != nil {
105+ t.Fatalf("plan after rpc clear = %#v, want nil", got)
106+ }
107+ if !strings.Contains(out.String(), `"success":true`) {
108+ t.Fatalf("rpc clear response = %q", out.String())
109+ }
110+}
111+
112+// appendPlanCall appends a plan tool call paired with a successful result. The
113+// load-time replay only counts a call that has a non-error result.
114+func appendPlanCall(t *testing.T, sess *core.Session, callID, name, args string) {
115+ t.Helper()
116+ if err := sess.AppendMessage(provider.Message{
117+ Role: provider.RoleAssistant,
118+ Content: []provider.Content{provider.ToolCallBlock{ID: callID, Name: name, Arguments: []byte(args)}},
119+ }); err != nil {
120+ t.Fatal(err)
121+ }
122+ if err := sess.AppendMessage(provider.Message{
123+ Role: provider.RoleTool,
124+ Content: []provider.Content{provider.ToolResultBlock{
125+ CallID: callID,
126+ Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
127+ }},
128+ }); err != nil {
129+ t.Fatal(err)
130+ }
131+}
132+
133+func newResumeBuilder(t *testing.T) func(providerName, model string) (*core.Agent, string, string, error) {
134+ t.Helper()
135+ return func(providerName, model string) (*core.Agent, string, string, error) {
136+ return core.NewAgent(nil, model, "", nil), providerName, model, nil
137+ }
138+}
139+
140+// TestResumePlanSnapshotsUseFullTranscriptBeyondTrimWindow guards GO-001: at
141+// resume only the trim window reaches the agent, so replaying its messages
142+// turns an in-window delta into a rebuild from an empty base. The reconstructed
143+// snapshots must instead come from the untrimmed session transcript.
144+func TestResumePlanSnapshotsUseFullTranscriptBeyondTrimWindow(t *testing.T) {
145+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "stored-provider", "stored-model", "test")
146+ if err != nil {
147+ t.Fatal(err)
148+ }
149+ appendPlanCall(t, sess, "set-1", "update_plan",
150+ `{"plan":[{"step":"one","status":"completed"},{"step":"two","status":"in_progress"},{"step":"three","status":"pending"}]}`)
151+ for i := 0; i < 120; i++ {
152+ if err := sess.AppendMessage(provider.Message{
153+ Role: provider.RoleUser,
154+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("filler-%d", i)}},
155+ }); err != nil {
156+ t.Fatal(err)
157+ }
158+ }
159+ appendPlanCall(t, sess, "add-four", "plan", `{"action":"add","steps":[{"step":"four"}]}`)
160+ appendPlanCall(t, sess, "upd-two", "plan", `{"action":"update","index":2,"status":"completed"}`)
161+ final := []core.PlanStep{
162+ {Step: "one", Status: core.PlanCompleted},
163+ {Step: "two", Status: core.PlanCompleted},
164+ {Step: "three", Status: core.PlanPending},
165+ {Step: "four", Status: core.PlanPending},
166+ }
167+ if err := sess.UpdatePlan(&core.SessionPlan{Steps: final}); err != nil {
168+ t.Fatal(err)
169+ }
170+ path := sess.Path
171+ if err := sess.Close(); err != nil {
172+ t.Fatal(err)
173+ }
174+
175+ resumed, _, err := core.OpenSession(path)
176+ if err != nil {
177+ t.Fatal(err)
178+ }
179+ defer resumed.Close()
180+ current := core.NewAgent(nil, "current-model", "", nil)
181+ candidate, err := applySessionResume(resumed, current, "current-provider", "current-model", false, false, newResumeBuilder(t))
182+ if err != nil {
183+ t.Fatal(err)
184+ }
185+ defer candidate.session.Close()
186+
187+ if got := candidate.agent.CurrentPlan(); !slices.Equal(got, final) {
188+ t.Fatalf("resumed plan = %#v, want %#v", got, final)
189+ }
190+ if len(candidate.planSnapshots) != 3 {
191+ t.Fatalf("snapshots = %#v, want the full transcript's replays", candidate.planSnapshots)
192+ }
193+ if got := candidate.planSnapshots["set-1"].Plan; !slices.Equal(got, []core.PlanStep{
194+ {Step: "one", Status: core.PlanCompleted},
195+ {Step: "two", Status: core.PlanInProgress},
196+ {Step: "three", Status: core.PlanPending},
197+ }) {
198+ t.Fatalf("set snapshot = %#v", got)
199+ }
200+ if got := candidate.planSnapshots["add-four"].Plan; !slices.Equal(got, []core.PlanStep{
201+ {Step: "one", Status: core.PlanCompleted},
202+ {Step: "two", Status: core.PlanInProgress},
203+ {Step: "three", Status: core.PlanPending},
204+ {Step: "four", Status: core.PlanPending},
205+ }) {
206+ t.Fatalf("add snapshot = %#v", got)
207+ }
208+ if got := candidate.planSnapshots["upd-two"].Plan; !slices.Equal(got, final) {
209+ t.Fatalf("update snapshot = %#v, want %#v", got, final)
210+ }
211+}
212+
213+// TestResumeSeedsInteractivePlanCountersAndSnapshots covers the CLI resume
214+// wiring the session picker shares: the candidate agent is seeded from
215+// SessionMeta.Plan, and the Interactive reconstructs its counters and per-call
216+// snapshot map from the resume result.
217+func TestResumeSeedsInteractivePlanCountersAndSnapshots(t *testing.T) {
218+ want := []core.PlanStep{
219+ {Step: "one", Status: core.PlanCompleted},
220+ {Step: "two", Status: core.PlanInProgress},
221+ }
222+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "stored-provider", "stored-model", "test")
223+ if err != nil {
224+ t.Fatal(err)
225+ }
226+ appendPlanCall(t, sess, "set-1", "plan", `{"action":"set","steps":[{"step":"one","status":"completed"},{"step":"two","status":"in_progress"}]}`)
227+ if err := sess.UpdatePlan(&core.SessionPlan{Steps: want}); err != nil {
228+ t.Fatal(err)
229+ }
230+ path := sess.Path
231+ if err := sess.Close(); err != nil {
232+ t.Fatal(err)
233+ }
234+
235+ resumed, _, err := core.OpenSession(path)
236+ if err != nil {
237+ t.Fatal(err)
238+ }
239+ defer resumed.Close()
240+ current := core.NewAgent(nil, "current-model", "", nil)
241+ candidate, err := applySessionResume(resumed, current, "current-provider", "current-model", false, false, newResumeBuilder(t))
242+ if err != nil {
243+ t.Fatal(err)
244+ }
245+ defer candidate.session.Close()
246+
247+ iv := modes.NewInteractive(modes.InteractiveConfig{
248+ Agent: candidate.agent,
249+ CurrentPlan: candidate.agent.CurrentPlan,
250+ InitialPlanSnapshots: candidate.planSnapshots,
251+ })
252+ if got := candidate.agent.CurrentPlan(); !slices.Equal(got, want) {
253+ t.Fatalf("resumed plan = %#v, want %#v", got, want)
254+ }
255+ if currentStep, total, snapshots := iv.PlanStatus(); currentStep != 2 || total != 2 || snapshots != 1 {
256+ t.Fatalf("interactive plan status = %d/%d with %d snapshots, want 2/2 with 1", currentStep, total, snapshots)
257+ }
258+}
M packages/agent/rpc.go +1 -0
@@ -349,6 +349,7 @@ func (s *rpcServer) dispatch(cmd, id string, raw []byte) {
349349
350350 case "clear":
351351 s.agent.SetMessages(nil)
352+ s.agent.SetPlan(nil)
352353 s.writeResponse(id, cmd, nil)
353354
354355 case "set_model":
M packages/agent/scheduler_runtime.go +8 -0
@@ -49,6 +49,14 @@ func runScheduledSession(ctx context.Context, task scheduler.Task, args Args, ba
4949 return fmt.Errorf("build scheduled session agent: %w", err)
5050 }
5151 ag.SetMessages(trimMessagesForResume(messages, 100))
52+ // Seed the agent-owned plan from the reconstructed session so a scheduled
53+ // turn sees the persisted checklist instead of empty core state.
54+ ag.SetPlan(sess.Meta.PlanSteps())
55+ // Route tool-result commits through the session so plan (and goal)
56+ // mutations the scheduled turn makes are durable, matching the other modes.
57+ ag.CommitToolResult = func(_ string, result core.ToolResult) error {
58+ return persistToolResultState(nil, sess, result)
59+ }
5260 if cumulative, lastTurn, usageErr := core.SessionUsageDetail(path); usageErr == nil {
5361 ag.SeedCost(cumulative)
5462 ag.SeedLastTurnUsage(lastTurn)
A packages/agent/sdk/plan_test.go +40 -0
@@ -0,0 +1,40 @@
1+package sdk
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/zut/packages/core"
7+)
8+
9+// TestRuntimePlanAccessors guards GO-004: an embedder that seeds history with
10+// SetMessages can also seed the session-scoped plan, so `show` and indexed
11+// updates see the persisted steps instead of an empty plan.
12+func TestRuntimePlanAccessors(t *testing.T) {
13+ rt := &Runtime{agent: core.NewAgent(nil, "model", "", nil)}
14+ rt.SetPlan([]PlanStep{
15+ {Step: "one", Status: "in_progress"},
16+ {Step: "two", Status: "pending"},
17+ })
18+
19+ got := rt.Plan()
20+ if len(got) != 2 || got[0].Step != "one" || got[0].Status != "in_progress" || got[1].Step != "two" || got[1].Status != "pending" {
21+ t.Fatalf("plan = %#v", got)
22+ }
23+ got[0].Step = "mutated"
24+ if again := rt.Plan(); again[0].Step != "one" {
25+ t.Fatalf("Plan returned an aliased slice: %#v", again)
26+ }
27+
28+ rt.SetPlan(nil)
29+ if cleared := rt.Plan(); cleared != nil {
30+ t.Fatalf("plan after clear = %#v, want nil", cleared)
31+ }
32+}
33+
34+func TestRuntimePlanAccessorsWithoutAgent(t *testing.T) {
35+ rt := &Runtime{}
36+ rt.SetPlan([]PlanStep{{Step: "one", Status: "pending"}})
37+ if got := rt.Plan(); got != nil {
38+ t.Fatalf("plan = %#v, want nil without an agent", got)
39+ }
40+}
M packages/agent/sdk/sdk.go +31 -1
@@ -79,7 +79,7 @@ type Config struct {
7979
8080 // Tools is the list of tools to enable. Nil/empty enables the ordinary
8181 // built-ins: read, write, edit, bash, python, worktree, grep, glob, ast, lsp,
82- // update_goal, and update_plan. The public-web capability is excluded unless this list
82+ // update_goal, and plan. The public-web capability is excluded unless this list
8383 // explicitly contains "web_search"; that selector enables web_search,
8484 // web_open, web_find, and web_click. Pass NoTools=true to disable everything.
8585 Tools []string
@@ -208,6 +208,36 @@ func (r *Runtime) SetMessages(msgs []Message) {
208208 r.agent.SetMessages(out)
209209 }
210210
211+// SetPlan replaces the agent-owned checklist. Use to seed plan state from a
212+// session file so `show` and indexed updates see the persisted steps instead
213+// of an empty plan. A plan is session-scoped, so seed it alongside SetMessages.
214+func (r *Runtime) SetPlan(steps []PlanStep) {
215+ if r.agent == nil {
216+ return
217+ }
218+ out := make([]core.PlanStep, len(steps))
219+ for i, step := range steps {
220+ out[i] = core.PlanStep{Step: step.Step, Status: core.PlanStepStatus(step.Status)}
221+ }
222+ r.agent.SetPlan(out)
223+}
224+
225+// Plan returns a copy of the agent-owned checklist.
226+func (r *Runtime) Plan() []PlanStep {
227+ if r.agent == nil {
228+ return nil
229+ }
230+ src := r.agent.CurrentPlan()
231+ if len(src) == 0 {
232+ return nil
233+ }
234+ out := make([]PlanStep, len(src))
235+ for i, step := range src {
236+ out[i] = PlanStep{Step: step.Step, Status: string(step.Status)}
237+ }
238+ return out
239+}
240+
211241 // Cost returns the cumulative usage and dollar cost for this Runtime.
212242 func (r *Runtime) Cost() Usage {
213243 if r.agent == nil {
M packages/agent/sdk/types.go +1 -0
@@ -200,6 +200,7 @@ func toEvent(ev core.AgentEvent) Event {
200200 out.ID = e.ID
201201 out.Text = e.Text
202202 case core.EvPlanUpdate:
203+ out.ID = e.CallID
203204 out.Explanation = e.Update.Explanation
204205 plan := make([]PlanStep, len(e.Update.Plan))
205206 for idx, step := range e.Update.Plan {
M packages/agent/sdk/types_test.go +5 -2
@@ -37,14 +37,14 @@ func TestToEventPreservesCacheDiagnostics(t *testing.T) {
3737
3838 func TestToEventPreservesPlanUpdate(t *testing.T) {
3939 explanation := "Discovery complete"
40- event := toEvent(core.EvPlanUpdate{Update: core.PlanUpdate{
40+ event := toEvent(core.EvPlanUpdate{CallID: "call-7", Update: core.PlanUpdate{
4141 Explanation: &explanation,
4242 Plan: []core.PlanStep{
4343 {Step: "Implement", Status: core.PlanInProgress},
4444 {Step: "Verify", Status: core.PlanPending},
4545 },
4646 }})
47- if event.Type != "plan_update" || event.Explanation == nil || *event.Explanation != explanation {
47+ if event.Type != "plan_update" || event.ID != "call-7" || event.Explanation == nil || *event.Explanation != explanation {
4848 t.Fatalf("plan event = %#v", event)
4949 }
5050 if event.Plan == nil || len(*event.Plan) != 2 || (*event.Plan)[0].Status != "in_progress" {
@@ -59,6 +59,9 @@ func TestToEventPreservesPlanUpdate(t *testing.T) {
5959 if !strings.Contains(string(encoded), `"plan":[]`) {
6060 t.Fatalf("empty plan JSON = %s, want explicit empty list", encoded)
6161 }
62+ if strings.Contains(string(encoded), `"id"`) {
63+ t.Fatalf("call id should be omitted when empty: %s", encoded)
64+ }
6265 }
6366
6467 func TestToEventPreservesActivityAndToolStreamFacts(t *testing.T) {
M packages/agent/subagent_factory.go +1 -1
@@ -283,7 +283,7 @@ func residentChildRegistry(catalogue core.Registry, names []string) (core.Regist
283283 if name == "" {
284284 return nil, fmt.Errorf("resident child has an empty tool name")
285285 }
286- if name == tools.SubagentToolName || name == "update_goal" {
286+ if name == tools.SubagentToolName || name == "update_goal" || name == tools.PlanToolName {
287287 return nil, fmt.Errorf("resident child tool %q is not allowed", name)
288288 }
289289 tool, ok := catalogue[name]
M packages/agent/subagent_factory_test.go +2 -1
@@ -211,6 +211,7 @@ func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T
211211 "bash": nil,
212212 "subagent": nil,
213213 "update_goal": nil,
214+ "plan": nil,
214215 }
215216 registry, err := residentChildRegistry(catalogue, []string{"read"})
216217 if err != nil {
@@ -219,7 +220,7 @@ func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T
219220 if len(registry) != 1 {
220221 t.Fatalf("registry = %#v", registry)
221222 }
222- for _, name := range []string{"subagent", "update_goal", "missing"} {
223+ for _, name := range []string{"subagent", "update_goal", "plan", "missing"} {
223224 _, err := residentChildRegistry(catalogue, []string{name})
224225 if err == nil || !strings.Contains(err.Error(), name) {
225226 t.Fatalf("residentChildRegistry(%q) error = %v", name, err)
M packages/agent/subagent_runtime.go +1 -1
@@ -358,7 +358,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
358358 allTools := make([]string, 0, len(catalogue))
359359 for name := range catalogue {
360360 switch name {
361- case tools.SubagentToolName, "update_goal":
361+ case tools.SubagentToolName, "update_goal", tools.PlanToolName:
362362 continue
363363 }
364364 if _, ok := resolved.ToolRegistry[name]; !ok {
M packages/agent/subagent_runtime_test.go +2 -1
@@ -154,6 +154,7 @@ func TestResidentChildSpecOmitsParentOnlyTools(t *testing.T) {
154154 "host-only-tool": nil,
155155 "subagent": nil,
156156 "update_goal": nil,
157+ "plan": nil,
157158 }
158159 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)
159160 if err != nil {
@@ -165,7 +166,7 @@ func TestResidentChildSpecOmitsParentOnlyTools(t *testing.T) {
165166 if !slices.Contains(spec.Tools, "read") {
166167 t.Fatalf("generic child lost supported tool: %v", spec.Tools)
167168 }
168- for _, name := range []string{"subagent", "update_goal"} {
169+ for _, name := range []string{"subagent", "update_goal", "plan"} {
169170 if slices.Contains(spec.Tools, name) {
170171 t.Fatalf("generic child kept forbidden tool %q: %v", name, spec.Tools)
171172 }
M packages/agent/systemprompt.go +4 -0
@@ -89,6 +89,8 @@ func BuildSystemPrompt(o SystemPromptOpts) string {
8989 sb.WriteString("\n\n")
9090 sb.WriteString(taskExecutionGuidance)
9191 sb.WriteString("\n\n")
92+ sb.WriteString(planGuidance)
93+ sb.WriteString("\n\n")
9294 sb.WriteString(skillPriorityGuidance)
9395 sb.WriteString("\n\n")
9496 sb.WriteString(writingGuidance)
@@ -109,6 +111,8 @@ const compactedSummaryHandoffInstruction = `When you see a "## Context Summary (
109111
110112 const taskExecutionGuidance = `Treat requests such as "can you" as instructions to do the requested work within the user's intended scope; do not stop at a plan or an offer to continue. Proceed with authorized, non-destructive work and reasonable assumptions until the task is complete or genuinely blocked. Follow each progress statement with the action it describes: pair every intermediate update with tool calls or a concrete next step in the same turn. Final answers contain the result of the work or a concrete blocker, never a bare promise to continue. Respect requests for explanation, planning, or review without making unsolicited changes. Ask only when a necessary decision blocks safe, correct progress; complete independent authorized work first. Preserve tool permissions, required confirmations, and explicit approval requirements; do not infer permission for destructive or irreversible actions.`
111113
114+const planGuidance = `For multi-phase or dependency-ordered work, and whenever the user asks for a plan, maintain the plan checklist instead of tracking steps in prose. Skip it for single-step or informational requests. Keep at most one step in_progress and update the plan at phase boundaries, not after every command. Group related steps instead of appending endlessly: a plan is phases you can verify, not a task log. Never mark a step completed without doing the work, and do not batch-complete steps after the fact. Prefer add, update, and remove over re-sending the whole list with set; use set only to replace the checklist and clear to remove it. After a plan call, do not restate the plan in prose because the interface already shows it. After compaction, or whenever step identity is unclear, call plan with action:"show" instead of guessing an index. A plan carried over from earlier work stays visible until it is cleared or replaced, so clear it once the work it describes is no longer in flight.`
115+
112116 const skillPriorityGuidance = `Explicit user instructions take precedence over skill guidelines. Skills do not grant permissions or override system or developer constraints. If a skill would block requested work or change its scope, identify its source and the relevant instruction, explain the conflict, and follow the applicable higher-priority instruction.`
113117
114118 const writingGuidance = `Adapt every response and document to its medium, audience, and reader's immediate need. Lead with the answer or next useful action. Use plain, precise language, concrete details, and strong verbs. Prefer verified facts to vague emphasis; qualify uncertain claims. Preserve useful structure, descriptive links, and accessibility. Default to concise paragraphs unless the requested format calls for another structure. Use lists when items are genuinely parallel, sequential, or easier to compare; avoid unnecessary nesting. Cut throat-clearing, ceremony, repetition, canned transitions, catalogue-like prose, and mechanical sentence patterns. Do not manufacture slang, errors, hesitation, or artificial variation to sound human. Revise silently unless asked to explain your writing choices.`
M packages/agent/systemprompt_test.go +36 -0
@@ -55,6 +55,42 @@ func TestBuildSystemPromptAlwaysIncludesFinalWritingGuidance(t *testing.T) {
5555 }
5656 }
5757
58+func TestBuildSystemPromptAlwaysIncludesPlanGuidance(t *testing.T) {
59+ for _, tt := range []struct {
60+ name string
61+ custom string
62+ append []string
63+ }{
64+ {name: "default"},
65+ {name: "custom identity", custom: "custom identity"},
66+ {name: "with appended context", append: []string{"Use terse replies."}},
67+ } {
68+ t.Run(tt.name, func(t *testing.T) {
69+ prompt := BuildSystemPrompt(SystemPromptOpts{Custom: tt.custom, Append: tt.append})
70+ if count := strings.Count(prompt, planGuidance); count != 1 {
71+ t.Fatalf("plan guidance count = %d, want 1:\n%s", count, prompt)
72+ }
73+ // Ordering keeps shared guidance stable: plan rules follow task rules and
74+ // precede skill priority.
75+ if strings.Index(prompt, planGuidance) < strings.Index(prompt, taskExecutionGuidance) {
76+ t.Fatalf("plan guidance must follow task guidance:\n%s", prompt)
77+ }
78+ if strings.Index(prompt, planGuidance) > strings.Index(prompt, skillPriorityGuidance) {
79+ t.Fatalf("plan guidance must precede skill priority:\n%s", prompt)
80+ }
81+ // The rules that matter for token cost and for correct index handling.
82+ for _, want := range []string{"in_progress", "add, update, and remove", "Group related steps", `action:"show"`, "do not restate the plan"} {
83+ if !strings.Contains(planGuidance, want) {
84+ t.Fatalf("plan guidance is missing %q", want)
85+ }
86+ }
87+ if strings.Contains(prompt, "update_plan") {
88+ t.Fatalf("prompt still references the retired tool name:\n%s", prompt)
89+ }
90+ })
91+ }
92+}
93+
5894 func TestBuildSystemPromptAlwaysIncludesTaskAndSkillGuidance(t *testing.T) {
5995 for _, custom := range []string{"", "Custom identity"} {
6096 t.Run(custom, func(t *testing.T) {
A packages/agent/tools/plan.go +111 -0
@@ -0,0 +1,111 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "io"
8+
9+ "github.com/bnema/zut/packages/core"
10+ "github.com/bnema/zut/packages/provider"
11+)
12+
13+const (
14+ PlanToolName = "plan"
15+ planSchema = `{
16+ "type":"object",
17+ "properties":{
18+ "action":{"type":"string","enum":["set","add","update","remove","clear","show"],"description":"Plan action to perform."},
19+ "explanation":{"type":"string","description":"Optional explanation for this plan change."},
20+ "steps":{"type":"array","description":"Steps for the set and add actions. set replaces the whole checklist and requires at least one step; add appends. Use clear to remove every step.","items":{"type":"object","properties":{"step":{"type":"string","description":"Task step text."},"status":{"type":"string","enum":["pending","in_progress","completed"],"description":"Step status, pending when omitted."}},"required":["step"],"additionalProperties":false}},
21+ "index":{"type":"integer","minimum":1,"description":"1-based step position for the update and remove actions."},
22+ "status":{"type":"string","enum":["pending","in_progress","completed"],"description":"New status for the update action."},
23+ "step":{"type":"string","description":"New step text for the update action."}
24+ },
25+ "required":["action"],
26+ "additionalProperties":false
27+}`
28+)
29+
30+// PlanTool lets the model maintain a persisted, session-scoped task checklist
31+// through one CRUD surface. It only parses arguments: core owns the plan state,
32+// so validation against the current checklist and the model-visible result text
33+// happen after execution, where that state is available.
34+type PlanTool struct{}
35+
36+var _ core.ToolArgumentRewritePolicy = (*PlanTool)(nil)
37+
38+type planArgs struct {
39+ Action *string `json:"action"`
40+ Explanation *string `json:"explanation"`
41+ Steps []planArgStep `json:"steps"`
42+ Index int `json:"index"`
43+ Status string `json:"status"`
44+ Step *string `json:"step"`
45+}
46+
47+type planArgStep struct {
48+ Step *string `json:"step"`
49+ Status *string `json:"status"`
50+}
51+
52+func (t *PlanTool) Name() string { return PlanToolName }
53+
54+// AllowArgumentRewrite keeps the displayed checklist identical to the plan the
55+// model submitted. Guards may still allow or refuse the call.
56+func (t *PlanTool) AllowArgumentRewrite() bool { return false }
57+
58+func (t *PlanTool) Description() string {
59+ return "Maintain a persisted task checklist. Actions: set (replace every step), add, update one step, remove one step, clear, show the current checklist. At most one step may be in_progress at a time; prefer add/update/remove deltas over re-sending the whole list with set."
60+}
61+
62+func (t *PlanTool) Schema() json.RawMessage { return json.RawMessage(planSchema) }
63+
64+func (t *PlanTool) Execute(_ context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
65+ var args planArgs
66+ decoder := json.NewDecoder(bytes.NewReader(raw))
67+ decoder.DisallowUnknownFields()
68+ if err := decoder.Decode(&args); err != nil {
69+ return planError(), nil
70+ }
71+ if err := decoder.Decode(&struct{}{}); err != io.EOF {
72+ return planError(), nil
73+ }
74+ if args.Action == nil || *args.Action == "" {
75+ return planError(), nil
76+ }
77+
78+ op := core.PlanOperation{
79+ Action: *args.Action,
80+ Explanation: args.Explanation,
81+ Index: args.Index,
82+ // Status and its values are validated by core, which rejects an unknown
83+ // status with an actionable message. The tool only carries the value.
84+ Status: core.PlanStepStatus(args.Status),
85+ Text: args.Step,
86+ }
87+ for _, step := range args.Steps {
88+ if step.Step == nil {
89+ return planError(), nil
90+ }
91+ var status core.PlanStepStatus
92+ if step.Status != nil {
93+ status = core.PlanStepStatus(*step.Status)
94+ }
95+ op.Steps = append(op.Steps, core.PlanStep{Step: *step.Step, Status: status})
96+ }
97+
98+ return core.ToolResult{
99+ // Placeholder content: core replaces it with the summary or listing
100+ // produced against the current plan.
101+ Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
102+ Details: op,
103+ }, nil
104+}
105+
106+func planError() core.ToolResult {
107+ return core.ToolResult{
108+ Content: []provider.Content{provider.TextBlock{Text: "invalid arguments"}},
109+ IsError: true,
110+ }
111+}
A packages/agent/tools/plan_test.go +200 -0
@@ -0,0 +1,200 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "testing"
7+
8+ "github.com/bnema/zut/packages/core"
9+)
10+
11+// TestPlanToolDecodesEachAction covers the six actions the schema advertises.
12+// The tool only parses: state-dependent validation and result text belong to
13+// core, so these tests assert exactly what the operation carries.
14+func TestPlanToolDecodesEachAction(t *testing.T) {
15+ explanation := "why"
16+ tests := []struct {
17+ name string
18+ raw string
19+ want core.PlanOperation
20+ }{
21+ {
22+ name: "set",
23+ raw: `{"action":"set","explanation":"why","steps":[{"step":"one","status":"pending"},{"step":"two"}]}`,
24+ want: core.PlanOperation{
25+ Action: "set",
26+ Explanation: &explanation,
27+ Steps: []core.PlanStep{{Step: "one", Status: core.PlanPending}, {Step: "two"}},
28+ },
29+ },
30+ {
31+ name: "add",
32+ raw: `{"action":"add","steps":[{"step":"three","status":"in_progress"}]}`,
33+ want: core.PlanOperation{
34+ Action: "add",
35+ Steps: []core.PlanStep{{Step: "three", Status: core.PlanInProgress}},
36+ },
37+ },
38+ {
39+ name: "update status",
40+ raw: `{"action":"update","index":2,"status":"completed"}`,
41+ want: core.PlanOperation{Action: "update", Index: 2, Status: core.PlanCompleted},
42+ },
43+ {
44+ name: "update text",
45+ raw: `{"action":"update","index":1,"step":"renamed"}`,
46+ want: core.PlanOperation{Action: "update", Index: 1},
47+ },
48+ {
49+ name: "remove",
50+ raw: `{"action":"remove","index":3}`,
51+ want: core.PlanOperation{Action: "remove", Index: 3},
52+ },
53+ {
54+ name: "clear",
55+ raw: `{"action":"clear"}`,
56+ want: core.PlanOperation{Action: "clear"},
57+ },
58+ {
59+ name: "show",
60+ raw: `{"action":"show"}`,
61+ want: core.PlanOperation{Action: "show"},
62+ },
63+ }
64+ for _, tt := range tests {
65+ t.Run(tt.name, func(t *testing.T) {
66+ result, err := (&PlanTool{}).Execute(context.Background(), json.RawMessage(tt.raw), nil)
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ if result.IsError {
71+ t.Fatalf("result = %#v, want success", result)
72+ }
73+ op, ok := result.Details.(core.PlanOperation)
74+ if !ok {
75+ t.Fatalf("details = %T, want core.PlanOperation", result.Details)
76+ }
77+ if op.Action != tt.want.Action || op.Index != tt.want.Index || op.Status != tt.want.Status {
78+ t.Fatalf("operation = %#v, want %#v", op, tt.want)
79+ }
80+ if (op.Explanation == nil) != (tt.want.Explanation == nil) {
81+ t.Fatalf("explanation = %#v, want %#v", op.Explanation, tt.want.Explanation)
82+ }
83+ if op.Explanation != nil && *op.Explanation != *tt.want.Explanation {
84+ t.Fatalf("explanation = %q, want %q", *op.Explanation, *tt.want.Explanation)
85+ }
86+ if len(op.Steps) != len(tt.want.Steps) {
87+ t.Fatalf("steps = %#v, want %#v", op.Steps, tt.want.Steps)
88+ }
89+ for idx := range op.Steps {
90+ if op.Steps[idx] != tt.want.Steps[idx] {
91+ t.Fatalf("step %d = %#v, want %#v", idx, op.Steps[idx], tt.want.Steps[idx])
92+ }
93+ }
94+ if tt.name == "update text" {
95+ if op.Text == nil || *op.Text != "renamed" {
96+ t.Fatalf("text = %#v, want renamed", op.Text)
97+ }
98+ } else if op.Text != nil {
99+ t.Fatalf("text = %#v, want nil", op.Text)
100+ }
101+ })
102+ }
103+}
104+
105+// TestPlanToolLeavesStateDependentValuesToCore guards the ownership split: an
106+// unknown status and an out-of-range index decode successfully and reach core,
107+// which rejects them against the current plan.
108+func TestPlanToolLeavesStateDependentValuesToCore(t *testing.T) {
109+ result, err := (&PlanTool{}).Execute(context.Background(), json.RawMessage(
110+ `{"action":"set","steps":[{"step":"one","status":"done"}]}`), nil)
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ if result.IsError {
115+ t.Fatalf("result = %#v, want decode to succeed", result)
116+ }
117+ op := result.Details.(core.PlanOperation)
118+ if op.Steps[0].Status != core.PlanStepStatus("done") {
119+ t.Fatalf("status = %q, want it passed through unchanged", op.Steps[0].Status)
120+ }
121+
122+ result, err = (&PlanTool{}).Execute(context.Background(), json.RawMessage(`{"action":"remove","index":0}`), nil)
123+ if err != nil {
124+ t.Fatal(err)
125+ }
126+ if result.IsError {
127+ t.Fatalf("result = %#v, want decode to succeed", result)
128+ }
129+}
130+
131+func TestPlanToolRejectsStructuralDecodeFailures(t *testing.T) {
132+ tests := []struct {
133+ name string
134+ raw string
135+ }{
136+ {name: "missing action", raw: `{}`},
137+ {name: "empty action", raw: `{"action":""}`},
138+ {name: "unknown top-level field", raw: `{"action":"clear","unexpected":true}`},
139+ {name: "unknown step field", raw: `{"action":"set","steps":[{"step":"one","unexpected":true}]}`},
140+ {name: "step text missing", raw: `{"action":"set","steps":[{"status":"pending"}]}`},
141+ {name: "trailing token", raw: `{"action":"clear"} trailing`},
142+ {name: "malformed json", raw: `{"action":`},
143+ {name: "wrong index type", raw: `{"action":"remove","index":"one"}`},
144+ }
145+ for _, tt := range tests {
146+ t.Run(tt.name, func(t *testing.T) {
147+ result, err := (&PlanTool{}).Execute(context.Background(), json.RawMessage(tt.raw), nil)
148+ if err != nil {
149+ t.Fatal(err)
150+ }
151+ if !result.IsError {
152+ t.Fatalf("result = %#v, want tool error", result)
153+ }
154+ if result.Details != nil {
155+ t.Fatalf("failed decode exposed details: %#v", result.Details)
156+ }
157+ })
158+ }
159+}
160+
161+func TestPlanToolSchemaShape(t *testing.T) {
162+ var schema struct {
163+ Properties map[string]struct {
164+ Enum []string `json:"enum"`
165+ Items *struct {
166+ Required []string `json:"required"`
167+ AdditionalProperties *bool `json:"additionalProperties"`
168+ } `json:"items"`
169+ } `json:"properties"`
170+ Required []string `json:"required"`
171+ AdditionalProperties *bool `json:"additionalProperties"`
172+ }
173+ if err := json.Unmarshal((&PlanTool{}).Schema(), &schema); err != nil {
174+ t.Fatalf("schema is not valid JSON: %v", err)
175+ }
176+ if len(schema.Required) != 1 || schema.Required[0] != "action" {
177+ t.Fatalf("required = %#v, want [action]", schema.Required)
178+ }
179+ if schema.AdditionalProperties == nil || *schema.AdditionalProperties {
180+ t.Fatalf("top-level additionalProperties = %#v, want false", schema.AdditionalProperties)
181+ }
182+ wantActions := []string{"set", "add", "update", "remove", "clear", "show"}
183+ if got := schema.Properties["action"].Enum; len(got) != len(wantActions) {
184+ t.Fatalf("action enum = %#v, want %#v", got, wantActions)
185+ }
186+ items := schema.Properties["steps"].Items
187+ if items == nil || len(items.Required) != 1 || items.Required[0] != "step" {
188+ t.Fatalf("step schema = %#v, want required [step]", items)
189+ }
190+ if items.AdditionalProperties == nil || *items.AdditionalProperties {
191+ t.Fatalf("step additionalProperties = %#v, want false", items.AdditionalProperties)
192+ }
193+}
194+
195+func TestPlanToolKeepsArgumentRewriteDisabled(t *testing.T) {
196+ var tool core.ToolArgumentRewritePolicy = &PlanTool{}
197+ if tool.AllowArgumentRewrite() {
198+ t.Fatal("AllowArgumentRewrite = true, want false so guards cannot rewrite the checklist")
199+ }
200+}
D packages/agent/tools/update_plan.go +0 -84
@@ -1,84 +0,0 @@
1-package tools
2-
3-import (
4- "bytes"
5- "context"
6- "encoding/json"
7- "io"
8-
9- "github.com/bnema/zut/packages/core"
10- "github.com/bnema/zut/packages/provider"
11-)
12-
13-const (
14- UpdatePlanToolName = "update_plan"
15- updatePlanSchema = `{"type":"object","properties":{"explanation":{"type":"string","description":"Optional explanation for this plan update."},"plan":{"type":"array","description":"The list of steps.","items":{"type":"object","properties":{"step":{"type":"string","description":"Task step text."},"status":{"type":"string","enum":["pending","in_progress","completed"],"description":"Step status."}},"required":["step","status"],"additionalProperties":false}}},"required":["plan"],"additionalProperties":false}`
16-)
17-
18-// UpdatePlanTool lets the model maintain a turn-scoped task checklist.
19-type UpdatePlanTool struct{}
20-
21-var _ core.ToolArgumentRewritePolicy = (*UpdatePlanTool)(nil)
22-
23-type updatePlanArgs struct {
24- Explanation *string `json:"explanation"`
25- Plan *[]updatePlanStep `json:"plan"`
26-}
27-
28-type updatePlanStep struct {
29- Step *string `json:"step"`
30- Status *string `json:"status"`
31-}
32-
33-func (t *UpdatePlanTool) Name() string { return UpdatePlanToolName }
34-
35-// AllowArgumentRewrite keeps the displayed checklist identical to the plan the
36-// model submitted. Guards may still allow or refuse the call.
37-func (t *UpdatePlanTool) AllowArgumentRewrite() bool { return false }
38-
39-func (t *UpdatePlanTool) Description() string {
40- return "Track steps and progress for complex, ambiguous, or multi-phase work. Skip trivial tasks and keep the plan current as work progresses."
41-}
42-
43-func (t *UpdatePlanTool) Schema() json.RawMessage { return json.RawMessage(updatePlanSchema) }
44-
45-func (t *UpdatePlanTool) Execute(_ context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
46- var args updatePlanArgs
47- decoder := json.NewDecoder(bytes.NewReader(raw))
48- decoder.DisallowUnknownFields()
49- if err := decoder.Decode(&args); err != nil {
50- return updatePlanError(), nil
51- }
52- if err := decoder.Decode(&struct{}{}); err != io.EOF || args.Plan == nil {
53- return updatePlanError(), nil
54- }
55-
56- update := core.PlanUpdate{
57- Explanation: args.Explanation,
58- Plan: make([]core.PlanStep, len(*args.Plan)),
59- }
60- for idx, step := range *args.Plan {
61- if step.Step == nil || step.Status == nil {
62- return updatePlanError(), nil
63- }
64- status := core.PlanStepStatus(*step.Status)
65- switch status {
66- case core.PlanPending, core.PlanInProgress, core.PlanCompleted:
67- default:
68- return updatePlanError(), nil
69- }
70- update.Plan[idx] = core.PlanStep{Step: *step.Step, Status: status}
71- }
72-
73- return core.ToolResult{
74- Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
75- Details: update,
76- }, nil
77-}
78-
79-func updatePlanError() core.ToolResult {
80- return core.ToolResult{
81- Content: []provider.Content{provider.TextBlock{Text: "invalid arguments"}},
82- IsError: true,
83- }
84-}
D packages/agent/tools/update_plan_test.go +0 -95
@@ -1,95 +0,0 @@
1-package tools
2-
3-import (
4- "context"
5- "encoding/json"
6- "testing"
7-
8- "github.com/bnema/zut/packages/core"
9- "github.com/bnema/zut/packages/provider"
10-)
11-
12-func TestUpdatePlanToolReturnsPlanUpdate(t *testing.T) {
13- tool := &UpdatePlanTool{}
14- result, err := tool.Execute(context.Background(), json.RawMessage(`{
15- "explanation":"Finished discovery",
16- "plan":[
17- {"step":"Map the implementation","status":"completed"},
18- {"step":"Add the built-in tool","status":"in_progress"},
19- {"step":"Verify the behavior","status":"pending"}
20- ]
21- }`), nil)
22- if err != nil {
23- t.Fatal(err)
24- }
25- if result.IsError {
26- t.Fatalf("result = %#v, want success", result)
27- }
28- update, ok := result.Details.(core.PlanUpdate)
29- if !ok {
30- t.Fatalf("details = %T, want core.PlanUpdate", result.Details)
31- }
32- if update.Explanation == nil || *update.Explanation != "Finished discovery" || len(update.Plan) != 3 {
33- t.Fatalf("update = %#v", update)
34- }
35- if update.Plan[1].Status != core.PlanInProgress {
36- t.Fatalf("second status = %q, want %q", update.Plan[1].Status, core.PlanInProgress)
37- }
38- if len(result.Content) != 1 {
39- t.Fatalf("content = %#v, want one text block", result.Content)
40- }
41- text, ok := result.Content[0].(provider.TextBlock)
42- if !ok || text.Text != "Plan updated" {
43- t.Fatalf("content = %#v, want Plan updated", result.Content)
44- }
45-}
46-
47-func TestUpdatePlanToolAcceptsEmptyPlan(t *testing.T) {
48- result, err := (&UpdatePlanTool{}).Execute(context.Background(), json.RawMessage(`{"plan":[]}`), nil)
49- if err != nil {
50- t.Fatal(err)
51- }
52- if result.IsError {
53- t.Fatalf("result = %#v, want success", result)
54- }
55- update, ok := result.Details.(core.PlanUpdate)
56- if !ok || update.Explanation != nil || len(update.Plan) != 0 {
57- t.Fatalf("details = %#v, want empty plan update without explanation", result.Details)
58- }
59-}
60-
61-func TestUpdatePlanToolAcceptsMultipleInProgressSteps(t *testing.T) {
62- result, err := (&UpdatePlanTool{}).Execute(context.Background(), json.RawMessage(`{"plan":[{"step":"one","status":"in_progress"},{"step":"two","status":"in_progress"}]}`), nil)
63- if err != nil {
64- t.Fatal(err)
65- }
66- if result.IsError {
67- t.Fatalf("result = %#v, want Codex-compatible acceptance", result)
68- }
69-}
70-
71-func TestUpdatePlanToolRejectsMalformedArguments(t *testing.T) {
72- tests := []string{
73- `{}`,
74- `{"plan":[{"status":"pending"}]}`,
75- `{"plan":[{"step":"work"}]}`,
76- `{"plan":[{"step":"work","status":"unknown"}]}`,
77- `{"plan":[],"unexpected":true}`,
78- `{"plan":[{"step":"work","status":"pending","unexpected":true}]}`,
79- `{"plan":[]} trailing`,
80- }
81- for _, raw := range tests {
82- t.Run(raw, func(t *testing.T) {
83- result, err := (&UpdatePlanTool{}).Execute(context.Background(), json.RawMessage(raw), nil)
84- if err != nil {
85- t.Fatal(err)
86- }
87- if !result.IsError {
88- t.Fatalf("result = %#v, want tool error", result)
89- }
90- if _, ok := result.Details.(core.PlanUpdate); ok {
91- t.Fatalf("invalid result exposed plan update: %#v", result.Details)
92- }
93- })
94- }
95-}
M packages/core/agent.go +35 -2
@@ -166,6 +166,9 @@ type Agent struct {
166166
167167 mu sync.Mutex
168168 messages []provider.Message
169+ // plan is the agent-owned task checklist. It is mutex-protected; use
170+ // SetPlan to seed it and CurrentPlan to read it.
171+ plan []PlanStep
169172 // rev increments whenever the transcript slice is replaced or a
170173 // message is appended. The TUI uses it as a cheap redraw cache key
171174 // so editor-only typing doesn't copy/rebuild a long transcript on
@@ -1331,6 +1334,32 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
13311334 continue
13321335 }
13331336 res := a.runOneTool(ctx, tc, tools, sink)
1337+ // A plan tool result carries the parsed operation, not the resulting
1338+ // checklist: core validates it against the current plan and materializes
1339+ // what would be stored. Nothing mutates state here; that happens only
1340+ // after CommitToolResult persisted the materialized list below.
1341+ if op, ok := res.Details.(PlanOperation); ok && !res.IsError {
1342+ update, err := a.previewPlanOperation(op)
1343+ switch {
1344+ case err != nil:
1345+ // Validation failed: keep the current plan and surface the reason.
1346+ res = ToolResult{
1347+ Content: []provider.Content{provider.TextBlock{Text: err.Error()}},
1348+ IsError: true,
1349+ Timing: res.Timing,
1350+ }
1351+ case op.Action == "show":
1352+ // show is a read: return the listing without persisting a meta row
1353+ // or committing anything.
1354+ res.Content = []provider.Content{provider.TextBlock{Text: planResultText(op.Action, update)}}
1355+ res.Details = nil
1356+ res.PlanOperation = nil
1357+ default:
1358+ res.Details = update
1359+ res.PlanOperation = &op
1360+ res.Content = []provider.Content{provider.TextBlock{Text: planResultText(op.Action, update)}}
1361+ }
1362+ }
13341363 if a.CommitToolResult != nil {
13351364 if err := a.CommitToolResult(tc.ID, res); err != nil {
13361365 res = ToolResult{
@@ -1358,8 +1387,12 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
13581387 if a.OnToolResult != nil {
13591388 a.OnToolResult(tc.ID, res)
13601389 }
1361- if update, ok := res.Details.(PlanUpdate); ok && !res.IsError {
1362- sink(EvPlanUpdate{Update: update})
1390+ // Commit the validated plan only after the result was persisted: a failed
1391+ // write replaced the result above and cleared PlanOperation, so state and
1392+ // the event stream stay untouched.
1393+ if update, ok := res.Details.(PlanUpdate); ok && !res.IsError && res.PlanOperation != nil {
1394+ a.commitPlanUpdate(update)
1395+ sink(EvPlanUpdate{CallID: tc.ID, Update: update})
13631396 }
13641397 sink(EvToolResult{ID: tc.ID, Result: res})
13651398 }
M packages/core/compact.go +75 -0
@@ -172,6 +172,17 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
172172 // a tool_use ID that doesn't exist.
173173 tail = repairOrphanedToolResults(tail)
174174
175+ // Carry the active plan over the compaction boundary. The step text is
176+ // otherwise lost with the summarized transcript, so the model could not
177+ // resolve the indices its indexed update and remove actions need. The block
178+ // is merged into the internal-context message that survives compaction;
179+ // with no plan the retained message is left byte-for-byte unchanged.
180+ if latestContext != nil {
181+ if block := planContextBlock(a.CurrentPlan()); block != "" {
182+ *latestContext = mergePlanContextBlock(*latestContext, block)
183+ }
184+ }
185+
175186 next := make([]provider.Message, 0, 2+len(tail))
176187 if latestContext != nil {
177188 next = append(next, *latestContext)
@@ -193,6 +204,70 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
193204 return summary, nil
194205 }
195206
207+// planContextStepLimit bounds the steps carried into the retained internal
208+// context. It is deliberately the only size bound on a plan: state, storage,
209+// and the tool itself have no cap, but the post-compaction block the model
210+// reads on every later request stays small.
211+const planContextStepLimit = 12
212+
213+// planContextHeader opens the plan block. The whole block is always appended
214+// last, so the final occurrence of this line marks where a previous block
215+// starts and mergePlanContextBlock can replace rather than duplicate it.
216+const planContextHeader = "## Current plan"
217+
218+// planContextBlock renders the plan carried across compaction. Step lines use
219+// the same markers as show output, so the model can map them to the listing and
220+// to the 1-based indices the tool accepts.
221+func planContextBlock(steps []PlanStep) string {
222+ if len(steps) == 0 {
223+ return ""
224+ }
225+ shown := steps
226+ if len(shown) > planContextStepLimit {
227+ shown = shown[:planContextStepLimit]
228+ }
229+ var b strings.Builder
230+ b.WriteString(planContextHeader)
231+ for idx, step := range shown {
232+ fmt.Fprintf(&b, "\n%d. [%s] %s", idx+1, planStepMarker(step.Status), step.Step)
233+ }
234+ if remaining := len(steps) - len(shown); remaining > 0 {
235+ fmt.Fprintf(&b, "\n…and %d more (call action:\"show\")", remaining)
236+ }
237+ return b.String()
238+}
239+
240+// mergePlanContextBlock returns message with block merged into its internal
241+// context text. An earlier plan block is replaced, so compacting twice cannot
242+// duplicate it. A message that is not a single text block is returned
243+// unchanged rather than risk dropping content.
244+func mergePlanContextBlock(message provider.Message, block string) provider.Message {
245+ if block == "" || len(message.Content) != 1 {
246+ return message
247+ }
248+ text, ok := message.Content[0].(provider.TextBlock)
249+ if !ok {
250+ return message
251+ }
252+ base := stripPlanContextBlock(text.Text)
253+ merged := block
254+ if base != "" {
255+ merged = base + "\n\n" + block
256+ }
257+ message.Content = []provider.Content{provider.TextBlock{Text: merged}}
258+ return message
259+}
260+
261+// stripPlanContextBlock removes a previously injected plan block. The block is
262+// always appended last, so the final header occurrence is its start.
263+func stripPlanContextBlock(text string) string {
264+ idx := strings.LastIndex(text, planContextHeader)
265+ if idx < 0 {
266+ return text
267+ }
268+ return strings.TrimRight(text[:idx], " \t\n")
269+}
270+
196271 // repairOrphanedToolResults removes tool_result content blocks (and
197272 // entire messages that become empty) when the matching tool_use ID
198273 // does not appear anywhere in the given messages. This happens after
A packages/core/compact_test.go +127 -0
@@ -0,0 +1,127 @@
1+package core
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "testing"
8+
9+ "github.com/bnema/zut/packages/provider"
10+)
11+
12+// planCompactionAgent returns an agent whose retained internal-context message
13+// is "clock", so a test can tell exactly what the plan block was merged into.
14+func planCompactionAgent(t *testing.T, steps []PlanStep) *Agent {
15+ t.Helper()
16+ agent := NewAgent(&compactLifecycleClient{}, "compact-model", "system", Registry{})
17+ agent.SetMessages([]provider.Message{
18+ {Role: provider.RoleDeveloper, Content: []provider.Content{provider.TextBlock{Text: "clock"}}, Meta: map[string]string{internalContextMarker: "true"}},
19+ textMessage(provider.RoleUser, "do the thing"),
20+ textMessage(provider.RoleAssistant, "working"),
21+ })
22+ agent.SetPlan(steps)
23+ return agent
24+}
25+
26+func compactOnce(t *testing.T, agent *Agent) {
27+ t.Helper()
28+ if _, err := agent.Compact(context.Background(), 0, nil); err != nil {
29+ t.Fatalf("Compact: %v", err)
30+ }
31+}
32+
33+func TestCompactCarriesPlanIntoRetainedInternalContext(t *testing.T) {
34+ agent := planCompactionAgent(t, []PlanStep{
35+ {Step: "first", Status: PlanCompleted},
36+ {Step: "second", Status: PlanInProgress},
37+ {Step: "third", Status: PlanPending},
38+ })
39+ compactOnce(t, agent)
40+
41+ // Assert on the post-compaction message list, not only the summarize call:
42+ // the block has to survive the rebuild that replaces the transcript.
43+ messages := agent.Messages()
44+ if len(messages) != 2 {
45+ t.Fatalf("compacted messages = %d, want retained internal context then summary", len(messages))
46+ }
47+ if messages[0].Role != provider.RoleDeveloper || !isInternalContextMessage(messages[0]) {
48+ t.Fatalf("first post-compaction message = %#v, want retained internal context", messages[0])
49+ }
50+ want := "clock\n\n## Current plan\n1. [x] first\n2. [~] second\n3. [ ] third"
51+ if got := internalContextText(messages[0]); got != want {
52+ t.Fatalf("post-compaction internal context =\n%q\nwant\n%q", got, want)
53+ }
54+}
55+
56+func TestCompactWithoutPlanLeavesInternalContextByteIdentical(t *testing.T) {
57+ agent := planCompactionAgent(t, nil)
58+ before := internalContextText(agent.Messages()[0])
59+ compactOnce(t, agent)
60+
61+ messages := agent.Messages()
62+ got := internalContextText(messages[0])
63+ if got != before {
64+ t.Fatalf("internal context changed without a plan: got %q, want %q", got, before)
65+ }
66+ if strings.Contains(got, planContextHeader) {
67+ t.Fatalf("internal context gained a plan block without a plan: %q", got)
68+ }
69+}
70+
71+func TestCompactPlanBlockTruncatesAfterTwelveSteps(t *testing.T) {
72+ steps := make([]PlanStep, 15)
73+ for i := range steps {
74+ steps[i] = PlanStep{Step: fmt.Sprintf("step %d", i+1), Status: PlanPending}
75+ }
76+ agent := planCompactionAgent(t, steps)
77+ compactOnce(t, agent)
78+
79+ got := internalContextText(agent.Messages()[0])
80+ if !strings.Contains(got, "12. [ ] step 12") {
81+ t.Fatalf("block did not include the twelfth step:\n%s", got)
82+ }
83+ if !strings.Contains(got, `…and 3 more (call action:"show")`) {
84+ t.Fatalf("block missing truncation trailer:\n%s", got)
85+ }
86+ if strings.Contains(got, "13. [") {
87+ t.Fatalf("block included a step past the cap:\n%s", got)
88+ }
89+}
90+
91+func TestCompactPlanBlockReplacesOnSecondCompaction(t *testing.T) {
92+ agent := planCompactionAgent(t, []PlanStep{{Step: "alpha", Status: PlanPending}})
93+ compactOnce(t, agent)
94+
95+ agent.SetPlan([]PlanStep{{Step: "beta", Status: PlanInProgress}})
96+ compactOnce(t, agent)
97+
98+ got := internalContextText(agent.Messages()[0])
99+ if count := strings.Count(got, planContextHeader); count != 1 {
100+ t.Fatalf("plan header appeared %d times, want 1:\n%s", count, got)
101+ }
102+ if strings.Contains(got, "alpha") {
103+ t.Fatalf("stale plan block survived the second compaction:\n%s", got)
104+ }
105+ want := "clock\n\n## Current plan\n1. [~] beta"
106+ if got != want {
107+ t.Fatalf("second-compaction internal context =\n%q\nwant\n%q", got, want)
108+ }
109+}
110+
111+func TestCompactPlanBlockHiddenWithoutRetainedInternalContext(t *testing.T) {
112+ agent := NewAgent(&compactLifecycleClient{}, "compact-model", "system", Registry{})
113+ agent.SetMessages([]provider.Message{
114+ textMessage(provider.RoleUser, "do the thing"),
115+ textMessage(provider.RoleAssistant, "working"),
116+ })
117+ agent.SetPlan([]PlanStep{{Step: "alpha", Status: PlanPending}})
118+ compactOnce(t, agent)
119+
120+ // No retained internal-context message means nowhere to merge the block:
121+ // the transcript stays the bare summary.
122+ for _, message := range agent.Messages() {
123+ if strings.Contains(internalContextText(message), planContextHeader) {
124+ t.Fatalf("plan block injected without a retained internal-context message: %#v", agent.Messages())
125+ }
126+ }
127+}
M packages/core/events.go +5 -2
@@ -171,7 +171,7 @@ type EvToolProgress struct {
171171
172172 func (EvToolProgress) Type() string { return "tool_progress" }
173173
174-// PlanStepStatus is the state of one update_plan checklist step.
174+// PlanStepStatus is the state of one plan checklist step.
175175 type PlanStepStatus string
176176
177177 const (
@@ -192,8 +192,11 @@ type PlanUpdate struct {
192192 Plan []PlanStep `json:"plan"`
193193 }
194194
195-// EvPlanUpdate reports a successful update_plan call before its tool result.
195+// EvPlanUpdate reports a successful plan call before its tool result.
196196 type EvPlanUpdate struct {
197+ // CallID identifies the tool call that produced this update. It is
198+ // optional for compatibility with hosts that predate persisted plans.
199+ CallID string
197200 Update PlanUpdate
198201 }
199202
M packages/core/intercept_test.go +2 -3
@@ -85,11 +85,10 @@ func TestToolResultPersistenceFailureBecomesToolError(t *testing.T) {
8585 }
8686
8787 func TestExecuteToolsEmitsPlanUpdateBeforeResult(t *testing.T) {
88- update := PlanUpdate{Plan: []PlanStep{{Step: "Implement", Status: PlanInProgress}}}
8988 agent := NewAgent(nil, "test", "", Registry{
9089 "result": &resultTool{result: ToolResult{
9190 Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
92- Details: update,
91+ Details: PlanOperation{Action: "set", Steps: []PlanStep{{Step: "Implement", Status: PlanInProgress}}},
9392 }},
9493 })
9594 message := provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{
@@ -107,7 +106,7 @@ func TestExecuteToolsEmitsPlanUpdateBeforeResult(t *testing.T) {
107106 t.Fatalf("events = %#v, want plan update and tool result", events)
108107 }
109108 planEvent, ok := events[0].(EvPlanUpdate)
110- if !ok || len(planEvent.Update.Plan) != 1 || planEvent.Update.Plan[0].Step != "Implement" {
109+ if !ok || planEvent.CallID != "call-1" || len(planEvent.Update.Plan) != 1 || planEvent.Update.Plan[0].Step != "Implement" {
111110 t.Fatalf("first event = %#v, want plan update", events[0])
112111 }
113112 if _, ok := events[1].(EvToolResult); !ok {
A packages/core/plan.go +245 -0
@@ -0,0 +1,245 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+)
8+
9+// PlanOperation is one parsed plan tool call. The tool decodes the arguments;
10+// core validates the operation against the current plan, materializes the
11+// resulting list, and only then commits it. Fields that do not apply to the
12+// selected action are ignored.
13+type PlanOperation struct {
14+ Action string
15+ Explanation *string
16+ Steps []PlanStep // set, add
17+ Index int // update, remove (1-based)
18+ Text *string // update
19+ Status PlanStepStatus // update
20+}
21+
22+// SessionPlan is the agent-owned checklist persisted in session metadata.
23+// Steps is optional so older sessions remain compatible.
24+type SessionPlan struct {
25+ Steps []PlanStep `json:"steps,omitempty"`
26+ // Updated is stamped by Session.UpdatePlan when the plan is persisted.
27+ Updated time.Time `json:"updated"`
28+}
29+
30+// SetPlan seeds the agent-owned plan. It copies the steps so the caller keeps
31+// ownership of its slice. Hosts seed state on resume and on /clear, where a nil
32+// agent is possible, so the guard is deliberate. Seeded steps are normalized, so
33+// a host cannot install state that the read action cannot report.
34+func (a *Agent) SetPlan(steps []PlanStep) {
35+ if a == nil {
36+ return
37+ }
38+ var seeded []PlanStep
39+ if normalized := normalizeSessionPlan(&SessionPlan{Steps: steps}); normalized != nil {
40+ seeded = clonePlanSteps(normalized.Steps)
41+ }
42+ a.mu.Lock()
43+ defer a.mu.Unlock()
44+ a.plan = seeded
45+}
46+
47+// CurrentPlan returns a copy of the agent-owned plan. Mutating the result does
48+// not affect agent state.
49+func (a *Agent) CurrentPlan() []PlanStep {
50+ if a == nil {
51+ return nil
52+ }
53+ a.mu.Lock()
54+ defer a.mu.Unlock()
55+ return clonePlanSteps(a.plan)
56+}
57+
58+// previewPlanOperation validates op against the current plan and returns the
59+// prospective update without mutating agent state. Callers commit a validated
60+// operation with commitPlanUpdate once persistence succeeds.
61+func (a *Agent) previewPlanOperation(op PlanOperation) (PlanUpdate, error) {
62+ a.mu.Lock()
63+ defer a.mu.Unlock()
64+ return a.previewPlanOperationLocked(op)
65+}
66+
67+// commitPlanUpdate stores the plan that was just persisted. Callers pass the
68+// value returned by previewPlanOperation after CommitToolResult succeeded, so
69+// agent state always matches what the session file holds.
70+//
71+// Preview and commit are separate lock windows. When a concurrent SetPlan lands
72+// between them, the persisted update wins: the session file now records that
73+// list, so state must agree with the file rather than with the intervening call.
74+func (a *Agent) commitPlanUpdate(update PlanUpdate) {
75+ a.mu.Lock()
76+ defer a.mu.Unlock()
77+ a.plan = clonePlanSteps(update.Plan)
78+}
79+
80+func (a *Agent) previewPlanOperationLocked(op PlanOperation) (PlanUpdate, error) {
81+ switch op.Action {
82+ case "set", "add", "update", "remove", "clear", "show":
83+ case "":
84+ return PlanUpdate{}, fmt.Errorf("plan: action is required")
85+ default:
86+ return PlanUpdate{}, fmt.Errorf("plan: unknown action %q", op.Action)
87+ }
88+
89+ plan := clonePlanSteps(a.plan)
90+ switch op.Action {
91+ case "set":
92+ // A replace must say what it replaces: an omitted or empty step list used
93+ // to wipe the checklist silently, which is too destructive a default for a
94+ // model that only meant to attach an explanation. Removing every step is an
95+ // explicit intent, so it has its own action.
96+ if len(op.Steps) == 0 {
97+ return PlanUpdate{}, fmt.Errorf("plan: steps are required for set (use clear to remove every step)")
98+ }
99+ plan = defaultPlanStepStatuses(clonePlanSteps(op.Steps))
100+ case "add":
101+ if len(op.Steps) == 0 {
102+ return PlanUpdate{}, fmt.Errorf("plan: steps are required for add")
103+ }
104+ plan = append(plan, defaultPlanStepStatuses(clonePlanSteps(op.Steps))...)
105+ case "update":
106+ if op.Index < 1 || op.Index > len(plan) {
107+ return PlanUpdate{}, fmt.Errorf("plan: index %d out of range (plan has %d steps)", op.Index, len(plan))
108+ }
109+ if op.Status == "" && op.Text == nil {
110+ return PlanUpdate{}, fmt.Errorf("plan: update requires status or step")
111+ }
112+ step := plan[op.Index-1]
113+ if op.Text != nil {
114+ step.Step = *op.Text
115+ }
116+ if op.Status != "" {
117+ step.Status = op.Status
118+ }
119+ plan[op.Index-1] = step
120+ case "remove":
121+ if op.Index < 1 || op.Index > len(plan) {
122+ return PlanUpdate{}, fmt.Errorf("plan: index %d out of range (plan has %d steps)", op.Index, len(plan))
123+ }
124+ plan = append(plan[:op.Index-1], plan[op.Index:]...)
125+ if len(plan) == 0 {
126+ plan = nil
127+ }
128+ case "clear":
129+ plan = nil
130+ case "show":
131+ // show is a read: report the current plan without validating it. Seeding
132+ // normalizes, but a read must not fail on state a host installed directly.
133+ return PlanUpdate{Explanation: op.Explanation, Plan: plan}, nil
134+ }
135+
136+ if err := validatePlanSteps(plan); err != nil {
137+ return PlanUpdate{}, err
138+ }
139+ return PlanUpdate{Explanation: op.Explanation, Plan: plan}, nil
140+}
141+
142+// validatePlanSteps rejects steps that cannot be rendered or persisted: empty
143+// text, an unknown status, or more than one in_progress step. There is
144+// deliberately no length cap.
145+func validatePlanSteps(plan []PlanStep) error {
146+ inProgress := 0
147+ for idx := range plan {
148+ if strings.TrimSpace(plan[idx].Step) == "" {
149+ return fmt.Errorf("plan: step text is required")
150+ }
151+ switch plan[idx].Status {
152+ case PlanPending, PlanCompleted:
153+ case PlanInProgress:
154+ inProgress++
155+ default:
156+ return fmt.Errorf("plan: unknown status %q", plan[idx].Status)
157+ }
158+ }
159+ if inProgress > 1 {
160+ return fmt.Errorf("plan: at most one step can be in_progress")
161+ }
162+ return nil
163+}
164+
165+// defaultPlanStepStatuses applies the schema's optional step status: a step that
166+// omits it is pending. Without this, an omitted status would reach agent state
167+// as an invalid value and later be dropped by normalizeSessionPlan on reload.
168+func defaultPlanStepStatuses(steps []PlanStep) []PlanStep {
169+ for idx := range steps {
170+ if steps[idx].Status == "" {
171+ steps[idx].Status = PlanPending
172+ }
173+ }
174+ return steps
175+}
176+
177+// planResultText renders the model-visible text for a validated plan
178+// operation. Mutations return a compact summary that never echoes the list;
179+// show returns the full checklist.
180+func planResultText(action string, update PlanUpdate) string {
181+ if action == "show" {
182+ return planShowListing(update.Plan)
183+ }
184+ return planMutationSummary(update.Plan)
185+}
186+
187+// planMutationSummary summarizes the resulting plan without repeating its steps:
188+// the model already holds them in its own earlier messages.
189+func planMutationSummary(plan []PlanStep) string {
190+ if len(plan) == 0 {
191+ return "Plan cleared"
192+ }
193+ completed := 0
194+ inProgressIndex := 0
195+ for idx, step := range plan {
196+ switch step.Status {
197+ case PlanCompleted:
198+ completed++
199+ case PlanInProgress:
200+ inProgressIndex = idx + 1
201+ }
202+ }
203+ summary := fmt.Sprintf("Plan updated (%d/%d completed", completed, len(plan))
204+ if inProgressIndex > 0 {
205+ summary += fmt.Sprintf("; in progress: step %d", inProgressIndex)
206+ }
207+ return summary + ")"
208+}
209+
210+// planShowListing renders the full checklist for the show action.
211+func planShowListing(plan []PlanStep) string {
212+ if len(plan) == 0 {
213+ return "Plan is empty"
214+ }
215+ var b strings.Builder
216+ for idx, step := range plan {
217+ if idx > 0 {
218+ b.WriteByte('\n')
219+ }
220+ fmt.Fprintf(&b, "%d. [%s] %s", idx+1, planStepMarker(step.Status), step.Step)
221+ }
222+ return b.String()
223+}
224+
225+// planStepMarker is the one-character checkbox shown for a step status.
226+func planStepMarker(status PlanStepStatus) string {
227+ switch status {
228+ case PlanCompleted:
229+ return "x"
230+ case PlanInProgress:
231+ return "~"
232+ default:
233+ return " "
234+ }
235+}
236+
237+// clonePlanSteps returns an independent copy of steps, or nil when empty.
238+func clonePlanSteps(steps []PlanStep) []PlanStep {
239+ if len(steps) == 0 {
240+ return nil
241+ }
242+ clone := make([]PlanStep, len(steps))
243+ copy(clone, steps)
244+ return clone
245+}
A packages/core/plan_flow_test.go +391 -0
@@ -0,0 +1,391 @@
1+package core
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "slices"
8+ "testing"
9+
10+ "github.com/bnema/zut/packages/provider"
11+)
12+
13+// planCallTool stands in for the plan tool in core's tests: executeTools only
14+// requires a tool whose Details is a parsed PlanOperation. Ops are popped in
15+// call order so one message can exercise a batch of plan calls.
16+type planCallTool struct {
17+ ops []PlanOperation
18+}
19+
20+func (*planCallTool) Name() string { return "plan" }
21+func (*planCallTool) Description() string { return "plan" }
22+func (*planCallTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
23+
24+// AllowArgumentRewrite mirrors the real tool: guards may allow or refuse a call
25+// but must never rewrite its arguments.
26+func (*planCallTool) AllowArgumentRewrite() bool { return false }
27+
28+func (t *planCallTool) Execute(context.Context, json.RawMessage, func(string)) (ToolResult, error) {
29+ op := t.ops[0]
30+ t.ops = t.ops[1:]
31+ return ToolResult{
32+ Content: []provider.Content{provider.TextBlock{Text: "raw plan result"}},
33+ Details: op,
34+ }, nil
35+}
36+
37+func planToolMessage(ids ...string) provider.Message {
38+ content := make([]provider.Content, 0, len(ids))
39+ for _, id := range ids {
40+ content = append(content, provider.ToolCallBlock{ID: id, Name: "plan", Arguments: json.RawMessage(`{}`)})
41+ }
42+ return provider.Message{Role: provider.RoleAssistant, Content: content}
43+}
44+
45+func planResultTextOf(t *testing.T, msg provider.Message, index int) string {
46+ t.Helper()
47+ if index >= len(msg.Content) {
48+ t.Fatalf("tool message has %d results, want index %d", len(msg.Content), index)
49+ }
50+ block, ok := msg.Content[index].(provider.ToolResultBlock)
51+ if !ok {
52+ t.Fatalf("result %d = %T, want provider.ToolResultBlock", index, msg.Content[index])
53+ }
54+ if len(block.Content) != 1 {
55+ t.Fatalf("result %d content = %#v, want one text block", index, block.Content)
56+ }
57+ text, ok := block.Content[0].(provider.TextBlock)
58+ if !ok {
59+ t.Fatalf("result %d text = %T, want provider.TextBlock", index, block.Content[0])
60+ }
61+ return text.Text
62+}
63+
64+// TestExecuteToolsAppliesPlanAfterCommit covers the happy path: preview
65+// materializes the list, the commit hook persists it, state advances, and the
66+// event carries the call ID and the materialized plan before the tool result.
67+func TestExecuteToolsAppliesPlanAfterCommit(t *testing.T) {
68+ want := []PlanStep{planStep("one", PlanPending), planStep("two", PlanInProgress)}
69+ agent := NewAgent(nil, "test", "", Registry{
70+ "plan": &planCallTool{ops: []PlanOperation{{Action: "set", Steps: []PlanStep{planStep("one", PlanPending), planStep("two", PlanInProgress)}}}},
71+ })
72+ var committed []PlanUpdate
73+ agent.CommitToolResult = func(_ string, result ToolResult) error {
74+ update, ok := result.Details.(PlanUpdate)
75+ if !ok {
76+ t.Errorf("CommitToolResult details = %T, want PlanUpdate", result.Details)
77+ }
78+ committed = append(committed, update)
79+ return nil
80+ }
81+
82+ var events []AgentEvent
83+ msg, hadError := agent.executeTools(context.Background(), planToolMessage("call-1"), func(ev AgentEvent) {
84+ events = append(events, ev)
85+ })
86+ if hadError {
87+ t.Fatal("successful plan call marked the batch failed")
88+ }
89+ if len(committed) != 1 || !slices.Equal(committed[0].Plan, want) {
90+ t.Fatalf("committed = %#v, want %#v", committed, want)
91+ }
92+ if got := agent.CurrentPlan(); !slices.Equal(got, want) {
93+ t.Fatalf("plan = %#v, want %#v", got, want)
94+ }
95+
96+ var order []string
97+ var planEvent EvPlanUpdate
98+ for _, ev := range events {
99+ switch e := ev.(type) {
100+ case EvPlanUpdate:
101+ planEvent = e
102+ order = append(order, "plan")
103+ case EvToolResult:
104+ order = append(order, "result")
105+ }
106+ }
107+ if len(order) != 2 || order[0] != "plan" || order[1] != "result" {
108+ t.Fatalf("event order = %#v, want plan update before tool result", order)
109+ }
110+ if planEvent.CallID != "call-1" || !slices.Equal(planEvent.Update.Plan, want) {
111+ t.Fatalf("plan event = %#v, want call-1 and %#v", planEvent, want)
112+ }
113+ if got := planResultTextOf(t, msg, 0); got != "Plan updated (0/2 completed; in progress: step 2)" {
114+ t.Fatalf("result text = %q", got)
115+ }
116+}
117+
118+// TestExecuteToolsPreviewFailureChangesNothing covers a state-dependent
119+// validation failure: the model gets the reason, and neither state nor the
120+// event stream moves.
121+func TestExecuteToolsPreviewFailureChangesNothing(t *testing.T) {
122+ before := []PlanStep{planStep("one", PlanPending)}
123+ agent := NewAgent(nil, "test", "", Registry{
124+ "plan": &planCallTool{ops: []PlanOperation{{Action: "remove", Index: 5}}},
125+ })
126+ agent.SetPlan(before)
127+
128+ var events []AgentEvent
129+ msg, hadError := agent.executeTools(context.Background(), planToolMessage("call-1"), func(ev AgentEvent) {
130+ if _, ok := ev.(EvPlanUpdate); ok {
131+ events = append(events, ev)
132+ }
133+ })
134+ if !hadError {
135+ t.Fatal("failed preview did not mark the batch failed")
136+ }
137+ if len(events) != 0 {
138+ t.Fatalf("events = %#v, want no plan update", events)
139+ }
140+ if got := agent.CurrentPlan(); !slices.Equal(got, before) {
141+ t.Fatalf("plan = %#v, want it untouched", got)
142+ }
143+ block := msg.Content[0].(provider.ToolResultBlock)
144+ if !block.IsError || planResultTextOf(t, msg, 0) != "plan: index 5 out of range (plan has 1 steps)" {
145+ t.Fatalf("result = %#v, want the validation error", block)
146+ }
147+}
148+
149+// TestExecuteToolsCommitFailureChangesNothing covers a rejected persistence:
150+// the existing commit-error path replaces the result, and because that clears
151+// PlanOperation, nothing is committed and no event is emitted.
152+func TestExecuteToolsCommitFailureChangesNothing(t *testing.T) {
153+ // A pre-existing plan makes "untouched" distinguishable from "was empty".
154+ existing := []PlanStep{planStep("existing", PlanPending)}
155+ agent := NewAgent(nil, "test", "", Registry{
156+ "plan": &planCallTool{ops: []PlanOperation{{Action: "set", Steps: []PlanStep{planStep("one", PlanPending)}}}},
157+ })
158+ agent.SetPlan(existing)
159+ agent.CommitToolResult = func(string, ToolResult) error { return errors.New("disk full") }
160+
161+ var events []AgentEvent
162+ msg, hadError := agent.executeTools(context.Background(), planToolMessage("call-1"), func(ev AgentEvent) {
163+ if _, ok := ev.(EvPlanUpdate); ok {
164+ events = append(events, ev)
165+ }
166+ })
167+ if !hadError {
168+ t.Fatal("commit failure did not mark the batch failed")
169+ }
170+ if len(events) != 0 {
171+ t.Fatalf("events = %#v, want no plan update", events)
172+ }
173+ if got := agent.CurrentPlan(); !slices.Equal(got, existing) {
174+ t.Fatalf("plan = %#v, want it untouched %#v", got, existing)
175+ }
176+ if got := planResultTextOf(t, msg, 0); got != "tool result state could not be persisted" {
177+ t.Fatalf("result text = %q", got)
178+ }
179+}
180+
181+// TestExecuteToolsDeniedPlanCallChangesNothing covers a guard or confirmation
182+// denial: Execute never runs, so nothing is previewed, persisted, committed, or
183+// emitted.
184+func TestExecuteToolsDeniedPlanCallChangesNothing(t *testing.T) {
185+ existing := []PlanStep{planStep("existing", PlanPending)}
186+ agent := NewAgent(nil, "test", "", Registry{
187+ "plan": &planCallTool{ops: []PlanOperation{{Action: "clear"}}},
188+ })
189+ agent.SetPlan(existing)
190+ agent.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) {
191+ return false, "denied by guard", nil
192+ }
193+ var commitCalls int
194+ agent.CommitToolResult = func(_ string, result ToolResult) error {
195+ commitCalls++
196+ if result.PlanOperation != nil {
197+ t.Fatalf("denied call reached persistence with state: %#v", result)
198+ }
199+ return nil
200+ }
201+
202+ var events []AgentEvent
203+ _, hadError := agent.executeTools(context.Background(), planToolMessage("call-1"), func(ev AgentEvent) {
204+ if _, ok := ev.(EvPlanUpdate); ok {
205+ events = append(events, ev)
206+ }
207+ })
208+ if !hadError {
209+ t.Fatal("denial did not mark the batch failed")
210+ }
211+ if commitCalls != 1 {
212+ t.Fatalf("commit hook calls = %d, want 1 without plan state", commitCalls)
213+ }
214+ if len(events) != 0 {
215+ t.Fatalf("events = %#v, want no plan update", events)
216+ }
217+ if got := agent.CurrentPlan(); !slices.Equal(got, existing) {
218+ t.Fatalf("plan = %#v, want it untouched %#v", got, existing)
219+ }
220+}
221+
222+// TestExecuteToolsRefusesPlanArgumentRewrite covers a guard that tries to rewrite
223+// the plan arguments: the call fails, and state stays untouched.
224+func TestExecuteToolsRefusesPlanArgumentRewrite(t *testing.T) {
225+ existing := []PlanStep{planStep("existing", PlanPending)}
226+ agent := NewAgent(nil, "test", "", Registry{
227+ "plan": &planCallTool{ops: []PlanOperation{{Action: "clear"}}},
228+ })
229+ agent.SetPlan(existing)
230+ agent.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) {
231+ return true, "", json.RawMessage(`{"action":"clear"}`)
232+ }
233+
234+ var events []AgentEvent
235+ msg, hadError := agent.executeTools(context.Background(), planToolMessage("call-1"), func(ev AgentEvent) {
236+ if _, ok := ev.(EvPlanUpdate); ok {
237+ events = append(events, ev)
238+ }
239+ })
240+ if !hadError {
241+ t.Fatal("rewrite refusal did not mark the batch failed")
242+ }
243+ if len(events) != 0 {
244+ t.Fatalf("events = %#v, want no plan update", events)
245+ }
246+ if got := agent.CurrentPlan(); !slices.Equal(got, existing) {
247+ t.Fatalf("plan = %#v, want it untouched %#v", got, existing)
248+ }
249+ if got := planResultTextOf(t, msg, 0); got != "tool arguments cannot be rewritten" {
250+ t.Fatalf("result text = %q", got)
251+ }
252+}
253+
254+// TestExecuteToolsBatchCommitsOnlyPersistedCalls covers a batch whose second
255+// persistence fails: the first call is persisted, committed, and emitted; the
256+// second changes nothing and reports the failure.
257+func TestExecuteToolsBatchCommitsOnlyPersistedCalls(t *testing.T) {
258+ agent := NewAgent(nil, "test", "", Registry{
259+ "plan": &planCallTool{ops: []PlanOperation{
260+ {Action: "set", Steps: []PlanStep{planStep("first", PlanPending)}},
261+ {Action: "add", Steps: []PlanStep{planStep("second", PlanPending)}},
262+ }},
263+ })
264+ var commitCalls int
265+ agent.CommitToolResult = func(string, ToolResult) error {
266+ commitCalls++
267+ if commitCalls == 2 {
268+ return errors.New("disk full")
269+ }
270+ return nil
271+ }
272+
273+ var events []AgentEvent
274+ msg, hadError := agent.executeTools(context.Background(), planToolMessage("call-1", "call-2"), func(ev AgentEvent) {
275+ if _, ok := ev.(EvPlanUpdate); ok {
276+ events = append(events, ev)
277+ }
278+ })
279+ if !hadError {
280+ t.Fatal("failed second commit did not mark the batch failed")
281+ }
282+ if len(events) != 1 {
283+ t.Fatalf("events = %d, want only the first call's update", len(events))
284+ }
285+ if planEvent, ok := events[0].(EvPlanUpdate); !ok || planEvent.CallID != "call-1" {
286+ t.Fatalf("event = %#v, want call-1's update", events[0])
287+ }
288+ want := []PlanStep{planStep("first", PlanPending)}
289+ if got := agent.CurrentPlan(); !slices.Equal(got, want) {
290+ t.Fatalf("plan = %#v, want only the committed call %#v", got, want)
291+ }
292+ if got := planResultTextOf(t, msg, 1); got != "tool result state could not be persisted" {
293+ t.Fatalf("second result text = %q", got)
294+ }
295+}
296+
297+// TestExecuteToolsShowReadsWithoutPersisting covers the read action: the model
298+// sees the listing, and nothing is committed or persisted for it.
299+func TestExecuteToolsShowReadsWithoutPersisting(t *testing.T) {
300+ plan := []PlanStep{planStep("done", PlanCompleted), planStep("todo", PlanPending)}
301+ agent := NewAgent(nil, "test", "", Registry{
302+ "plan": &planCallTool{ops: []PlanOperation{{Action: "show"}}},
303+ })
304+ agent.SetPlan(plan)
305+ var commitCalls int
306+ agent.CommitToolResult = func(_ string, result ToolResult) error {
307+ commitCalls++
308+ if result.PlanOperation != nil || result.Details != nil {
309+ t.Fatalf("show result reached persistence with state: %#v", result)
310+ }
311+ return nil
312+ }
313+
314+ var events []AgentEvent
315+ msg, hadError := agent.executeTools(context.Background(), planToolMessage("call-1"), func(ev AgentEvent) {
316+ if _, ok := ev.(EvPlanUpdate); ok {
317+ events = append(events, ev)
318+ }
319+ })
320+ if hadError {
321+ t.Fatal("show marked the batch failed")
322+ }
323+ if commitCalls != 1 {
324+ t.Fatalf("commit hook calls = %d, want 1", commitCalls)
325+ }
326+ if len(events) != 0 {
327+ t.Fatalf("events = %#v, want no plan update", events)
328+ }
329+ if got := agent.CurrentPlan(); !slices.Equal(got, plan) {
330+ t.Fatalf("plan = %#v, want it untouched", got)
331+ }
332+ if got := planResultTextOf(t, msg, 0); got != "1. [x] done\n2. [ ] todo" {
333+ t.Fatalf("result text = %q", got)
334+ }
335+}
336+
337+// TestExecuteToolsAppliesPlanBatchInOrder covers two plan calls in one assistant
338+// message: each is previewed, persisted, committed, and emitted in call order.
339+func TestExecuteToolsAppliesPlanBatchInOrder(t *testing.T) {
340+ agent := NewAgent(nil, "test", "", Registry{
341+ "plan": &planCallTool{ops: []PlanOperation{
342+ {Action: "set", Steps: []PlanStep{planStep("one", PlanPending), planStep("two", PlanPending)}},
343+ {Action: "add", Steps: []PlanStep{planStep("three", PlanPending)}},
344+ }},
345+ })
346+ var committed []PlanUpdate
347+ agent.CommitToolResult = func(_ string, result ToolResult) error {
348+ committed = append(committed, result.Details.(PlanUpdate))
349+ return nil
350+ }
351+
352+ var callIDs []string
353+ msg, hadError := agent.executeTools(context.Background(), planToolMessage("call-1", "call-2"), func(ev AgentEvent) {
354+ if planEvent, ok := ev.(EvPlanUpdate); ok {
355+ callIDs = append(callIDs, planEvent.CallID)
356+ }
357+ })
358+ if hadError {
359+ t.Fatal("batch marked failed")
360+ }
361+ if len(committed) != 2 || len(committed[0].Plan) != 2 || len(committed[1].Plan) != 3 {
362+ t.Fatalf("committed = %#v, want a 2-step then a 3-step plan", committed)
363+ }
364+ want := []PlanStep{planStep("one", PlanPending), planStep("two", PlanPending), planStep("three", PlanPending)}
365+ if got := agent.CurrentPlan(); !slices.Equal(got, want) {
366+ t.Fatalf("plan = %#v, want %#v", got, want)
367+ }
368+ if len(callIDs) != 2 || callIDs[0] != "call-1" || callIDs[1] != "call-2" {
369+ t.Fatalf("plan events = %#v, want call-1 then call-2", callIDs)
370+ }
371+ if len(msg.Content) != 2 {
372+ t.Fatalf("tool results = %d, want 2", len(msg.Content))
373+ }
374+}
375+
376+// TestExecuteToolsRejectsUnknownActionFromCore covers the one interface-table
377+// error the tool may hand through: core owns the unknown-action message.
378+func TestExecuteToolsRejectsUnknownActionFromCore(t *testing.T) {
379+ agent := NewAgent(nil, "test", "", Registry{
380+ "plan": &planCallTool{ops: []PlanOperation{{Action: "foo"}}},
381+ })
382+ if got := planResultTextOf(t, mustExecuteTools(t, agent, "call-1"), 0); got != `plan: unknown action "foo"` {
383+ t.Fatalf("result text = %q", got)
384+ }
385+}
386+
387+func mustExecuteTools(t *testing.T, agent *Agent, ids ...string) provider.Message {
388+ t.Helper()
389+ msg, _ := agent.executeTools(context.Background(), planToolMessage(ids...), func(AgentEvent) {})
390+ return msg
391+}
A packages/core/plan_test.go +954 -0
@@ -0,0 +1,954 @@
1+package core
2+
3+import (
4+ "slices"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/zut/packages/provider"
9+)
10+
11+func planStep(text string, status PlanStepStatus) PlanStep {
12+ return PlanStep{Step: text, Status: status}
13+}
14+
15+func TestAgentPlanCopySemantics(t *testing.T) {
16+ agent := &Agent{}
17+ original := []PlanStep{
18+ planStep("first", PlanPending),
19+ planStep("second", PlanInProgress),
20+ }
21+ agent.SetPlan(original)
22+
23+ // Mutating the caller's slice after SetPlan must not affect state.
24+ original[0].Step = "mutated"
25+ if got := agent.CurrentPlan(); got[0].Step != "first" {
26+ t.Fatalf("SetPlan aliased caller slice: %#v", got)
27+ }
28+
29+ // Mutating the slice returned by CurrentPlan must not affect state.
30+ snapshot := agent.CurrentPlan()
31+ snapshot[0].Step = "mutated again"
32+ snapshot[1].Status = PlanCompleted
33+ if got := agent.CurrentPlan(); got[0].Step != "first" || got[1].Status != PlanInProgress {
34+ t.Fatalf("CurrentPlan returned shared state: %#v", got)
35+ }
36+
37+ agent.SetPlan(nil)
38+ if got := agent.CurrentPlan(); got != nil {
39+ t.Fatalf("SetPlan(nil) = %#v, want nil", got)
40+ }
41+}
42+
43+func TestPreviewPlanOperationDoesNotMutate(t *testing.T) {
44+ agent := &Agent{}
45+ agent.SetPlan([]PlanStep{
46+ planStep("first", PlanInProgress),
47+ planStep("second", PlanPending),
48+ })
49+ before := agent.CurrentPlan()
50+
51+ text := "renamed"
52+ operations := []PlanOperation{
53+ {Action: "set", Steps: []PlanStep{planStep("only", PlanPending)}},
54+ {Action: "add", Steps: []PlanStep{planStep("third", PlanPending)}},
55+ {Action: "update", Index: 2, Status: PlanCompleted},
56+ {Action: "update", Index: 1, Text: &text},
57+ {Action: "remove", Index: 1},
58+ {Action: "clear"},
59+ {Action: "show"},
60+ }
61+ for _, op := range operations {
62+ if _, err := agent.previewPlanOperation(op); err != nil {
63+ t.Fatalf("preview %q: %v", op.Action, err)
64+ }
65+ if got := agent.CurrentPlan(); !slices.Equal(got, before) {
66+ t.Fatalf("preview %q mutated state: %#v, want %#v", op.Action, got, before)
67+ }
68+ }
69+}
70+
71+func TestPreviewPlanOperationErrors(t *testing.T) {
72+ base := []PlanStep{
73+ planStep("first", PlanInProgress),
74+ planStep("second", PlanPending),
75+ planStep("third", PlanPending),
76+ }
77+ blank := ""
78+ tests := []struct {
79+ name string
80+ op PlanOperation
81+ want string
82+ }{
83+ {
84+ name: "missing action",
85+ op: PlanOperation{},
86+ want: "plan: action is required",
87+ },
88+ {
89+ name: "unknown action",
90+ op: PlanOperation{Action: "foo"},
91+ want: `plan: unknown action "foo"`,
92+ },
93+ {
94+ name: "update index out of range",
95+ op: PlanOperation{Action: "update", Index: 4, Status: PlanCompleted},
96+ want: "plan: index 4 out of range (plan has 3 steps)",
97+ },
98+ {
99+ name: "update index zero",
100+ op: PlanOperation{Action: "update", Index: 0, Status: PlanCompleted},
101+ want: "plan: index 0 out of range (plan has 3 steps)",
102+ },
103+ {
104+ name: "update without status or text",
105+ op: PlanOperation{Action: "update", Index: 2},
106+ want: "plan: update requires status or step",
107+ },
108+ {
109+ name: "update with blank text",
110+ op: PlanOperation{Action: "update", Index: 2, Text: &blank},
111+ want: "plan: step text is required",
112+ },
113+ {
114+ name: "remove index out of range",
115+ op: PlanOperation{Action: "remove", Index: 9},
116+ want: "plan: index 9 out of range (plan has 3 steps)",
117+ },
118+ {
119+ name: "remove index negative",
120+ op: PlanOperation{Action: "remove", Index: -1},
121+ want: "plan: index -1 out of range (plan has 3 steps)",
122+ },
123+ {
124+ name: "add without steps",
125+ op: PlanOperation{Action: "add"},
126+ want: "plan: steps are required for add",
127+ },
128+ {
129+ name: "set blank step",
130+ op: PlanOperation{Action: "set", Steps: []PlanStep{planStep("ok", PlanPending), planStep(" ", PlanPending)}},
131+ want: "plan: step text is required",
132+ },
133+ {
134+ name: "set without steps",
135+ op: PlanOperation{Action: "set"},
136+ want: "plan: steps are required for set (use clear to remove every step)",
137+ },
138+ {
139+ name: "set two in progress",
140+ op: PlanOperation{Action: "set", Steps: []PlanStep{planStep("a", PlanInProgress), planStep("b", PlanInProgress)}},
141+ want: "plan: at most one step can be in_progress",
142+ },
143+ {
144+ name: "add second in progress",
145+ op: PlanOperation{Action: "add", Steps: []PlanStep{planStep("fourth", PlanInProgress)}},
146+ want: "plan: at most one step can be in_progress",
147+ },
148+ {
149+ name: "update second in progress",
150+ op: PlanOperation{Action: "update", Index: 2, Status: PlanInProgress},
151+ want: "plan: at most one step can be in_progress",
152+ },
153+ {
154+ name: "set unknown status",
155+ op: PlanOperation{Action: "set", Steps: []PlanStep{planStep("a", PlanStepStatus("done"))}},
156+ want: `plan: unknown status "done"`,
157+ },
158+ {
159+ name: "update unknown status",
160+ op: PlanOperation{Action: "update", Index: 2, Status: PlanStepStatus("done")},
161+ want: `plan: unknown status "done"`,
162+ },
163+ }
164+ for _, tt := range tests {
165+ t.Run(tt.name, func(t *testing.T) {
166+ agent := &Agent{}
167+ agent.SetPlan(base)
168+ _, err := agent.previewPlanOperation(tt.op)
169+ if err == nil {
170+ t.Fatalf("preview succeeded, want error %q", tt.want)
171+ }
172+ if err.Error() != tt.want {
173+ t.Fatalf("error = %q, want %q", err.Error(), tt.want)
174+ }
175+ if got := agent.CurrentPlan(); !slices.Equal(got, base) {
176+ t.Fatalf("failed preview mutated state: %#v", got)
177+ }
178+ })
179+ }
180+}
181+
182+// TestPreviewPlanOperationDefaultsMissingStatus covers the schema's optional step
183+// status. A step that omits it must materialize as pending, otherwise agent state
184+// would hold a status the session normalizer later drops on reload and the live
185+// plan would silently lose a step across a restart.
186+func TestPreviewPlanOperationDefaultsMissingStatus(t *testing.T) {
187+ tests := []struct {
188+ name string
189+ op PlanOperation
190+ want []PlanStep
191+ }{
192+ {
193+ name: "set without status",
194+ op: PlanOperation{Action: "set", Steps: []PlanStep{{Step: "a"}}},
195+ want: []PlanStep{planStep("a", PlanPending)},
196+ },
197+ {
198+ name: "add without status",
199+ op: PlanOperation{Action: "add", Steps: []PlanStep{{Step: "first"}, {Step: "second"}}},
200+ want: []PlanStep{planStep("first", PlanPending), planStep("second", PlanPending)},
201+ },
202+ }
203+ for _, tt := range tests {
204+ t.Run(tt.name, func(t *testing.T) {
205+ agent := &Agent{}
206+ update, err := agent.previewPlanOperation(tt.op)
207+ if err != nil {
208+ t.Fatalf("preview: %v", err)
209+ }
210+ if !slices.Equal(update.Plan, tt.want) {
211+ t.Fatalf("plan = %#v, want %#v", update.Plan, tt.want)
212+ }
213+ // The same steps must survive a persist/reload cycle unchanged.
214+ reloaded := normalizeSessionPlan(&SessionPlan{Steps: update.Plan})
215+ if reloaded == nil || !slices.Equal(reloaded.Steps, tt.want) {
216+ t.Fatalf("reloaded plan = %#v, want %#v", reloaded, tt.want)
217+ }
218+ })
219+ }
220+}
221+
222+func TestPreviewThenCommitAppliesActions(t *testing.T) {
223+ text := "renamed"
224+ tests := []struct {
225+ name string
226+ start []PlanStep
227+ op PlanOperation
228+ want []PlanStep
229+ }{
230+ {
231+ name: "set replaces",
232+ start: []PlanStep{planStep("first", PlanPending)},
233+ op: PlanOperation{Action: "set", Steps: []PlanStep{planStep("a", PlanPending), planStep("b", PlanCompleted)}},
234+ want: []PlanStep{planStep("a", PlanPending), planStep("b", PlanCompleted)},
235+ },
236+ {
237+ name: "clear removes every step",
238+ start: []PlanStep{planStep("first", PlanPending), planStep("second", PlanCompleted)},
239+ op: PlanOperation{Action: "clear"},
240+ want: nil,
241+ },
242+ {
243+ name: "add appends",
244+ start: []PlanStep{planStep("first", PlanPending)},
245+ op: PlanOperation{Action: "add", Steps: []PlanStep{planStep("second", PlanPending)}},
246+ want: []PlanStep{planStep("first", PlanPending), planStep("second", PlanPending)},
247+ },
248+ {
249+ name: "update status",
250+ start: []PlanStep{planStep("first", PlanPending), planStep("second", PlanPending)},
251+ op: PlanOperation{Action: "update", Index: 2, Status: PlanCompleted},
252+ want: []PlanStep{planStep("first", PlanPending), planStep("second", PlanCompleted)},
253+ },
254+ {
255+ name: "update text",
256+ start: []PlanStep{planStep("first", PlanPending)},
257+ op: PlanOperation{Action: "update", Index: 1, Text: &text},
258+ want: []PlanStep{planStep("renamed", PlanPending)},
259+ },
260+ {
261+ name: "remove",
262+ start: []PlanStep{planStep("first", PlanPending), planStep("second", PlanPending)},
263+ op: PlanOperation{Action: "remove", Index: 1},
264+ want: []PlanStep{planStep("second", PlanPending)},
265+ },
266+ {
267+ name: "remove last",
268+ start: []PlanStep{planStep("first", PlanPending)},
269+ op: PlanOperation{Action: "remove", Index: 1},
270+ want: nil,
271+ },
272+ {
273+ name: "clear",
274+ start: []PlanStep{planStep("first", PlanPending)},
275+ op: PlanOperation{Action: "clear"},
276+ want: nil,
277+ },
278+ {
279+ name: "show is a no-op",
280+ start: []PlanStep{planStep("first", PlanPending)},
281+ op: PlanOperation{Action: "show"},
282+ want: []PlanStep{planStep("first", PlanPending)},
283+ },
284+ }
285+ for _, tt := range tests {
286+ t.Run(tt.name, func(t *testing.T) {
287+ agent := &Agent{}
288+ agent.SetPlan(tt.start)
289+ update, err := agent.previewPlanOperation(tt.op)
290+ if err != nil {
291+ t.Fatalf("preview: %v", err)
292+ }
293+ // A failed preview means nothing is persisted and nothing is committed,
294+ // so state must still be the pre-operation plan.
295+ if got := agent.CurrentPlan(); !slices.Equal(got, tt.start) {
296+ t.Fatalf("preview mutated state: %#v", got)
297+ }
298+ agent.commitPlanUpdate(update)
299+ if got := agent.CurrentPlan(); !slices.Equal(got, tt.want) {
300+ t.Fatalf("plan = %#v, want %#v", got, tt.want)
301+ }
302+ })
303+ }
304+}
305+
306+// TestCommitPlanUpdateStoresPersistedValue covers the documented interleaving: a
307+// SetPlan between preview and commit must not win, because the persisted list is
308+// what the session file now records.
309+func TestCommitPlanUpdateStoresPersistedValue(t *testing.T) {
310+ agent := &Agent{}
311+ agent.SetPlan([]PlanStep{planStep("first", PlanPending)})
312+ update, err := agent.previewPlanOperation(PlanOperation{
313+ Action: "add",
314+ Steps: []PlanStep{planStep("second", PlanPending)},
315+ })
316+ if err != nil {
317+ t.Fatalf("preview: %v", err)
318+ }
319+
320+ agent.SetPlan([]PlanStep{planStep("cleared by /clear", PlanPending)})
321+ agent.commitPlanUpdate(update)
322+
323+ want := []PlanStep{planStep("first", PlanPending), planStep("second", PlanPending)}
324+ if got := agent.CurrentPlan(); !slices.Equal(got, want) {
325+ t.Fatalf("plan = %#v, want the persisted %#v", got, want)
326+ }
327+}
328+
329+// TestAgentPlanStateIsConcurrencySafe exercises the plan accessors under -race.
330+func TestAgentPlanStateIsConcurrencySafe(t *testing.T) {
331+ agent := &Agent{}
332+ agent.SetPlan([]PlanStep{planStep("first", PlanPending)})
333+
334+ var wg sync.WaitGroup
335+ for worker := 0; worker < 4; worker++ {
336+ wg.Add(1)
337+ go func() {
338+ defer wg.Done()
339+ for iter := 0; iter < 50; iter++ {
340+ agent.SetPlan([]PlanStep{planStep("first", PlanPending)})
341+ _ = agent.CurrentPlan()
342+ if update, err := agent.previewPlanOperation(PlanOperation{
343+ Action: "update",
344+ Index: 1,
345+ Status: PlanCompleted,
346+ }); err == nil {
347+ agent.commitPlanUpdate(update)
348+ }
349+ }
350+ }()
351+ }
352+ wg.Wait()
353+}
354+
355+func TestSessionPlanMetaRoundTrip(t *testing.T) {
356+ root := t.TempDir()
357+ cwd := "/project/plan"
358+ sess, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
359+ if err != nil {
360+ t.Fatal(err)
361+ }
362+ steps := []PlanStep{
363+ planStep("first", PlanCompleted),
364+ planStep("second", PlanInProgress),
365+ }
366+ if err := sess.UpdatePlan(&SessionPlan{Steps: steps}); err != nil {
367+ t.Fatalf("UpdatePlan: %v", err)
368+ }
369+ // Mutating the caller's slice after UpdatePlan must not affect session state.
370+ steps[0].Step = "mutated"
371+ if sess.Meta.Plan == nil || sess.Meta.Plan.Steps[0].Step != "first" {
372+ t.Fatalf("UpdatePlan aliased caller slice: %#v", sess.Meta.Plan)
373+ }
374+ if err := sess.Close(); err != nil {
375+ t.Fatal(err)
376+ }
377+
378+ // A plan-only session must survive the fresh-file deletion guard.
379+ reopened, messages, err := OpenSession(sess.Path)
380+ if err != nil {
381+ t.Fatalf("OpenSession: %v", err)
382+ }
383+ defer reopened.Close()
384+ if len(messages) != 0 {
385+ t.Fatalf("want no messages, got %d", len(messages))
386+ }
387+ if reopened.Meta.Plan == nil {
388+ t.Fatal("plan not restored")
389+ }
390+ want := []PlanStep{planStep("first", PlanCompleted), planStep("second", PlanInProgress)}
391+ if !slices.Equal(reopened.Meta.Plan.Steps, want) {
392+ t.Fatalf("plan = %#v, want %#v", reopened.Meta.Plan.Steps, want)
393+ }
394+}
395+
396+func TestSessionUpdatePlanNilClears(t *testing.T) {
397+ root := t.TempDir()
398+ cwd := "/project/plan"
399+ sess, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
400+ if err != nil {
401+ t.Fatal(err)
402+ }
403+ // Keep the session file after Close: a fresh plan-only session whose plan is
404+ // cleared is deleted by the fresh-file guard, which this test is not about.
405+ if err := sess.AppendMessage(provider.Message{
406+ Role: provider.RoleUser,
407+ Content: []provider.Content{provider.TextBlock{Text: "clear my plan"}},
408+ }); err != nil {
409+ t.Fatal(err)
410+ }
411+ if err := sess.UpdatePlan(&SessionPlan{Steps: []PlanStep{planStep("first", PlanPending)}}); err != nil {
412+ t.Fatal(err)
413+ }
414+ if err := sess.UpdatePlan(nil); err != nil {
415+ t.Fatal(err)
416+ }
417+ if sess.Meta.Plan != nil {
418+ t.Fatalf("in-memory plan = %#v, want nil", sess.Meta.Plan)
419+ }
420+ if err := sess.Close(); err != nil {
421+ t.Fatal(err)
422+ }
423+ reopened, _, err := OpenSession(sess.Path)
424+ if err != nil {
425+ t.Fatalf("OpenSession: %v", err)
426+ }
427+ defer reopened.Close()
428+ if reopened.Meta.Plan != nil {
429+ t.Fatalf("persisted plan = %#v, want nil", reopened.Meta.Plan)
430+ }
431+}
432+
433+func TestSessionPlanNormalizesOnLoad(t *testing.T) {
434+ root := t.TempDir()
435+ cwd := "/project/plan"
436+ sess, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
437+ if err != nil {
438+ t.Fatal(err)
439+ }
440+ if err := sess.AppendMessage(provider.Message{
441+ Role: provider.RoleUser,
442+ Content: []provider.Content{provider.TextBlock{Text: "normalize me"}},
443+ }); err != nil {
444+ t.Fatal(err)
445+ }
446+ // UpdatePlan writes verbatim; the load path is responsible for normalization.
447+ raw := &SessionPlan{Steps: []PlanStep{
448+ planStep("", PlanPending),
449+ planStep("unknown", "sleeping"),
450+ planStep("first", PlanInProgress),
451+ planStep("second", PlanInProgress),
452+ planStep("third", PlanCompleted),
453+ }}
454+ if err := sess.UpdatePlan(raw); err != nil {
455+ t.Fatal(err)
456+ }
457+ _ = sess.Close()
458+
459+ reopened, _, err := OpenSession(sess.Path)
460+ if err != nil {
461+ t.Fatalf("OpenSession: %v", err)
462+ }
463+ defer reopened.Close()
464+ want := []PlanStep{
465+ planStep("first", PlanInProgress),
466+ planStep("third", PlanCompleted),
467+ }
468+ if reopened.Meta.Plan == nil || !slices.Equal(reopened.Meta.Plan.Steps, want) {
469+ t.Fatalf("normalized plan = %#v, want %#v", reopened.Meta.Plan, want)
470+ }
471+}
472+
473+func TestImportSessionPreservesPlan(t *testing.T) {
474+ root := t.TempDir()
475+ cwd := "/project/plan"
476+ sess, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
477+ if err != nil {
478+ t.Fatal(err)
479+ }
480+ _ = sess.AppendMessage(provider.Message{
481+ Role: provider.RoleUser,
482+ Content: []provider.Content{provider.TextBlock{Text: "carry my plan"}},
483+ })
484+ want := []PlanStep{planStep("first", PlanCompleted), planStep("second", PlanInProgress)}
485+ if err := sess.UpdatePlan(&SessionPlan{Steps: want}); err != nil {
486+ t.Fatal(err)
487+ }
488+ _ = sess.Close()
489+
490+ exportDir := t.TempDir()
491+ exportPath, err := ExportSession(sess.Path, exportDir)
492+ if err != nil {
493+ t.Fatalf("ExportSession: %v", err)
494+ }
495+ root2 := t.TempDir()
496+ importedPath, err := ImportSession(exportPath, root2, "/other/project", "0.0.0-test")
497+ if err != nil {
498+ t.Fatalf("ImportSession: %v", err)
499+ }
500+ imported, _, err := OpenSession(importedPath)
501+ if err != nil {
502+ t.Fatalf("OpenSession: %v", err)
503+ }
504+ defer imported.Close()
505+ if imported.Meta.Plan == nil {
506+ t.Fatal("imported session dropped the plan")
507+ }
508+ if !slices.Equal(imported.Meta.Plan.Steps, want) {
509+ t.Fatalf("imported plan = %#v, want %#v", imported.Meta.Plan.Steps, want)
510+ }
511+}
512+
513+func TestBranchSessionReconstructsPlanAtForkPoint(t *testing.T) {
514+ root := t.TempDir()
515+ cwd := "/project/plan"
516+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
517+ if err != nil {
518+ t.Fatal(err)
519+ }
520+ appendPlanTranscript(t, parent)
521+
522+ // The persisted plan deliberately differs from what the fork-point replay
523+ // should reconstruct, proving replay wins over the snapshot plan.
524+ if err := parent.UpdatePlan(&SessionPlan{Steps: []PlanStep{planStep("unrelated", PlanPending)}}); err != nil {
525+ t.Fatal(err)
526+ }
527+ _ = parent.Close()
528+
529+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 5)
530+ if err != nil {
531+ t.Fatalf("BranchSession: %v", err)
532+ }
533+ branch, _, err := OpenSession(branchPath)
534+ if err != nil {
535+ t.Fatalf("OpenSession: %v", err)
536+ }
537+ defer branch.Close()
538+ want := []PlanStep{
539+ planStep("one", PlanInProgress),
540+ planStep("two", PlanPending),
541+ planStep("three", PlanPending),
542+ }
543+ if branch.Meta.Plan == nil {
544+ t.Fatal("branch has no plan")
545+ }
546+ if !slices.Equal(branch.Meta.Plan.Steps, want) {
547+ t.Fatalf("fork-point plan = %#v, want %#v", branch.Meta.Plan.Steps, want)
548+ }
549+
550+ // Forking earlier reconstructs the plan as it stood then: only the legacy
551+ // update_plan call has been replayed.
552+ earlyPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
553+ if err != nil {
554+ t.Fatalf("BranchSession(early): %v", err)
555+ }
556+ early, _, err := OpenSession(earlyPath)
557+ if err != nil {
558+ t.Fatalf("OpenSession(early): %v", err)
559+ }
560+ defer early.Close()
561+ earlyWant := []PlanStep{planStep("one", PlanInProgress), planStep("two", PlanPending)}
562+ if early.Meta.Plan == nil || !slices.Equal(early.Meta.Plan.Steps, earlyWant) {
563+ t.Fatalf("early plan = %#v, want %#v", early.Meta.Plan, earlyWant)
564+ }
565+}
566+
567+// TestBranchSessionFullForkKeepsPersistedPlan covers the end-of-transcript fork
568+// rule: the parent's persisted plan is authoritative, including for a compacted
569+// parent whose plan calls are no longer in the transcript.
570+func TestBranchSessionFullForkKeepsPersistedPlan(t *testing.T) {
571+ root := t.TempDir()
572+ cwd := "/project/plan"
573+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
574+ if err != nil {
575+ t.Fatal(err)
576+ }
577+ _ = parent.AppendMessage(provider.Message{
578+ Role: provider.RoleUser,
579+ Content: []provider.Content{provider.TextBlock{Text: "no plan calls here"}},
580+ })
581+ _ = parent.AppendMessage(provider.Message{
582+ Role: provider.RoleAssistant,
583+ Content: []provider.Content{provider.TextBlock{Text: "a compacted parent"}},
584+ })
585+ want := []PlanStep{planStep("inherited", PlanInProgress)}
586+ if err := parent.UpdatePlan(&SessionPlan{Steps: want}); err != nil {
587+ t.Fatal(err)
588+ }
589+ _ = parent.Close()
590+
591+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
592+ if err != nil {
593+ t.Fatalf("BranchSession: %v", err)
594+ }
595+ branch, _, err := OpenSession(branchPath)
596+ if err != nil {
597+ t.Fatalf("OpenSession: %v", err)
598+ }
599+ defer branch.Close()
600+ if branch.Meta.Plan == nil || !slices.Equal(branch.Meta.Plan.Steps, want) {
601+ t.Fatalf("fallback plan = %#v, want %#v", branch.Meta.Plan, want)
602+ }
603+}
604+
605+// TestBranchSessionNormalizesReplayedPlan guards the fork replay against a
606+// transcript that carries a step the live agent could never hold, such as a
607+// hand-edited or pre-validation session file.
608+func TestBranchSessionNormalizesReplayedPlan(t *testing.T) {
609+ root := t.TempDir()
610+ cwd := "/project/plan"
611+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
612+ if err != nil {
613+ t.Fatal(err)
614+ }
615+ messages := []provider.Message{
616+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "start"}}},
617+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
618+ ID: "legacy-1",
619+ Name: legacyPlanToolName,
620+ Arguments: []byte(`{"plan":[
621+ {"step":"keep me","status":"pending"},
622+ {"step":"bogus status","status":"done"},
623+ {"step":" ","status":"pending"}
624+ ]}`),
625+ }}},
626+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
627+ CallID: "legacy-1",
628+ Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
629+ }}},
630+ // Keeps the fork point short of the transcript end so the replay path runs
631+ // instead of the end-of-transcript rule.
632+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "more"}}},
633+ }
634+ for _, msg := range messages {
635+ if err := parent.AppendMessage(msg); err != nil {
636+ t.Fatalf("AppendMessage: %v", err)
637+ }
638+ }
639+ _ = parent.Close()
640+
641+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
642+ if err != nil {
643+ t.Fatalf("BranchSession: %v", err)
644+ }
645+ branch, _, err := OpenSession(branchPath)
646+ if err != nil {
647+ t.Fatalf("OpenSession: %v", err)
648+ }
649+ defer branch.Close()
650+
651+ want := []PlanStep{planStep("keep me", PlanPending)}
652+ if branch.Meta.Plan == nil || !slices.Equal(branch.Meta.Plan.Steps, want) {
653+ t.Fatalf("branch plan = %#v, want %#v", branch.Meta.Plan, want)
654+ }
655+}
656+
657+// TestBranchSessionSkipsFailedPlanCalls guards the replay against calls that left
658+// a transcript row but changed nothing: a rejected, denied, or abandoned plan
659+// call must not shape the branch's plan.
660+func TestBranchSessionSkipsFailedPlanCalls(t *testing.T) {
661+ root := t.TempDir()
662+ cwd := "/project/plan"
663+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
664+ if err != nil {
665+ t.Fatal(err)
666+ }
667+ messages := []provider.Message{
668+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "start"}}},
669+ // Applied: one in_progress step and one pending step.
670+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
671+ ID: "applied-1",
672+ Name: legacyPlanToolName,
673+ Arguments: []byte(`{"plan":[{"step":"one","status":"in_progress"},{"step":"two","status":"pending"}]}`),
674+ }}},
675+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
676+ CallID: "applied-1",
677+ Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
678+ }}},
679+ // Rejected by the tool: the result is an error, so nothing was applied.
680+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
681+ ID: "rejected-1",
682+ Name: legacyPlanToolName,
683+ Arguments: []byte(`{"plan":[{"step":"never applied","status":"pending"}]}`),
684+ }}},
685+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
686+ CallID: "rejected-1",
687+ IsError: true,
688+ Content: []provider.Content{provider.TextBlock{Text: "invalid arguments"}},
689+ }}},
690+ // Denied clear: also an error result.
691+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
692+ ID: "denied-1",
693+ Name: planToolName,
694+ Arguments: []byte(`{"action":"clear"}`),
695+ }}},
696+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
697+ CallID: "denied-1",
698+ IsError: true,
699+ Content: []provider.Content{provider.TextBlock{Text: "tool call refused by extension guard"}},
700+ }}},
701+ // Keeps the fork point short of the transcript end so the replay path runs.
702+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "more"}}},
703+ }
704+ for _, msg := range messages {
705+ if err := parent.AppendMessage(msg); err != nil {
706+ t.Fatalf("AppendMessage: %v", err)
707+ }
708+ }
709+ _ = parent.Close()
710+
711+ // Fork after the rejected call so the replay covers every call above.
712+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 7)
713+ if err != nil {
714+ t.Fatalf("BranchSession: %v", err)
715+ }
716+ branch, _, err := OpenSession(branchPath)
717+ if err != nil {
718+ t.Fatalf("OpenSession: %v", err)
719+ }
720+ defer branch.Close()
721+
722+ want := []PlanStep{planStep("one", PlanInProgress), planStep("two", PlanPending)}
723+ if branch.Meta.Plan == nil || !slices.Equal(branch.Meta.Plan.Steps, want) {
724+ t.Fatalf("branch plan = %#v, want only the applied call %#v", branch.Meta.Plan, want)
725+ }
726+}
727+
728+// TestBranchSessionPartialForkWithoutAppliedCallHasNoPlan covers the other half of
729+// the rule: a short fork whose only plan call failed inherits nothing, rather than
730+// reaching forward to the parent's later plan the way goals refuse to do.
731+func TestBranchSessionPartialForkWithoutAppliedCallHasNoPlan(t *testing.T) {
732+ root := t.TempDir()
733+ cwd := "/project/plan"
734+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
735+ if err != nil {
736+ t.Fatal(err)
737+ }
738+ messages := []provider.Message{
739+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "start"}}},
740+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
741+ ID: "rejected-1",
742+ Name: legacyPlanToolName,
743+ Arguments: []byte(`{"plan":[{"step":"never applied","status":"pending"}]}`),
744+ }}},
745+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
746+ CallID: "rejected-1",
747+ IsError: true,
748+ Content: []provider.Content{provider.TextBlock{Text: "invalid arguments"}},
749+ }}},
750+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "later work"}}},
751+ }
752+ for _, msg := range messages {
753+ if err := parent.AppendMessage(msg); err != nil {
754+ t.Fatalf("AppendMessage: %v", err)
755+ }
756+ }
757+ // The parent's own plan describes work after the fork point.
758+ if err := parent.UpdatePlan(&SessionPlan{Steps: []PlanStep{planStep("later", PlanPending)}}); err != nil {
759+ t.Fatal(err)
760+ }
761+ _ = parent.Close()
762+
763+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 3)
764+ if err != nil {
765+ t.Fatalf("BranchSession: %v", err)
766+ }
767+ branch, _, err := OpenSession(branchPath)
768+ if err != nil {
769+ t.Fatalf("OpenSession: %v", err)
770+ }
771+ defer branch.Close()
772+
773+ if branch.Meta.Plan != nil {
774+ t.Fatalf("branch plan = %#v, want none", branch.Meta.Plan)
775+ }
776+}
777+
778+func TestNormalizeSessionPlan(t *testing.T) {
779+ tests := []struct {
780+ name string
781+ in *SessionPlan
782+ want []PlanStep
783+ }{
784+ {
785+ name: "nil stays nil",
786+ in: nil,
787+ want: nil,
788+ },
789+ {
790+ name: "nothing remains",
791+ in: &SessionPlan{Steps: []PlanStep{planStep("", PlanPending), planStep("kept", "unknown")}},
792+ want: nil,
793+ },
794+ {
795+ name: "drops empty text and unknown status",
796+ in: &SessionPlan{Steps: []PlanStep{
797+ planStep("", PlanPending),
798+ planStep("unknown", "sleeping"),
799+ planStep("kept", PlanCompleted),
800+ }},
801+ want: []PlanStep{planStep("kept", PlanCompleted)},
802+ },
803+ {
804+ name: "keeps only first in progress",
805+ in: &SessionPlan{Steps: []PlanStep{
806+ planStep("first", PlanInProgress),
807+ planStep("second", PlanInProgress),
808+ planStep("third", PlanPending),
809+ }},
810+ want: []PlanStep{planStep("first", PlanInProgress), planStep("third", PlanPending)},
811+ },
812+ }
813+ for _, tt := range tests {
814+ t.Run(tt.name, func(t *testing.T) {
815+ got := normalizeSessionPlan(tt.in)
816+ var gotSteps []PlanStep
817+ if got != nil {
818+ gotSteps = got.Steps
819+ }
820+ if !slices.Equal(gotSteps, tt.want) {
821+ t.Fatalf("plan = %#v, want %#v", gotSteps, tt.want)
822+ }
823+ })
824+ }
825+
826+ long := &SessionPlan{}
827+ for i := 0; i < 50; i++ {
828+ long.Steps = append(long.Steps, planStep("step", PlanPending))
829+ }
830+ if got := normalizeSessionPlan(long); got == nil || len(got.Steps) != 50 {
831+ t.Fatalf("plan was capped: %d steps", len(got.Steps))
832+ }
833+}
834+
835+func appendPlanTranscript(t *testing.T, sess *Session) {
836+ t.Helper()
837+ messages := []provider.Message{
838+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "start"}}},
839+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
840+ ID: "legacy-1",
841+ Name: legacyPlanToolName,
842+ Arguments: []byte(`{"explanation":"initial","plan":[
843+ {"step":"one","status":"in_progress"},
844+ {"step":"two","status":"pending"}
845+ ]}`),
846+ }}},
847+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
848+ CallID: "legacy-1",
849+ Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
850+ }}},
851+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
852+ ID: "plan-2",
853+ Name: planToolName,
854+ Arguments: []byte(`{"action":"add","steps":[{"step":"three","status":"pending"}]}`),
855+ }}},
856+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
857+ CallID: "plan-2",
858+ Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
859+ }}},
860+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "more"}}},
861+ }
862+ for _, msg := range messages {
863+ if err := sess.AppendMessage(msg); err != nil {
864+ t.Fatalf("AppendMessage: %v", err)
865+ }
866+ }
867+}
868+
869+// TestPlanUpdateSnapshotsRebuildsPerCallChecklists guards the load-time replay
870+// the host renders from: a transcript mixing the legacy update_plan full-list
871+// shape and the plan delta shape must yield the materialized checklist after
872+// each applied call, keyed by call id, while failed and read-only calls produce
873+// no snapshot.
874+func TestPlanUpdateSnapshotsRebuildsPerCallChecklists(t *testing.T) {
875+ explanation := "replanned"
876+ messages := []provider.Message{
877+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
878+ ID: "legacy-1",
879+ Name: legacyPlanToolName,
880+ Arguments: []byte(`{"plan":[{"step":"one","status":"completed"},{"step":"two","status":"in_progress"}]}`),
881+ }}},
882+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
883+ CallID: "legacy-1",
884+ Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
885+ }}},
886+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
887+ ID: "delta-1",
888+ Name: planToolName,
889+ Arguments: []byte(`{"action":"add","explanation":"replanned","steps":[{"step":"three"}]}`),
890+ }}},
891+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
892+ CallID: "delta-1",
893+ Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
894+ }}},
895+ // A read never changes state and must not produce a snapshot.
896+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
897+ ID: "show-1",
898+ Name: planToolName,
899+ Arguments: []byte(`{"action":"show"}`),
900+ }}},
901+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
902+ CallID: "show-1",
903+ Content: []provider.Content{provider.TextBlock{Text: "1. [x] one"}},
904+ }}},
905+ // A rejected call left a transcript row but changed nothing.
906+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
907+ ID: "rejected-1",
908+ Name: planToolName,
909+ Arguments: []byte(`{"action":"set","steps":[{"step":"never","status":"pending"}]}`),
910+ }}},
911+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
912+ CallID: "rejected-1",
913+ IsError: true,
914+ Content: []provider.Content{provider.TextBlock{Text: "plan: step text is required"}},
915+ }}},
916+ }
917+
918+ snapshots := PlanUpdateSnapshots(messages)
919+
920+ legacy, ok := snapshots["legacy-1"]
921+ if !ok {
922+ t.Fatalf("legacy call produced no snapshot: %#v", snapshots)
923+ }
924+ wantLegacy := []PlanStep{planStep("one", PlanCompleted), planStep("two", PlanInProgress)}
925+ if !slices.Equal(legacy.Plan, wantLegacy) {
926+ t.Fatalf("legacy snapshot = %#v, want %#v", legacy.Plan, wantLegacy)
927+ }
928+
929+ delta, ok := snapshots["delta-1"]
930+ if !ok {
931+ t.Fatalf("delta call produced no snapshot: %#v", snapshots)
932+ }
933+ wantDelta := []PlanStep{planStep("one", PlanCompleted), planStep("two", PlanInProgress), planStep("three", PlanPending)}
934+ if !slices.Equal(delta.Plan, wantDelta) {
935+ t.Fatalf("delta snapshot = %#v, want %#v", delta.Plan, wantDelta)
936+ }
937+ if delta.Explanation == nil || *delta.Explanation != explanation {
938+ t.Fatalf("delta explanation = %#v, want %q", delta.Explanation, explanation)
939+ }
940+
941+ if snap, ok := snapshots["show-1"]; ok {
942+ t.Fatalf("read-only call produced a snapshot: %#v", snap)
943+ }
944+ if snap, ok := snapshots["rejected-1"]; ok {
945+ t.Fatalf("rejected call produced a snapshot: %#v", snap)
946+ }
947+
948+ // Each snapshot must own its slice: mutating the newest must not corrupt an
949+ // earlier checklist the transcript still renders.
950+ delta.Plan[0].Step = "mutated"
951+ if legacy.Plan[0].Step != "one" {
952+ t.Fatalf("snapshots share a backing slice: legacy = %#v", legacy.Plan)
953+ }
954+}
M packages/core/session.go +83 -1
@@ -149,6 +149,9 @@ type SessionMeta struct {
149149 // GoalHistory records prior durable goal states in chronological order.
150150 // It stays linear: there is only one current goal at a time.
151151 GoalHistory []SessionGoal `json:"goal_history,omitempty"`
152+ // Plan is the agent-owned task checklist. It is optional so older
153+ // sessions remain compatible.
154+ Plan *SessionPlan `json:"plan,omitempty"`
152155
153156 // Parent is the ID of the session this one was forked from, or
154157 // empty for top-level sessions. The tree picker walks parents
@@ -463,6 +466,9 @@ func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, err
463466 return SessionSnapshot{}, fmt.Errorf("session snapshot %q: %w", path, err)
464467 }
465468 normalizeSessionGoalMeta(&snapshot.Meta)
469+ // The goal normalizer returns early when Meta.Goal is nil, so plan-only
470+ // sessions normalize here as a sibling call.
471+ snapshot.Meta.Plan = normalizeSessionPlan(snapshot.Meta.Plan)
466472 snapshot.CompactionGeneration = generation
467473 snapshot.ExtensionState = extensionState
468474
@@ -1313,6 +1319,42 @@ func (s *Session) UpdateGoal(goal *SessionGoal) error {
13131319 return nil
13141320 }
13151321
1322+// PlanSteps returns the persisted plan's steps, or nil when no plan is stored.
1323+// It lets hosts seed agent plan state without nil-checking Meta.Plan at every
1324+// call site.
1325+func (m *SessionMeta) PlanSteps() []PlanStep {
1326+ if m == nil || m.Plan == nil {
1327+ return nil
1328+ }
1329+ return m.Plan.Steps
1330+}
1331+
1332+// UpdatePlan persists the agent-owned plan as a meta transition. It deep-copies
1333+// the steps into session state, stamps the update time, and rolls back the
1334+// in-memory plan when the write fails, mirroring UpdateGoal. A nil plan clears
1335+// it.
1336+func (s *Session) UpdatePlan(plan *SessionPlan) error {
1337+ if s == nil {
1338+ return nil
1339+ }
1340+ previous := cloneSessionPlan(s.Meta.Plan)
1341+ if plan == nil {
1342+ s.Meta.Plan = nil
1343+ } else {
1344+ copyPlan := *plan
1345+ copyPlan.Steps = clonePlanSteps(plan.Steps)
1346+ // Stamping here, rather than in every caller, keeps a persisted plan from
1347+ // carrying a zero timestamp.
1348+ copyPlan.Updated = time.Now().UTC()
1349+ s.Meta.Plan = &copyPlan
1350+ }
1351+ if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
1352+ s.Meta.Plan = previous
1353+ return fmt.Errorf("update plan: %w", err)
1354+ }
1355+ return nil
1356+}
1357+
13161358 // SupersedeGoal atomically archives the active goal as superseded and starts
13171359 // its replacement in the same mission. Mission assignment enforces the normal
13181360 // manager transition bound.
@@ -1422,6 +1464,46 @@ func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error {
14221464 return nil
14231465 }
14241466
1467+// normalizeSessionPlan drops steps that carry no text or an unknown status and
1468+// keeps only the first in_progress step. It returns nil when nothing remains.
1469+// It does not cap the plan length.
1470+func normalizeSessionPlan(plan *SessionPlan) *SessionPlan {
1471+ if plan == nil {
1472+ return nil
1473+ }
1474+ steps := make([]PlanStep, 0, len(plan.Steps))
1475+ seenInProgress := false
1476+ for _, step := range plan.Steps {
1477+ if strings.TrimSpace(step.Step) == "" {
1478+ continue
1479+ }
1480+ // An omitted status means pending, exactly as previewPlanOperation
1481+ // materializes it. Dropping the step instead would make the same JSON mean
1482+ // two different things before and after a reload.
1483+ status := step.Status
1484+ if status == "" {
1485+ status = PlanPending
1486+ }
1487+ switch status {
1488+ case PlanPending, PlanCompleted:
1489+ case PlanInProgress:
1490+ if seenInProgress {
1491+ continue
1492+ }
1493+ seenInProgress = true
1494+ default:
1495+ continue
1496+ }
1497+ steps = append(steps, PlanStep{Step: step.Step, Status: status})
1498+ }
1499+ if len(steps) == 0 {
1500+ return nil
1501+ }
1502+ clone := *plan
1503+ clone.Steps = steps
1504+ return &clone
1505+}
1506+
14251507 func normalizeSessionGoalMeta(meta *SessionMeta) {
14261508 if meta == nil || meta.Goal == nil {
14271509 return
@@ -1643,7 +1725,7 @@ func (s *Session) Close() error {
16431725 defer s.writerMu.Unlock()
16441726 flushErr := s.buf.Flush()
16451727 closeErr := s.writer.Close()
1646- if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 && s.Meta.Mission == nil && s.Meta.Goal == nil && len(s.Meta.GoalHistory) == 0 {
1728+ if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 && s.Meta.Mission == nil && s.Meta.Goal == nil && len(s.Meta.GoalHistory) == 0 && s.Meta.Plan == nil {
16471729 // Best-effort cleanup. We deliberately don't propagate the
16481730 // remove error: if it fails (file already gone, perms changed)
16491731 // the worst case is one stale empty file in the listing.
M packages/core/session_portable.go +220 -0
@@ -231,6 +231,7 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
231231 Mission: cloneSessionMission(snapshot.Meta.Mission),
232232 Goal: cloneSessionGoal(snapshot.Meta.Goal),
233233 GoalHistory: cloneGoalHistory(snapshot.Meta.GoalHistory),
234+ Plan: cloneSessionPlan(snapshot.Meta.Plan),
234235 }
235236 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
236237 if err != nil {
@@ -466,6 +467,224 @@ func goalHistoryAtBranch(parent SessionMeta, messages []provider.Message, limit
466467 return cloneGoalHistory(parent.GoalHistory)
467468 }
468469
470+// cloneSessionPlan returns an independent copy of plan, or nil.
471+func cloneSessionPlan(plan *SessionPlan) *SessionPlan {
472+ if plan == nil {
473+ return nil
474+ }
475+ clone := *plan
476+ clone.Steps = clonePlanSteps(plan.Steps)
477+ return &clone
478+}
479+
480+// planAtBranch reconstructs the agent-owned plan as it stood at a fork point.
481+//
482+// A fork at the end of the effective transcript keeps the parent's persisted
483+// plan: it is what the host recorded, and it is the only surviving record when
484+// the parent was compacted and its plan calls are gone.
485+//
486+// A fork that cuts the transcript short replays the plan calls inside that prefix
487+// and reaches back no further, so the branch never inherits work its own
488+// transcript does not contain — the rule goals already follow. Replay skips calls
489+// whose paired tool result failed, was denied, or is missing: those calls left a
490+// transcript row but changed nothing.
491+func planAtBranch(parent SessionMeta, messages []provider.Message, limit int) *SessionPlan {
492+ if limit > len(messages) {
493+ limit = len(messages)
494+ }
495+ if limit == len(messages) {
496+ return cloneSessionPlan(parent.Plan)
497+ }
498+ steps, _ := replayPlanOps(messages[:limit])
499+ if len(steps) == 0 {
500+ return nil
501+ }
502+ // Normalize the replay result so a hand-edited or legacy transcript cannot
503+ // seed a branch with steps the live agent could never hold.
504+ return normalizeSessionPlan(&SessionPlan{Steps: steps})
505+}
506+
507+// PlanUpdateSnapshots reconstructs the per-call checklist snapshots the host
508+// renders from a transcript. The transcript stores only per-call deltas, so the
509+// materialized list shown for a historical plan call cannot be read back from
510+// its arguments; it must be rebuilt by applying each accepted operation in
511+// order. The result is keyed by tool-call id. Both the legacy update_plan
512+// full-list shape and the plan action shape are accepted, and calls whose paired
513+// tool result failed, was denied, or is missing are skipped, matching
514+// planAtBranch.
515+func PlanUpdateSnapshots(messages []provider.Message) map[string]PlanUpdate {
516+ _, snapshots := replayPlanOps(messages)
517+ return snapshots
518+}
519+
520+// replayPlanOps applies every successful plan call in segment in order and
521+// returns the final steps plus the materialized checklist after each applied
522+// call, keyed by tool-call id. Read-only or invalid operations do not change
523+// state and therefore produce no snapshot.
524+func replayPlanOps(segment []provider.Message) ([]PlanStep, map[string]PlanUpdate) {
525+ applied := planToolResults(segment)
526+ var steps []PlanStep
527+ var snapshots map[string]PlanUpdate
528+ for idx := range segment {
529+ for _, content := range segment[idx].Content {
530+ call, ok := content.(provider.ToolCallBlock)
531+ if !ok || !applied[call.ID] {
532+ continue
533+ }
534+ op, ok := planOperationFromToolCall(call.Name, call.Arguments)
535+ if !ok {
536+ continue
537+ }
538+ next, changed := applyPlanOperation(steps, op)
539+ if !changed {
540+ continue
541+ }
542+ steps = next
543+ if snapshots == nil {
544+ snapshots = make(map[string]PlanUpdate)
545+ }
546+ snapshots[call.ID] = PlanUpdate{Explanation: op.Explanation, Plan: clonePlanSteps(steps)}
547+ }
548+ }
549+ return steps, snapshots
550+}
551+
552+// planToolResults maps the call ids in segment to whether their tool result
553+// reports success. A call whose result is an error, or that has no result at all
554+// because the turn ended first, never changed the plan.
555+func planToolResults(segment []provider.Message) map[string]bool {
556+ applied := make(map[string]bool)
557+ for idx := range segment {
558+ for _, content := range segment[idx].Content {
559+ result, ok := content.(provider.ToolResultBlock)
560+ if !ok || result.CallID == "" {
561+ continue
562+ }
563+ applied[result.CallID] = !result.IsError
564+ }
565+ }
566+ return applied
567+}
568+
569+const (
570+ // planToolName is the current plan tool name. Core cannot import
571+ // packages/agent/tools, so the wire name is repeated here for transcript
572+ // replay.
573+ planToolName = "plan"
574+ // legacyPlanToolName is the pre-rename checklist tool. Historical sessions
575+ // still carry its calls.
576+ legacyPlanToolName = "update_plan"
577+)
578+
579+type planToolStep struct {
580+ Step *string `json:"step"`
581+ Status *string `json:"status"`
582+}
583+
584+// planOperationFromToolCall decodes one plan-affecting tool call. The legacy
585+// update_plan shape carries a full list with replace semantics, so it maps to a
586+// set operation; the new shape carries an explicit action.
587+func planOperationFromToolCall(name string, args json.RawMessage) (PlanOperation, bool) {
588+ switch name {
589+ case legacyPlanToolName:
590+ var legacy struct {
591+ Explanation *string `json:"explanation"`
592+ Plan *[]planToolStep `json:"plan"`
593+ }
594+ if err := json.Unmarshal(args, &legacy); err != nil || legacy.Plan == nil {
595+ return PlanOperation{}, false
596+ }
597+ steps, ok := planToolSteps(*legacy.Plan)
598+ if !ok {
599+ return PlanOperation{}, false
600+ }
601+ return PlanOperation{Action: "set", Explanation: legacy.Explanation, Steps: steps}, true
602+ case planToolName:
603+ var raw struct {
604+ Action string `json:"action"`
605+ Explanation *string `json:"explanation"`
606+ Steps []planToolStep `json:"steps"`
607+ Index int `json:"index"`
608+ Status string `json:"status"`
609+ Step *string `json:"step"`
610+ }
611+ if err := json.Unmarshal(args, &raw); err != nil {
612+ return PlanOperation{}, false
613+ }
614+ steps, ok := planToolSteps(raw.Steps)
615+ if !ok {
616+ return PlanOperation{}, false
617+ }
618+ return PlanOperation{
619+ Action: raw.Action,
620+ Explanation: raw.Explanation,
621+ Steps: steps,
622+ Index: raw.Index,
623+ Text: raw.Step,
624+ Status: PlanStepStatus(raw.Status),
625+ }, true
626+ default:
627+ return PlanOperation{}, false
628+ }
629+}
630+
631+func planToolSteps(raw []planToolStep) ([]PlanStep, bool) {
632+ if len(raw) == 0 {
633+ return nil, true
634+ }
635+ steps := make([]PlanStep, 0, len(raw))
636+ for _, step := range raw {
637+ if step.Step == nil {
638+ return nil, false
639+ }
640+ steps = append(steps, PlanStep{Step: *step.Step, Status: planToolStatus(step.Status)})
641+ }
642+ return steps, true
643+}
644+
645+// planToolStatus defaults an omitted status to pending, matching the schema's
646+// optional status on new-shape steps.
647+func planToolStatus(status *string) PlanStepStatus {
648+ if status == nil {
649+ return PlanPending
650+ }
651+ return PlanStepStatus(*status)
652+}
653+
654+// applyPlanOperation replays op against steps and reports whether it changed
655+// state. Invalid or read-only operations leave steps untouched.
656+func applyPlanOperation(steps []PlanStep, op PlanOperation) ([]PlanStep, bool) {
657+ switch op.Action {
658+ case "set":
659+ return clonePlanSteps(op.Steps), true
660+ case "add":
661+ if len(op.Steps) == 0 {
662+ return steps, false
663+ }
664+ return append(clonePlanSteps(steps), clonePlanSteps(op.Steps)...), true
665+ case "update":
666+ if op.Index < 1 || op.Index > len(steps) {
667+ return steps, false
668+ }
669+ if op.Text != nil {
670+ steps[op.Index-1].Step = *op.Text
671+ }
672+ if op.Status != "" {
673+ steps[op.Index-1].Status = op.Status
674+ }
675+ return steps, true
676+ case "remove":
677+ if op.Index < 1 || op.Index > len(steps) {
678+ return steps, false
679+ }
680+ return append(steps[:op.Index-1:op.Index-1], steps[op.Index:]...), true
681+ case "clear":
682+ return nil, true
683+ default:
684+ return steps, false
685+ }
686+}
687+
469688 func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage, compactHandoff json.RawMessage, goal *SessionGoal) (string, error) {
470689 dir := SessionsDir(root, cwd)
471690 if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -515,6 +734,7 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages
515734 Mission: missionAtBranch(parent, messages, limit),
516735 Goal: cloneSessionGoal(goal),
517736 GoalHistory: goalHistoryAtBranch(parent, messages, limit),
737+ Plan: planAtBranch(parent, messages, limit),
518738 }
519739 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
520740 if err != nil {
M packages/core/tool.go +4 -0
@@ -56,6 +56,10 @@ type ToolResult struct {
5656 // Context describes workspace resources read, discovered, or mutated by
5757 // this result. Core uses it only for provider-visible stale-result pruning.
5858 Context provider.ToolContext
59+ // PlanOperation is set by core when a plan call passed validation and is
60+ // pending commit. It is applied to agent state only after CommitToolResult
61+ // succeeds.
62+ PlanOperation *PlanOperation
5963 }
6064
6165 // Registry is a name->Tool map.
M packages/tui/view.go +77 -64
@@ -1,16 +1,15 @@
11 package tui
22
33 import (
4- "bytes"
54 "encoding/json"
65 "fmt"
7- "io"
86 "os"
97 "regexp"
108 "strconv"
119 "strings"
1210 "unicode/utf8"
1311
12+ "github.com/bnema/zut/packages/core"
1413 "github.com/bnema/zut/packages/provider"
1514 "github.com/mattn/go-runewidth"
1615 )
@@ -129,9 +128,19 @@ type View struct {
129128 // short args) for the call, so a tool_result message can render
130129 // the box top edge without having to look back at the assistant
131130 // message that originated the call. Rebuilt on each Build().
132- toolCallLabels map[string]string
133- toolCallNames map[string]string
134- toolCallArgs map[string]json.RawMessage
131+ toolCallLabels map[string]string
132+ toolCallNames map[string]string
133+ toolCallArgs map[string]json.RawMessage
134+ // PlanUpdates maps a plan tool-call id to the checklist snapshot materialized
135+ // after that call. The transcript only stores per-call deltas, so the host
136+ // replays them at load and fills this map from plan_update events live. It is
137+ // deep-copied by CloneForRender so the render goroutine never shares it with
138+ // the live view.
139+ PlanUpdates map[string]core.PlanUpdate
140+ // PlanRevision advances whenever PlanUpdates changes. It is part of
141+ // msgVisualKey so a snapshot arriving after a message was first cached
142+ // invalidates that cache entry.
143+ PlanRevision uint64
135144 Streaming string // current assistant text delta
136145 StreamingActive bool
137146 ToolCalls []ToolCallView // tool calls in flight or completed
@@ -220,6 +229,9 @@ type msgVisualKey struct {
220229 compactUser bool
221230 imageProto ImageProtocol
222231 theme uint64
232+ // planRev carries the plan snapshot revision so a new snapshot invalidates
233+ // cached message rows that render a plan checklist.
234+ planRev uint64
223235 }
224236
225237 // msgCacheKey identifies a cached message render. hash is a 64-bit
@@ -281,6 +293,7 @@ func (v *View) CloneForRender() *View {
281293 clone.toolCallLabels = cloneStringMap(v.toolCallLabels)
282294 clone.toolCallNames = cloneStringMap(v.toolCallNames)
283295 clone.toolCallArgs = cloneRawMessageMap(v.toolCallArgs)
296+ clone.PlanUpdates = clonePlanUpdateMap(v.PlanUpdates)
284297 clone.liveBodyHigh = cloneIntMap(v.liveBodyHigh)
285298 if v.renderCache != nil {
286299 clone.renderCache = make(map[msgCacheKey][]string, len(v.renderCache))
@@ -331,6 +344,25 @@ func cloneIntMap(src map[string]int) map[string]int {
331344 return dst
332345 }
333346
347+// clonePlanUpdateMap deep-copies a plan snapshot map. Each entry's Plan slice is
348+// copied too, so the render clone owns every step it reads on the other
349+// goroutine.
350+func clonePlanUpdateMap(src map[string]core.PlanUpdate) map[string]core.PlanUpdate {
351+ if src == nil {
352+ return nil
353+ }
354+ dst := make(map[string]core.PlanUpdate, len(src))
355+ for key, value := range src {
356+ if value.Plan != nil {
357+ steps := make([]core.PlanStep, len(value.Plan))
358+ copy(steps, value.Plan)
359+ value.Plan = steps
360+ }
361+ dst[key] = value
362+ }
363+ return dst
364+}
365+
334366 // AdoptRenderCacheFrom transfers caches produced from a render snapshot
335367 // back to the owning view. Callers must ensure the transcript revision is
336368 // still current before adopting them.
@@ -348,6 +380,8 @@ func (v *View) AdoptRenderCacheFrom(snapshot *View) {
348380 v.toolCallLabels = snapshot.toolCallLabels
349381 v.toolCallNames = snapshot.toolCallNames
350382 v.toolCallArgs = snapshot.toolCallArgs
383+ v.PlanUpdates = snapshot.PlanUpdates
384+ v.PlanRevision = snapshot.PlanRevision
351385 v.toolPathRevision = snapshot.toolPathRevision
352386 }
353387
@@ -786,6 +820,7 @@ func (v *View) renderMessageCached(idx int, m provider.Message, width int, turnO
786820 compactUser: v.CompactUser,
787821 imageProto: v.ImageProto,
788822 theme: themeKey,
823+ planRev: v.PlanRevision,
789824 }
790825 if v.MessagesRevision != 0 {
791826 cacheIdx := idx - v.messageCacheStart
@@ -945,65 +980,18 @@ func fnv64aWrite(h uint64, p []byte) uint64 {
945980 return h
946981 }
947982
948-type planUpdateArgs struct {
949- Explanation string `json:"explanation,omitempty"`
950- Plan []planUpdateStep `json:"plan"`
951-}
952-
953-type planUpdateStep struct {
954- Step string `json:"step"`
955- Status string `json:"status"`
956-}
957-
958-type planUpdateWire struct {
959- Explanation *string `json:"explanation"`
960- Plan *[]planUpdateStepWire `json:"plan"`
961-}
962-
963-type planUpdateStepWire struct {
964- Step *string `json:"step"`
965- Status *string `json:"status"`
966-}
967-
968-func parsePlanUpdate(raw json.RawMessage) (planUpdateArgs, bool) {
969- var wire planUpdateWire
970- decoder := json.NewDecoder(bytes.NewReader(raw))
971- decoder.DisallowUnknownFields()
972- if err := decoder.Decode(&wire); err != nil {
973- return planUpdateArgs{}, false
974- }
975- if err := decoder.Decode(&struct{}{}); err != io.EOF || wire.Plan == nil {
976- return planUpdateArgs{}, false
977- }
978-
979- update := planUpdateArgs{Plan: make([]planUpdateStep, len(*wire.Plan))}
980- if wire.Explanation != nil {
981- update.Explanation = *wire.Explanation
982- }
983- for idx, step := range *wire.Plan {
984- if step.Step == nil || step.Status == nil {
985- return planUpdateArgs{}, false
986- }
987- switch *step.Status {
988- case "pending", "in_progress", "completed":
989- default:
990- return planUpdateArgs{}, false
991- }
992- update.Plan[idx] = planUpdateStep{Step: *step.Step, Status: *step.Status}
993- }
994- return update, true
995-}
996-
997-func (v *View) renderPlanUpdate(update planUpdateArgs, width int) []string {
983+func (v *View) renderPlanUpdate(update core.PlanUpdate, width int) []string {
998984 lines := []string{v.Theme.FGColor(v.Theme.Muted, "• ") + Bold("Updated Plan")}
999985 bodyWidth := width - 4
1000986 if bodyWidth < 1 {
1001987 bodyWidth = 1
1002988 }
1003989 var body []string
1004- if explanation := strings.TrimSpace(update.Explanation); explanation != "" {
1005- for _, line := range wrapLine(explanation, bodyWidth, "") {
1006- body = append(body, Dim(Italic(line)))
990+ if update.Explanation != nil {
991+ if explanation := strings.TrimSpace(*update.Explanation); explanation != "" {
992+ for _, line := range wrapLine(explanation, bodyWidth, "") {
993+ body = append(body, Dim(Italic(line)))
994+ }
1007995 }
1008996 }
1009997 if len(update.Plan) == 0 {
@@ -1013,9 +1001,9 @@ func (v *View) renderPlanUpdate(update planUpdateArgs, width int) []string {
10131001 marker := "□ "
10141002 style := func(text string) string { return Dim(text) }
10151003 switch step.Status {
1016- case "completed":
1004+ case core.PlanCompleted:
10171005 marker = "✓ "
1018- case "in_progress":
1006+ case core.PlanInProgress:
10191007 style = func(text string) string { return v.Theme.FGColor(v.Theme.Accent, Bold(text)) }
10201008 }
10211009 stepWidth := bodyWidth - 2
@@ -1042,6 +1030,25 @@ func (v *View) renderPlanUpdate(update planUpdateArgs, width int) []string {
10421030 return lines
10431031 }
10441032
1033+// isPlanToolName reports whether a tool name is a plan checklist tool. It
1034+// accepts the legacy update_plan name too, so a pre-rename transcript's
1035+// replayed snapshots keep rendering as checklists.
1036+func isPlanToolName(name string) bool {
1037+ return name == "plan" || name == "update_plan"
1038+}
1039+
1040+// renderPlanCompact is the live-overlay fallback for a plan call whose
1041+// checklist snapshot does not exist yet: the arguments are still streaming, so
1042+// there is nothing to render but the action being invoked.
1043+func (v *View) renderPlanCompact(tc ToolCallView, width int) []string {
1044+ action, _, _ := ExtractPartialStringField(tc.RawJSONBuf, "action")
1045+ label := "plan"
1046+ if action != "" {
1047+ label = "plan: " + action
1048+ }
1049+ return []string{v.Theme.FGColor(v.Theme.Muted, "• "+truncateToWidth(label, width-2))}
1050+}
1051+
10451052 func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []string {
10461053 var lines []string
10471054
@@ -1152,8 +1159,8 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
11521159 case provider.RoleTool:
11531160 for _, c := range m.Content {
11541161 if tr, ok := c.(provider.ToolResultBlock); ok {
1155- if !tr.IsError && v.toolCallNames[tr.CallID] == "update_plan" {
1156- if update, ok := parsePlanUpdate(v.toolCallArgs[tr.CallID]); ok {
1162+ if !tr.IsError && isPlanToolName(v.toolCallNames[tr.CallID]) {
1163+ if update, ok := v.PlanUpdates[tr.CallID]; ok {
11571164 lines = append(lines, v.renderPlanUpdate(update, width)...)
11581165 continue
11591166 }
@@ -1365,10 +1372,16 @@ func hashThemeColor(h uint64, c TerminalColor) uint64 {
13651372 }
13661373
13671374 func (v *View) renderToolCall(tc ToolCallView, width int) []string {
1368- if tc.Name == "update_plan" && tc.Done && !tc.Error {
1369- if update, ok := parsePlanUpdate(json.RawMessage(tc.RawJSONBuf)); ok {
1375+ if isPlanToolName(tc.Name) {
1376+ // The checklist is materialized only once core accepts the call, so the
1377+ // live overlay renders from the snapshot map. Before execution there is
1378+ // no snapshot yet: name the action instead of inventing a list.
1379+ if update, ok := v.PlanUpdates[tc.ID]; ok {
13701380 return v.renderPlanUpdate(update, width)
13711381 }
1382+ if !tc.Done && !tc.Error {
1383+ return v.renderPlanCompact(tc, width)
1384+ }
13721385 }
13731386
13741387 var lines []string
M packages/tui/view_plan_test.go +174 -70
@@ -1,31 +1,45 @@
11 package tui
22
33 import (
4- "encoding/json"
4+ "fmt"
55 "strings"
66 "testing"
77
8+ "github.com/bnema/zut/packages/core"
89 "github.com/bnema/zut/packages/provider"
910 )
1011
11-func TestViewRendersUpdatePlanAsChecklist(t *testing.T) {
12- callID := "plan-1"
13- args := json.RawMessage(`{
14- "explanation":"Finished discovery",
15- "plan":[
16- {"step":"Map the implementation","status":"completed"},
17- {"step":"Add the built-in tool","status":"in_progress"},
18- {"step":"Verify the behavior","status":"pending"}
19- ]
20- }`)
21- view := &View{Theme: Dark, Messages: []provider.Message{
12+// planTestMessages builds the assistant tool_use + tool result pair a plan call
13+// leaves in the transcript. Rendering now reads the checklist from the snapshot
14+// map keyed by the call id, not from these arguments.
15+func planTestMessages(callID, name, args string) []provider.Message {
16+ return []provider.Message{
2217 {Role: provider.RoleAssistant, Content: []provider.Content{
23- provider.ToolCallBlock{ID: callID, Name: "update_plan", Arguments: args},
18+ provider.ToolCallBlock{ID: callID, Name: name, Arguments: []byte(args)},
2419 }},
2520 {Role: provider.RoleTool, Content: []provider.Content{
2621 provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}},
2722 }},
28- }}
23+ }
24+}
25+
26+func TestViewRendersPlanSnapshotAsChecklist(t *testing.T) {
27+ callID := "plan-1"
28+ explanation := "Finished discovery"
29+ view := &View{
30+ Theme: Dark,
31+ Messages: planTestMessages(callID, "plan", `{"action":"set"}`),
32+ PlanUpdates: map[string]core.PlanUpdate{
33+ callID: {
34+ Explanation: &explanation,
35+ Plan: []core.PlanStep{
36+ {Step: "Map the implementation", Status: core.PlanCompleted},
37+ {Step: "Add the built-in tool", Status: core.PlanInProgress},
38+ {Step: "Verify the behavior", Status: core.PlanPending},
39+ },
40+ },
41+ },
42+ }
2943
3044 plain := stripANSI(strings.Join(view.Build(60), "\n"))
3145 for _, want := range []string{
@@ -39,51 +53,77 @@ func TestViewRendersUpdatePlanAsChecklist(t *testing.T) {
3953 t.Fatalf("rendered plan omitted %q:\n%s", want, plain)
4054 }
4155 }
42- if strings.Contains(plain, "update_plan") || strings.Contains(plain, "Plan updated") {
56+ if strings.Contains(plain, "plan ") || strings.Contains(plain, "Plan updated") {
4357 t.Fatalf("rendered plan leaked generic tool chrome:\n%s", plain)
4458 }
4559 }
4660
47-func TestViewRendersLiveUpdatePlanAsChecklist(t *testing.T) {
48- args := json.RawMessage(`{"plan":[{"step":"Implement","status":"in_progress"}]}`)
49- view := &View{Theme: Dark}
61+func TestViewRendersLegacyUpdatePlanSnapshotFromMap(t *testing.T) {
62+ callID := "legacy-1"
63+ view := &View{
64+ Theme: Dark,
65+ Messages: planTestMessages(callID, "update_plan", `{"plan":[{"step":"Implement","status":"in_progress"}]}`),
66+ PlanUpdates: map[string]core.PlanUpdate{
67+ callID: {Plan: []core.PlanStep{{Step: "Implement", Status: core.PlanInProgress}}},
68+ },
69+ }
70+ plain := stripANSI(strings.Join(view.Build(60), "\n"))
71+ if !strings.Contains(plain, "Updated Plan") || !strings.Contains(plain, "□ Implement") {
72+ t.Fatalf("legacy plan rendering = %q", plain)
73+ }
74+}
75+
76+func TestViewRendersLivePlanFromSnapshot(t *testing.T) {
77+ view := &View{
78+ Theme: Dark,
79+ PlanUpdates: map[string]core.PlanUpdate{
80+ "plan-1": {Plan: []core.PlanStep{{Step: "Implement", Status: core.PlanInProgress}}},
81+ },
82+ }
5083 plain := stripANSI(strings.Join(view.RenderToolCall(ToolCallView{
5184 ID: "plan-1",
52- Name: "update_plan",
53- RawJSONBuf: string(args),
54- Result: "Plan updated",
85+ Name: "plan",
86+ RawJSONBuf: `{"action":"update","index":1,"status":"in_progress"}`,
5587 Done: true,
5688 }, 50), "\n"))
5789 if !strings.Contains(plain, "Updated Plan") || !strings.Contains(plain, "□ Implement") {
5890 t.Fatalf("live plan rendering = %q", plain)
5991 }
60- if strings.Contains(plain, "update_plan") || strings.Contains(plain, "Plan updated") {
92+ if strings.Contains(plain, "plan:") || strings.Contains(plain, "Plan updated") {
6193 t.Fatalf("live plan leaked generic tool chrome: %q", plain)
6294 }
6395 }
6496
97+// TestViewRendersCompactPlanLineWithoutSnapshot covers a plan call whose
98+// arguments are still streaming, before execution materialized any checklist.
99+func TestViewRendersCompactPlanLineWithoutSnapshot(t *testing.T) {
100+ view := &View{Theme: Dark}
101+ plain := stripANSI(strings.Join(view.RenderToolCall(ToolCallView{
102+ ID: "plan-1",
103+ Name: "plan",
104+ RawJSONBuf: `{"action":"update"`,
105+ }, 50), "\n"))
106+ if !strings.Contains(plain, "plan: update") {
107+ t.Fatalf("compact plan line = %q", plain)
108+ }
109+ if strings.Contains(plain, "Updated Plan") {
110+ t.Fatalf("compact plan line rendered a checklist: %q", plain)
111+ }
112+}
113+
65114 func TestViewRenderSnapshotsPreservePlanMetadata(t *testing.T) {
66115 callID := "plan-1"
67- args := json.RawMessage(`{"plan":[{"step":"Implement","status":"in_progress"}]}`)
68- messages := []provider.Message{
69- {Role: provider.RoleAssistant, Content: []provider.Content{
70- provider.ToolCallBlock{ID: callID, Name: "update_plan", Arguments: args},
71- }},
72- {Role: provider.RoleTool, Content: []provider.Content{
73- provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}},
74- }},
75- }
116+ messages := planTestMessages(callID, "plan", `{"action":"set"}`)
76117 seed := func() *View {
77118 return &View{
78119 Theme: Dark,
79120 Messages: messages,
80121 MessagesRevision: 7,
81122 toolPathRevision: 7,
82- toolCallNames: map[string]string{callID: "update_plan"},
83- toolCallArgs: map[string]json.RawMessage{callID: args},
84- toolCallLabels: map[string]string{callID: "update_plan"},
85- toolPaths: map[string]string{},
86- toolStartLines: map[string]int{},
123+ toolCallNames: map[string]string{callID: "plan"},
124+ PlanUpdates: map[string]core.PlanUpdate{
125+ callID: {Plan: []core.PlanStep{{Step: "Implement", Status: core.PlanInProgress}}},
126+ },
87127 }
88128 }
89129 assertChecklist := func(t *testing.T, view *View) {
@@ -102,47 +142,111 @@ func TestViewRenderSnapshotsPreservePlanMetadata(t *testing.T) {
102142 assertChecklist(t, owner)
103143 }
104144
105-func TestViewFallsBackForMalformedUpdatePlan(t *testing.T) {
106- cases := []string{
107- `{"plan":[{"status":"pending"}]}`,
108- `{"plan":[{"step":"work"}]}`,
109- `{"plan":[{"step":"work","status":"unknown"}]}`,
110- `{"plan":[{"step":"work","status":"pending","extra":true}]}`,
111- `{"plan":[],"extra":true}`,
112- `{"plan":[]} trailing`,
113- }
114- for _, raw := range cases {
115- t.Run(raw, func(t *testing.T) {
116- callID := "plan-1"
117- view := &View{Theme: Dark, Messages: []provider.Message{
118- {Role: provider.RoleAssistant, Content: []provider.Content{
119- provider.ToolCallBlock{ID: callID, Name: "update_plan", Arguments: json.RawMessage(raw)},
120- }},
121- {Role: provider.RoleTool, Content: []provider.Content{
122- provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}},
123- }},
124- }}
125- plain := stripANSI(strings.Join(view.Build(50), "\n"))
126- if strings.Contains(plain, "Updated Plan") || !strings.Contains(plain, "Plan updated") {
127- t.Fatalf("malformed plan did not use generic fallback: %q", plain)
128- }
129- })
145+// TestViewCloneForRenderCopiesPlanSnapshots pins the deep copy: the render
146+// goroutine reads the clone while the live view keeps mutating its own map.
147+func TestViewCloneForRenderCopiesPlanSnapshots(t *testing.T) {
148+ callID := "plan-1"
149+ view := &View{
150+ Theme: Dark,
151+ PlanUpdates: map[string]core.PlanUpdate{
152+ callID: {Plan: []core.PlanStep{{Step: "one", Status: core.PlanPending}}},
153+ },
154+ }
155+ clone := view.CloneForRender()
156+
157+ entry := view.PlanUpdates[callID]
158+ entry.Plan[0].Step = "changed"
159+ view.PlanUpdates[callID] = entry
160+ view.PlanUpdates["plan-2"] = core.PlanUpdate{}
161+
162+ if got := clone.PlanUpdates[callID]; len(got.Plan) != 1 || got.Plan[0].Step != "one" {
163+ t.Fatalf("clone shares a plan slice with the source: %#v", got)
164+ }
165+ if _, ok := clone.PlanUpdates["plan-2"]; ok {
166+ t.Fatalf("clone observed a map insertion made after it was cloned")
130167 }
131168 }
132169
133-func TestViewRendersEmptyUpdatePlan(t *testing.T) {
170+// TestViewPlanSnapshotsNotSharedWithRenderGoroutine is the -race guard for the
171+// render clone: the goroutine renders from its own copy of the map while the
172+// live view writes new snapshots.
173+func TestViewPlanSnapshotsNotSharedWithRenderGoroutine(t *testing.T) {
134174 callID := "plan-1"
135- view := &View{Theme: Dark, Messages: []provider.Message{
136- {Role: provider.RoleAssistant, Content: []provider.Content{
137- provider.ToolCallBlock{ID: callID, Name: "update_plan", Arguments: json.RawMessage(`{"plan":[]}`)},
138- }},
139- {Role: provider.RoleTool, Content: []provider.Content{
140- provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}},
141- }},
142- }}
175+ view := &View{
176+ Theme: Dark,
177+ Messages: planTestMessages(callID, "plan", `{"action":"set"}`),
178+ PlanUpdates: map[string]core.PlanUpdate{callID: {Plan: []core.PlanStep{{Step: "one", Status: core.PlanPending}}}},
179+ }
180+ clone := view.CloneForRender()
181+ done := make(chan struct{})
182+ go func() {
183+ defer close(done)
184+ for i := 0; i < 25; i++ {
185+ _ = clone.Build(60)
186+ }
187+ }()
188+ for i := 0; i < 500; i++ {
189+ view.PlanUpdates[callID] = core.PlanUpdate{
190+ Plan: []core.PlanStep{{Step: fmt.Sprintf("step-%d", i), Status: core.PlanInProgress}},
191+ }
192+ }
193+ <-done
194+}
195+
196+// TestViewPlanWithoutSnapshotFallsBackToToolChrome covers a plan call that
197+// produced no checklist: a failed call, or a read such as show.
198+func TestViewPlanWithoutSnapshotFallsBackToToolChrome(t *testing.T) {
199+ callID := "plan-1"
200+ view := &View{Theme: Dark, Messages: planTestMessages(callID, "plan", `{"action":"show"}`)}
201+ plain := stripANSI(strings.Join(view.Build(60), "\n"))
202+ if strings.Contains(plain, "Updated Plan") || !strings.Contains(plain, "Plan updated") {
203+ t.Fatalf("plan without a snapshot did not use generic fallback: %q", plain)
204+ }
205+}
143206
207+func TestViewRendersEmptyPlanSnapshot(t *testing.T) {
208+ callID := "plan-1"
209+ view := &View{
210+ Theme: Dark,
211+ Messages: planTestMessages(callID, "plan", `{"action":"clear"}`),
212+ PlanUpdates: map[string]core.PlanUpdate{callID: {}},
213+ }
144214 plain := stripANSI(strings.Join(view.Build(40), "\n"))
145215 if !strings.Contains(plain, "(no steps provided)") {
146216 t.Fatalf("empty plan rendering = %q", plain)
147217 }
148218 }
219+
220+// TestViewCachedPlanRowInvalidatesOnRevision guards GO-009: PlanRevision is part
221+// of the render-cache key so a late or changed snapshot invalidates a message
222+// row that already rendered a checklist. Without it the cached path serves the
223+// old steps forever.
224+func TestViewCachedPlanRowInvalidatesOnRevision(t *testing.T) {
225+ callID := "plan-1"
226+ view := &View{
227+ Theme: Dark,
228+ MessagesRevision: 1,
229+ Messages: planTestMessages(callID, "plan", `{"action":"update"}`),
230+ PlanUpdates: map[string]core.PlanUpdate{
231+ callID: {Plan: []core.PlanStep{{Step: "old step", Status: core.PlanInProgress}}},
232+ },
233+ }
234+
235+ first := stripANSI(strings.Join(view.Build(60), "\n"))
236+ if !strings.Contains(first, "old step") {
237+ t.Fatalf("first render = %q", first)
238+ }
239+
240+ view.PlanUpdates = map[string]core.PlanUpdate{
241+ callID: {Plan: []core.PlanStep{{Step: "new step", Status: core.PlanInProgress}}},
242+ }
243+ view.PlanRevision++
244+
245+ second := stripANSI(strings.Join(view.Build(60), "\n"))
246+ if strings.Contains(second, "old step") {
247+ t.Fatalf("second render reused the cached checklist: %q", second)
248+ }
249+ if !strings.Contains(second, "new step") {
250+ t.Fatalf("second render omitted the new checklist: %q", second)
251+ }
252+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close